Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a193481d4 | |||
| aea27950ad | |||
| 76ddcf134e | |||
| 5a0feba96e | |||
| 97bc68efd8 | |||
| 464edf0656 | |||
| 43c2589a38 | |||
| 4c82884722 | |||
| 56d0485412 | |||
| 4998eaf8c2 | |||
| 5993b6969d | |||
| dc0201b63f | |||
| 3811512750 | |||
| 9a9f6a42cd | |||
| 3b13b25f1a |
@@ -6,16 +6,15 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "0.8.0"
|
||||
"version": "1.4.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "content-skills",
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"description": "Content generation and publishing skills",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./skills/baoyu-gemini-web",
|
||||
"./skills/baoyu-xhs-images",
|
||||
"./skills/baoyu-post-to-x",
|
||||
"./skills/baoyu-post-to-wechat",
|
||||
@@ -24,6 +23,25 @@
|
||||
"./skills/baoyu-slide-deck",
|
||||
"./skills/baoyu-comic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ai-generation-skills",
|
||||
"description": "AI-powered generation backends",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./skills/baoyu-danger-gemini-web"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "utility-skills",
|
||||
"description": "Utility tools for content processing",
|
||||
"source": "./",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./skills/baoyu-danger-x-to-markdown",
|
||||
"./skills/baoyu-compress-image"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ git add README.md README.zh.md CHANGELOG.md CHANGELOG.zh.md .claude-plugin/marke
|
||||
git commit -m "chore: release v{NEW_VERSION}"
|
||||
```
|
||||
|
||||
**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution.
|
||||
|
||||
### Step 7: Create Version Tag
|
||||
|
||||
```bash
|
||||
|
||||
@@ -140,3 +140,7 @@ vite.config.ts.timestamp-*
|
||||
tests-data/
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Skill extensions (user customization)
|
||||
.baoyu-skills/
|
||||
x-to-markdown/
|
||||
|
||||
@@ -2,6 +2,97 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.4.0 - 2026-01-18
|
||||
|
||||
### Features
|
||||
- `baoyu-cover-image`: expands style library from 8 to 19 styles with 12 new additions—`blueprint`, `bold-editorial`, `chalkboard`, `dark-atmospheric`, `editorial-infographic`, `fantasy-animation`, `intuition-machine`, `notion`, `pixel-art`, `sketch-notes`, `vector-illustration`, `vintage`, `watercolor`.
|
||||
- `baoyu-slide-deck`: adds `chalkboard` style—black chalkboard background with colorful chalk drawings for education and tutorials.
|
||||
|
||||
### Breaking Changes
|
||||
- `baoyu-cover-image`: removes `tech` style (use `blueprint` or `editorial-infographic` for technical content).
|
||||
|
||||
### Documentation
|
||||
- `README.md`, `README.zh.md`: updates style preview screenshots for cover-image and slide-deck.
|
||||
|
||||
## 1.3.0 - 2026-01-18
|
||||
|
||||
### Features
|
||||
- `baoyu-comic`: adds `wuxia` style—Hong Kong martial arts comic style with ink brush strokes, dynamic combat poses, and qi energy effects. Best for wuxia/xianxia and Chinese historical fiction.
|
||||
- `baoyu-comic`: adds style and layout preview screenshots for all 8 styles and 6 layouts in README.
|
||||
|
||||
### Refactor
|
||||
- `baoyu-comic`: removes `tech` style (replaced by `ohmsha` for technical content).
|
||||
|
||||
## 1.2.0 - 2026-01-18
|
||||
|
||||
### Features
|
||||
- Session-independent output directories: each generation session creates a new directory (`<skill-suffix>/<topic-slug>/`), even for the same source file. Conflicts resolved by appending timestamp.
|
||||
- Multi-source file support: source files now saved as `source-{slug}.{ext}`, supporting multiple inputs (text, images, files from conversation).
|
||||
|
||||
### Documentation
|
||||
- `CLAUDE.md`: updates Output Path Convention with new session-independent directory structure and multi-source file naming.
|
||||
- Multiple skills: updates file management sections to reflect new directory and source file conventions.
|
||||
- `baoyu-slide-deck`, `baoyu-article-illustrator`, `baoyu-cover-image`, `baoyu-xhs-images`, `baoyu-comic`
|
||||
|
||||
## 1.1.0 - 2026-01-18
|
||||
|
||||
### Features
|
||||
- `baoyu-compress-image`: new utility skill for cross-platform image compression. Converts to WebP by default with PNG-to-PNG support. Uses system tools (sips, cwebp, ImageMagick) with Sharp fallback.
|
||||
|
||||
### Refactor
|
||||
- Marketplace structure: reorganizes plugins into three categories—`content-skills`, `ai-generation-skills`, and `utility-skills`—for better organization.
|
||||
|
||||
### Documentation
|
||||
- `CLAUDE.md`, `README.md`, `README.zh.md`: updates skill architecture documentation to reflect the new three-category structure.
|
||||
|
||||
## 1.0.1 - 2026-01-18
|
||||
|
||||
### Refactor
|
||||
- Code structure improvements for better readability and maintainability.
|
||||
- `baoyu-slide-deck`: unified style reference file formats.
|
||||
|
||||
### Other
|
||||
- Screenshots: converted from PNG to WebP format for smaller file sizes; added screenshots for new styles.
|
||||
|
||||
## 1.0.0 - 2026-01-18
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: new skill to convert X/Twitter posts and threads to Markdown format.
|
||||
|
||||
### Breaking Changes
|
||||
- `baoyu-gemini-web` renamed to `baoyu-danger-gemini-web` to indicate potential risks of using reverse-engineered APIs.
|
||||
|
||||
## 0.11.0 - 2026-01-18
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-gemini-web`: adds disclaimer consent check flow—requires user acceptance before first use, with persistent consent storage per platform.
|
||||
|
||||
## 0.10.0 - 2026-01-18
|
||||
|
||||
### Features
|
||||
- `baoyu-slide-deck`: expands style library from 10 to 15 styles with 8 new additions—`dark-atmospheric`, `editorial-infographic`, `fantasy-animation`, `intuition-machine`, `pixel-art`, `scientific`, `vintage`, `watercolor`.
|
||||
|
||||
### Breaking Changes
|
||||
- `baoyu-slide-deck`: removes 3 styles (`playful`, `storytelling`, `warm`); changes default style from `notion` to `blueprint`.
|
||||
|
||||
## 0.9.0 - 2026-01-17
|
||||
|
||||
### Features
|
||||
- Extension support: all skills now support customization via `EXTEND.md` files. Check `.baoyu-skills/<skill-name>/EXTEND.md` (project) or `~/.baoyu-skills/<skill-name>/EXTEND.md` (user) for custom styles and configurations.
|
||||
|
||||
### Other
|
||||
- `.gitignore`: adds `.baoyu-skills/` directory for user extension files.
|
||||
|
||||
## 0.8.2 - 2026-01-17
|
||||
|
||||
### Refactor
|
||||
- `baoyu-danger-gemini-web`: reorganizes script architecture—moves modular files into `gemini-webapi/` subdirectory and updates SKILL.md with `${SKILL_DIR}` path references.
|
||||
|
||||
## 0.8.1 - 2026-01-17
|
||||
|
||||
### Refactor
|
||||
- `baoyu-danger-gemini-web`: refactors script architecture—consolidates 10 separate files into a structured `gemini-webapi/` module (TypeScript port of gemini_webapi Python library).
|
||||
|
||||
## 0.8.0 - 2026-01-17
|
||||
|
||||
### Features
|
||||
@@ -30,7 +121,7 @@ English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
- `baoyu-slide-deck`: adds `scripts/merge-to-pdf.ts` to export generated slides into a single PDF; docs updated with pptx/pdf outputs.
|
||||
- `baoyu-comic`: adds `scripts/merge-to-pdf.ts` to merge cover/pages into a PDF; docs clarify character reference handling (image vs text).
|
||||
- Docs conventions: adds a “Script Directory” template to `CLAUDE.md`; aligns `baoyu-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` docs to use `${SKILL_DIR}` in commands so agents can run scripts from any install location.
|
||||
- Docs conventions: adds a “Script Directory” template to `CLAUDE.md`; aligns `baoyu-danger-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` docs to use `${SKILL_DIR}` in commands so agents can run scripts from any install location.
|
||||
|
||||
## 0.6.0 - 2026-01-17
|
||||
|
||||
@@ -45,8 +136,8 @@ English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 0.5.2 - 2026-01-16
|
||||
|
||||
- `baoyu-gemini-web`: adds `--sessionId` (local persisted sessions, plus `--list-sessions`) for multi-turn conversations and consistent multi-image generation.
|
||||
- `baoyu-gemini-web`: adds `--reference/--ref` for reference images (vision input), plus stronger timeout handling and cookie refresh recovery.
|
||||
- `baoyu-danger-gemini-web`: adds `--sessionId` (local persisted sessions, plus `--list-sessions`) for multi-turn conversations and consistent multi-image generation.
|
||||
- `baoyu-danger-gemini-web`: adds `--reference/--ref` for reference images (vision input), plus stronger timeout handling and cookie refresh recovery.
|
||||
- Docs: `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic` document session usage (reuse one `sessionId` per set) to improve visual consistency.
|
||||
|
||||
## 0.5.1 - 2026-01-16
|
||||
@@ -62,7 +153,7 @@ English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 0.4.2 - 2026-01-15
|
||||
|
||||
- `baoyu-gemini-web`: updates description to clarify it as the image-generation backend for other skills (e.g. `cover-image`, `xhs-images`, `article-illustrator`).
|
||||
- `baoyu-danger-gemini-web`: updates description to clarify it as the image-generation backend for other skills (e.g. `cover-image`, `xhs-images`, `article-illustrator`).
|
||||
|
||||
## 0.4.1 - 2026-01-15
|
||||
|
||||
|
||||
@@ -2,6 +2,97 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.4.0 - 2026-01-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-cover-image`:风格库从 8 个扩展至 19 个,新增 12 种风格——`blueprint`(蓝图)、`bold-editorial`(大胆编辑)、`chalkboard`(黑板)、`dark-atmospheric`(暗黑氛围)、`editorial-infographic`(杂志信息图)、`fantasy-animation`(奇幻动画)、`intuition-machine`(技术简报)、`notion`(Notion 风格)、`pixel-art`(像素艺术)、`sketch-notes`(手绘笔记)、`vector-illustration`(矢量插画)、`vintage`(复古文献)、`watercolor`(水彩)。
|
||||
- `baoyu-slide-deck`:新增 `chalkboard`(黑板)风格——黑色黑板背景配彩色粉笔绘画,适合教育和教程内容。
|
||||
|
||||
### 破坏性变更
|
||||
- `baoyu-cover-image`:移除 `tech` 风格(技术内容改用 `blueprint` 或 `editorial-infographic` 风格)。
|
||||
|
||||
### 文档
|
||||
- `README.md`、`README.zh.md`:更新 cover-image 和 slide-deck 风格预览截图。
|
||||
|
||||
## 1.3.0 - 2026-01-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-comic`:新增 `wuxia` 武侠风格——港漫武侠风格,水墨笔触、动态打斗、气功特效。适用于武侠、仙侠、中国历史小说。
|
||||
- `baoyu-comic`:README 新增风格和布局预览截图(8 种风格 + 6 种布局)。
|
||||
|
||||
### 重构
|
||||
- `baoyu-comic`:移除 `tech` 风格(技术内容改用 `ohmsha` 风格)。
|
||||
|
||||
## 1.2.0 - 2026-01-18
|
||||
|
||||
### 新功能
|
||||
- Session 独立输出目录:每次生成创建独立目录(`<skill-suffix>/<topic-slug>/`),即使是同一源文件也会新建目录。目录冲突时追加时间戳。
|
||||
- 多源文件支持:源文件现以 `source-{slug}.{ext}` 命名,支持多个输入(文本、图片、会话中的文件)。
|
||||
|
||||
### 文档
|
||||
- `CLAUDE.md`:更新 Output Path Convention,采用新的 session 独立目录结构和多源文件命名规范。
|
||||
- 多个技能:更新文件管理部分,反映新的目录和源文件规范。
|
||||
- `baoyu-slide-deck`、`baoyu-article-illustrator`、`baoyu-cover-image`、`baoyu-xhs-images`、`baoyu-comic`
|
||||
|
||||
## 1.1.0 - 2026-01-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-compress-image`:新增跨平台图片压缩技能。默认转换为 WebP 格式,支持 PNG 转 PNG。自动选择系统工具(sips、cwebp、ImageMagick),Sharp 作为兜底方案。
|
||||
|
||||
### 重构
|
||||
- Marketplace 结构重组:将插件分为三大类——`content-skills`(内容技能)、`ai-generation-skills`(AI 生成技能)和 `utility-skills`(工具技能),便于管理和发现。
|
||||
|
||||
### 文档
|
||||
- `CLAUDE.md`、`README.md`、`README.zh.md`:更新技能架构文档,反映新的三类分组结构。
|
||||
|
||||
## 1.0.1 - 2026-01-18
|
||||
|
||||
### 重构
|
||||
- 代码结构优化,提升可读性和可维护性。
|
||||
- `baoyu-slide-deck`:统一风格参考文件格式。
|
||||
|
||||
### 其他
|
||||
- 截图:从 PNG 转换为 WebP 格式,减小文件体积;新增新风格的截图。
|
||||
|
||||
## 1.0.0 - 2026-01-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:新增技能,将 X/Twitter 帖子和线程转换为 Markdown 格式。
|
||||
|
||||
### 破坏性变更
|
||||
- `baoyu-gemini-web` 重命名为 `baoyu-danger-gemini-web`,以提示使用逆向工程 API 的潜在风险。
|
||||
|
||||
## 0.11.0 - 2026-01-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-gemini-web`:新增 Disclaimer 同意检查流程——首次使用前需用户确认接受,同意状态按平台持久化存储。
|
||||
|
||||
## 0.10.0 - 2026-01-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-slide-deck`:风格库从 10 个扩展至 15 个,新增 8 种风格——`dark-atmospheric`(暗黑氛围)、`editorial-infographic`(杂志信息图)、`fantasy-animation`(奇幻动画)、`intuition-machine`(技术简报)、`pixel-art`(像素艺术)、`scientific`(科学图解)、`vintage`(复古文献)、`watercolor`(水彩手绘)。
|
||||
|
||||
### 破坏性变更
|
||||
- `baoyu-slide-deck`:移除 3 种风格(`playful`、`storytelling`、`warm`);默认风格从 `notion` 改为 `blueprint`。
|
||||
|
||||
## 0.9.0 - 2026-01-17
|
||||
|
||||
### 新功能
|
||||
- 扩展支持:所有技能现支持通过 `EXTEND.md` 文件自定义。检查 `.baoyu-skills/<skill-name>/EXTEND.md`(项目级)或 `~/.baoyu-skills/<skill-name>/EXTEND.md`(用户级)配置自定义样式与设置。
|
||||
|
||||
### 其他
|
||||
- `.gitignore`:添加 `.baoyu-skills/` 目录忽略,存放用户扩展文件。
|
||||
|
||||
## 0.8.2 - 2026-01-17
|
||||
|
||||
### 重构
|
||||
- `baoyu-danger-gemini-web`:重组脚本架构——将模块文件移至 `gemini-webapi/` 子目录,并更新 SKILL.md 使用 `${SKILL_DIR}` 路径引用。
|
||||
|
||||
## 0.8.1 - 2026-01-17
|
||||
|
||||
### 重构
|
||||
- `baoyu-danger-gemini-web`:重构脚本架构——将 10 个分散的脚本文件整合为结构化的 `gemini-webapi/` 模块(gemini_webapi Python 库的 TypeScript 移植版)。
|
||||
|
||||
## 0.8.0 - 2026-01-17
|
||||
|
||||
### 新功能
|
||||
@@ -30,7 +121,7 @@
|
||||
|
||||
- `baoyu-slide-deck`:新增 `scripts/merge-to-pdf.ts`,可将生成的 slide 图片一键合并为 PDF;文档补充导出步骤与产物命名(pptx/pdf)。
|
||||
- `baoyu-comic`:新增 `scripts/merge-to-pdf.ts`,将封面/分页图片合并为 PDF;补充角色参考(图片/文本)处理说明。
|
||||
- 文档规范:在 `CLAUDE.md` 中补充“Script Directory”模板;`baoyu-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` 文档统一用 `${SKILL_DIR}` 引用脚本路径,方便 agent 在任意安装目录运行。
|
||||
- 文档规范:在 `CLAUDE.md` 中补充“Script Directory”模板;`baoyu-danger-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` 文档统一用 `${SKILL_DIR}` 引用脚本路径,方便 agent 在任意安装目录运行。
|
||||
|
||||
## 0.6.0 - 2026-01-17
|
||||
|
||||
@@ -45,8 +136,8 @@
|
||||
|
||||
## 0.5.2 - 2026-01-16
|
||||
|
||||
- `baoyu-gemini-web`:新增 `--sessionId`(本地持久化会话,支持 `--list-sessions`),用于多轮对话/多图生成保持上下文一致。
|
||||
- `baoyu-gemini-web`:新增 `--reference/--ref` 传入参考图片(vision 输入),并增强超时与 cookie 失效自动恢复逻辑。
|
||||
- `baoyu-danger-gemini-web`:新增 `--sessionId`(本地持久化会话,支持 `--list-sessions`),用于多轮对话/多图生成保持上下文一致。
|
||||
- `baoyu-danger-gemini-web`:新增 `--reference/--ref` 传入参考图片(vision 输入),并增强超时与 cookie 失效自动恢复逻辑。
|
||||
- `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic`:文档补充 session 约定(整套图使用同一 `sessionId`,增强风格一致性)。
|
||||
|
||||
## 0.5.1 - 2026-01-16
|
||||
@@ -62,7 +153,7 @@
|
||||
|
||||
## 0.4.2 - 2026-01-15
|
||||
|
||||
- `baoyu-gemini-web`:描述信息更新,明确其作为 `cover-image` / `xhs-images` / `article-illustrator` 等技能的图片生成后端。
|
||||
- `baoyu-danger-gemini-web`:描述信息更新,明确其作为 `cover-image` / `xhs-images` / `article-illustrator` 等技能的图片生成后端。
|
||||
|
||||
## 0.4.1 - 2026-01-15
|
||||
|
||||
|
||||
@@ -8,17 +8,34 @@ Claude Code marketplace plugin providing AI-powered content generation skills. S
|
||||
|
||||
## Architecture
|
||||
|
||||
Skills are organized into three plugin categories in `marketplace.json`:
|
||||
|
||||
```
|
||||
skills/
|
||||
├── baoyu-gemini-web/ # Core: Gemini API wrapper (text + image gen)
|
||||
├── baoyu-xhs-images/ # Xiaohongshu infographic series (1-10 images)
|
||||
├── baoyu-cover-image/ # Article cover images (2.35:1 aspect)
|
||||
├── baoyu-slide-deck/ # Presentation slides with outlines
|
||||
├── baoyu-article-illustrator/ # Smart illustration placement
|
||||
├── baoyu-post-to-x/ # X/Twitter posting automation
|
||||
└── baoyu-post-to-wechat/ # WeChat Official Account posting
|
||||
├── [content-skills] # Content generation and publishing
|
||||
│ ├── baoyu-xhs-images/ # Xiaohongshu infographic series (1-10 images)
|
||||
│ ├── baoyu-cover-image/ # Article cover images (2.35:1 aspect)
|
||||
│ ├── baoyu-slide-deck/ # Presentation slides with outlines
|
||||
│ ├── baoyu-article-illustrator/ # Smart illustration placement
|
||||
│ ├── baoyu-comic/ # Knowledge comics (Logicomix/Ohmsha style)
|
||||
│ ├── baoyu-post-to-x/ # X/Twitter posting automation
|
||||
│ └── baoyu-post-to-wechat/ # WeChat Official Account posting
|
||||
│
|
||||
├── [ai-generation-skills] # AI-powered generation backends
|
||||
│ └── baoyu-danger-gemini-web/ # Gemini API wrapper (text + image gen)
|
||||
│
|
||||
└── [utility-skills] # Utility tools for content processing
|
||||
├── baoyu-danger-x-to-markdown/ # X/Twitter content to markdown
|
||||
└── baoyu-compress-image/ # Image compression
|
||||
```
|
||||
|
||||
**Plugin Categories**:
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| `content-skills` | Skills that generate or publish content (images, slides, comics, posts) |
|
||||
| `ai-generation-skills` | Backend skills providing AI generation capabilities |
|
||||
| `utility-skills` | Helper tools for content processing (conversion, compression) |
|
||||
|
||||
Each skill contains:
|
||||
- `SKILL.md` - YAML front matter (name, description) + documentation
|
||||
- `scripts/` - TypeScript implementations
|
||||
@@ -35,24 +52,24 @@ npx -y bun skills/<skill>/scripts/main.ts [options]
|
||||
Examples:
|
||||
```bash
|
||||
# Text generation
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello"
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "Hello"
|
||||
|
||||
# Image generation
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
|
||||
# From prompt files
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **Bun**: TypeScript runtime (via `npx -y bun`)
|
||||
- **Chrome**: Required for `baoyu-gemini-web` auth and `baoyu-post-to-x` automation
|
||||
- **Chrome**: Required for `baoyu-danger-gemini-web` auth and `baoyu-post-to-x` automation
|
||||
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
||||
|
||||
## Authentication
|
||||
|
||||
`baoyu-gemini-web` uses browser cookies for Google auth:
|
||||
`baoyu-danger-gemini-web` uses browser cookies for Google auth:
|
||||
- First run opens Chrome for login
|
||||
- Cookies cached in data directory
|
||||
- Force refresh: `--login` flag
|
||||
@@ -70,9 +87,28 @@ npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md conte
|
||||
- SKILL.md `name` field: `baoyu-<name>`
|
||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
|
||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||
4. Register in `marketplace.json` plugins[0].skills array as `./skills/baoyu-<name>`
|
||||
4. **Choose the appropriate category** and register in `marketplace.json`:
|
||||
- `content-skills`: For content generation/publishing (images, slides, posts)
|
||||
- `ai-generation-skills`: For AI backend capabilities
|
||||
- `utility-skills`: For helper tools (conversion, compression)
|
||||
- If none fit, create a new category with descriptive name
|
||||
5. **Add Script Directory section** to SKILL.md (see template below)
|
||||
|
||||
### Choosing a Category
|
||||
|
||||
| If your skill... | Use category |
|
||||
|------------------|--------------|
|
||||
| Generates visual content (images, slides, comics) | `content-skills` |
|
||||
| Publishes to platforms (X, WeChat, etc.) | `content-skills` |
|
||||
| Provides AI generation backend | `ai-generation-skills` |
|
||||
| Converts or processes content | `utility-skills` |
|
||||
| Compresses or optimizes files | `utility-skills` |
|
||||
|
||||
**Creating a new category**: If the skill doesn't fit existing categories, add a new plugin object to `marketplace.json` with:
|
||||
- `name`: Descriptive kebab-case name (e.g., `analytics-skills`)
|
||||
- `description`: Brief description of the category
|
||||
- `skills`: Array with the skill path
|
||||
|
||||
### Script Directory Template
|
||||
|
||||
Every SKILL.md with scripts MUST include this section after Usage:
|
||||
@@ -121,7 +157,7 @@ Use this template when implementing image generation in skills:
|
||||
### Step N: Generate Images
|
||||
|
||||
**Skill Selection**:
|
||||
1. Check available image generation skills (e.g., `baoyu-gemini-web`)
|
||||
1. Check available image generation skills (e.g., `baoyu-danger-gemini-web`)
|
||||
2. Read selected skill's SKILL.md for parameter reference
|
||||
3. If multiple skills available, ask user to choose
|
||||
|
||||
@@ -137,27 +173,33 @@ Use this template when implementing image generation in skills:
|
||||
|
||||
### Output Path Convention
|
||||
|
||||
Generated images from the same skill and source file MUST be grouped together:
|
||||
Each session creates an independent directory. Even the same source file generates a new directory per session.
|
||||
|
||||
**With source file** (e.g., `/path/to/project/content/my-article.md`):
|
||||
**Output Directory**:
|
||||
```
|
||||
/path/to/project/content/my-article/<skill-suffix>/
|
||||
<skill-suffix>/<topic-slug>/
|
||||
```
|
||||
- Remove file extension from source filename
|
||||
- Use skill name suffix (e.g., `xhs-images`, `cover-image`, `slide-deck`)
|
||||
- Example: source `/tests-data/anthropic-economic-index.md` + skill `baoyu-xhs-images` → `/tests-data/anthropic-economic-index/xhs-images/`
|
||||
- `<skill-suffix>`: Skill name suffix (e.g., `xhs-images`, `cover-image`, `slide-deck`, `comic`)
|
||||
- `<topic-slug>`: Generated from content topic (2-4 words, kebab-case)
|
||||
- Example: `xhs-images/ai-future-trends/`
|
||||
|
||||
**Without source file**:
|
||||
```
|
||||
./<skill-suffix>/<source-slug>/
|
||||
```
|
||||
- Place under current project directory
|
||||
- Use descriptive slug for the content
|
||||
**Slug Generation**:
|
||||
1. Extract main topic from content (2-4 words, kebab-case)
|
||||
2. Example: "Introduction to Machine Learning" → `intro-machine-learning`
|
||||
|
||||
**Directory Backup**:
|
||||
- If output directory already exists, rename existing directory with timestamp
|
||||
- Format: `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
- Example: `xhs-images` → `xhs-images-backup-20260117-143052`
|
||||
**Conflict Resolution**:
|
||||
If `<skill-suffix>/<topic-slug>/` already exists:
|
||||
- Append timestamp: `<topic-slug>-YYYYMMDD-HHMMSS`
|
||||
- Example: `ai-future` exists → `ai-future-20260118-143052`
|
||||
|
||||
**Source Files**:
|
||||
- Copy all sources to `<skill-suffix>/<topic-slug>/` with naming: `source-{slug}.{ext}`
|
||||
- Multiple sources supported: text, images, files from conversation
|
||||
- Examples:
|
||||
- `source-article.md` (main text content)
|
||||
- `source-reference.png` (image from conversation)
|
||||
- `source-data.csv` (additional file)
|
||||
- Original source files remain unchanged
|
||||
|
||||
### Image Naming Convention
|
||||
|
||||
@@ -188,3 +230,21 @@ Image filenames MUST include meaningful slugs for readability:
|
||||
- Pass parameters exactly as documented in the skill
|
||||
- Handle failures gracefully with retry logic
|
||||
- Provide clear progress feedback to user
|
||||
|
||||
## Extension Support
|
||||
|
||||
Every SKILL.md MUST include an Extension Support section at the end:
|
||||
|
||||
```markdown
|
||||
## Extension Support
|
||||
|
||||
Custom styles and configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/<skill-name>/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/<skill-name>/EXTEND.md` (user)
|
||||
|
||||
If found, load before Step 1. Extension content overrides defaults.
|
||||
```
|
||||
|
||||
Replace `<skill-name>` with the actual skill name (e.g., `baoyu-cover-image`).
|
||||
|
||||
@@ -31,13 +31,16 @@ Run the following command in Claude Code:
|
||||
|
||||
1. Select **Browse and install plugins**
|
||||
2. Select **baoyu-skills**
|
||||
3. Select **content-skills**
|
||||
3. Select the plugin(s) you want to install
|
||||
4. Select **Install now**
|
||||
|
||||
**Option 2: Direct Install**
|
||||
|
||||
```bash
|
||||
# Install specific plugin
|
||||
/plugin install content-skills@baoyu-skills
|
||||
/plugin install ai-generation-skills@baoyu-skills
|
||||
/plugin install utility-skills@baoyu-skills
|
||||
```
|
||||
|
||||
**Option 3: Ask the Agent**
|
||||
@@ -46,6 +49,14 @@ Simply tell Claude Code:
|
||||
|
||||
> Please install Skills from github.com/JimLiu/baoyu-skills
|
||||
|
||||
### Available Plugins
|
||||
|
||||
| Plugin | Description | Skills |
|
||||
|--------|-------------|--------|
|
||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [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) |
|
||||
| **ai-generation-skills** | AI-powered generation backends | [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||
| **utility-skills** | Utility tools for content processing | [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
|
||||
|
||||
## Update Skills
|
||||
|
||||
To update skills to the latest version:
|
||||
@@ -61,25 +72,13 @@ You can also **Enable auto-update** to get the latest versions automatically.
|
||||
|
||||
## Available Skills
|
||||
|
||||
### baoyu-gemini-web
|
||||
Skills are organized into three categories:
|
||||
|
||||
Interacts with Gemini Web to generate text and images.
|
||||
### Content Skills
|
||||
|
||||
**Text Generation:**
|
||||
Content generation and publishing skills.
|
||||
|
||||
```bash
|
||||
/baoyu-gemini-web "Hello, Gemini"
|
||||
/baoyu-gemini-web --prompt "Explain quantum computing"
|
||||
```
|
||||
|
||||
**Image Generation:**
|
||||
|
||||
```bash
|
||||
/baoyu-gemini-web --prompt "A cute cat" --image cat.png
|
||||
/baoyu-gemini-web --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
|
||||
### baoyu-xhs-images
|
||||
#### baoyu-xhs-images
|
||||
|
||||
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics with **Style × Layout** two-dimensional system.
|
||||
|
||||
@@ -112,7 +111,7 @@ Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-1
|
||||
| `comparison` | 2 sides | Before/after, pros/cons |
|
||||
| `flow` | 3-6 steps | Processes, timelines |
|
||||
|
||||
### baoyu-cover-image
|
||||
#### baoyu-cover-image
|
||||
|
||||
Generate hand-drawn style cover images for articles with multiple style options.
|
||||
|
||||
@@ -128,9 +127,28 @@ Generate hand-drawn style cover images for articles with multiple style options.
|
||||
/baoyu-cover-image path/to/article.md --no-title
|
||||
```
|
||||
|
||||
Available styles: `elegant` (default), `tech`, `warm`, `bold`, `minimal`, `playful`, `nature`, `retro`
|
||||
Available styles: `elegant` (default), `blueprint`, `bold`, `bold-editorial`, `chalkboard`, `dark-atmospheric`, `editorial-infographic`, `fantasy-animation`, `intuition-machine`, `minimal`, `nature`, `notion`, `pixel-art`, `playful`, `retro`, `sketch-notes`, `vector-illustration`, `vintage`, `warm`, `watercolor`
|
||||
|
||||
### baoyu-slide-deck
|
||||
**Style Previews**:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| elegant | blueprint | bold |
|
||||
|  |  |  |
|
||||
| bold-editorial | chalkboard | dark-atmospheric |
|
||||
|  |  |  |
|
||||
| editorial-infographic | fantasy-animation | intuition-machine |
|
||||
|  |  |  |
|
||||
| minimal | nature | notion |
|
||||
|  |  |  |
|
||||
| pixel-art | playful | retro |
|
||||
|  |  |  |
|
||||
| sketch-notes | vector-illustration | vintage |
|
||||
|  |  | |
|
||||
| warm | watercolor | |
|
||||
|
||||
#### baoyu-slide-deck
|
||||
|
||||
Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images.
|
||||
|
||||
@@ -153,20 +171,42 @@ Generate professional slide deck images from content. Creates comprehensive outl
|
||||
|
||||
| Style | Description | Best For |
|
||||
|-------|-------------|----------|
|
||||
| `notion` (default) | SaaS dashboard aesthetic with clean data focus, card-based layouts | Product demos, SaaS, productivity tools, B2B |
|
||||
| `sketch-notes` | Hand-drawn feel with soft brush strokes, warm off-white background | Educational, tutorials, knowledge sharing |
|
||||
| `blueprint` | Technical schematics with grid texture, engineering precision | Architecture, system design, data analysis |
|
||||
| `bold-editorial` | High-impact magazine style, bold typography, dark backgrounds | Product launches, marketing, keynotes |
|
||||
| `vector-illustration` | Flat vector with black outlines, retro soft colors, toy model aesthetic | Creative proposals, children's content, explainers |
|
||||
| `minimal` | Ultra-clean with maximum whitespace, single accent color, zen-like | Executive briefings, keynotes, premium brands |
|
||||
| `storytelling` | Cinematic full-bleed visuals, emotional photography | Case studies, narratives, customer journeys |
|
||||
| `warm` | Soft gradients, rounded shapes, wellness palette | Lifestyle, wellness, personal development |
|
||||
| `corporate` | Navy/gold palette, structured layouts, professional iconography | Investor decks, client proposals, quarterly reports |
|
||||
| `playful` | Vibrant coral/teal/yellow, rounded shapes, dynamic layouts | Workshops, training, creative pitches |
|
||||
| `blueprint` (default) | Technical schematics, grid texture, engineering precision | Architecture, system design |
|
||||
| `notion` | SaaS dashboard aesthetic, card-based layouts, clean data focus | Product demos, SaaS, B2B |
|
||||
| `bold-editorial` | High-impact magazine style, bold typography, dark backgrounds | Product launches, keynotes |
|
||||
| `corporate` | Navy/gold palette, structured layouts, professional icons | Investor decks, proposals |
|
||||
| `dark-atmospheric` | Cinematic dark mode, glowing accents, atmospheric depth | Entertainment, gaming, creative |
|
||||
| `editorial-infographic` | Magazine-style explainers, flat illustrations | Tech explainers, research |
|
||||
| `fantasy-animation` | Whimsical Ghibli/Disney style, hand-drawn animation | Educational, storytelling |
|
||||
| `intuition-machine` | Technical briefing, bilingual labels, aged paper texture | Technical docs, bilingual |
|
||||
| `minimal` | Ultra-clean, maximum whitespace, single accent color | Executive briefings, premium |
|
||||
| `pixel-art` | Retro 8-bit aesthetic, chunky pixels, nostalgic gaming | Gaming, developer talks |
|
||||
| `scientific` | Academic diagrams, biological pathways, precise labeling | Biology, chemistry, medical |
|
||||
| `sketch-notes` | Hand-drawn feel, soft brush strokes, warm background | Educational, tutorials |
|
||||
| `vector-illustration` | Flat vector, black outlines, retro soft colors | Creative proposals, explainers |
|
||||
| `vintage` | Aged-paper aesthetic, historical document styling | Historical, heritage, biography |
|
||||
| `watercolor` | Soft hand-painted textures, natural warmth | Lifestyle, wellness, travel |
|
||||
|
||||
**Style Previews**:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| blueprint | chalkboard | bold-editorial |
|
||||
|  |  |  |
|
||||
| corporate | dark-atmospheric | editorial-infographic |
|
||||
|  |  |  |
|
||||
| fantasy-animation | intuition-machine | minimal |
|
||||
|  |  |  |
|
||||
| notion | pixel-art | scientific |
|
||||
|  |  |  |
|
||||
| sketch-notes | vector-illustration | vintage |
|
||||
|  | | |
|
||||
| watercolor | | |
|
||||
|
||||
After generation, slides are automatically merged into a `.pptx` file for easy sharing.
|
||||
|
||||
### baoyu-comic
|
||||
#### baoyu-comic
|
||||
|
||||
Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation.
|
||||
|
||||
@@ -195,7 +235,7 @@ Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohms
|
||||
**Options**:
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--style` | `classic` (default), `dramatic`, `warm`, `tech`, `sepia`, `vibrant`, `ohmsha`, `realistic`, or custom description |
|
||||
| `--style` | `classic` (default), `dramatic`, `warm`, `sepia`, `vibrant`, `ohmsha`, `realistic`, `wuxia`, or custom description |
|
||||
| `--layout` | `standard` (default), `cinematic`, `dense`, `splash`, `mixed`, `webtoon` |
|
||||
| `--aspect` | `3:4` (default, portrait), `4:3` (landscape), `16:9` (widescreen) |
|
||||
| `--lang` | `auto` (default), `zh`, `en`, `ja`, etc. |
|
||||
@@ -207,11 +247,22 @@ Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohms
|
||||
| `classic` (default) | Traditional Ligne Claire with clean uniform outlines, flat colors, detailed backgrounds | Biographies, balanced narratives, educational content |
|
||||
| `dramatic` | High contrast with heavy shadows, intense expressions, angular compositions | Pivotal discoveries, conflicts, climactic scenes |
|
||||
| `warm` | Soft edges, golden tones, cozy interiors with nostalgic feel | Personal stories, childhood scenes, mentorship |
|
||||
| `tech` | Precise geometric lines, circuit motifs, neon accents on dark backgrounds | Computing history, AI stories, modern tech |
|
||||
| `sepia` | Vintage illustration style with aged paper effect, period-accurate details | Pre-1950s stories, classical science, historical figures |
|
||||
| `vibrant` | Energetic lines with weight variation, bright colors, dynamic poses | Science explanations, "aha" moments, young audience |
|
||||
| `ohmsha` | Manga guide style with visual metaphors, gadgets, student/mentor dynamic | Technical tutorials, complex concepts (ML, physics) |
|
||||
| `realistic` | Full-color realistic manga with digital painting, smooth gradients, accurate proportions | Wine, food, business, lifestyle, professional topics |
|
||||
| `wuxia` | Hong Kong martial arts style with ink brush strokes, dynamic combat, qi effects | Martial arts, wuxia/xianxia, Chinese historical fiction |
|
||||
|
||||
**Style Previews**:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| classic | dramatic | warm |
|
||||
|  |  |  |
|
||||
| sepia | vibrant | ohmsha |
|
||||
|  |  | |
|
||||
| realistic | wuxia | |
|
||||
|
||||
**Layouts** (panel arrangement):
|
||||
| Layout | Panels/Page | Best for |
|
||||
@@ -223,7 +274,39 @@ Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohms
|
||||
| `mixed` | 3-7 varies | Complex narratives, emotional arcs |
|
||||
| `webtoon` | 3-5 vertical | Ohmsha tutorials, mobile reading |
|
||||
|
||||
### baoyu-post-to-wechat
|
||||
**Layout Previews**:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| standard | cinematic | dense |
|
||||
|  |  |  |
|
||||
| splash | mixed | webtoon |
|
||||
|
||||
#### baoyu-article-illustrator
|
||||
|
||||
Smart article illustration skill. Analyzes article content and generates illustrations at positions requiring visual aids.
|
||||
|
||||
```bash
|
||||
/baoyu-article-illustrator path/to/article.md
|
||||
```
|
||||
|
||||
#### baoyu-post-to-x
|
||||
|
||||
Post content and articles to X (Twitter). Supports regular posts with images and X Articles (long-form Markdown). Uses real Chrome with CDP to bypass anti-automation.
|
||||
|
||||
```bash
|
||||
# Post with text
|
||||
/baoyu-post-to-x "Hello from Claude Code!"
|
||||
|
||||
# Post with images
|
||||
/baoyu-post-to-x "Check this out" --image photo.png
|
||||
|
||||
# Post X Article
|
||||
/baoyu-post-to-x --article path/to/article.md
|
||||
```
|
||||
|
||||
#### baoyu-post-to-wechat
|
||||
|
||||
Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
|
||||
@@ -245,9 +328,94 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
|
||||
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
||||
|
||||
### AI Generation Skills
|
||||
|
||||
AI-powered generation backends.
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
Interacts with Gemini Web to generate text and images.
|
||||
|
||||
**Text Generation:**
|
||||
|
||||
```bash
|
||||
/baoyu-danger-gemini-web "Hello, Gemini"
|
||||
/baoyu-danger-gemini-web --prompt "Explain quantum computing"
|
||||
```
|
||||
|
||||
**Image Generation:**
|
||||
|
||||
```bash
|
||||
/baoyu-danger-gemini-web --prompt "A cute cat" --image cat.png
|
||||
/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
|
||||
### Utility Skills
|
||||
|
||||
Utility tools for content processing.
|
||||
|
||||
#### baoyu-danger-x-to-markdown
|
||||
|
||||
Converts X (Twitter) content to markdown format. Supports tweet threads and X Articles.
|
||||
|
||||
```bash
|
||||
# Convert tweet to markdown
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456
|
||||
|
||||
# Save to specific file
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md
|
||||
|
||||
# JSON output
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
||||
```
|
||||
|
||||
**Supported URLs:**
|
||||
- `https://x.com/<user>/status/<id>`
|
||||
- `https://twitter.com/<user>/status/<id>`
|
||||
- `https://x.com/i/article/<id>`
|
||||
|
||||
**Authentication:** Uses environment variables (`X_AUTH_TOKEN`, `X_CT0`) or Chrome login for cookie-based auth.
|
||||
|
||||
#### baoyu-compress-image
|
||||
|
||||
Compress images to reduce file size while maintaining quality.
|
||||
|
||||
```bash
|
||||
/baoyu-compress-image path/to/image.png
|
||||
/baoyu-compress-image path/to/images/ --quality 80
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
All skills support customization via `EXTEND.md` files. Create an extension file to override default styles, add custom configurations, or define your own presets.
|
||||
|
||||
**Extension paths** (checked in priority order):
|
||||
1. `.baoyu-skills/<skill-name>/EXTEND.md` - Project-level (for team/project-specific settings)
|
||||
2. `~/.baoyu-skills/<skill-name>/EXTEND.md` - User-level (for personal preferences)
|
||||
|
||||
**Example**: To customize `baoyu-cover-image` with your brand colors:
|
||||
|
||||
```bash
|
||||
mkdir -p .baoyu-skills/baoyu-cover-image
|
||||
```
|
||||
|
||||
Then create `.baoyu-skills/baoyu-cover-image/EXTEND.md`:
|
||||
|
||||
```markdown
|
||||
## Custom Styles
|
||||
|
||||
### brand
|
||||
- Primary color: #1a73e8
|
||||
- Secondary color: #34a853
|
||||
- Font style: Modern sans-serif
|
||||
- Always include company logo watermark
|
||||
```
|
||||
|
||||
The extension content will be loaded before skill execution and override defaults.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
### baoyu-gemini-web
|
||||
### baoyu-danger-gemini-web
|
||||
|
||||
This skill uses the Gemini Web API (reverse-engineered).
|
||||
|
||||
@@ -257,6 +425,17 @@ This skill uses the Gemini Web API (reverse-engineered).
|
||||
- Cookies are cached for subsequent runs
|
||||
- No guarantees on API stability or availability
|
||||
|
||||
### baoyu-danger-x-to-markdown
|
||||
|
||||
This skill uses a reverse-engineered X (Twitter) API.
|
||||
|
||||
**Warning:** This is NOT an official API. Use at your own risk.
|
||||
|
||||
- May break without notice if X changes their API
|
||||
- Account restrictions possible if API usage detected
|
||||
- First use requires consent acknowledgment
|
||||
- Authentication via environment variables or Chrome login
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -31,13 +31,16 @@ npx add-skill jimliu/baoyu-skills
|
||||
|
||||
1. 选择 **Browse and install plugins**
|
||||
2. 选择 **baoyu-skills**
|
||||
3. 选择 **content-skills**
|
||||
3. 选择要安装的插件
|
||||
4. 选择 **Install now**
|
||||
|
||||
**方式二:直接安装**
|
||||
|
||||
```bash
|
||||
# 安装指定插件
|
||||
/plugin install content-skills@baoyu-skills
|
||||
/plugin install ai-generation-skills@baoyu-skills
|
||||
/plugin install utility-skills@baoyu-skills
|
||||
```
|
||||
|
||||
**方式三:告诉 Agent**
|
||||
@@ -46,6 +49,14 @@ npx add-skill jimliu/baoyu-skills
|
||||
|
||||
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
|
||||
|
||||
### 可用插件
|
||||
|
||||
| 插件 | 说明 | 包含技能 |
|
||||
|------|------|----------|
|
||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [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) |
|
||||
| **ai-generation-skills** | AI 生成后端 | [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||
| **utility-skills** | 内容处理工具 | [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
|
||||
|
||||
## 更新技能
|
||||
|
||||
更新技能到最新版本:
|
||||
@@ -61,25 +72,13 @@ npx add-skill jimliu/baoyu-skills
|
||||
|
||||
## 可用技能
|
||||
|
||||
### baoyu-gemini-web
|
||||
技能分为三大类:
|
||||
|
||||
与 Gemini Web 交互,生成文本和图片。
|
||||
### 内容技能 (Content Skills)
|
||||
|
||||
**文本生成:**
|
||||
内容生成和发布技能。
|
||||
|
||||
```bash
|
||||
/baoyu-gemini-web "你好,Gemini"
|
||||
/baoyu-gemini-web --prompt "解释量子计算"
|
||||
```
|
||||
|
||||
**图片生成:**
|
||||
|
||||
```bash
|
||||
/baoyu-gemini-web --prompt "一只可爱的猫" --image cat.png
|
||||
/baoyu-gemini-web --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
|
||||
### baoyu-xhs-images
|
||||
#### baoyu-xhs-images
|
||||
|
||||
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图,支持 **风格 × 布局** 二维系统。
|
||||
|
||||
@@ -112,7 +111,7 @@ npx add-skill jimliu/baoyu-skills
|
||||
| `comparison` | 双栏 | 对比、优劣 |
|
||||
| `flow` | 3-6 步 | 流程、时间线 |
|
||||
|
||||
### baoyu-cover-image
|
||||
#### baoyu-cover-image
|
||||
|
||||
为文章生成手绘风格封面图,支持多种风格选项。
|
||||
|
||||
@@ -128,9 +127,28 @@ npx add-skill jimliu/baoyu-skills
|
||||
/baoyu-cover-image path/to/article.md --no-title
|
||||
```
|
||||
|
||||
可用风格:`elegant`(默认)、`tech`、`warm`、`bold`、`minimal`、`playful`、`nature`、`retro`
|
||||
可用风格:`elegant`(默认)、`blueprint`、`bold`、`bold-editorial`、`chalkboard`、`dark-atmospheric`、`editorial-infographic`、`fantasy-animation`、`intuition-machine`、`minimal`、`nature`、`notion`、`pixel-art`、`playful`、`retro`、`sketch-notes`、`vector-illustration`、`vintage`、`warm`、`watercolor`
|
||||
|
||||
### baoyu-slide-deck
|
||||
**风格预览**:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| elegant | blueprint | bold |
|
||||
|  |  |  |
|
||||
| bold-editorial | chalkboard | dark-atmospheric |
|
||||
|  |  |  |
|
||||
| editorial-infographic | fantasy-animation | intuition-machine |
|
||||
|  |  |  |
|
||||
| minimal | nature | notion |
|
||||
|  |  |  |
|
||||
| pixel-art | playful | retro |
|
||||
|  |  |  |
|
||||
| sketch-notes | vector-illustration | vintage |
|
||||
|  |  | |
|
||||
| warm | watercolor | |
|
||||
|
||||
#### baoyu-slide-deck
|
||||
|
||||
从内容生成专业的幻灯片图片。先创建包含样式说明的完整大纲,然后逐页生成幻灯片图片。
|
||||
|
||||
@@ -153,20 +171,42 @@ npx add-skill jimliu/baoyu-skills
|
||||
|
||||
| 风格 | 描述 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `notion`(默认) | SaaS 仪表盘美学,卡片式布局,数据展示清晰 | 产品演示、SaaS、生产力工具、B2B |
|
||||
| `blueprint`(默认) | 技术蓝图风格,网格纹理,工程精度 | 架构设计、系统设计 |
|
||||
| `notion` | SaaS 仪表盘美学,卡片式布局,数据清晰 | 产品演示、SaaS、B2B |
|
||||
| `bold-editorial` | 杂志社论风格,粗体排版,深色背景 | 产品发布、主题演讲 |
|
||||
| `corporate` | 海军蓝/金色配色,结构化布局,专业图标 | 投资者演示、客户提案 |
|
||||
| `dark-atmospheric` | 电影级暗色调,发光效果,氛围感 | 娱乐、游戏、创意 |
|
||||
| `editorial-infographic` | 杂志风格信息图,扁平插画 | 科技解说、研究报告 |
|
||||
| `fantasy-animation` | 吉卜力/迪士尼风格,手绘动画 | 教育、故事讲述 |
|
||||
| `intuition-machine` | 技术简报,双语标签,做旧纸张纹理 | 技术文档、双语内容 |
|
||||
| `minimal` | 极简风格,大量留白,单一强调色 | 高管简报、高端品牌 |
|
||||
| `pixel-art` | 复古 8-bit 像素风,怀旧游戏感 | 游戏、开发者分享 |
|
||||
| `scientific` | 学术图表,生物通路,精确标注 | 生物、化学、医学 |
|
||||
| `sketch-notes` | 手绘风格,柔和笔触,暖白色背景 | 教育、教程、知识分享 |
|
||||
| `blueprint` | 技术蓝图风格,网格纹理,工程精度 | 架构设计、系统设计、数据分析 |
|
||||
| `bold-editorial` | 杂志社论风格,粗体排版,深色背景,高冲击力 | 产品发布、营销、主题演讲 |
|
||||
| `vector-illustration` | 扁平矢量风格,黑色轮廓线,复古柔和配色,玩具模型感 | 创意提案、儿童内容、说明性内容 |
|
||||
| `minimal` | 极简风格,大量留白,单一强调色,禅意美学 | 高管简报、主题演讲、高端品牌 |
|
||||
| `storytelling` | 电影风格,全幅视觉,情感化摄影 | 案例研究、叙事、客户旅程 |
|
||||
| `warm` | 柔和渐变,圆润形状,健康生活配色 | 生活方式、健康养生、个人成长 |
|
||||
| `corporate` | 海军蓝/金色配色,结构化布局,专业图标 | 投资者演示、客户提案、季度报告 |
|
||||
| `playful` | 活力珊瑚/青色/黄色,圆润形状,动感布局 | 工作坊、培训、创意提案 |
|
||||
| `vector-illustration` | 扁平矢量风格,黑色轮廓线,复古柔和配色 | 创意提案、说明性内容 |
|
||||
| `vintage` | 做旧纸张美学,历史文档风格 | 历史、传记、人文 |
|
||||
| `watercolor` | 柔和手绘水彩纹理,自然温暖 | 生活方式、健康、旅行 |
|
||||
|
||||
**风格预览**:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| blueprint | chalkboard | bold-editorial |
|
||||
|  |  |  |
|
||||
| corporate | dark-atmospheric | editorial-infographic |
|
||||
|  |  |  |
|
||||
| fantasy-animation | intuition-machine | minimal |
|
||||
|  |  |  |
|
||||
| notion | pixel-art | scientific |
|
||||
|  |  |  |
|
||||
| sketch-notes | vector-illustration | vintage |
|
||||
|  | | |
|
||||
| watercolor | | |
|
||||
|
||||
生成完成后,所有幻灯片会自动合并为 `.pptx` 文件,方便分享。
|
||||
|
||||
### baoyu-comic
|
||||
#### baoyu-comic
|
||||
|
||||
知识漫画创作器,支持多种风格(Logicomix/清线风格、欧姆社漫画教程风格)。创作带有详细分镜布局的原创教育漫画,逐页生成图片。
|
||||
|
||||
@@ -195,7 +235,7 @@ npx add-skill jimliu/baoyu-skills
|
||||
**选项**:
|
||||
| 选项 | 取值 |
|
||||
|------|------|
|
||||
| `--style` | `classic`(默认)、`dramatic`、`warm`、`tech`、`sepia`、`vibrant`、`ohmsha`、`realistic`,或自然语言描述 |
|
||||
| `--style` | `classic`(默认)、`dramatic`、`warm`、`sepia`、`vibrant`、`ohmsha`、`realistic`、`wuxia`,或自然语言描述 |
|
||||
| `--layout` | `standard`(默认)、`cinematic`、`dense`、`splash`、`mixed`、`webtoon` |
|
||||
| `--aspect` | `3:4`(默认,竖版)、`4:3`(横版)、`16:9`(宽屏) |
|
||||
| `--lang` | `auto`(默认)、`zh`、`en`、`ja` 等 |
|
||||
@@ -207,11 +247,22 @@ npx add-skill jimliu/baoyu-skills
|
||||
| `classic`(默认) | 传统清线风格,统一线条、平涂色彩、精细背景 | 传记、平衡叙事、教育内容 |
|
||||
| `dramatic` | 高对比度,重阴影、紧张表情、棱角分明的构图 | 重大发现、冲突、高潮场景 |
|
||||
| `warm` | 柔和边缘、金色调、温馨室内、怀旧感 | 个人故事、童年场景、师生情 |
|
||||
| `tech` | 精确几何线条、电路纹理、深色背景配霓虹色 | 计算机史、AI 故事、现代科技 |
|
||||
| `sepia` | 复古插画风格、做旧纸张效果、时代准确细节 | 1950 年前故事、古典科学、历史人物 |
|
||||
| `vibrant` | 富有活力的线条、明亮色彩、动感姿态 | 科学解说、"顿悟"时刻、青少年读者 |
|
||||
| `ohmsha` | 欧姆社漫画风格,视觉比喻、道具、学生/导师互动 | 技术教程、复杂概念(机器学习、物理) |
|
||||
| `realistic` | 全彩写实日漫风格,数字绘画、平滑渐变、准确人体比例 | 红酒、美食、商业、生活方式、专业话题 |
|
||||
| `wuxia` | 港漫武侠风格,水墨笔触、动态打斗、气功特效 | 武侠、仙侠、中国历史小说 |
|
||||
|
||||
**风格预览**:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| classic | dramatic | warm |
|
||||
|  |  |  |
|
||||
| sepia | vibrant | ohmsha |
|
||||
|  |  | |
|
||||
| realistic | wuxia | |
|
||||
|
||||
**布局**(分镜排列):
|
||||
| 布局 | 每页分镜数 | 适用场景 |
|
||||
@@ -223,7 +274,39 @@ npx add-skill jimliu/baoyu-skills
|
||||
| `mixed` | 3-7 不等 | 复杂叙事、情感弧线 |
|
||||
| `webtoon` | 3-5 竖向 | 欧姆社教程、手机阅读 |
|
||||
|
||||
### baoyu-post-to-wechat
|
||||
**布局预览**:
|
||||
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| standard | cinematic | dense |
|
||||
|  |  |  |
|
||||
| splash | mixed | webtoon |
|
||||
|
||||
#### baoyu-article-illustrator
|
||||
|
||||
智能文章插图技能。分析文章内容,在需要视觉辅助的位置生成插图。
|
||||
|
||||
```bash
|
||||
/baoyu-article-illustrator path/to/article.md
|
||||
```
|
||||
|
||||
#### baoyu-post-to-x
|
||||
|
||||
发布内容和文章到 X (Twitter)。支持带图片的普通帖子和 X 文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。
|
||||
|
||||
```bash
|
||||
# 发布文字
|
||||
/baoyu-post-to-x "Hello from Claude Code!"
|
||||
|
||||
# 发布带图片
|
||||
/baoyu-post-to-x "看看这个" --image photo.png
|
||||
|
||||
# 发布 X 文章
|
||||
/baoyu-post-to-x --article path/to/article.md
|
||||
```
|
||||
|
||||
#### baoyu-post-to-wechat
|
||||
|
||||
发布内容到微信公众号,支持两种模式:
|
||||
|
||||
@@ -245,9 +328,94 @@ npx add-skill jimliu/baoyu-skills
|
||||
|
||||
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
|
||||
### AI 生成技能 (AI Generation Skills)
|
||||
|
||||
AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-danger-gemini-web
|
||||
|
||||
与 Gemini Web 交互,生成文本和图片。
|
||||
|
||||
**文本生成:**
|
||||
|
||||
```bash
|
||||
/baoyu-danger-gemini-web "你好,Gemini"
|
||||
/baoyu-danger-gemini-web --prompt "解释量子计算"
|
||||
```
|
||||
|
||||
**图片生成:**
|
||||
|
||||
```bash
|
||||
/baoyu-danger-gemini-web --prompt "一只可爱的猫" --image cat.png
|
||||
/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
|
||||
### 工具技能 (Utility Skills)
|
||||
|
||||
内容处理工具。
|
||||
|
||||
#### baoyu-danger-x-to-markdown
|
||||
|
||||
将 X (Twitter) 内容转换为 markdown 格式。支持推文串和 X 文章。
|
||||
|
||||
```bash
|
||||
# 将推文转换为 markdown
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456
|
||||
|
||||
# 保存到指定文件
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md
|
||||
|
||||
# JSON 输出
|
||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
||||
```
|
||||
|
||||
**支持的 URL:**
|
||||
- `https://x.com/<user>/status/<id>`
|
||||
- `https://twitter.com/<user>/status/<id>`
|
||||
- `https://x.com/i/article/<id>`
|
||||
|
||||
**身份验证:** 使用环境变量(`X_AUTH_TOKEN`、`X_CT0`)或 Chrome 登录进行 cookie 认证。
|
||||
|
||||
#### baoyu-compress-image
|
||||
|
||||
压缩图片以减小文件大小,同时保持质量。
|
||||
|
||||
```bash
|
||||
/baoyu-compress-image path/to/image.png
|
||||
/baoyu-compress-image path/to/images/ --quality 80
|
||||
```
|
||||
|
||||
## 自定义扩展
|
||||
|
||||
所有技能支持通过 `EXTEND.md` 文件自定义。创建扩展文件可覆盖默认样式、添加自定义配置或定义个人预设。
|
||||
|
||||
**扩展路径**(按优先级检查):
|
||||
1. `.baoyu-skills/<skill-name>/EXTEND.md` - 项目级(团队/项目特定设置)
|
||||
2. `~/.baoyu-skills/<skill-name>/EXTEND.md` - 用户级(个人偏好设置)
|
||||
|
||||
**示例**:为 `baoyu-cover-image` 自定义品牌配色:
|
||||
|
||||
```bash
|
||||
mkdir -p .baoyu-skills/baoyu-cover-image
|
||||
```
|
||||
|
||||
然后创建 `.baoyu-skills/baoyu-cover-image/EXTEND.md`:
|
||||
|
||||
```markdown
|
||||
## 自定义风格
|
||||
|
||||
### brand
|
||||
- 主色:#1a73e8
|
||||
- 辅色:#34a853
|
||||
- 字体风格:现代无衬线
|
||||
- 始终包含公司 logo 水印
|
||||
```
|
||||
|
||||
扩展内容会在技能执行前加载,并覆盖默认设置。
|
||||
|
||||
## 免责声明
|
||||
|
||||
### baoyu-gemini-web
|
||||
### baoyu-danger-gemini-web
|
||||
|
||||
此技能使用 Gemini Web API(逆向工程)。
|
||||
|
||||
@@ -257,6 +425,17 @@ npx add-skill jimliu/baoyu-skills
|
||||
- Cookies 会被缓存供后续使用
|
||||
- 不保证 API 的稳定性或可用性
|
||||
|
||||
### baoyu-danger-x-to-markdown
|
||||
|
||||
此技能使用逆向工程的 X (Twitter) API。
|
||||
|
||||
**警告:** 这不是官方 API。使用风险自负。
|
||||
|
||||
- 如果 X 更改其 API,可能会无预警失效
|
||||
- 如检测到 API 使用,账号可能受限
|
||||
- 首次使用需确认免责声明
|
||||
- 通过环境变量或 Chrome 登录进行身份验证
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -102,43 +102,42 @@ When no `--style` is specified, analyze content to select the best style:
|
||||
|
||||
## File Management
|
||||
|
||||
### With Article Path
|
||||
### Output Directory
|
||||
|
||||
Save illustrations to `[source-name-no-ext]/illustrations/` subdirectory in the same folder as the article:
|
||||
Each session creates an independent directory named by content slug:
|
||||
|
||||
```
|
||||
path/to/
|
||||
├── article.md
|
||||
└── article/
|
||||
└── illustrations/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── illustration-concept-a.md
|
||||
│ ├── illustration-concept-b.md
|
||||
│ └── ...
|
||||
├── illustration-concept-a.png
|
||||
├── illustration-concept-b.png
|
||||
└── ...
|
||||
illustrations/{topic-slug}/
|
||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
||||
├── outline.md
|
||||
├── outline-{style}.md # Style variant outlines
|
||||
├── prompts/
|
||||
│ ├── illustration-concept-a.md
|
||||
│ ├── illustration-concept-b.md
|
||||
│ └── ...
|
||||
├── illustration-concept-a.png
|
||||
├── illustration-concept-b.png
|
||||
└── ...
|
||||
```
|
||||
|
||||
Example: `/posts/ai-future.md` → `/posts/ai-future/illustrations/`
|
||||
**Slug Generation**:
|
||||
1. Extract main topic from content (2-4 words, kebab-case)
|
||||
2. Example: "The Future of AI" → `future-of-ai`
|
||||
|
||||
### Without Article Path (Pasted Content)
|
||||
### Conflict Resolution
|
||||
|
||||
Save to `./illustrations/[topic-slug]/`:
|
||||
If `illustrations/{topic-slug}/` already exists:
|
||||
- Append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
||||
- Example: `ai-future` exists → `ai-future-20260118-143052`
|
||||
|
||||
```
|
||||
illustrations/
|
||||
└── ai-future/
|
||||
├── source.md
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
└── *.png
|
||||
```
|
||||
### Source Files
|
||||
|
||||
### Directory Backup
|
||||
Copy all sources with naming `source-{slug}.{ext}`:
|
||||
- `source-article.md` (main text content)
|
||||
- `source-photo.jpg` (image from conversation)
|
||||
- `source-reference.pdf` (additional file)
|
||||
|
||||
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
Multiple sources supported: text, images, files from conversation.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -449,3 +448,13 @@ Typography: Clean hand-drawn lettering, simple sans-serif labels
|
||||
- Sensitive figures should use cartoon alternatives
|
||||
- Prompts written in user's confirmed language preference
|
||||
- Illustration text (labels, captions) should match article language
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom styles and configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-article-illustrator/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-article-illustrator/EXTEND.md` (user)
|
||||
|
||||
If found, load before Step 1. Extension content overrides defaults.
|
||||
|
||||
@@ -18,7 +18,7 @@ Create original knowledge comics with multiple visual styles.
|
||||
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--style` | classic (default), dramatic, warm, tech, sepia, vibrant, ohmsha, realistic, or custom description |
|
||||
| `--style` | classic (default), dramatic, warm, sepia, vibrant, ohmsha, realistic, wuxia, or custom description |
|
||||
| `--layout` | standard (default), cinematic, dense, splash, mixed, webtoon |
|
||||
| `--aspect` | 3:4 (default, portrait), 4:3 (landscape), 16:9 (widescreen) |
|
||||
| `--lang` | auto (default), zh, en, ja, etc. |
|
||||
@@ -32,11 +32,12 @@ Style × Layout × Aspect can be freely combined. Custom styles can be described
|
||||
| Content Signals | Style | Layout |
|
||||
|-----------------|-------|--------|
|
||||
| Tutorial, how-to, beginner | ohmsha | webtoon |
|
||||
| Computing, AI, programming | tech | dense |
|
||||
| Computing, AI, programming | ohmsha | dense |
|
||||
| Pre-1950, classical, ancient | sepia | cinematic |
|
||||
| Personal story, mentor | warm | standard |
|
||||
| Conflict, breakthrough | dramatic | splash |
|
||||
| Wine, food, business, lifestyle, professional | realistic | cinematic |
|
||||
| Martial arts, wuxia, xianxia, Chinese historical | wuxia | splash |
|
||||
| Biography, balanced | classic | mixed |
|
||||
|
||||
## Script Directory
|
||||
@@ -55,9 +56,11 @@ Style × Layout × Aspect can be freely combined. Custom styles can be described
|
||||
|
||||
## File Structure
|
||||
|
||||
Each session creates an independent directory named by content slug:
|
||||
|
||||
```
|
||||
[target]/
|
||||
├── source.md # Source content (if pasted, not file)
|
||||
comic/{topic-slug}/
|
||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
||||
├── analysis.md # Deep analysis results (YAML+MD)
|
||||
├── storyboard-chronological.md # Variant A (preserved)
|
||||
├── storyboard-thematic.md # Variant B (preserved)
|
||||
@@ -83,13 +86,19 @@ Style × Layout × Aspect can be freely combined. Custom styles can be described
|
||||
└── {topic-slug}.pdf
|
||||
```
|
||||
|
||||
**Target directory**:
|
||||
- With source path: `[source-dir]/[source-name-no-ext]/comic/`
|
||||
- Example: `/posts/turing-story.md` → `/posts/turing-story/comic/`
|
||||
- Without source: `./comic/[topic-slug]/`
|
||||
**Slug Generation**:
|
||||
1. Extract main topic from content (2-4 words, kebab-case)
|
||||
2. Example: "Alan Turing Biography" → `alan-turing-bio`
|
||||
|
||||
**Directory backup**:
|
||||
- If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
**Conflict Resolution**:
|
||||
If `comic/{topic-slug}/` already exists:
|
||||
- Append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
||||
- Example: `turing-story` exists → `turing-story-20260118-143052`
|
||||
|
||||
**Source Files**:
|
||||
Copy all sources with naming `source-{slug}.{ext}`:
|
||||
- `source-biography.md`, `source-portrait.jpg`, `source-timeline.png`, etc.
|
||||
- Multiple sources supported: text, images, files from conversation
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -164,7 +173,7 @@ What readers will gain:
|
||||
## Content Signals
|
||||
|
||||
- "biography" → classic + mixed
|
||||
- "computing history" → tech + dense
|
||||
- "computing history" → ohmsha + dense
|
||||
- "personal tragedy" → dramatic + splash
|
||||
|
||||
## Recommended Approaches
|
||||
@@ -181,7 +190,7 @@ Create three distinct variants, each combining a narrative approach with a recom
|
||||
| Variant | Narrative Approach | Recommended Style | Layout |
|
||||
|---------|-------------------|-------------------|--------|
|
||||
| A | Chronological | sepia | cinematic |
|
||||
| B | Thematic | tech | dense |
|
||||
| B | Thematic | ohmsha | dense |
|
||||
| C | Character-focused | warm | standard |
|
||||
|
||||
**For each variant**:
|
||||
@@ -229,13 +238,13 @@ Create three distinct variants, each combining a narrative approach with a recom
|
||||
```
|
||||
Question 1 (Storyboard): Which storyboard variant?
|
||||
- A: Chronological + sepia (Recommended)
|
||||
- B: Thematic + tech
|
||||
- B: Thematic + ohmsha
|
||||
- C: Character-focused + warm
|
||||
- Custom
|
||||
|
||||
Question 2 (Style): Which visual style?
|
||||
- sepia (Recommended from variant)
|
||||
- classic / dramatic / warm / tech / vibrant / ohmsha / realistic
|
||||
- classic / dramatic / warm / sepia / vibrant / ohmsha / realistic / wuxia
|
||||
- Custom description
|
||||
|
||||
Question 3 (Language) - only if mismatch:
|
||||
@@ -388,3 +397,13 @@ Detailed templates and guidelines in `references/` directory:
|
||||
- `ohmsha-guide.md` - Ohmsha manga style specifics
|
||||
- `styles/` - Detailed style definitions
|
||||
- `layouts/` - Detailed layout definitions
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom styles and configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-comic/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-comic/EXTEND.md` (user)
|
||||
|
||||
If found, load before Step 1. Extension content overrides defaults.
|
||||
|
||||
@@ -20,4 +20,4 @@ Technical explanations, complex narratives, timelines
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
tech, ohmsha, vibrant
|
||||
ohmsha, vibrant
|
||||
|
||||
@@ -27,4 +27,4 @@ Ohmsha-style tutorials, mobile reading, step-by-step guides
|
||||
|
||||
## Best Style Pairings
|
||||
|
||||
ohmsha, tech, vibrant
|
||||
ohmsha, vibrant
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# tech
|
||||
|
||||
Modern, digital-age narratives
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- 2px, precise geometric undertones
|
||||
- Clean, technical precision
|
||||
- Circuit-like patterns in backgrounds
|
||||
|
||||
### Character Design
|
||||
- Contemporary clothing, focused expressions
|
||||
- Modern tech accessories
|
||||
- Clean, precise features
|
||||
|
||||
### Background Treatment
|
||||
- Digital elements, screens, circuit motifs
|
||||
- Grid patterns
|
||||
- Glowing interface elements
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Cyan (#00D4FF), deep blue (#1A365D), white
|
||||
- Accents: Neon green (#00FF88), electric purple (#805AD5)
|
||||
- Background: Dark gray (#1A202C), grid patterns
|
||||
|
||||
## Mood
|
||||
|
||||
Innovation, digital, contemporary
|
||||
|
||||
## Best For
|
||||
|
||||
Computing history, AI stories, modern tech
|
||||
@@ -0,0 +1,54 @@
|
||||
# wuxia
|
||||
|
||||
Hong Kong martial arts comic style (港漫武侠风格)
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
### Line Work
|
||||
- 2-3px dynamic brush strokes with varying weight
|
||||
- Ink wash effects, traditional Chinese brush feel
|
||||
- Speed lines and impact effects for action scenes
|
||||
- Bold, confident strokes with sharp edges
|
||||
|
||||
### Character Design
|
||||
- Realistic human proportions (7.5-8 head heights)
|
||||
- Defined musculature, dynamic warrior poses
|
||||
- Flowing hair and clothing in motion
|
||||
- Intense, determined facial expressions
|
||||
- Traditional Chinese martial arts attire (robes, armor)
|
||||
|
||||
### Background Treatment
|
||||
- Dramatic landscapes: mountains, waterfalls, temples
|
||||
- Ink wash atmospheric effects
|
||||
- Flying debris, dust clouds during combat
|
||||
- Energy/qi effects with flowing lines
|
||||
- High contrast silhouettes
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Deep black ink, crimson red (#8B0000), gold (#D4AF37)
|
||||
- Skin: Natural tan with dramatic shadows
|
||||
- Background: Misty grays, earth tones, ink wash gradients
|
||||
- Effects: Glowing qi energy (blue, gold, white)
|
||||
|
||||
## Special Effects
|
||||
|
||||
- **Combat impact**: Radiating lines, shockwaves, debris
|
||||
- **Qi/energy**: Flowing aura, glowing effects around characters
|
||||
- **Movement**: Extreme speed lines, motion blur
|
||||
- **Atmosphere**: Ink wash clouds, mist, flying leaves/petals
|
||||
|
||||
## Quality Markers
|
||||
|
||||
A good wuxia comic page exhibits:
|
||||
- ✓ Dynamic action poses with sense of motion
|
||||
- ✓ Ink brush aesthetic in line work
|
||||
- ✓ High contrast dramatic lighting
|
||||
- ✓ Atmospheric backgrounds with Chinese elements
|
||||
- ✓ Energy/qi effects that feel powerful
|
||||
- ✓ Flowing fabric and hair movement
|
||||
- ✓ Impactful combat moments
|
||||
|
||||
## Best For
|
||||
|
||||
Martial arts stories, Chinese historical fiction, action-heavy narratives, wuxia/xianxia adaptations
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
name: baoyu-compress-image
|
||||
description: Cross-platform image compression skill. Converts images to WebP by default with PNG-to-PNG support. Uses system tools (sips, cwebp, ImageMagick) with Sharp fallback.
|
||||
---
|
||||
|
||||
# Image Compressor
|
||||
|
||||
Cross-platform image compression with WebP default output, PNG-to-PNG support, preferring system tools with Sharp fallback.
|
||||
|
||||
## Script Directory
|
||||
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/main.ts` | CLI entry point for image compression |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Compress to WebP (default)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png
|
||||
|
||||
# Keep original format (PNG → PNG)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --format png
|
||||
|
||||
# Custom quality
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -q 75
|
||||
|
||||
# Process directory
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Single File Compression
|
||||
|
||||
```bash
|
||||
# Basic (converts to WebP, replaces original)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png
|
||||
|
||||
# Custom output path
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -o compressed.webp
|
||||
|
||||
# Keep original file
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --keep
|
||||
|
||||
# Custom quality (0-100, default: 80)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -q 75
|
||||
|
||||
# Keep original format
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -f png
|
||||
```
|
||||
|
||||
### Directory Processing
|
||||
|
||||
```bash
|
||||
# Process all images in directory
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/
|
||||
|
||||
# Recursive processing
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r
|
||||
|
||||
# With custom quality
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r -q 75
|
||||
```
|
||||
|
||||
### Output Formats
|
||||
|
||||
```bash
|
||||
# Plain text (default)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png
|
||||
|
||||
# JSON output
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Short | Description | Default |
|
||||
|--------|-------|-------------|---------|
|
||||
| `<input>` | | Input file or directory | Required |
|
||||
| `--output <path>` | `-o` | Output path | Same path, new extension |
|
||||
| `--format <fmt>` | `-f` | webp, png, jpeg | webp |
|
||||
| `--quality <n>` | `-q` | Quality 0-100 | 80 |
|
||||
| `--keep` | `-k` | Keep original file | false |
|
||||
| `--recursive` | `-r` | Process directories recursively | false |
|
||||
| `--json` | | JSON output | false |
|
||||
| `--help` | `-h` | Show help | |
|
||||
|
||||
## Compressor Selection
|
||||
|
||||
Priority order (auto-detected):
|
||||
|
||||
1. **sips** (macOS built-in, WebP support since macOS 11)
|
||||
2. **cwebp** (Google's official WebP tool)
|
||||
3. **ImageMagick** (`convert` command)
|
||||
4. **Sharp** (npm package, auto-installed by Bun)
|
||||
|
||||
The skill automatically selects the best available compressor.
|
||||
|
||||
## Output Format
|
||||
|
||||
### Text Mode (default)
|
||||
|
||||
```
|
||||
image.png → image.webp (245KB → 89KB, 64% reduction)
|
||||
```
|
||||
|
||||
### JSON Mode
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "image.png",
|
||||
"output": "image.webp",
|
||||
"inputSize": 250880,
|
||||
"outputSize": 91136,
|
||||
"ratio": 0.36,
|
||||
"compressor": "sips"
|
||||
}
|
||||
```
|
||||
|
||||
### Directory JSON Mode
|
||||
|
||||
```json
|
||||
{
|
||||
"files": [...],
|
||||
"summary": {
|
||||
"totalFiles": 10,
|
||||
"totalInputSize": 2508800,
|
||||
"totalOutputSize": 911360,
|
||||
"ratio": 0.36,
|
||||
"compressor": "sips"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Compress single image
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts photo.png
|
||||
# photo.png → photo.webp (1.2MB → 340KB, 72% reduction)
|
||||
```
|
||||
|
||||
### Compress with custom quality
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts photo.png -q 60
|
||||
# photo.png → photo.webp (1.2MB → 280KB, 77% reduction)
|
||||
```
|
||||
|
||||
### Keep original format
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts screenshot.png -f png --keep
|
||||
# screenshot.png → screenshot-compressed.png (500KB → 380KB, 24% reduction)
|
||||
```
|
||||
|
||||
### Process entire directory
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./screenshots/ -r
|
||||
# Processed 15 files: 12.5MB → 4.2MB (66% reduction)
|
||||
```
|
||||
|
||||
### Get JSON for scripting
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --json | jq '.ratio'
|
||||
```
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-compress-image/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-compress-image/EXTEND.md` (user)
|
||||
|
||||
If found, load before workflow. Extension content overrides defaults.
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env bun
|
||||
import { existsSync, statSync, readdirSync, unlinkSync, renameSync } from "fs";
|
||||
import { basename, dirname, extname, join, resolve } from "path";
|
||||
import { spawn } from "child_process";
|
||||
|
||||
type Compressor = "sips" | "cwebp" | "imagemagick" | "sharp";
|
||||
type Format = "webp" | "png" | "jpeg";
|
||||
|
||||
interface Options {
|
||||
input: string;
|
||||
output?: string;
|
||||
format: Format;
|
||||
quality: number;
|
||||
keep: boolean;
|
||||
recursive: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
input: string;
|
||||
output: string;
|
||||
inputSize: number;
|
||||
outputSize: number;
|
||||
ratio: number;
|
||||
compressor: Compressor;
|
||||
}
|
||||
|
||||
const SUPPORTED_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff"];
|
||||
|
||||
async function commandExists(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
const proc = spawn("which", [cmd], { stdio: "pipe" });
|
||||
return new Promise((res) => {
|
||||
proc.on("close", (code) => res(code === 0));
|
||||
proc.on("error", () => res(false));
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectCompressor(format: Format): Promise<Compressor> {
|
||||
if (format === "webp") {
|
||||
if (await commandExists("cwebp")) return "cwebp";
|
||||
if (await commandExists("convert")) return "imagemagick";
|
||||
return "sharp";
|
||||
}
|
||||
if (process.platform === "darwin") return "sips";
|
||||
if (await commandExists("convert")) return "imagemagick";
|
||||
return "sharp";
|
||||
}
|
||||
|
||||
function runCmd(cmd: string, args: string[]): Promise<{ code: number; stderr: string }> {
|
||||
return new Promise((res) => {
|
||||
const proc = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let stderr = "";
|
||||
proc.stderr?.on("data", (d) => (stderr += d.toString()));
|
||||
proc.on("close", (code) => res({ code: code ?? 1, stderr }));
|
||||
proc.on("error", (e) => res({ code: 1, stderr: e.message }));
|
||||
});
|
||||
}
|
||||
|
||||
async function compressWithSips(input: string, output: string, format: Format, quality: number): Promise<void> {
|
||||
const fmt = format === "jpeg" ? "jpeg" : format;
|
||||
const args = ["-s", "format", fmt, "-s", "formatOptions", String(quality), input, "--out", output];
|
||||
const { code, stderr } = await runCmd("sips", args);
|
||||
if (code !== 0) throw new Error(`sips failed: ${stderr}`);
|
||||
}
|
||||
|
||||
async function compressWithCwebp(input: string, output: string, quality: number): Promise<void> {
|
||||
const args = ["-q", String(quality), input, "-o", output];
|
||||
const { code, stderr } = await runCmd("cwebp", args);
|
||||
if (code !== 0) throw new Error(`cwebp failed: ${stderr}`);
|
||||
}
|
||||
|
||||
async function compressWithImagemagick(input: string, output: string, quality: number): Promise<void> {
|
||||
const args = [input, "-quality", String(quality), output];
|
||||
const { code, stderr } = await runCmd("convert", args);
|
||||
if (code !== 0) throw new Error(`convert failed: ${stderr}`);
|
||||
}
|
||||
|
||||
async function compressWithSharp(input: string, output: string, format: Format, quality: number): Promise<void> {
|
||||
const sharp = (await import("sharp")).default;
|
||||
let pipeline = sharp(input);
|
||||
if (format === "webp") pipeline = pipeline.webp({ quality });
|
||||
else if (format === "png") pipeline = pipeline.png({ quality });
|
||||
else if (format === "jpeg") pipeline = pipeline.jpeg({ quality });
|
||||
await pipeline.toFile(output);
|
||||
}
|
||||
|
||||
async function compress(
|
||||
compressor: Compressor,
|
||||
input: string,
|
||||
output: string,
|
||||
format: Format,
|
||||
quality: number
|
||||
): Promise<void> {
|
||||
switch (compressor) {
|
||||
case "sips":
|
||||
await compressWithSips(input, output, format, quality);
|
||||
break;
|
||||
case "cwebp":
|
||||
if (format !== "webp") {
|
||||
await compressWithSharp(input, output, format, quality);
|
||||
} else {
|
||||
await compressWithCwebp(input, output, quality);
|
||||
}
|
||||
break;
|
||||
case "imagemagick":
|
||||
await compressWithImagemagick(input, output, quality);
|
||||
break;
|
||||
case "sharp":
|
||||
await compressWithSharp(input, output, format, quality);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function getOutputPath(input: string, format: Format, keep: boolean, customOutput?: string): string {
|
||||
if (customOutput) return resolve(customOutput);
|
||||
const dir = dirname(input);
|
||||
const base = basename(input, extname(input));
|
||||
const ext = format === "jpeg" ? ".jpg" : `.${format}`;
|
||||
if (keep && extname(input).toLowerCase() === ext) {
|
||||
return join(dir, `${base}-compressed${ext}`);
|
||||
}
|
||||
return join(dir, `${base}${ext}`);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
async function processFile(
|
||||
compressor: Compressor,
|
||||
input: string,
|
||||
opts: Options
|
||||
): Promise<Result> {
|
||||
const absInput = resolve(input);
|
||||
const inputSize = statSync(absInput).size;
|
||||
const output = getOutputPath(absInput, opts.format, opts.keep, opts.output);
|
||||
const tempOutput = output + ".tmp";
|
||||
|
||||
await compress(compressor, absInput, tempOutput, opts.format, opts.quality);
|
||||
|
||||
const outputSize = statSync(tempOutput).size;
|
||||
|
||||
if (!opts.keep && absInput !== output) {
|
||||
unlinkSync(absInput);
|
||||
}
|
||||
renameSync(tempOutput, output);
|
||||
|
||||
return {
|
||||
input: absInput,
|
||||
output,
|
||||
inputSize,
|
||||
outputSize,
|
||||
ratio: outputSize / inputSize,
|
||||
compressor,
|
||||
};
|
||||
}
|
||||
|
||||
function collectFiles(dir: string, recursive: boolean): string[] {
|
||||
const files: string[] = [];
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory() && recursive) {
|
||||
files.push(...collectFiles(full, recursive));
|
||||
} else if (entry.isFile() && SUPPORTED_EXTS.includes(extname(entry.name).toLowerCase())) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: bun main.ts <input> [options]
|
||||
|
||||
Options:
|
||||
-o, --output <path> Output path
|
||||
-f, --format <fmt> Output format: webp, png, jpeg (default: webp)
|
||||
-q, --quality <n> Quality 0-100 (default: 80)
|
||||
-k, --keep Keep original file
|
||||
-r, --recursive Process directories recursively
|
||||
--json JSON output
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Options | null {
|
||||
const opts: Options = {
|
||||
input: "",
|
||||
format: "webp",
|
||||
quality: 80,
|
||||
keep: false,
|
||||
recursive: false,
|
||||
json: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else if (arg === "-o" || arg === "--output") {
|
||||
opts.output = args[++i];
|
||||
} else if (arg === "-f" || arg === "--format") {
|
||||
const fmt = args[++i]?.toLowerCase();
|
||||
if (fmt === "webp" || fmt === "png" || fmt === "jpeg" || fmt === "jpg") {
|
||||
opts.format = fmt === "jpg" ? "jpeg" : (fmt as Format);
|
||||
} else {
|
||||
console.error(`Invalid format: ${fmt}`);
|
||||
return null;
|
||||
}
|
||||
} else if (arg === "-q" || arg === "--quality") {
|
||||
const q = parseInt(args[++i], 10);
|
||||
if (isNaN(q) || q < 0 || q > 100) {
|
||||
console.error(`Invalid quality: ${args[i]}`);
|
||||
return null;
|
||||
}
|
||||
opts.quality = q;
|
||||
} else if (arg === "-k" || arg === "--keep") {
|
||||
opts.keep = true;
|
||||
} else if (arg === "-r" || arg === "--recursive") {
|
||||
opts.recursive = true;
|
||||
} else if (arg === "--json") {
|
||||
opts.json = true;
|
||||
} else if (!arg.startsWith("-") && !opts.input) {
|
||||
opts.input = arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.input) {
|
||||
console.error("Error: Input file or directory required");
|
||||
printHelp();
|
||||
return null;
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts = parseArgs(args);
|
||||
if (!opts) process.exit(1);
|
||||
|
||||
const input = resolve(opts.input);
|
||||
if (!existsSync(input)) {
|
||||
console.error(`Error: ${input} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const compressor = await detectCompressor(opts.format);
|
||||
const isDir = statSync(input).isDirectory();
|
||||
|
||||
if (isDir) {
|
||||
const files = collectFiles(input, opts.recursive);
|
||||
if (files.length === 0) {
|
||||
console.error("No supported images found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results: Result[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const r = await processFile(compressor, file, { ...opts, output: undefined });
|
||||
results.push(r);
|
||||
if (!opts.json) {
|
||||
const reduction = Math.round((1 - r.ratio) * 100);
|
||||
console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!opts.json) console.error(`Error processing ${file}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
const totalInput = results.reduce((s, r) => s + r.inputSize, 0);
|
||||
const totalOutput = results.reduce((s, r) => s + r.outputSize, 0);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
files: results,
|
||||
summary: {
|
||||
totalFiles: results.length,
|
||||
totalInputSize: totalInput,
|
||||
totalOutputSize: totalOutput,
|
||||
ratio: totalInput > 0 ? totalOutput / totalInput : 0,
|
||||
compressor,
|
||||
},
|
||||
}, null, 2)
|
||||
);
|
||||
} else {
|
||||
const totalInput = results.reduce((s, r) => s + r.inputSize, 0);
|
||||
const totalOutput = results.reduce((s, r) => s + r.outputSize, 0);
|
||||
const reduction = Math.round((1 - totalOutput / totalInput) * 100);
|
||||
console.log(`\nProcessed ${results.length} files: ${formatSize(totalInput)} → ${formatSize(totalOutput)} (${reduction}% reduction)`);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const r = await processFile(compressor, input, opts);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
} else {
|
||||
const reduction = Math.round((1 - r.ratio) * 100);
|
||||
console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -14,9 +14,9 @@ Generate hand-drawn style cover images for articles with multiple style options.
|
||||
/baoyu-cover-image path/to/article.md
|
||||
|
||||
# Specify a style
|
||||
/baoyu-cover-image path/to/article.md --style tech
|
||||
/baoyu-cover-image path/to/article.md --style blueprint
|
||||
/baoyu-cover-image path/to/article.md --style warm
|
||||
/baoyu-cover-image path/to/article.md --style bold
|
||||
/baoyu-cover-image path/to/article.md --style dark-atmospheric
|
||||
|
||||
# Without title text
|
||||
/baoyu-cover-image path/to/article.md --no-title
|
||||
@@ -47,13 +47,25 @@ Generate hand-drawn style cover images for articles with multiple style options.
|
||||
| Style | Description |
|
||||
|-------|-------------|
|
||||
| `elegant` (Default) | Refined, sophisticated, understated |
|
||||
| `tech` | Modern, clean, futuristic |
|
||||
| `warm` | Friendly, approachable, human-centered |
|
||||
| `blueprint` | Technical schematics, engineering precision |
|
||||
| `bold` | High contrast, attention-grabbing, energetic |
|
||||
| `bold-editorial` | Magazine cover impact, dramatic typography |
|
||||
| `chalkboard` | Black chalkboard, colorful chalk drawings |
|
||||
| `dark-atmospheric` | Cinematic dark mode, glowing accents |
|
||||
| `editorial-infographic` | Magazine explainer, visual storytelling |
|
||||
| `fantasy-animation` | Ghibli/Disney inspired, whimsical charm |
|
||||
| `intuition-machine` | Technical briefing, bilingual labels |
|
||||
| `minimal` | Ultra-clean, zen-like, focused |
|
||||
| `playful` | Fun, creative, whimsical |
|
||||
| `nature` | Organic, calm, earthy |
|
||||
| `retro` | Vintage, nostalgic, classic |
|
||||
| `notion` | Clean SaaS dashboard, productivity styling |
|
||||
| `pixel-art` | Retro 8-bit, nostalgic gaming aesthetic |
|
||||
| `playful` | Fun, creative, whimsical |
|
||||
| `retro` | Halftone dots, vintage badges, classic |
|
||||
| `sketch-notes` | Hand-drawn, educational, warm |
|
||||
| `vector-illustration` | Flat vector, black outlines, retro colors |
|
||||
| `vintage` | Aged paper, historical, expedition style |
|
||||
| `warm` | Friendly, approachable, human-centered |
|
||||
| `watercolor` | Soft hand-painted, natural warmth |
|
||||
|
||||
Detailed style definitions: `references/styles/<style>.md`
|
||||
|
||||
@@ -63,49 +75,58 @@ When no `--style` is specified, the system analyzes content to select the best s
|
||||
|
||||
| Content Signals | Selected Style |
|
||||
|----------------|----------------|
|
||||
| AI, coding, tech, digital, algorithm | `tech` |
|
||||
| Architecture, system design, engineering | `blueprint` |
|
||||
| Product launch, keynote, marketing, brand | `bold-editorial` |
|
||||
| Education, classroom, tutorial, teaching | `chalkboard` |
|
||||
| Entertainment, creative, premium, cinematic | `dark-atmospheric` |
|
||||
| Technology explainer, science, research | `editorial-infographic` |
|
||||
| Storytelling, children, fantasy, magical | `fantasy-animation` |
|
||||
| Technical docs, academic, bilingual | `intuition-machine` |
|
||||
| Personal story, emotion, growth, life | `warm` |
|
||||
| Controversial, urgent, must-read, warning | `bold` |
|
||||
| Simple, zen, focus, essential | `minimal` |
|
||||
| Fun, easy, beginner, casual, tutorial | `playful` |
|
||||
| Fun, easy, beginner, casual | `playful` |
|
||||
| Nature, eco, wellness, health, organic | `nature` |
|
||||
| History, classic, vintage, old, traditional | `retro` |
|
||||
| Pop culture, 80s/90s nostalgia, badges | `retro` |
|
||||
| Product, SaaS, dashboard, productivity | `notion` |
|
||||
| Gaming, retro tech, developer, 8-bit | `pixel-art` |
|
||||
| Educational, tutorial, knowledge sharing | `sketch-notes` |
|
||||
| Creative proposals, brand, toy-like | `vector-illustration` |
|
||||
| History, exploration, heritage, biography | `vintage` |
|
||||
| Lifestyle, travel, food, personal | `watercolor` |
|
||||
| Business, professional, strategy, analysis | `elegant` |
|
||||
|
||||
## File Management
|
||||
|
||||
### With Article Path
|
||||
### Output Directory
|
||||
|
||||
Save to `[source-name-no-ext]/cover-image/` subdirectory in the same folder as the article:
|
||||
Each session creates an independent directory named by content slug:
|
||||
|
||||
```
|
||||
path/to/
|
||||
├── article.md
|
||||
└── article/
|
||||
└── cover-image/
|
||||
├── prompts/
|
||||
│ └── cover.md
|
||||
└── cover.png
|
||||
cover-image/{topic-slug}/
|
||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
||||
├── prompts/
|
||||
│ └── cover.md
|
||||
└── cover.png
|
||||
```
|
||||
|
||||
Example: `/posts/ai-future.md` → `/posts/ai-future/cover-image/`
|
||||
**Slug Generation**:
|
||||
1. Extract main topic from content (2-4 words, kebab-case)
|
||||
2. Example: "The Future of AI" → `future-of-ai`
|
||||
|
||||
### Without Article Path (Pasted Content)
|
||||
### Conflict Resolution
|
||||
|
||||
Save to `./cover-image/[topic-slug]/`:
|
||||
If `cover-image/{topic-slug}/` already exists:
|
||||
- Append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
||||
- Example: `ai-future` exists → `ai-future-20260118-143052`
|
||||
|
||||
```
|
||||
cover-image/
|
||||
└── ai-future/
|
||||
├── source.md # Saved pasted content
|
||||
├── prompts/
|
||||
│ └── cover.md
|
||||
└── cover.png
|
||||
```
|
||||
### Source Files
|
||||
|
||||
### Directory Backup
|
||||
Copy all sources with naming `source-{slug}.{ext}`:
|
||||
- `source-article.md` (main text content)
|
||||
- `source-logo.png` (image from conversation)
|
||||
|
||||
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
Multiple sources supported: text, images, files from conversation.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -252,3 +273,13 @@ Preview the image to verify it matches your expectations.
|
||||
- Image generation typically takes 10-30 seconds
|
||||
- Title text uses user's confirmed language preference
|
||||
- Aspect ratio: 2.35:1 for cinematic/dramatic, 16:9 for widescreen, 1:1 for social media
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom styles and configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-cover-image/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-cover-image/EXTEND.md` (user)
|
||||
|
||||
If found, load before Step 1. Extension content overrides defaults.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# blueprint
|
||||
|
||||
Precise technical blueprint style with engineering aesthetic
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Engineering Blue (#2563EB), Navy Blue (#1E3A5F)
|
||||
- Background: Blueprint Off-White (#FAF8F5), subtle grid overlay
|
||||
- Accents: Amber (#F59E0B), Light Blue (#BFDBFE)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Precise lines with consistent stroke weights
|
||||
- Technical schematics and clean vector graphics
|
||||
- Dimension lines and measurement indicators
|
||||
- Grid alignment for all elements
|
||||
- Geometric precision for all shapes
|
||||
|
||||
## Typography
|
||||
|
||||
- Clean sans-serif hand lettering, technical and authoritative
|
||||
|
||||
## Best For
|
||||
|
||||
Technical architecture, system design, data analysis, engineering documentation
|
||||
@@ -0,0 +1,26 @@
|
||||
# bold-editorial
|
||||
|
||||
High-impact magazine editorial with bold visual expression
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Pure White (#FFFFFF), Electric Blue (#3B82F6), Bright Orange (#FB923C)
|
||||
- Background: Deep Black (#0A0A0A), Deep Blue (#0F172A)
|
||||
- Accents: Magenta (#EC4899), Neon Green (#22C55E), Violet (#8B5CF6)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Strong typography as visual element itself
|
||||
- Geometric shapes and bold color blocks
|
||||
- Full-bleed solid color backgrounds
|
||||
- Dynamic diagonal lines and angles
|
||||
- Dramatic lighting effects on text
|
||||
- Minimal decoration, maximum impact
|
||||
|
||||
## Typography
|
||||
|
||||
- Bold condensed typeface, oversized headlines, all-caps, tight letter-spacing
|
||||
|
||||
## Best For
|
||||
|
||||
Product launches, marketing, keynote speeches, brand showcases, investor pitches
|
||||
@@ -0,0 +1,26 @@
|
||||
# chalkboard
|
||||
|
||||
Black chalkboard background with colorful chalk drawing style
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Chalk White (#F5F5F5), Chalk Yellow (#FFE566), Chalk Pink (#FF9999)
|
||||
- Background: Chalkboard Black (#1A1A1A), Dark Green-Black (#1C2B1C)
|
||||
- Accents: Chalk Blue (#66B3FF), Chalk Green (#90EE90), Chalk Orange (#FFB366)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Chalkboard texture with subtle dust and smudges
|
||||
- Hand-drawn chalk illustrations with sketchy lines
|
||||
- Chalk dust effects around text and drawings
|
||||
- Imperfect lines with visible texture
|
||||
- Doodles, arrows, underlines, stars
|
||||
- Eraser marks and chalk residue
|
||||
|
||||
## Typography
|
||||
|
||||
- Hand-drawn chalk lettering, slightly uneven, with chalk texture
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, tutorials, classroom themes, back-to-school, teaching materials
|
||||
@@ -0,0 +1,25 @@
|
||||
# dark-atmospheric
|
||||
|
||||
Dark moody aesthetic with glowing accent elements
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Electric Purple (#8B5CF6), Cyan Blue (#06B6D4), Magenta Pink (#EC4899)
|
||||
- Background: Deep Purple-Black (#0D0D1A), Rich Navy (#1A1A2E)
|
||||
- Accents: Amber (#F59E0B), Pure White (#FFFFFF)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Glowing accent elements and borders
|
||||
- Subtle gradient backgrounds
|
||||
- Atmospheric fog or particle effects
|
||||
- Neon-style highlights on key elements
|
||||
- Silhouettes with backlit edges
|
||||
|
||||
## Typography
|
||||
|
||||
- Elegant serif or refined sans-serif in light/white with subtle glow
|
||||
|
||||
## Best For
|
||||
|
||||
Entertainment, creative industries, premium brands, cinematic storytelling
|
||||
@@ -0,0 +1,26 @@
|
||||
# editorial-infographic
|
||||
|
||||
Modern magazine-style editorial with clear visual storytelling
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Near Black (#1A1A1A), Editorial Blue (#2563EB)
|
||||
- Background: Pure White (#FFFFFF), Light Gray (#F8F9FA)
|
||||
- Accents: Coral (#F97316), Emerald (#10B981), Amber (#F59E0B)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Clean flat illustrations (not photos)
|
||||
- Structured multi-section layouts
|
||||
- Callout boxes for key insights
|
||||
- Icon-based data visualization
|
||||
- Visual metaphors for abstract concepts
|
||||
- Pull quotes and highlight boxes
|
||||
|
||||
## Typography
|
||||
|
||||
- Bold display serif or modern sans-serif, editorial sophistication
|
||||
|
||||
## Best For
|
||||
|
||||
Technology explainers, science communication, research summaries, thought leadership
|
||||
@@ -0,0 +1,25 @@
|
||||
# fantasy-animation
|
||||
|
||||
Whimsical hand-drawn animation style inspired by Ghibli and Disney
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Golden Yellow (#F4D03F), Rose Pink (#E8A0BF), Deep Forest (#2D5A3D)
|
||||
- Background: Soft Sky Blue (#E8F4FC), Warm Cream (#FFF8E7)
|
||||
- Accents: Sage Green (#87A96B), Sky Blue (#7EC8E3), Coral (#F08080)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Central illustrated character (friendly, expressive)
|
||||
- Magical floating objects (books, orbs, sparkles)
|
||||
- Storybook-style environment backgrounds
|
||||
- Soft shadows and gentle highlights
|
||||
- Decorative elements: stars, sparkles, flowers, leaves
|
||||
|
||||
## Typography
|
||||
|
||||
- Whimsical serif or decorative hand-lettered style, organic feel
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, storytelling, creative workshops, fantasy/gaming content
|
||||
@@ -0,0 +1,26 @@
|
||||
# intuition-machine
|
||||
|
||||
Technical briefing style with aged paper and bilingual labels
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Dark Maroon (#5D3A3A), Teal (#2F7373)
|
||||
- Background: Aged Cream (#F5F0E6), subtle paper texture with light creases
|
||||
- Accents: Warm Brown (#8B7355), Maroon (#722F37), Deep Charcoal (#2D2D2D)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Isometric 3D technical illustrations or flat 2D diagrams
|
||||
- Bilingual callout labels (English + Chinese)
|
||||
- Faded thematic background patterns
|
||||
- Clean black outlines on all elements
|
||||
- Split or triptych layouts
|
||||
- Key quote box at bottom
|
||||
|
||||
## Typography
|
||||
|
||||
- Bold display font in dark maroon, ALL CAPS for titles, bilingual labels
|
||||
|
||||
## Best For
|
||||
|
||||
Technical explanations, academic presentations, bilingual audiences, knowledge docs
|
||||
@@ -0,0 +1,25 @@
|
||||
# notion
|
||||
|
||||
Clean SaaS dashboard aesthetic with productivity tool styling
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Near Black (#1F1F1F), Notion Blue (#2383E2)
|
||||
- Background: Light Gray (#F7F7F5), Pure White (#FFFFFF)
|
||||
- Accents: Success Green (#0F7B6C), Alert Red (#E03E3E), Warning Yellow (#DFAB01)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Card-based layouts with subtle borders
|
||||
- Clean data visualizations
|
||||
- Tag and label chips
|
||||
- Icon-based navigation hints
|
||||
- Progress bars and metric displays
|
||||
|
||||
## Typography
|
||||
|
||||
- System UI or Inter style, clean and functional
|
||||
|
||||
## Best For
|
||||
|
||||
Product demos, SaaS presentations, productivity content, B2B topics
|
||||
@@ -0,0 +1,26 @@
|
||||
# pixel-art
|
||||
|
||||
Retro 8-bit pixel art aesthetic with nostalgic gaming style
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Dark Navy (#1A1A2E), Pixel Green (#00FF00)
|
||||
- Background: Light Blue (#87CEEB), Soft Lavender (#E6E6FA)
|
||||
- Accents: Pixel Red (#FF0000), Pixel Yellow (#FFFF00), Pixel Cyan (#00FFFF)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- All elements with visible pixel structure
|
||||
- Simple iconography: stars, hearts, arrows
|
||||
- Text bubbles with pixel borders
|
||||
- Progress bars with chunky segments
|
||||
- Dithering patterns for gradients
|
||||
- Limited 16-32 color palette
|
||||
|
||||
## Typography
|
||||
|
||||
- Pixelated bitmap font style, chunky blocky letterforms
|
||||
|
||||
## Best For
|
||||
|
||||
Gaming content, tech tutorials, developer talks, retro-themed topics
|
||||
@@ -0,0 +1,25 @@
|
||||
# sketch-notes
|
||||
|
||||
Soft hand-drawn illustration style with fresh minimalist aesthetic
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Deep Charcoal (#2C3E50), Soft Orange (#F4A261)
|
||||
- Background: Warm Off-White (#FAF8F0), subtle paper grain
|
||||
- Accents: Mustard Yellow (#E9C46A), Sage Green (#87A96B), Light Blue (#7EC8E3)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Connection lines with hand-drawn wavy feel
|
||||
- Conceptual abstract icons illustrating ideas
|
||||
- Color fills with hand-painted casual feel
|
||||
- Doodle-style decorative elements
|
||||
- Arrows and pointers with sketchy style
|
||||
|
||||
## Typography
|
||||
|
||||
- Bold hand-written marker font for headlines, casual handwriting for body
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, knowledge sharing, technical explanations, tutorials
|
||||
@@ -1,23 +0,0 @@
|
||||
# tech
|
||||
|
||||
Modern, clean, futuristic
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Deep blue (#1A365D), electric cyan (#00D4FF), purple (#6B46C1)
|
||||
- Background: Dark gray (#1A202C), near-black (#0D1117)
|
||||
- Accents: Neon green (#00FF88), bright white
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Circuit patterns, data nodes
|
||||
- Geometric grids, code snippets
|
||||
- Glowing effects
|
||||
|
||||
## Typography
|
||||
|
||||
- Monospace-style hand lettering, glowing effects
|
||||
|
||||
## Best For
|
||||
|
||||
AI, programming, technology, digital transformation
|
||||
@@ -0,0 +1,25 @@
|
||||
# vector-illustration
|
||||
|
||||
Flat vector illustration with black outlines and retro soft colors
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Coral Red (#E07A5F), Mint Green (#81B29A), Mustard Yellow (#F2CC8F)
|
||||
- Background: Cream Off-White (#F5F0E6), subtle paper texture
|
||||
- Accents: Burnt Orange (#D4764A), Rock Blue (#577590), Deep Charcoal (#2D2D2D) outlines
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- All objects have closed black outlines (coloring book style)
|
||||
- Rounded line endings, avoid sharp corners
|
||||
- Simplified geometric shapes
|
||||
- 2.5D perspective with layering
|
||||
- Decorative elements: sunbursts, pill clouds, dots, stars
|
||||
|
||||
## Typography
|
||||
|
||||
- Large bold retro serif for titles, clean geometric sans-serif for body
|
||||
|
||||
## Best For
|
||||
|
||||
Educational content, creative proposals, brand showcases, explainer content
|
||||
@@ -0,0 +1,26 @@
|
||||
# vintage
|
||||
|
||||
Vintage aged-paper aesthetic with historical document styling
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Dark Brown (#3D2914), Forest Green (#2D5A3D), Navy Blue (#1E3A5F)
|
||||
- Background: Aged Parchment (#F5E6D3), Sepia Cream (#FFF8DC)
|
||||
- Accents: Burgundy (#722F37), Gold (#C9A227), Sepia Black (#3D3D3D)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Antique maps with route lines and landmarks
|
||||
- Compass roses and nautical elements
|
||||
- Specimen drawings (flora, fauna)
|
||||
- Handwritten-style annotations
|
||||
- Rope, leather, brass decorative motifs
|
||||
- Aged texture with subtle creases
|
||||
|
||||
## Typography
|
||||
|
||||
- Classic serif with historical character, elegant flourishes
|
||||
|
||||
## Best For
|
||||
|
||||
Historical content, travel, heritage storytelling, biography, scientific discovery
|
||||
@@ -0,0 +1,25 @@
|
||||
# watercolor
|
||||
|
||||
Soft watercolor illustration style with hand-painted textures
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Primary: Soft Coral (#F4A261), Dusty Rose (#E8A0A0)
|
||||
- Background: Warm Off-White (#FAF8F0), Soft Cream (#FFF9E6)
|
||||
- Accents: Sage Green (#87A96B), Sky Blue (#7EC8E3), Soft Lavender (#C5B4E3)
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Watercolor washes as backgrounds
|
||||
- Visible brush strokes and textures
|
||||
- Natural elements: leaves, flowers, organic shapes
|
||||
- Color bleeds and soft edges
|
||||
- Hand-drawn arrows and connections
|
||||
|
||||
## Typography
|
||||
|
||||
- Elegant handwritten or brush script, organic letterforms
|
||||
|
||||
## Best For
|
||||
|
||||
Lifestyle content, wellness, travel guides, personal stories, creative topics
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
name: baoyu-danger-gemini-web
|
||||
description: Image generation skill using Gemini Web. Generates images from text prompts via Google Gemini. Also supports text generation. Use as the image generation backend for other skills like cover-image, xhs-images, article-illustrator.
|
||||
---
|
||||
|
||||
# Gemini Web Client
|
||||
|
||||
Supports:
|
||||
- Text generation
|
||||
- Image generation (download + save)
|
||||
- Reference images for vision input (attach local images)
|
||||
- Multi-turn conversations via persisted `--sessionId`
|
||||
|
||||
## Script Directory
|
||||
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/main.ts` | CLI entry point for text/image generation |
|
||||
| `scripts/gemini-webapi/*` | TypeScript port of `gemini_webapi` (GeminiClient, types, utils) |
|
||||
|
||||
## ⚠️ Disclaimer (REQUIRED)
|
||||
|
||||
**Before using this skill**, the consent check MUST be performed.
|
||||
|
||||
### Consent Check Flow
|
||||
|
||||
**Step 1**: Check consent file
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
cat ~/Library/Application\ Support/baoyu-skills/gemini-web/consent.json 2>/dev/null
|
||||
|
||||
# Linux
|
||||
cat ~/.local/share/baoyu-skills/gemini-web/consent.json 2>/dev/null
|
||||
|
||||
# Windows (PowerShell)
|
||||
Get-Content "$env:APPDATA\baoyu-skills\gemini-web\consent.json" 2>$null
|
||||
```
|
||||
|
||||
**Step 2**: If consent exists and `accepted: true` with matching `disclaimerVersion: "1.0"`:
|
||||
|
||||
Print warning and proceed:
|
||||
```
|
||||
⚠️ Warning: Using reverse-engineered Gemini Web API (not official). Accepted on: <acceptedAt date>
|
||||
```
|
||||
|
||||
**Step 3**: If consent file doesn't exist or `disclaimerVersion` mismatch:
|
||||
|
||||
Display disclaimer and ask user:
|
||||
|
||||
```
|
||||
⚠️ DISCLAIMER
|
||||
|
||||
This tool uses a reverse-engineered Gemini Web API, NOT an official Google API.
|
||||
|
||||
Risks:
|
||||
- May break without notice if Google changes their API
|
||||
- No official support or guarantees
|
||||
- Use at your own risk
|
||||
|
||||
Do you accept these terms and wish to continue?
|
||||
```
|
||||
|
||||
Use `AskUserQuestion` tool with options:
|
||||
- **Yes, I accept** - Continue and save consent
|
||||
- **No, I decline** - Exit immediately
|
||||
|
||||
**Step 4**: On acceptance, create consent file:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
mkdir -p ~/Library/Application\ Support/baoyu-skills/gemini-web
|
||||
cat > ~/Library/Application\ Support/baoyu-skills/gemini-web/consent.json << 'EOF'
|
||||
{
|
||||
"version": 1,
|
||||
"accepted": true,
|
||||
"acceptedAt": "<ISO timestamp>",
|
||||
"disclaimerVersion": "1.0"
|
||||
}
|
||||
EOF
|
||||
|
||||
# Linux
|
||||
mkdir -p ~/.local/share/baoyu-skills/gemini-web
|
||||
cat > ~/.local/share/baoyu-skills/gemini-web/consent.json << 'EOF'
|
||||
{
|
||||
"version": 1,
|
||||
"accepted": true,
|
||||
"acceptedAt": "<ISO timestamp>",
|
||||
"disclaimerVersion": "1.0"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Step 5**: On decline, output message and stop:
|
||||
```
|
||||
User declined the disclaimer. Exiting.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello, Gemini"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Explain quantum computing"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# Multi-turn conversation (agent generates unique sessionId)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Remember this: 42" --sessionId my-unique-id-123
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "What number?" --sessionId my-unique-id-123
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Text generation
|
||||
|
||||
```bash
|
||||
# Simple prompt (positional)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt here"
|
||||
|
||||
# Explicit prompt flag
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt here"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Your prompt here"
|
||||
|
||||
# With model selection
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Hello" -m gemini-2.5-pro
|
||||
|
||||
# Pipe from stdin
|
||||
echo "Summarize this" | npx -y bun ${SKILL_DIR}/scripts/main.ts
|
||||
```
|
||||
|
||||
### Image generation
|
||||
|
||||
```bash
|
||||
# Generate image with default path (./generated.png)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A sunset over mountains" --image
|
||||
|
||||
# Generate image with custom path
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute robot" --image robot.png
|
||||
|
||||
# Shorthand
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "A dragon" --image=dragon.png
|
||||
```
|
||||
|
||||
### Vision input (reference images)
|
||||
|
||||
```bash
|
||||
# Text + image -> text
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Describe this image" --reference a.png
|
||||
|
||||
# Text + image -> image
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Generate a variation" --reference a.png --image out.png
|
||||
```
|
||||
|
||||
### Output formats
|
||||
|
||||
```bash
|
||||
# Plain text (default)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello"
|
||||
|
||||
# JSON output
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--prompt <text>`, `-p` | Prompt text |
|
||||
| `--promptfiles <files...>` | Read prompt from files (concatenated in order) |
|
||||
| `--model <id>`, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
|
||||
| `--image [path]` | Generate image, save to path (default: generated.png) |
|
||||
| `--reference <files...>`, `--ref <files...>` | Reference images for vision input |
|
||||
| `--sessionId <id>` | Session ID for multi-turn conversation (agent generates unique ID) |
|
||||
| `--list-sessions` | List saved sessions (max 100, sorted by update time) |
|
||||
| `--json` | Output as JSON |
|
||||
| `--login` | Refresh cookies only, then exit |
|
||||
| `--cookie-path <path>` | Custom cookie file path |
|
||||
| `--profile-dir <path>` | Chrome profile directory |
|
||||
| `--help`, `-h` | Show help |
|
||||
|
||||
CLI note: `scripts/main.ts` supports text generation, image generation, reference images (`--reference/--ref`), and multi-turn conversations via `--sessionId`.
|
||||
|
||||
## Models
|
||||
|
||||
- `gemini-3-pro` - Default, latest model
|
||||
- `gemini-2.5-pro` - Previous generation pro
|
||||
- `gemini-2.5-flash` - Fast, lightweight
|
||||
|
||||
## Authentication
|
||||
|
||||
First run opens Chrome to authenticate with Google. Cookies are cached for subsequent runs.
|
||||
|
||||
```bash
|
||||
# Force cookie refresh
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --login
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GEMINI_WEB_DATA_DIR` | Data directory |
|
||||
| `GEMINI_WEB_COOKIE_PATH` | Cookie file path |
|
||||
| `GEMINI_WEB_CHROME_PROFILE_DIR` | Chrome profile directory |
|
||||
| `GEMINI_WEB_CHROME_PATH` | Chrome executable path |
|
||||
|
||||
## Examples
|
||||
|
||||
### Generate text response
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "What is the capital of France?"
|
||||
```
|
||||
|
||||
### Generate image
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png
|
||||
```
|
||||
|
||||
### Get JSON output for parsing
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json | jq '.text'
|
||||
```
|
||||
|
||||
### Generate image from prompt files
|
||||
```bash
|
||||
# Concatenate system.md + content.md as prompt
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image output.png
|
||||
```
|
||||
|
||||
### Multi-turn conversation
|
||||
```bash
|
||||
# Start a session with unique ID (agent generates this)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "You are a helpful math tutor." --sessionId task-abc123
|
||||
|
||||
# Continue the conversation (remembers context)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "What is 2+2?" --sessionId task-abc123
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Now multiply that by 10" --sessionId task-abc123
|
||||
|
||||
# List recent sessions (max 100, sorted by update time)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --list-sessions
|
||||
```
|
||||
|
||||
Session files are stored in `~/Library/Application Support/baoyu-skills/gemini-web/sessions/<id>.json` and contain:
|
||||
- `id`: Session ID
|
||||
- `metadata`: Gemini chat metadata for continuation
|
||||
- `messages`: Array of `{role, content, timestamp, error?}`
|
||||
- `createdAt`, `updatedAt`: Timestamps
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md` (user)
|
||||
|
||||
If found, load before workflow. Extension content overrides defaults.
|
||||
@@ -0,0 +1,627 @@
|
||||
import { Endpoint, ErrorCode, Headers, Model } from './constants.js';
|
||||
import { GemMixin } from './components/gem-mixin.js';
|
||||
import {
|
||||
APIError,
|
||||
AuthError,
|
||||
GeminiError,
|
||||
ImageGenerationError,
|
||||
ModelInvalid,
|
||||
TemporarilyBlocked,
|
||||
TimeoutError,
|
||||
UsageLimitExceeded,
|
||||
} from './exceptions.js';
|
||||
import { Candidate, Gem, GeneratedImage, ModelOutput, RPCData, WebImage } from './types/index.js';
|
||||
import {
|
||||
extract_json_from_response,
|
||||
get_access_token,
|
||||
get_nested_value,
|
||||
logger,
|
||||
parse_file_name,
|
||||
rotate_1psidts,
|
||||
rotate_tasks,
|
||||
fetch_with_timeout,
|
||||
sleep,
|
||||
upload_file,
|
||||
write_cookie_file,
|
||||
resolveGeminiWebCookiePath,
|
||||
} from './utils/index.js';
|
||||
|
||||
type InitOptions = {
|
||||
timeout?: number;
|
||||
auto_close?: boolean;
|
||||
close_delay?: number;
|
||||
auto_refresh?: boolean;
|
||||
refresh_interval?: number;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
type RequestKwargs = RequestInit & { timeout_ms?: number };
|
||||
|
||||
function normalize_headers(h?: HeadersInit): Record<string, string> {
|
||||
if (!h) return {};
|
||||
if (Array.isArray(h)) return Object.fromEntries(h.map(([k, v]) => [k, v]));
|
||||
if (h instanceof Headers) {
|
||||
const out: Record<string, string> = {};
|
||||
h.forEach((v, k) => {
|
||||
out[k] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
return { ...(h as Record<string, string>) };
|
||||
}
|
||||
|
||||
function collect_strings(root: unknown, accept: (s: string) => boolean, limit: number = 20): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const stack: unknown[] = [root];
|
||||
|
||||
while (stack.length > 0 && out.length < limit) {
|
||||
const v = stack.pop();
|
||||
if (typeof v === 'string') {
|
||||
if (accept(v) && !seen.has(v)) {
|
||||
seen.add(v);
|
||||
out.push(v);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
for (let i = 0; i < v.length; i++) stack.push(v[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (v && typeof v === 'object') {
|
||||
for (const val of Object.values(v as Record<string, unknown>)) stack.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export class GeminiClient extends GemMixin {
|
||||
public cookies: Record<string, string> = {};
|
||||
public proxy: string | null = null;
|
||||
public _running: boolean = false;
|
||||
public access_token: string | null = null;
|
||||
public timeout: number = 300;
|
||||
public auto_close: boolean = false;
|
||||
public close_delay: number = 300;
|
||||
public auto_refresh: boolean = true;
|
||||
public refresh_interval: number = 540;
|
||||
public kwargs: RequestInit;
|
||||
|
||||
private close_timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private refresh_abort: AbortController | null = null;
|
||||
|
||||
constructor(
|
||||
secure_1psid: string | null = null,
|
||||
secure_1psidts: string | null = null,
|
||||
proxy: string | null = null,
|
||||
kwargs: RequestInit = {},
|
||||
) {
|
||||
super();
|
||||
this.proxy = proxy;
|
||||
this.kwargs = kwargs;
|
||||
|
||||
if (secure_1psid) {
|
||||
this.cookies['__Secure-1PSID'] = secure_1psid;
|
||||
if (secure_1psidts) this.cookies['__Secure-1PSIDTS'] = secure_1psidts;
|
||||
}
|
||||
}
|
||||
|
||||
async init(
|
||||
timeoutOrOpts: number | InitOptions = 300,
|
||||
auto_close: boolean = false,
|
||||
close_delay: number = 300,
|
||||
auto_refresh: boolean = true,
|
||||
refresh_interval: number = 540,
|
||||
verbose: boolean = true,
|
||||
): Promise<void> {
|
||||
const opts: InitOptions =
|
||||
typeof timeoutOrOpts === 'object'
|
||||
? timeoutOrOpts
|
||||
: { timeout: timeoutOrOpts, auto_close, close_delay, auto_refresh, refresh_interval, verbose };
|
||||
|
||||
const timeout = opts.timeout ?? 300;
|
||||
const ac = opts.auto_close ?? false;
|
||||
const cd = opts.close_delay ?? 300;
|
||||
const ar = opts.auto_refresh ?? true;
|
||||
const ri = opts.refresh_interval ?? 540;
|
||||
const vb = opts.verbose ?? true;
|
||||
|
||||
try {
|
||||
const [token, valid] = await get_access_token(this.cookies, this.proxy, vb);
|
||||
this.access_token = token;
|
||||
this.cookies = valid;
|
||||
this._running = true;
|
||||
|
||||
this.timeout = timeout;
|
||||
this.auto_close = ac;
|
||||
this.close_delay = cd;
|
||||
if (this.auto_close) await this.reset_close_task();
|
||||
|
||||
this.auto_refresh = ar;
|
||||
this.refresh_interval = ri;
|
||||
|
||||
const sid = this.cookies['__Secure-1PSID'];
|
||||
if (sid) {
|
||||
const existing = rotate_tasks.get(sid);
|
||||
if (existing && existing instanceof AbortController) existing.abort();
|
||||
rotate_tasks.delete(sid);
|
||||
}
|
||||
|
||||
if (this.auto_refresh && sid) {
|
||||
const ctl = new AbortController();
|
||||
this.refresh_abort?.abort();
|
||||
this.refresh_abort = ctl;
|
||||
rotate_tasks.set(sid, ctl);
|
||||
void this.start_auto_refresh(ctl.signal);
|
||||
}
|
||||
|
||||
await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'client').catch(() => {});
|
||||
|
||||
if (vb) logger.success('Gemini client initialized successfully.');
|
||||
} catch (e) {
|
||||
await this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async close(delay: number = 0): Promise<void> {
|
||||
if (delay > 0) await sleep(delay * 1000);
|
||||
this._running = false;
|
||||
|
||||
if (this.close_timer) {
|
||||
clearTimeout(this.close_timer);
|
||||
this.close_timer = null;
|
||||
}
|
||||
|
||||
this.refresh_abort?.abort();
|
||||
this.refresh_abort = null;
|
||||
|
||||
const sid = this.cookies['__Secure-1PSID'];
|
||||
const t = sid ? rotate_tasks.get(sid) : null;
|
||||
if (t && t instanceof AbortController) t.abort();
|
||||
if (sid) rotate_tasks.delete(sid);
|
||||
}
|
||||
|
||||
async reset_close_task(): Promise<void> {
|
||||
if (this.close_timer) {
|
||||
clearTimeout(this.close_timer);
|
||||
this.close_timer = null;
|
||||
}
|
||||
|
||||
this.close_timer = setTimeout(() => {
|
||||
void this.close(0);
|
||||
}, this.close_delay * 1000);
|
||||
this.close_timer.unref?.();
|
||||
}
|
||||
|
||||
async start_auto_refresh(signal: AbortSignal): Promise<void> {
|
||||
while (!signal.aborted) {
|
||||
let newTs: string | null = null;
|
||||
try {
|
||||
newTs = await rotate_1psidts(this.cookies, this.proxy);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) {
|
||||
logger.warning('AuthError: Failed to refresh cookies. Auto refresh task canceled.');
|
||||
return;
|
||||
}
|
||||
logger.warning(`Unexpected error while refreshing cookies: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
if (newTs) {
|
||||
this.cookies['__Secure-1PSIDTS'] = newTs;
|
||||
await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'refresh').catch(() => {});
|
||||
logger.debug('Cookies refreshed. New __Secure-1PSIDTS applied.');
|
||||
}
|
||||
|
||||
await sleep(this.refresh_interval * 1000, signal);
|
||||
}
|
||||
}
|
||||
|
||||
protected async _run<T>(fn: () => Promise<T>, retry: number): Promise<T> {
|
||||
try {
|
||||
if (!this._running) {
|
||||
await this.init({
|
||||
timeout: this.timeout,
|
||||
auto_close: this.auto_close,
|
||||
close_delay: this.close_delay,
|
||||
auto_refresh: this.auto_refresh,
|
||||
refresh_interval: this.refresh_interval,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
if (!this._running) {
|
||||
throw new APIError('Client initialization failed.');
|
||||
}
|
||||
}
|
||||
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
let r = retry;
|
||||
if (e instanceof ImageGenerationError) r = Math.min(1, r);
|
||||
if (e instanceof APIError && r > 0) {
|
||||
await sleep(1000);
|
||||
return await this._run(fn, r - 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generate_content(
|
||||
prompt: string,
|
||||
files: string[] | null = null,
|
||||
model: Model | string | Record<string, unknown> = Model.UNSPECIFIED,
|
||||
gem: Gem | string | null = null,
|
||||
chat: ChatSession | null = null,
|
||||
kwargs: RequestKwargs = {},
|
||||
): Promise<ModelOutput> {
|
||||
return await this._run(async () => {
|
||||
if (!prompt) throw new Error('Prompt cannot be empty.');
|
||||
|
||||
let mdl: Model;
|
||||
if (typeof model === 'string') mdl = Model.from_name(model);
|
||||
else if (model instanceof Model) mdl = model;
|
||||
else if (model && typeof model === 'object') mdl = Model.from_dict(model);
|
||||
else throw new TypeError(`'model' must be a Model instance, string, or dictionary; got ${typeof model}`);
|
||||
|
||||
const gem_id = gem instanceof Gem ? gem.id : gem;
|
||||
|
||||
if (this.auto_close) await this.reset_close_task();
|
||||
|
||||
if (!this.access_token) throw new APIError('Missing access token.');
|
||||
|
||||
const f = files?.length ? files : null;
|
||||
const uploaded =
|
||||
f &&
|
||||
(await Promise.all(
|
||||
f.map(async (p) => [[await upload_file(p, this.proxy)], parse_file_name(p)] as [string[], string]),
|
||||
));
|
||||
|
||||
const first = uploaded ? [prompt, 0, null, uploaded] : [prompt];
|
||||
const inner: unknown[] = [first, null, chat ? chat.metadata : null];
|
||||
|
||||
if (gem_id) {
|
||||
for (let i = 0; i < 16; i++) inner.push(null);
|
||||
inner.push(gem_id);
|
||||
}
|
||||
|
||||
const f_req = JSON.stringify([null, JSON.stringify(inner)]);
|
||||
const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();
|
||||
|
||||
const h0 = { ...Headers.GEMINI, ...mdl.model_header, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };
|
||||
const h1 = { ...h0, ...normalize_headers(kwargs.headers) };
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
const timeout_ms = typeof kwargs.timeout_ms === 'number' ? kwargs.timeout_ms : this.timeout * 1000;
|
||||
const { timeout_ms: _t, ...rest } = kwargs;
|
||||
res = await fetch_with_timeout(Endpoint.GENERATE, {
|
||||
method: 'POST',
|
||||
headers: h1,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
...this.kwargs,
|
||||
...rest,
|
||||
timeout_ms,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new TimeoutError(
|
||||
`Generate content request timed out, please try again. If the problem persists, consider setting a higher 'timeout' value when initializing GeminiClient. (${e instanceof Error ? e.message : String(e)})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
await this.close();
|
||||
throw new APIError(`Failed to generate contents. Request failed with status code ${res.status}`);
|
||||
}
|
||||
|
||||
const txt = await res.text();
|
||||
const response_json = extract_json_from_response(txt);
|
||||
|
||||
let body_json: unknown[] | null = null;
|
||||
let body_index = 0;
|
||||
|
||||
try {
|
||||
if (!Array.isArray(response_json)) throw new Error('Invalid JSON');
|
||||
for (let part_index = 0; part_index < response_json.length; part_index++) {
|
||||
const part = response_json[part_index];
|
||||
if (!Array.isArray(part)) continue;
|
||||
const part_body = get_nested_value<string | null>(part, [2], null);
|
||||
if (!part_body) continue;
|
||||
try {
|
||||
const part_json = JSON.parse(part_body) as unknown[];
|
||||
if (get_nested_value(part_json, [4], null)) {
|
||||
body_index = part_index;
|
||||
body_json = part_json;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!body_json) throw new Error('No body');
|
||||
} catch {
|
||||
await this.close();
|
||||
try {
|
||||
const code = get_nested_value<number>(response_json, [0, 5, 2, 0, 1, 0], -1);
|
||||
if (code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
|
||||
throw new UsageLimitExceeded(
|
||||
`Failed to generate contents. Usage limit of ${mdl.model_name} model has exceeded. Please try switching to another model.`,
|
||||
);
|
||||
}
|
||||
if (code === ErrorCode.MODEL_INCONSISTENT) {
|
||||
throw new ModelInvalid(
|
||||
'Failed to generate contents. The specified model is inconsistent with the chat history. Please make sure to pass the same `model` parameter when starting a chat session with previous metadata.',
|
||||
);
|
||||
}
|
||||
if (code === ErrorCode.MODEL_HEADER_INVALID) {
|
||||
throw new ModelInvalid(
|
||||
'Failed to generate contents. The specified model is not available. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
|
||||
);
|
||||
}
|
||||
if (code === ErrorCode.IP_TEMPORARILY_BLOCKED) {
|
||||
throw new TemporarilyBlocked(
|
||||
'Failed to generate contents. Your IP address is temporarily blocked by Google. Please try using a proxy or waiting for a while.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof GeminiError) throw e;
|
||||
}
|
||||
|
||||
logger.debug(`Invalid response: ${txt.slice(0, 500)}`);
|
||||
throw new APIError('Failed to generate contents. Invalid response data received. Client will try to re-initialize on next request.');
|
||||
}
|
||||
|
||||
try {
|
||||
const candidate_list = get_nested_value<unknown[]>(body_json, [4], []);
|
||||
const out: Candidate[] = [];
|
||||
|
||||
for (let candidate_index = 0; candidate_index < candidate_list.length; candidate_index++) {
|
||||
const candidate = candidate_list[candidate_index];
|
||||
if (!Array.isArray(candidate)) continue;
|
||||
|
||||
const rcid = get_nested_value<string | null>(candidate, [0], null);
|
||||
if (!rcid) continue;
|
||||
|
||||
let text = String(get_nested_value(candidate, [1, 0], ''));
|
||||
if (/^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(text)) {
|
||||
text = String(get_nested_value(candidate, [22, 0], text));
|
||||
}
|
||||
|
||||
const thoughts = get_nested_value<string | null>(candidate, [37, 0, 0], null);
|
||||
|
||||
const web_images: WebImage[] = [];
|
||||
for (const w of get_nested_value<unknown[]>(candidate, [12, 1], [])) {
|
||||
if (!Array.isArray(w)) continue;
|
||||
const url = get_nested_value<string | null>(w, [0, 0, 0], null);
|
||||
if (!url) continue;
|
||||
web_images.push(new WebImage(url, String(get_nested_value(w, [7, 0], '')), String(get_nested_value(w, [0, 4], '')), this.proxy));
|
||||
}
|
||||
|
||||
const generated_images: GeneratedImage[] = [];
|
||||
const wants_generated =
|
||||
get_nested_value(candidate, [12, 7, 0], null) != null ||
|
||||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text);
|
||||
|
||||
if (wants_generated) {
|
||||
let img_body: unknown[] | null = null;
|
||||
for (let part_index = body_index; part_index < (response_json as unknown[]).length; part_index++) {
|
||||
const part = (response_json as unknown[])[part_index];
|
||||
if (!Array.isArray(part)) continue;
|
||||
const part_body = get_nested_value<string | null>(part, [2], null);
|
||||
if (!part_body) continue;
|
||||
try {
|
||||
const part_json = JSON.parse(part_body) as unknown[];
|
||||
const cand = get_nested_value<unknown>(part_json, [4, candidate_index], null);
|
||||
if (!cand) continue;
|
||||
|
||||
const urls = collect_strings(cand, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 1);
|
||||
if (urls.length > 0) {
|
||||
img_body = part_json;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!img_body) {
|
||||
throw new ImageGenerationError(
|
||||
'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
|
||||
);
|
||||
}
|
||||
|
||||
const img_candidate = get_nested_value<unknown[]>(img_body, [4, candidate_index], []);
|
||||
const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);
|
||||
if (finished) {
|
||||
text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();
|
||||
}
|
||||
|
||||
const gen = get_nested_value<unknown[]>(img_candidate, [12, 7, 0], []);
|
||||
for (let img_index = 0; img_index < gen.length; img_index++) {
|
||||
const g = gen[img_index];
|
||||
if (!Array.isArray(g)) continue;
|
||||
const url = get_nested_value<string | null>(g, [0, 3, 3], null);
|
||||
if (!url) continue;
|
||||
const img_num = get_nested_value<number | null>(g, [3, 6], null);
|
||||
const title = img_num ? `[Generated Image ${img_num}]` : '[Generated Image]';
|
||||
const alt_list = get_nested_value<unknown[]>(g, [3, 5], []);
|
||||
const alt =
|
||||
(typeof alt_list[img_index] === 'string' ? (alt_list[img_index] as string) : null) ??
|
||||
(typeof alt_list[0] === 'string' ? (alt_list[0] as string) : '') ??
|
||||
'';
|
||||
generated_images.push(new GeneratedImage(url, title, alt, this.proxy, this.cookies));
|
||||
}
|
||||
|
||||
if (generated_images.length === 0) {
|
||||
const urls = collect_strings(img_candidate, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 4);
|
||||
for (const url of urls) {
|
||||
generated_images.push(new GeneratedImage(url, '[Generated Image]', '', this.proxy, this.cookies));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push(new Candidate({ rcid, text, thoughts, web_images, generated_images }));
|
||||
}
|
||||
|
||||
if (out.length === 0) {
|
||||
throw new GeminiError('Failed to generate contents. No output data found in response.');
|
||||
}
|
||||
|
||||
const metadata = get_nested_value<string[]>(body_json, [1], []);
|
||||
const output = new ModelOutput({ metadata, candidates: out });
|
||||
|
||||
if (chat instanceof ChatSession) chat.last_output = output;
|
||||
return output;
|
||||
} catch (e) {
|
||||
if (e instanceof GeminiError || e instanceof APIError) throw e;
|
||||
throw new APIError('Failed to parse response body. Data structure is invalid.');
|
||||
}
|
||||
}, 2);
|
||||
}
|
||||
|
||||
async generateContent(
|
||||
prompt: string,
|
||||
files?: string[] | null,
|
||||
model?: Model | string | Record<string, unknown>,
|
||||
gem?: Gem | string | null,
|
||||
chat?: ChatSession | null,
|
||||
kwargs?: RequestKwargs,
|
||||
): Promise<ModelOutput> {
|
||||
return await this.generate_content(prompt, files ?? null, model ?? Model.UNSPECIFIED, gem ?? null, chat ?? null, kwargs ?? {});
|
||||
}
|
||||
|
||||
start_chat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
|
||||
return new ChatSession(this, opts);
|
||||
}
|
||||
|
||||
startChat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
|
||||
return this.start_chat(opts);
|
||||
}
|
||||
|
||||
protected async _batch_execute(payloads: RPCData[], opts: RequestInit = {}): Promise<Response> {
|
||||
if (!this.access_token) throw new APIError('Missing access token.');
|
||||
|
||||
const f_req = JSON.stringify([payloads.map((p) => p.serialize())]);
|
||||
const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();
|
||||
|
||||
const h0 = { ...Headers.GEMINI, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };
|
||||
const h1 = { ...h0, ...normalize_headers(opts.headers) };
|
||||
|
||||
const res = await fetch_with_timeout(Endpoint.BATCH_EXEC, {
|
||||
method: 'POST',
|
||||
headers: h1,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
...this.kwargs,
|
||||
...opts,
|
||||
timeout_ms: this.timeout * 1000,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
await this.close();
|
||||
throw new APIError(`Batch execution failed with status code ${res.status}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatSession {
|
||||
private __metadata: Array<string | null> = [null, null, null];
|
||||
public geminiclient: GeminiClient;
|
||||
private _last_output: ModelOutput | null = null;
|
||||
public model: Model | string | Record<string, unknown>;
|
||||
public gem: Gem | string | null;
|
||||
|
||||
constructor(
|
||||
geminiclient: GeminiClient,
|
||||
opts: {
|
||||
metadata?: Array<string | null>;
|
||||
cid?: string | null;
|
||||
rid?: string | null;
|
||||
rcid?: string | null;
|
||||
model?: Model | string | Record<string, unknown>;
|
||||
gem?: Gem | string | null;
|
||||
} = {},
|
||||
) {
|
||||
this.geminiclient = geminiclient;
|
||||
this.model = opts.model ?? Model.UNSPECIFIED;
|
||||
this.gem = opts.gem ?? null;
|
||||
|
||||
if (opts.metadata) this.metadata = opts.metadata;
|
||||
if (opts.cid) this.cid = opts.cid;
|
||||
if (opts.rid) this.rid = opts.rid;
|
||||
if (opts.rcid) this.rcid = opts.rcid;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `ChatSession(cid='${this.cid}', rid='${this.rid}', rcid='${this.rcid}')`;
|
||||
}
|
||||
|
||||
get last_output(): ModelOutput | null {
|
||||
return this._last_output;
|
||||
}
|
||||
|
||||
set last_output(v: ModelOutput | null) {
|
||||
this._last_output = v;
|
||||
if (v) {
|
||||
this.metadata = (v.metadata ?? []) as Array<string | null>;
|
||||
this.rcid = v.rcid;
|
||||
}
|
||||
}
|
||||
|
||||
async send_message(prompt: string, files: string[] | null = null, kwargs: RequestKwargs = {}): Promise<ModelOutput> {
|
||||
return await this.geminiclient.generate_content(prompt, files, this.model, this.gem, this, kwargs);
|
||||
}
|
||||
|
||||
async sendMessage(prompt: string, files?: string[] | null, kwargs?: RequestKwargs): Promise<ModelOutput> {
|
||||
return await this.send_message(prompt, files ?? null, kwargs ?? {});
|
||||
}
|
||||
|
||||
choose_candidate(index: number): ModelOutput {
|
||||
if (!this.last_output) throw new Error('No previous output data found in this chat session.');
|
||||
if (index >= this.last_output.candidates.length) {
|
||||
throw new Error(`Index ${index} exceeds the number of candidates in last model output.`);
|
||||
}
|
||||
this.last_output.chosen = index;
|
||||
this.rcid = this.last_output.rcid;
|
||||
return this.last_output;
|
||||
}
|
||||
|
||||
chooseCandidate(index: number): ModelOutput {
|
||||
return this.choose_candidate(index);
|
||||
}
|
||||
|
||||
get metadata(): Array<string | null> {
|
||||
return this.__metadata;
|
||||
}
|
||||
|
||||
set metadata(v: Array<string | null>) {
|
||||
if (v.length > 3) throw new Error('metadata cannot exceed 3 elements');
|
||||
this.__metadata = [null, null, null];
|
||||
for (let i = 0; i < v.length; i++) this.__metadata[i] = v[i] ?? null;
|
||||
}
|
||||
|
||||
get cid(): string | null {
|
||||
return this.__metadata[0];
|
||||
}
|
||||
|
||||
set cid(v: string | null) {
|
||||
this.__metadata[0] = v;
|
||||
}
|
||||
|
||||
get rid(): string | null {
|
||||
return this.__metadata[1];
|
||||
}
|
||||
|
||||
set rid(v: string | null) {
|
||||
this.__metadata[1] = v;
|
||||
}
|
||||
|
||||
get rcid(): string | null {
|
||||
return this.__metadata[2];
|
||||
}
|
||||
|
||||
set rcid(v: string | null) {
|
||||
this.__metadata[2] = v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { GRPC } from '../constants.js';
|
||||
import { APIError } from '../exceptions.js';
|
||||
import { Gem, GemJar, RPCData } from '../types/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { extract_json_from_response, get_nested_value } from '../utils/parsing.js';
|
||||
|
||||
export abstract class GemMixin {
|
||||
protected _gems: GemJar | null = null;
|
||||
|
||||
protected abstract _run<T>(fn: () => Promise<T>, retry: number): Promise<T>;
|
||||
protected abstract _batch_execute(payloads: RPCData[], opts?: RequestInit): Promise<Response>;
|
||||
protected abstract close(delay?: number): Promise<void>;
|
||||
|
||||
get gems(): GemJar {
|
||||
if (this._gems == null) {
|
||||
throw new Error(
|
||||
'Gems not fetched yet. Call `GeminiClient.fetch_gems()` method to fetch gems from gemini.google.com.',
|
||||
);
|
||||
}
|
||||
return this._gems;
|
||||
}
|
||||
|
||||
async fetch_gems(include_hidden: boolean = false, opts?: RequestInit): Promise<GemJar> {
|
||||
return await this._run(async () => {
|
||||
const res = await this._batch_execute(
|
||||
[
|
||||
new RPCData(GRPC.LIST_GEMS, include_hidden ? '[4]' : '[3]', 'system'),
|
||||
new RPCData(GRPC.LIST_GEMS, '[2]', 'custom'),
|
||||
],
|
||||
opts,
|
||||
);
|
||||
|
||||
let response_json: unknown;
|
||||
try {
|
||||
response_json = extract_json_from_response(await res.text());
|
||||
if (!Array.isArray(response_json)) throw new Error('Invalid response');
|
||||
} catch {
|
||||
await this.close();
|
||||
throw new APIError('Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request.');
|
||||
}
|
||||
|
||||
let predefined: unknown[] = [];
|
||||
let custom: unknown[] = [];
|
||||
|
||||
try {
|
||||
for (const part of response_json as unknown[]) {
|
||||
if (!Array.isArray(part)) continue;
|
||||
const ident = part[part.length - 1];
|
||||
const body = get_nested_value<string | null>(part, [2], null);
|
||||
if (!body) continue;
|
||||
|
||||
if (ident === 'system') {
|
||||
const parsed = JSON.parse(body) as unknown[];
|
||||
predefined = (Array.isArray(parsed) ? (parsed[2] as unknown[]) : []) ?? [];
|
||||
} else if (ident === 'custom') {
|
||||
const parsed = JSON.parse(body) as unknown[] | null;
|
||||
if (parsed) custom = (parsed[2] as unknown[]) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
if (predefined.length === 0 && custom.length === 0) throw new Error('No gems');
|
||||
} catch {
|
||||
await this.close();
|
||||
logger.debug('Invalid response while parsing gems');
|
||||
throw new APIError('Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request.');
|
||||
}
|
||||
|
||||
const entries: [string, Gem][] = [];
|
||||
|
||||
for (const gem of predefined) {
|
||||
if (!Array.isArray(gem)) continue;
|
||||
const id = String(get_nested_value(gem, [0], ''));
|
||||
if (!id) continue;
|
||||
entries.push([
|
||||
id,
|
||||
new Gem(
|
||||
id,
|
||||
String(get_nested_value(gem, [1, 0], '')),
|
||||
get_nested_value<string | null>(gem, [1, 1], null),
|
||||
get_nested_value<string | null>(gem, [2, 0], null),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
for (const gem of custom) {
|
||||
if (!Array.isArray(gem)) continue;
|
||||
const id = String(get_nested_value(gem, [0], ''));
|
||||
if (!id) continue;
|
||||
entries.push([
|
||||
id,
|
||||
new Gem(
|
||||
id,
|
||||
String(get_nested_value(gem, [1, 0], '')),
|
||||
get_nested_value<string | null>(gem, [1, 1], null),
|
||||
get_nested_value<string | null>(gem, [2, 0], null),
|
||||
false,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
this._gems = new GemJar(entries);
|
||||
return this._gems;
|
||||
}, 2);
|
||||
}
|
||||
|
||||
async create_gem(name: string, prompt: string, description: string = ''): Promise<Gem> {
|
||||
return await this._run(async () => {
|
||||
const payload = JSON.stringify([
|
||||
[
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
],
|
||||
]);
|
||||
|
||||
const res = await this._batch_execute([new RPCData(GRPC.CREATE_GEM, payload)]);
|
||||
try {
|
||||
const response_json = extract_json_from_response(await res.text()) as unknown[];
|
||||
const gem_id = JSON.parse(String((response_json[0] as unknown[])[2]))[0] as string;
|
||||
return new Gem(gem_id, name, description, prompt, false);
|
||||
} catch {
|
||||
await this.close();
|
||||
throw new APIError('Failed to create gem. Invalid response data received. Client will try to re-initialize on next request.');
|
||||
}
|
||||
}, 2);
|
||||
}
|
||||
|
||||
async update_gem(gem: Gem | string, name: string, prompt: string, description: string = ''): Promise<Gem> {
|
||||
return await this._run(async () => {
|
||||
const gem_id = typeof gem === 'string' ? gem : gem.id;
|
||||
const payload = JSON.stringify([
|
||||
gem_id,
|
||||
[
|
||||
name,
|
||||
description,
|
||||
prompt,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
0,
|
||||
],
|
||||
]);
|
||||
|
||||
await this._batch_execute([new RPCData(GRPC.UPDATE_GEM, payload)]);
|
||||
return new Gem(gem_id, name, description, prompt, false);
|
||||
}, 2);
|
||||
}
|
||||
|
||||
async delete_gem(gem: Gem | string, opts?: RequestInit): Promise<void> {
|
||||
return await this._run(async () => {
|
||||
const gem_id = typeof gem === 'string' ? gem : gem.id;
|
||||
const payload = JSON.stringify([gem_id]);
|
||||
await this._batch_execute([new RPCData(GRPC.DELETE_GEM, payload)], opts);
|
||||
}, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GemMixin } from './gem-mixin.js';
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export const Endpoint = {
|
||||
GOOGLE: 'https://www.google.com',
|
||||
INIT: 'https://gemini.google.com/app',
|
||||
GENERATE:
|
||||
'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate',
|
||||
ROTATE_COOKIES: 'https://accounts.google.com/RotateCookies',
|
||||
UPLOAD: 'https://content-push.googleapis.com/upload',
|
||||
BATCH_EXEC: 'https://gemini.google.com/_/BardChatUi/data/batchexecute',
|
||||
} as const;
|
||||
|
||||
export const GRPC = {
|
||||
LIST_CHATS: 'MaZiqc',
|
||||
READ_CHAT: 'hNvQHb',
|
||||
LIST_GEMS: 'CNgdBe',
|
||||
CREATE_GEM: 'oMH3Zd',
|
||||
UPDATE_GEM: 'kHv0Vd',
|
||||
DELETE_GEM: 'UXcSJb',
|
||||
} as const;
|
||||
|
||||
export const Headers = {
|
||||
GEMINI: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
|
||||
Host: 'gemini.google.com',
|
||||
Origin: 'https://gemini.google.com',
|
||||
Referer: 'https://gemini.google.com/',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'X-Same-Domain': '1',
|
||||
},
|
||||
ROTATE_COOKIES: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
UPLOAD: {
|
||||
'Push-ID': 'feeds/mcudyrk2a4khkz',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const ErrorCode = {
|
||||
TEMPORARY_ERROR_1013: 1013,
|
||||
USAGE_LIMIT_EXCEEDED: 1037,
|
||||
MODEL_INCONSISTENT: 1050,
|
||||
MODEL_HEADER_INVALID: 1052,
|
||||
IP_TEMPORARILY_BLOCKED: 1060,
|
||||
} as const;
|
||||
|
||||
export class Model {
|
||||
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
|
||||
static readonly G_3_0_PRO = new Model(
|
||||
'gemini-3.0-pro',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' },
|
||||
false,
|
||||
);
|
||||
static readonly G_2_5_PRO = new Model(
|
||||
'gemini-2.5-pro',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
|
||||
false,
|
||||
);
|
||||
static readonly G_2_5_FLASH = new Model(
|
||||
'gemini-2.5-flash',
|
||||
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
|
||||
false,
|
||||
);
|
||||
|
||||
constructor(
|
||||
public readonly model_name: string,
|
||||
public readonly model_header: Record<string, string>,
|
||||
public readonly advanced_only: boolean,
|
||||
) {}
|
||||
|
||||
static from_name(name: string): Model {
|
||||
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) {
|
||||
if (model.model_name === name) return model;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]
|
||||
.map((m) => m.model_name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
static from_dict(model_dict: { model_name?: unknown; model_header?: unknown }): Model {
|
||||
if (!model_dict || typeof model_dict !== 'object') {
|
||||
throw new Error("When passing a custom model as a dictionary, 'model_name' and 'model_header' keys must be provided.");
|
||||
}
|
||||
|
||||
if (!('model_name' in model_dict) || !('model_header' in model_dict)) {
|
||||
throw new Error("When passing a custom model as a dictionary, 'model_name' and 'model_header' keys must be provided.");
|
||||
}
|
||||
|
||||
if (typeof model_dict.model_name !== 'string' || !model_dict.model_name.trim()) {
|
||||
throw new Error("When passing a custom model as a dictionary, 'model_name' must be a non-empty string.");
|
||||
}
|
||||
|
||||
if (!model_dict.model_header || typeof model_dict.model_header !== 'object') {
|
||||
throw new Error("When passing a custom model as a dictionary, 'model_header' must be a dictionary containing valid header strings.");
|
||||
}
|
||||
|
||||
const header: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(model_dict.model_header as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') header[k] = v;
|
||||
}
|
||||
|
||||
return new Model(model_dict.model_name, header, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export class AuthError extends Error {
|
||||
constructor(message = 'AuthError') {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(message = 'APIError') {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageGenerationError extends APIError {
|
||||
constructor(message = 'ImageGenerationError') {
|
||||
super(message);
|
||||
this.name = 'ImageGenerationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class GeminiError extends Error {
|
||||
constructor(message = 'GeminiError') {
|
||||
super(message);
|
||||
this.name = 'GeminiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends GeminiError {
|
||||
constructor(message = 'TimeoutError') {
|
||||
super(message);
|
||||
this.name = 'TimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UsageLimitExceeded extends GeminiError {
|
||||
constructor(message = 'UsageLimitExceeded') {
|
||||
super(message);
|
||||
this.name = 'UsageLimitExceeded';
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelInvalid extends GeminiError {
|
||||
constructor(message = 'ModelInvalid') {
|
||||
super(message);
|
||||
this.name = 'ModelInvalid';
|
||||
}
|
||||
}
|
||||
|
||||
export class TemporarilyBlocked extends GeminiError {
|
||||
constructor(message = 'TemporarilyBlocked') {
|
||||
super(message);
|
||||
this.name = 'TemporarilyBlocked';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { GeminiClient, ChatSession } from './client.js';
|
||||
|
||||
export * from './exceptions.js';
|
||||
export * from './types/index.js';
|
||||
export * from './constants.js';
|
||||
export { logger, set_log_level, setLogLevel } from './utils/logger.js';
|
||||
export * as utils from './utils/index.js';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { GeneratedImage, type Image, WebImage } from './image.js';
|
||||
|
||||
function decode_html(s: string | null | undefined): string | null | undefined {
|
||||
if (s == null) return s;
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
|
||||
}
|
||||
|
||||
export class Candidate {
|
||||
public rcid: string;
|
||||
public text: string;
|
||||
public thoughts: string | null;
|
||||
public web_images: WebImage[];
|
||||
public generated_images: GeneratedImage[];
|
||||
|
||||
constructor(params: {
|
||||
rcid: string;
|
||||
text: string;
|
||||
thoughts?: string | null;
|
||||
web_images?: WebImage[];
|
||||
generated_images?: GeneratedImage[];
|
||||
}) {
|
||||
this.rcid = params.rcid;
|
||||
this.text = decode_html(params.text) ?? '';
|
||||
this.thoughts = decode_html(params.thoughts) ?? null;
|
||||
this.web_images = params.web_images ?? [];
|
||||
this.generated_images = params.generated_images ?? [];
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
get images(): Image[] {
|
||||
return [...this.web_images, ...this.generated_images];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export class Gem {
|
||||
constructor(
|
||||
public id: string,
|
||||
public name: string,
|
||||
public description: string | null,
|
||||
public prompt: string | null,
|
||||
public predefined: boolean,
|
||||
) {}
|
||||
|
||||
toString(): string {
|
||||
return `Gem(id='${this.id}', name='${this.name}', description='${this.description}', prompt='${this.prompt}', predefined=${this.predefined})`;
|
||||
}
|
||||
}
|
||||
|
||||
export class GemJar implements Iterable<Gem> {
|
||||
private m = new Map<string, Gem>();
|
||||
|
||||
constructor(entries?: Iterable<[string, Gem]>) {
|
||||
if (entries) for (const [id, gem] of entries) this.m.set(id, gem);
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<Gem> {
|
||||
return this.m.values();
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[string, Gem]> {
|
||||
return this.m.entries();
|
||||
}
|
||||
|
||||
values(): IterableIterator<Gem> {
|
||||
return this.m.values();
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.m.has(id);
|
||||
}
|
||||
|
||||
set(id: string, gem: Gem): this {
|
||||
this.m.set(id, gem);
|
||||
return this;
|
||||
}
|
||||
|
||||
get(id?: string | null, name?: string | null, def: Gem | null = null): Gem | null {
|
||||
if (id == null && name == null) {
|
||||
throw new Error('At least one of gem id or name must be provided.');
|
||||
}
|
||||
|
||||
if (id != null) {
|
||||
const g = this.m.get(id) ?? null;
|
||||
if (!g) return def;
|
||||
if (name != null) return g.name === name ? g : def;
|
||||
return g;
|
||||
}
|
||||
|
||||
if (name != null) {
|
||||
for (const g of this.m.values()) {
|
||||
if (g.name === name) return g;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
filter(predefined: boolean | null = null, name: string | null = null): GemJar {
|
||||
const out: [string, Gem][] = [];
|
||||
for (const [id, gem] of this.m.entries()) {
|
||||
if (predefined != null && gem.predefined !== predefined) continue;
|
||||
if (name != null && gem.name !== name) continue;
|
||||
out.push([id, gem]);
|
||||
}
|
||||
return new GemJar(out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export class RPCData {
|
||||
constructor(
|
||||
public rpcid: string,
|
||||
public payload: string,
|
||||
public identifier: string = 'generic',
|
||||
) {}
|
||||
|
||||
toString(): string {
|
||||
return `GRPC(rpcid='${this.rpcid}', payload='${this.payload}', identifier='${this.identifier}')`;
|
||||
}
|
||||
|
||||
serialize(): unknown[] {
|
||||
return [this.rpcid, this.payload, null, this.identifier];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import path from 'node:path';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { cookie_header, fetch_with_timeout } from '../utils/http.js';
|
||||
|
||||
export class Image {
|
||||
constructor(
|
||||
public url: string,
|
||||
public title = '[Image]',
|
||||
public alt = '',
|
||||
public proxy: string | null = null,
|
||||
) {}
|
||||
|
||||
toString(): string {
|
||||
const u = this.url.length <= 20 ? this.url : `${this.url.slice(0, 8)}...${this.url.slice(-12)}`;
|
||||
return `Image(title='${this.title}', alt='${this.alt}', url='${u}')`;
|
||||
}
|
||||
|
||||
async save(
|
||||
p: string = 'temp',
|
||||
filename: string | null = null,
|
||||
cookies: Record<string, string> | null = null,
|
||||
verbose: boolean = false,
|
||||
skip_invalid_filename: boolean = false,
|
||||
): Promise<string | null> {
|
||||
filename = filename ?? this.url.split('/').pop()?.split('?')[0] ?? 'image';
|
||||
const m = filename.match(/^(.*\.\w+)/);
|
||||
if (m) filename = m[1]!;
|
||||
else {
|
||||
if (verbose) logger.warning(`Invalid filename: ${filename}`);
|
||||
if (skip_invalid_filename) return null;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||
Referer: 'https://gemini.google.com/',
|
||||
};
|
||||
if (cookies) headers.Cookie = cookie_header(cookies);
|
||||
|
||||
let url = this.url;
|
||||
let res: Response | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
res = await fetch_with_timeout(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
redirect: 'manual',
|
||||
timeout_ms: 30_000,
|
||||
});
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const loc = res.headers.get('location');
|
||||
if (!loc) break;
|
||||
url = new URL(loc, url).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (!res) throw new Error('Image download failed: no response');
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Error downloading image: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const ct = res.headers.get('content-type');
|
||||
if (ct && !ct.includes('image')) {
|
||||
logger.warning(`Content type of ${filename} is not image, but ${ct}.`);
|
||||
}
|
||||
|
||||
const dir = path.resolve(p);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const dest = path.join(dir, filename);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await writeFile(dest, buf);
|
||||
|
||||
if (verbose) logger.info(`Image saved as ${dest}`);
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
|
||||
export class WebImage extends Image {}
|
||||
|
||||
export class GeneratedImage extends Image {
|
||||
constructor(
|
||||
url: string,
|
||||
title: string,
|
||||
alt: string,
|
||||
proxy: string | null,
|
||||
public cookies: Record<string, string>,
|
||||
) {
|
||||
super(url, title, alt, proxy);
|
||||
if (!cookies || Object.keys(cookies).length === 0) {
|
||||
throw new Error('GeneratedImage is designed to be initialized with same cookies as GeminiClient.');
|
||||
}
|
||||
}
|
||||
|
||||
async save(
|
||||
p: string = 'temp',
|
||||
filename: string | null = null,
|
||||
cookies: Record<string, string> | null = null,
|
||||
verbose: boolean = false,
|
||||
skip_invalid_filename: boolean = false,
|
||||
full_size: boolean = true,
|
||||
): Promise<string | null> {
|
||||
const u = full_size ? `${this.url}=s2048` : this.url;
|
||||
const f = filename ?? `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}_${u.slice(-10)}.png`;
|
||||
const img = new Image(u, this.title, this.alt, this.proxy);
|
||||
return await img.save(p, f, cookies ?? this.cookies, verbose, skip_invalid_filename);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { Candidate } from './candidate.js';
|
||||
export { Gem, GemJar } from './gem.js';
|
||||
export { RPCData } from './grpc.js';
|
||||
export { GeneratedImage, Image, WebImage } from './image.js';
|
||||
export { ModelOutput } from './modeloutput.js';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Image } from './image.js';
|
||||
import type { Candidate } from './candidate.js';
|
||||
|
||||
export class ModelOutput {
|
||||
public metadata: string[];
|
||||
public candidates: Candidate[];
|
||||
public chosen: number;
|
||||
|
||||
constructor(params: { metadata: string[]; candidates: Candidate[]; chosen?: number }) {
|
||||
this.metadata = params.metadata;
|
||||
this.candidates = params.candidates;
|
||||
this.chosen = params.chosen ?? 0;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
get text(): string {
|
||||
return this.candidates[this.chosen]?.text ?? '';
|
||||
}
|
||||
|
||||
get thoughts(): string | null {
|
||||
return this.candidates[this.chosen]?.thoughts ?? null;
|
||||
}
|
||||
|
||||
get images(): Image[] {
|
||||
return this.candidates[this.chosen]?.images ?? [];
|
||||
}
|
||||
|
||||
get rcid(): string {
|
||||
return this.candidates[this.chosen]?.rcid ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
export type CookieMap = Record<string, string>;
|
||||
|
||||
export type CookieFileData =
|
||||
| {
|
||||
cookies: CookieMap;
|
||||
updated_at: number;
|
||||
source?: string;
|
||||
}
|
||||
| {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
cookieMap: CookieMap;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export async function read_cookie_file(p: string = resolveGeminiWebCookiePath()): Promise<CookieMap | null> {
|
||||
try {
|
||||
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return null;
|
||||
const raw = await readFile(p, 'utf8');
|
||||
const data = JSON.parse(raw) as unknown;
|
||||
|
||||
if (data && typeof data === 'object' && 'cookies' in (data as any)) {
|
||||
const cookies = (data as any).cookies as unknown;
|
||||
if (cookies && typeof cookies === 'object') {
|
||||
const out: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(cookies as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && 'cookieMap' in (data as any)) {
|
||||
const cookies = (data as any).cookieMap as unknown;
|
||||
if (cookies && typeof cookies === 'object') {
|
||||
const out: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(cookies as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const out: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function write_cookie_file(
|
||||
cookies: CookieMap,
|
||||
p: string = resolveGeminiWebCookiePath(),
|
||||
source?: string,
|
||||
): Promise<void> {
|
||||
const dir = path.dirname(p);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const payload: CookieFileData = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
cookieMap: cookies,
|
||||
source,
|
||||
};
|
||||
await writeFile(p, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export const readCookieFile = read_cookie_file;
|
||||
export const writeCookieFile = write_cookie_file;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { APIError, ImageGenerationError } from '../exceptions.js';
|
||||
import { sleep } from './http.js';
|
||||
|
||||
export function running(retry: number = 0) {
|
||||
return <TArgs extends unknown[], TResult>(
|
||||
fn: (client: any, ...args: TArgs) => Promise<TResult>,
|
||||
): ((client: any, ...args: TArgs) => Promise<TResult>) => {
|
||||
const wrap = async (client: any, ...args: TArgs): Promise<TResult> => {
|
||||
try {
|
||||
if (!client?._running) {
|
||||
await client.init?.({
|
||||
timeout: client.timeout,
|
||||
auto_close: client.auto_close,
|
||||
close_delay: client.close_delay,
|
||||
auto_refresh: client.auto_refresh,
|
||||
refresh_interval: client.refresh_interval,
|
||||
verbose: false,
|
||||
});
|
||||
}
|
||||
return await fn(client, ...args);
|
||||
} catch (e) {
|
||||
let r = retry;
|
||||
if (e instanceof ImageGenerationError) r = Math.min(1, r);
|
||||
if (e instanceof APIError && r > 0) {
|
||||
await sleep(1000);
|
||||
return await running(r - 1)(fn)(client, ...args);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
return wrap;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { Endpoint, Headers } from '../constants.js';
|
||||
import { AuthError } from '../exceptions.js';
|
||||
import { cookie_header, extract_set_cookie_value, fetch_with_timeout } from './http.js';
|
||||
import { logger } from './logger.js';
|
||||
import { read_cookie_file, write_cookie_file } from './cookie-file.js';
|
||||
import { resolveGeminiWebDataDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
import { load_browser_cookies } from './load-browser-cookies.js';
|
||||
|
||||
async function send_request(cookies: Record<string, string>, verbose: boolean): Promise<[string, Record<string, string>]> {
|
||||
const res = await fetch_with_timeout(Endpoint.INIT, {
|
||||
method: 'GET',
|
||||
headers: { ...Headers.GEMINI, Cookie: cookie_header(cookies) },
|
||||
redirect: 'follow',
|
||||
timeout_ms: 30_000,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Init failed: ${res.status} ${res.statusText}`);
|
||||
const text = await res.text();
|
||||
const m = text.match(/\"SNlM0e\":\"(.*?)\"/);
|
||||
if (!m) throw new Error('Missing SNlM0e in response');
|
||||
if (verbose) logger.debug('Init succeeded. Initializing client...');
|
||||
return [m[1]!, cookies];
|
||||
}
|
||||
|
||||
function merge_cookie_maps(...maps: Array<Record<string, string> | null | undefined>): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of maps) {
|
||||
if (!m) continue;
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
if (typeof v === 'string' && v.length > 0) out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function read_cached_1psidts_file(dir: string, sid: string): string | null {
|
||||
try {
|
||||
const p = path.join(dir, `.cached_1psidts_${sid}.txt`);
|
||||
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return null;
|
||||
const v = fs.readFileSync(p, 'utf8').trim();
|
||||
return v || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function list_cached_1psidts(dir: string): Array<{ sid: string; sidts: string }> {
|
||||
const out: Array<{ sid: string; sidts: string }> = [];
|
||||
try {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return out;
|
||||
for (const f of fs.readdirSync(dir)) {
|
||||
if (!f.startsWith('.cached_1psidts_') || !f.endsWith('.txt')) continue;
|
||||
const sid = f.slice('.cached_1psidts_'.length, -'.txt'.length);
|
||||
if (!sid) continue;
|
||||
const sidts = read_cached_1psidts_file(dir, sid);
|
||||
if (sidts) out.push({ sid, sidts });
|
||||
}
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetch_google_extra_cookies(proxy: string | null, verbose: boolean): Promise<Record<string, string>> {
|
||||
void proxy;
|
||||
try {
|
||||
const res = await fetch_with_timeout(Endpoint.GOOGLE, { timeout_ms: 15_000 });
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
const nid = extract_set_cookie_value(setCookie, 'NID');
|
||||
if (nid) return { NID: nid };
|
||||
} catch (e) {
|
||||
if (verbose) logger.debug(`Skipping google.com preflight: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function get_access_token(
|
||||
base_cookies: Record<string, string>,
|
||||
proxy: string | null = null,
|
||||
verbose: boolean = false,
|
||||
): Promise<[string, Record<string, string>]> {
|
||||
const extra = await fetch_google_extra_cookies(proxy, verbose);
|
||||
|
||||
const cacheDir = resolveGeminiWebDataDir();
|
||||
const candidates: Record<string, string>[] = [];
|
||||
|
||||
const cookieFilePath = resolveGeminiWebCookiePath();
|
||||
const cachedFile = await read_cookie_file(cookieFilePath);
|
||||
const forceLogin = !!(process.env.GEMINI_WEB_LOGIN?.trim() || process.env.GEMINI_WEB_FORCE_LOGIN?.trim());
|
||||
const shouldUseChromeFirst = forceLogin || (!cachedFile && !base_cookies['__Secure-1PSID'] && !base_cookies['__Secure-1PSIDTS']);
|
||||
|
||||
if (shouldUseChromeFirst) {
|
||||
try {
|
||||
const browser = await load_browser_cookies('google.com', verbose);
|
||||
for (const cookies of Object.values(browser)) {
|
||||
candidates.push(merge_cookie_maps(extra, cookies));
|
||||
}
|
||||
} catch (e) {
|
||||
if (verbose) logger.warning(`Failed to load cookies via Chrome CDP: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (base_cookies['__Secure-1PSID'] && base_cookies['__Secure-1PSIDTS']) {
|
||||
candidates.push(merge_cookie_maps(extra, base_cookies));
|
||||
} else if (verbose) {
|
||||
logger.debug('Skipping loading base cookies. Either __Secure-1PSID or __Secure-1PSIDTS is not provided.');
|
||||
}
|
||||
|
||||
if (cachedFile) {
|
||||
candidates.push(merge_cookie_maps(extra, cachedFile));
|
||||
}
|
||||
|
||||
if (base_cookies['__Secure-1PSID'] && !base_cookies['__Secure-1PSIDTS']) {
|
||||
const sid = base_cookies['__Secure-1PSID'];
|
||||
const sidts = read_cached_1psidts_file(cacheDir, sid);
|
||||
if (sidts) {
|
||||
candidates.push(merge_cookie_maps(extra, base_cookies, { '__Secure-1PSIDTS': sidts }));
|
||||
} else if (verbose) {
|
||||
logger.debug('Skipping loading cached cookies. Cache file not found or empty.');
|
||||
}
|
||||
} else if (!base_cookies['__Secure-1PSID']) {
|
||||
const caches = list_cached_1psidts(cacheDir);
|
||||
for (const c of caches) {
|
||||
candidates.push(merge_cookie_maps(extra, { '__Secure-1PSID': c.sid, '__Secure-1PSIDTS': c.sidts }));
|
||||
}
|
||||
if (caches.length === 0 && verbose) {
|
||||
logger.debug('Skipping loading cached cookies. Cookies will be cached after successful initialization.');
|
||||
}
|
||||
}
|
||||
|
||||
const unique: Record<string, string>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const c of candidates) {
|
||||
const key = `${c['__Secure-1PSID'] ?? ''}:${c['__Secure-1PSIDTS'] ?? ''}:${c.NID ?? ''}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(c);
|
||||
}
|
||||
|
||||
const try_candidates = async (): Promise<[string, Record<string, string>]> => {
|
||||
if (unique.length === 0) throw new Error('no candidates');
|
||||
const attempts = unique.map(async (c, i) => {
|
||||
try {
|
||||
if (verbose) logger.debug(`Init attempt (${i + 1}/${unique.length})...`);
|
||||
return await send_request(c, verbose);
|
||||
} catch (e) {
|
||||
if (verbose) logger.debug(`Init attempt (${i + 1}/${unique.length}) failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
return (await Promise.any(attempts)) as [string, Record<string, string>];
|
||||
};
|
||||
|
||||
try {
|
||||
const [token, cookies] = await try_candidates();
|
||||
await write_cookie_file(cookies, resolveGeminiWebCookiePath(), 'init').catch(() => {});
|
||||
return [token, cookies];
|
||||
} catch {
|
||||
if (verbose) logger.debug('Cookie attempts failed. Falling back to Chrome CDP cookie load...');
|
||||
}
|
||||
|
||||
const browser = await load_browser_cookies('google.com', verbose);
|
||||
let valid = 0;
|
||||
for (const cookies of Object.values(browser)) {
|
||||
if (cookies['__Secure-1PSID']) valid++;
|
||||
if (base_cookies['__Secure-1PSID'] && cookies['__Secure-1PSID'] && cookies['__Secure-1PSID'] !== base_cookies['__Secure-1PSID']) {
|
||||
if (verbose) logger.debug('Skipping loaded browser cookies: __Secure-1PSID does not match the one provided.');
|
||||
continue;
|
||||
}
|
||||
unique.push(merge_cookie_maps(extra, cookies));
|
||||
}
|
||||
|
||||
if (valid === 0) {
|
||||
throw new AuthError(
|
||||
'No valid cookies available for initialization. Please pass __Secure-1PSID and __Secure-1PSIDTS manually.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const [token, cookies] = await try_candidates();
|
||||
await write_cookie_file(cookies, resolveGeminiWebCookiePath(), 'init').catch(() => {});
|
||||
return [token, cookies];
|
||||
} catch {
|
||||
throw new AuthError(
|
||||
`Failed to initialize client. SECURE_1PSIDTS could get expired frequently, please make sure cookie values are up to date. (Failed initialization attempts: ${unique.length})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const getAccessToken = get_access_token;
|
||||
@@ -0,0 +1,57 @@
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(t);
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function cookie_header(cookies: Record<string, string>): string {
|
||||
return Object.entries(cookies)
|
||||
.filter(([, v]) => typeof v === 'string' && v.length > 0)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
export const cookieHeader = cookie_header;
|
||||
|
||||
export function extract_set_cookie_value(setCookie: string | null, name: string): string | null {
|
||||
if (!setCookie) return null;
|
||||
const re = new RegExp(`(?:^|[;,\\s])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}=([^;]+)`, 'i');
|
||||
const m = setCookie.match(re);
|
||||
if (!m) return null;
|
||||
return m[1] ?? null;
|
||||
}
|
||||
|
||||
export async function fetch_with_timeout(
|
||||
url: string,
|
||||
init: RequestInit & { timeout_ms?: number } = {},
|
||||
): Promise<Response> {
|
||||
const { timeout_ms, ...rest } = init;
|
||||
if (!timeout_ms || timeout_ms <= 0) return fetch(url, rest);
|
||||
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), timeout_ms);
|
||||
try {
|
||||
return await fetch(url, { ...rest, signal: ctl.signal });
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchWithTimeout = fetch_with_timeout;
|
||||
@@ -0,0 +1,20 @@
|
||||
export { running } from './decorators.js';
|
||||
export { get_access_token, getAccessToken } from './get-access-token.js';
|
||||
export { load_browser_cookies, loadBrowserCookies } from './load-browser-cookies.js';
|
||||
export { logger, set_log_level, setLogLevel } from './logger.js';
|
||||
export { extract_json_from_response, extractJsonFromResponse, get_nested_value, getNestedValue } from './parsing.js';
|
||||
export { rotate_1psidts, rotate1psidts } from './rotate-1psidts.js';
|
||||
export { upload_file, uploadFile, parse_file_name, parseFileName } from './upload-file.js';
|
||||
export { read_cookie_file, readCookieFile, write_cookie_file, writeCookieFile } from './cookie-file.js';
|
||||
export {
|
||||
resolveUserDataRoot,
|
||||
resolveGeminiWebChromeProfileDir,
|
||||
resolveGeminiWebCookiePath,
|
||||
resolveGeminiWebDataDir,
|
||||
resolveGeminiWebSessionPath,
|
||||
resolveGeminiWebSessionsDir,
|
||||
} from './paths.js';
|
||||
export { cookie_header, cookieHeader, fetch_with_timeout, fetchWithTimeout, sleep } from './http.js';
|
||||
|
||||
export const rotate_tasks = new Map<string, unknown>();
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import process from 'node:process';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
import { fetch_with_timeout, sleep } from './http.js';
|
||||
import { read_cookie_file, type CookieMap, write_cookie_file } from './cookie-file.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (msg.id) {
|
||||
const p = this.pending.get(msg.id);
|
||||
if (p) {
|
||||
this.pending.delete(msg.id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, p] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
p.reject(new Error('CDP connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('CDP connection failed.'));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, opts?: CdpSendOptions): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const msg: Record<string, unknown> = { id, method };
|
||||
if (params) msg.params = params;
|
||||
if (opts?.sessionId) msg.sessionId = opts.sessionId;
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
||||
const out = await new Promise<unknown>((resolve, reject) => {
|
||||
const t =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer: t });
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
});
|
||||
return out as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function get_free_port(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
srv.on('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
srv.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function find_chrome_executable(): string | null {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function wait_for_chrome_debug_port(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch_with_timeout(`http://127.0.0.1:${port}/json/version`, { timeout_ms: 5_000 });
|
||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error('Chrome debug port not ready');
|
||||
}
|
||||
|
||||
async function launch_chrome(profileDir: string, port: number): Promise<ChildProcess> {
|
||||
const chrome = find_chrome_executable();
|
||||
if (!chrome) throw new Error('Chrome executable not found.');
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-popup-blocking',
|
||||
'https://gemini.google.com/app',
|
||||
];
|
||||
|
||||
return spawn(chrome, args, { stdio: 'ignore' });
|
||||
}
|
||||
|
||||
async function fetch_google_cookies_via_cdp(
|
||||
profileDir: string,
|
||||
timeoutMs: number,
|
||||
verbose: boolean,
|
||||
): Promise<CookieMap> {
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await get_free_port();
|
||||
const chrome = await launch_chrome(profileDir, port);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
try {
|
||||
const wsUrl = await wait_for_chrome_debug_port(port, 30_000);
|
||||
cdp = await CdpConnection.connect(wsUrl, 15_000);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', {
|
||||
url: 'https://gemini.google.com/app',
|
||||
newWindow: true,
|
||||
});
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
|
||||
await cdp.send('Network.enable', {}, { sessionId });
|
||||
|
||||
if (verbose) {
|
||||
logger.info('Chrome opened. If needed, complete Google login in the window. Waiting for cookies...');
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let last: CookieMap = {};
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { cookies } = await cdp.send<{ cookies: Array<{ name: string; value: string }> }>(
|
||||
'Network.getCookies',
|
||||
{ urls: ['https://gemini.google.com/', 'https://accounts.google.com/', 'https://www.google.com/'] },
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const m: CookieMap = {};
|
||||
for (const c of cookies) {
|
||||
if (c?.name && typeof c.value === 'string') m[c.name] = c.value;
|
||||
}
|
||||
|
||||
last = m;
|
||||
if (m['__Secure-1PSID'] && (m['__Secure-1PSIDTS'] || Date.now() - start > 10_000)) {
|
||||
return m;
|
||||
}
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for Google cookies. Last keys: ${Object.keys(last).join(', ')}`);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
try {
|
||||
chrome.kill('SIGTERM');
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill('SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
export async function load_browser_cookies(domain_name: string = '', verbose: boolean = true): Promise<Record<string, CookieMap>> {
|
||||
const force = process.env.GEMINI_WEB_LOGIN?.trim() || process.env.GEMINI_WEB_FORCE_LOGIN?.trim();
|
||||
if (!force) {
|
||||
const cached = await read_cookie_file();
|
||||
if (cached) return { chrome: cached };
|
||||
}
|
||||
|
||||
const profileDir = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || resolveGeminiWebChromeProfileDir();
|
||||
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
||||
|
||||
const filtered: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(cookies)) {
|
||||
if (typeof v === 'string' && v.length > 0) filtered[k] = v;
|
||||
}
|
||||
|
||||
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp');
|
||||
void domain_name;
|
||||
return { chrome: filtered };
|
||||
}
|
||||
|
||||
export const loadBrowserCookies = load_browser_cookies;
|
||||