mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3811512750 | |||
| 9a9f6a42cd | |||
| 3b13b25f1a | |||
| 688d1760ed | |||
| a701be873b | |||
| 1df5e4974d | |||
| 2677e730b9 | |||
| 080f2eff48 | |||
| bb4f0dc52c |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "0.6.0"
|
||||
"version": "0.9.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
name: release-skills
|
||||
description: Release workflow for baoyu-skills plugin. This skill should be used when the user wants to create a new release version. It analyzes changes since the last version tag, updates changelogs (EN/CN), bumps the version in marketplace.json, commits changes, and creates a version tag. Supports dry-run mode and breaking change detection.
|
||||
---
|
||||
|
||||
# Release Skills
|
||||
|
||||
Automate the release process for baoyu-skills plugin: analyze changes, update changelogs, bump version, commit, and tag.
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger this skill when user requests:
|
||||
- "release", "发布", "create release", "new version"
|
||||
- "bump version", "update version"
|
||||
- "prepare release"
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Changes Since Last Tag
|
||||
|
||||
```bash
|
||||
# Get the latest version tag
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
|
||||
# Show changes since last tag
|
||||
git log ${LAST_TAG}..HEAD --oneline
|
||||
git diff ${LAST_TAG}..HEAD --stat
|
||||
```
|
||||
|
||||
Categorize changes by type based on commit messages and file changes:
|
||||
|
||||
| Type | Prefix | Description |
|
||||
|------|--------|-------------|
|
||||
| feat | `feat:` | New features, new skills |
|
||||
| fix | `fix:` | Bug fixes |
|
||||
| docs | `docs:` | Documentation only |
|
||||
| refactor | `refactor:` | Code refactoring |
|
||||
| style | `style:` | Formatting, styling |
|
||||
| chore | `chore:` | Build, tooling, maintenance |
|
||||
|
||||
**Breaking Change Detection**: If changes include:
|
||||
- Removed skills or scripts
|
||||
- Changed API/interfaces
|
||||
- Renamed public functions/options
|
||||
|
||||
Warn user: "Breaking changes detected. Consider major version bump (--major flag)."
|
||||
|
||||
### Step 2: Determine Version Bump
|
||||
|
||||
Current version location: `.claude-plugin/marketplace.json` → `metadata.version`
|
||||
|
||||
Version rules:
|
||||
- **Patch** (0.6.1 → 0.6.2): Bug fixes, docs updates, minor improvements
|
||||
- **Minor** (0.6.x → 0.7.0): New features, new skills, significant enhancements
|
||||
- **Major** (0.x → 1.0): Breaking changes, only when user explicitly requests with `--major`
|
||||
|
||||
Default behavior:
|
||||
- If changes include `feat:` or new skills → Minor bump
|
||||
- Otherwise → Patch bump
|
||||
|
||||
### Step 3: Check and Update README
|
||||
|
||||
Before updating changelogs, check if README files need updates based on changes:
|
||||
|
||||
**When to update README**:
|
||||
- New skills added → Add to skill list
|
||||
- Skills removed → Remove from skill list
|
||||
- Skill renamed → Update references
|
||||
- New features affecting usage → Update usage section
|
||||
- Breaking changes → Update migration notes
|
||||
|
||||
**Files to sync**:
|
||||
- `README.md` (English)
|
||||
- `README.zh.md` (Chinese)
|
||||
|
||||
If changes include new skills or significant feature changes, update both README files to reflect the new capabilities. Keep both files in sync with the same structure and information.
|
||||
|
||||
### Step 4: Update Changelogs
|
||||
|
||||
Files to update:
|
||||
- `CHANGELOG.md` (English)
|
||||
- `CHANGELOG.zh.md` (Chinese)
|
||||
|
||||
Format (insert after header, before previous version):
|
||||
|
||||
```markdown
|
||||
## {NEW_VERSION} - {YYYY-MM-DD}
|
||||
|
||||
### Features
|
||||
- `skill-name`: description of new feature
|
||||
|
||||
### Fixes
|
||||
- `skill-name`: description of fix
|
||||
|
||||
### Documentation
|
||||
- description of docs changes
|
||||
|
||||
### Other
|
||||
- description of other changes
|
||||
```
|
||||
|
||||
Only include sections that have changes. Omit empty sections.
|
||||
|
||||
For Chinese changelog, translate the content maintaining the same structure.
|
||||
|
||||
### Step 5: Update marketplace.json
|
||||
|
||||
Update `.claude-plugin/marketplace.json`:
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"version": "{NEW_VERSION}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Commit Changes
|
||||
|
||||
```bash
|
||||
git add README.md README.zh.md CHANGELOG.md CHANGELOG.zh.md .claude-plugin/marketplace.json
|
||||
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
|
||||
git tag v{NEW_VERSION}
|
||||
```
|
||||
|
||||
**Important**: Do NOT push to remote. User will push manually when ready.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--dry-run` | Preview changes without executing. Show what would be updated. |
|
||||
| `--major` | Force major version bump (0.x → 1.0 or 1.x → 2.0) |
|
||||
| `--minor` | Force minor version bump |
|
||||
| `--patch` | Force patch version bump |
|
||||
| `--pre <tag>` | (Reserved) Create pre-release version, e.g., `--pre beta` → `0.7.0-beta.1` |
|
||||
|
||||
## Dry-Run Mode
|
||||
|
||||
When `--dry-run` is specified:
|
||||
1. Show all changes since last tag
|
||||
2. Show proposed version bump (current → new)
|
||||
3. Show draft changelog entries (EN and CN)
|
||||
4. Show files that would be modified
|
||||
5. Do NOT make any actual changes
|
||||
|
||||
Output format:
|
||||
```
|
||||
=== DRY RUN MODE ===
|
||||
|
||||
Last tag: v0.6.1
|
||||
Proposed version: v0.7.0
|
||||
|
||||
Changes detected:
|
||||
- feat: new skill baoyu-foo added
|
||||
- fix: baoyu-bar timeout issue
|
||||
- docs: updated README
|
||||
|
||||
Changelog preview (EN):
|
||||
## 0.7.0 - 2026-01-17
|
||||
### Features
|
||||
- `baoyu-foo`: new skill for ...
|
||||
### Fixes
|
||||
- `baoyu-bar`: fixed timeout issue
|
||||
|
||||
README updates needed: Yes/No
|
||||
(If yes, show proposed changes)
|
||||
|
||||
Files to modify:
|
||||
- README.md (if updates needed)
|
||||
- README.zh.md (if updates needed)
|
||||
- CHANGELOG.md
|
||||
- CHANGELOG.zh.md
|
||||
- .claude-plugin/marketplace.json
|
||||
|
||||
No changes made. Run without --dry-run to execute.
|
||||
```
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/release-skills # Auto-detect version bump
|
||||
/release-skills --dry-run # Preview only
|
||||
/release-skills --minor # Force minor bump
|
||||
/release-skills --major # Force major bump (with confirmation)
|
||||
```
|
||||
|
||||
## Post-Release Reminder
|
||||
|
||||
After successful release, remind user:
|
||||
```
|
||||
Release v{NEW_VERSION} created locally.
|
||||
|
||||
To publish:
|
||||
git push origin main
|
||||
git push origin v{NEW_VERSION}
|
||||
```
|
||||
@@ -140,3 +140,6 @@ vite.config.ts.timestamp-*
|
||||
tests-data/
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Skill extensions (user customization)
|
||||
.baoyu-skills/
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Changelog
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 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-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-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
|
||||
- `baoyu-xhs-images`: adds content analysis framework (`analysis-framework.md`, `outline-template.md`) for structured content breakdown and outline generation.
|
||||
|
||||
### Documentation
|
||||
- `CLAUDE.md`: adds Output Path Convention (directory structure, backup rules) and Image Naming Convention (format, slug rules) to standardize image generation outputs.
|
||||
- Multiple skills: updates file management conventions to use unified directory structure (`[source-name-no-ext]/<skill-suffix>/`).
|
||||
- `baoyu-article-illustrator`, `baoyu-comic`, `baoyu-cover-image`, `baoyu-slide-deck`, `baoyu-xhs-images`
|
||||
|
||||
## 0.7.0 - 2026-01-17
|
||||
|
||||
### Features
|
||||
- `baoyu-comic`: adds `--aspect` (3:4, 4:3, 16:9) and `--lang` options; introduces multi-variant storyboard workflow (chronological, thematic, character-centric) with user selection.
|
||||
|
||||
### Enhancements
|
||||
- `baoyu-comic`: adds `analysis-framework.md` and `storyboard-template.md` for structured content analysis and variant generation.
|
||||
- `baoyu-slide-deck`: adds `analysis-framework.md`, `content-rules.md`, `modification-guide.md`, and `outline-template.md` references for improved outline quality.
|
||||
- `baoyu-article-illustrator`, `baoyu-cover-image`, `baoyu-xhs-images`: enhanced SKILL.md documentation with clearer workflows.
|
||||
|
||||
### Documentation
|
||||
- Multiple skills: restructured SKILL.md files—moved detailed content to `references/` directory for maintainability.
|
||||
- `baoyu-slide-deck`: simplified SKILL.md, consolidated style descriptions.
|
||||
|
||||
## 0.6.1 - 2026-01-17
|
||||
|
||||
- `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.
|
||||
|
||||
## 0.6.0 - 2026-01-17
|
||||
|
||||
- `baoyu-slide-deck`: adds `scripts/merge-to-pptx.ts` to merge slide images into a PPTX and attach `prompts/` content as speaker notes.
|
||||
- `baoyu-slide-deck`: reshapes/expands the style library (adds `blueprint` / `bold-editorial` / `sketch-notes` / `vector-illustration`, and adjusts/replaces some older styles).
|
||||
- `baoyu-comic`: adds a `realistic` style reference.
|
||||
- Docs: refreshes `README.md` / `README.zh.md`.
|
||||
|
||||
## 0.5.3 - 2026-01-17
|
||||
|
||||
- `baoyu-post-to-x` (X Articles): makes image placeholder replacement more reliable (selection retry + verification; deletes via Backspace and verifies deletion before pasting), reducing mis-insertions/failures.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
- `baoyu-comic`: adds creation templates/references (character template, Ohmsha guide, outline template) to speed up “characters → storyboard → generation”.
|
||||
|
||||
## 0.5.0 - 2026-01-16
|
||||
|
||||
- Adds `baoyu-comic`: a knowledge-comic generator with `style × layout` and a full set of style/layout references for more stable output.
|
||||
- `baoyu-xhs-images`: moves style/layout details into `references/styles/*` and `references/layouts/*`, and migrates the base prompt into `references/base-prompt.md` for easier maintenance/reuse.
|
||||
- `baoyu-slide-deck` / `baoyu-cover-image`: similarly split base prompt and style references into `references/`, reducing SKILL.md complexity and making style expansion easier.
|
||||
- Docs: updates `README.md` / `README.zh.md` skill list and examples.
|
||||
|
||||
## 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`).
|
||||
|
||||
## 0.4.1 - 2026-01-15
|
||||
|
||||
- `baoyu-post-to-x` / `baoyu-post-to-wechat`: adds `scripts/paste-from-clipboard.ts` to send a “real paste” keystroke (Cmd/Ctrl+V), avoiding sites ignoring CDP synthetic events.
|
||||
- `baoyu-post-to-x`: adds docs for X Articles/regular posts, and switches image upload to prefer real paste (with a CDP fallback).
|
||||
- `baoyu-post-to-wechat`: docs add script-location guidance and `${SKILL_DIR}` path usage for reliable agent execution.
|
||||
- Docs: adds `screenshots/update-plugins.png` for the marketplace update flow.
|
||||
|
||||
## 0.4.0 - 2026-01-15
|
||||
|
||||
- Adds `baoyu-` prefix to skill directories and updates marketplace paths/docs accordingly to reduce naming collisions.
|
||||
|
||||
## 0.3.1 - 2026-01-15
|
||||
|
||||
- `xhs-images`: upgrades docs to a Style × Layout system (adds `--layout`, auto layout selection, and a `notion` style), with more complete usage examples.
|
||||
- `article-illustrator` / `cover-image`: docs no longer hard-code `gemini-web`; instead they instruct the agent to pick an available image-generation skill.
|
||||
- `slide-deck`: docs add the `notion` style and update auto-style mapping.
|
||||
- Tooling/docs: adds `.DS_Store` to `.gitignore`; refreshes `README.md` / `README.zh.md`.
|
||||
|
||||
## 0.3.0 - 2026-01-14
|
||||
|
||||
- Adds `post-to-wechat`: Chrome CDP automation for WeChat Official Account posting (image-text + full article), including Markdown → WeChat HTML conversion and multiple themes.
|
||||
- Adds `CLAUDE.md`: repository structure, running conventions, and “add new skill” guidelines.
|
||||
- Docs: updates `README.md` / `README.zh.md` install/update/usage instructions.
|
||||
|
||||
## 0.2.0 - 2026-01-13
|
||||
|
||||
- Adds new skills: `post-to-x` (real Chrome/CDP automation for posts and X Articles), `article-illustrator`, `cover-image`, and `slide-deck`.
|
||||
- `xhs-images`: adds multi-style support (`--style`) with auto style selection and updates the base prompt (e.g. language follows input, hand-drawn infographic constraints).
|
||||
- Docs: adds `README.zh.md` and improves `README.md` and `.gitignore`.
|
||||
|
||||
## 0.1.1 - 2026-01-13
|
||||
|
||||
- Marketplace refactor: introduces `metadata` (including `version`), renames the plugin entry to `content-skills` and explicitly lists installable skills; removes legacy `.claude-plugin/plugin.json`.
|
||||
- Adds `xhs-images`: Xiaohongshu infographic series generator (outline + per-image prompts).
|
||||
- `gemini-web`: adds `--promptfiles` to build prompts from multiple files (system/content separation).
|
||||
- Docs: adds `README.md`.
|
||||
|
||||
## 0.1.0 - 2026-01-13
|
||||
|
||||
- Initial release: `.claude-plugin/marketplace.json` plus `gemini-web` (text/image generation, browser login + cookie cache).
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# Changelog
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 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-gemini-web`:重组脚本架构——将模块文件移至 `gemini-webapi/` 子目录,并更新 SKILL.md 使用 `${SKILL_DIR}` 路径引用。
|
||||
|
||||
## 0.8.1 - 2026-01-17
|
||||
|
||||
### 重构
|
||||
- `baoyu-gemini-web`:重构脚本架构——将 10 个分散的脚本文件整合为结构化的 `gemini-webapi/` 模块(gemini_webapi Python 库的 TypeScript 移植版)。
|
||||
|
||||
## 0.8.0 - 2026-01-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-xhs-images`:新增内容分析框架(`analysis-framework.md`、`outline-template.md`),提供结构化内容拆解与大纲生成方案。
|
||||
|
||||
### 文档
|
||||
- `CLAUDE.md`:新增 Output Path Convention(目录结构、备份规则)和 Image Naming Convention(文件命名格式、slug 规则),统一图片生成输出规范。
|
||||
- 多个技能:更新文件管理规范,采用统一目录结构(`[source-name-no-ext]/<skill-suffix>/`)。
|
||||
- `baoyu-article-illustrator`、`baoyu-comic`、`baoyu-cover-image`、`baoyu-slide-deck`、`baoyu-xhs-images`
|
||||
|
||||
## 0.7.0 - 2026-01-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-comic`:新增 `--aspect`(3:4、4:3、16:9)和 `--lang` 选项;引入多变体分镜工作流(时间线、主题、人物视角),支持用户选择最佳方案。
|
||||
|
||||
### 增强
|
||||
- `baoyu-comic`:新增 `analysis-framework.md` 和 `storyboard-template.md`,提供结构化内容分析与变体生成框架。
|
||||
- `baoyu-slide-deck`:新增 `analysis-framework.md`、`content-rules.md`、`modification-guide.md`、`outline-template.md` 参考文档,提升大纲质量。
|
||||
- `baoyu-article-illustrator`、`baoyu-cover-image`、`baoyu-xhs-images`:SKILL.md 文档增强,工作流程更清晰。
|
||||
|
||||
### 文档
|
||||
- 多个技能:重构 SKILL.md 结构,将详细内容移至 `references/` 目录,便于维护。
|
||||
- `baoyu-slide-deck`:精简 SKILL.md,整合风格描述。
|
||||
|
||||
## 0.6.1 - 2026-01-17
|
||||
|
||||
- `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 在任意安装目录运行。
|
||||
|
||||
## 0.6.0 - 2026-01-17
|
||||
|
||||
- `baoyu-slide-deck`:新增 `scripts/merge-to-pptx.ts`,将生成的 slide 图片合并为 PPTX,并可把 `prompts/` 写入 speaker notes。
|
||||
- `baoyu-slide-deck`:风格库重组与扩充(新增 `blueprint` / `bold-editorial` / `sketch-notes` / `vector-illustration`,并调整/替换部分旧风格定义)。
|
||||
- `baoyu-comic`:新增 `realistic` 风格参考文件。
|
||||
- 文档:README / README.zh 同步更新技能说明与用法示例。
|
||||
|
||||
## 0.5.3 - 2026-01-17
|
||||
|
||||
- `baoyu-post-to-x`(X Articles):插图占位符替换更稳定——选中占位符增加重试与校验,改用 Backspace 删除并确认删除后再粘贴图片,降低插图错位/替换失败概率。
|
||||
|
||||
## 0.5.2 - 2026-01-16
|
||||
|
||||
- `baoyu-gemini-web`:新增 `--sessionId`(本地持久化会话,支持 `--list-sessions`),用于多轮对话/多图生成保持上下文一致。
|
||||
- `baoyu-gemini-web`:新增 `--reference/--ref` 传入参考图片(vision 输入),并增强超时与 cookie 失效自动恢复逻辑。
|
||||
- `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic`:文档补充 session 约定(整套图使用同一 `sessionId`,增强风格一致性)。
|
||||
|
||||
## 0.5.1 - 2026-01-16
|
||||
|
||||
- `baoyu-comic`:补齐创作模板与参考(角色模板、Ohmsha 教学漫画指南、大纲模板),更适合从“设定 → 分镜 → 生成”快速落地。
|
||||
|
||||
## 0.5.0 - 2026-01-16
|
||||
|
||||
- 新增 `baoyu-comic`:知识漫画生成器,支持 `style × layout` 组合,并提供风格/布局参考文件用于稳定出图。
|
||||
- `baoyu-xhs-images`:将 Style/Layout 的细节从 SKILL.md 拆分到 `references/styles/*` 与 `references/layouts/*`,并将基础提示词迁移到 `references/base-prompt.md`,便于维护和复用。
|
||||
- `baoyu-slide-deck` / `baoyu-cover-image`:同样将基础提示词与风格拆分到 `references/`,降低 SKILL.md 复杂度,便于扩展更多风格。
|
||||
- 文档:README / README.zh 更新技能清单与用法示例。
|
||||
|
||||
## 0.4.2 - 2026-01-15
|
||||
|
||||
- `baoyu-gemini-web`:描述信息更新,明确其作为 `cover-image` / `xhs-images` / `article-illustrator` 等技能的图片生成后端。
|
||||
|
||||
## 0.4.1 - 2026-01-15
|
||||
|
||||
- `baoyu-post-to-x` / `baoyu-post-to-wechat`:新增 `scripts/paste-from-clipboard.ts`,通过系统级 Cmd/Ctrl+V 发送“真实粘贴”按键,规避 CDP 合成事件在站点侧被忽略的问题。
|
||||
- `baoyu-post-to-x`:补充 X Articles/普通推文的操作文档(`references/articles.md`、`references/regular-posts.md`),并将发图流程改为优先使用“真实粘贴”(保留 CDP 兜底)。
|
||||
- `baoyu-post-to-wechat`:文档补充脚本目录说明与 `${SKILL_DIR}` 路径写法,便于 agent 可靠定位脚本。
|
||||
- 文档:新增插件更新流程截图 `screenshots/update-plugins.png`。
|
||||
|
||||
## 0.4.0 - 2026-01-15
|
||||
|
||||
- 技能命名统一加 `baoyu-` 前缀:目录结构、marketplace 清单与文档示例命令同步更新,减少与其它插件技能的命名冲突。
|
||||
|
||||
## 0.3.1 - 2026-01-15
|
||||
|
||||
- `xhs-images`:升级为 Style × Layout 二维系统(新增 `--layout`、自动布局选择与 Notion 风格),文档示例更完整。
|
||||
- `article-illustrator` / `slide-deck` / `cover-image`:文档改为“选择可用的图片生成技能”而非强绑定 `gemini-web`,并补充 Notion 风格相关说明。
|
||||
- 工程化:`.gitignore` 增加 `.DS_Store` 忽略;README / README.zh 同步调整。
|
||||
|
||||
## 0.3.0 - 2026-01-14
|
||||
|
||||
- 新增 `post-to-wechat`:基于 Chrome CDP 自动化发布公众号图文/文章,包含 Markdown → 微信 HTML 转换与多主题样式支持。
|
||||
- 新增 `CLAUDE.md`:补充仓库结构、运行方式与添加新技能的约定,方便协作与二次开发。
|
||||
- 文档:README / README.zh 更新安装、更新与使用说明。
|
||||
|
||||
## 0.2.0 - 2026-01-13
|
||||
|
||||
- 新增技能:`post-to-x`(真实 Chrome/CDP 自动化发布推文与 X Articles)、`article-illustrator`(文章智能插图规划)、`cover-image`(文章封面图生成)、`slide-deck`(幻灯片大纲与图片生成)。
|
||||
- `xhs-images`:新增 `--style` 多风格与自动风格选择,并更新基础提示词(例如语言随内容、强调手绘信息图等)。
|
||||
- 文档:新增 `README.zh.md`,并完善 README 与 `.gitignore`。
|
||||
|
||||
## 0.1.1 - 2026-01-13
|
||||
|
||||
- marketplace 结构重构:引入 `metadata`(含 `version`),插件名调整为 `content-skills` 并显式列出可安装 skills;移除旧 `.claude-plugin/plugin.json`。
|
||||
- 新增 `xhs-images`:小红书信息图系列生成技能(拆解内容、生成 outline 与提示词)。
|
||||
- `gemini-web`:新增 `--promptfiles`,支持从多个文件拼接 prompt(便于 system/content 分离)。
|
||||
- 文档:新增 `README.md`。
|
||||
|
||||
## 0.1.0 - 2026-01-13
|
||||
|
||||
- 初始发布:提供 `.claude-plugin/marketplace.json` 与 `gemini-web`(文本/图片生成、cookie 登录与缓存流程)。
|
||||
@@ -71,6 +71,30 @@ npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md conte
|
||||
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>`
|
||||
5. **Add Script Directory section** to SKILL.md (see template below)
|
||||
|
||||
### Script Directory Template
|
||||
|
||||
Every SKILL.md with scripts MUST include this section after Usage:
|
||||
|
||||
```markdown
|
||||
## 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` | Main entry point |
|
||||
| `scripts/other.ts` | Other functionality |
|
||||
```
|
||||
|
||||
When referencing scripts in workflow sections, use `${SKILL_DIR}/scripts/<name>.ts` so agents can resolve the correct path.
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -78,3 +102,107 @@ npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md conte
|
||||
- Async/await patterns
|
||||
- Short variable names
|
||||
- Type-safe interfaces
|
||||
|
||||
## Image Generation Guidelines
|
||||
|
||||
Skills that require image generation MUST delegate to available image generation skills rather than implementing their own.
|
||||
|
||||
### Image Generation Skill Selection
|
||||
|
||||
1. Check available image generation skills in `skills/` directory
|
||||
2. Read each skill's SKILL.md to understand parameters and capabilities
|
||||
3. If multiple image generation skills available, ask user to choose preferred skill
|
||||
|
||||
### Generation Flow Template
|
||||
|
||||
Use this template when implementing image generation in skills:
|
||||
|
||||
```markdown
|
||||
### Step N: Generate Images
|
||||
|
||||
**Skill Selection**:
|
||||
1. Check available image generation skills (e.g., `baoyu-gemini-web`)
|
||||
2. Read selected skill's SKILL.md for parameter reference
|
||||
3. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call selected image generation skill with:
|
||||
- Prompt file path (or inline prompt)
|
||||
- Output image path
|
||||
- Any skill-specific parameters (refer to skill's SKILL.md)
|
||||
2. Generate images sequentially (one at a time)
|
||||
3. After each image, output progress: "Generated X/N"
|
||||
4. On failure, auto-retry once before reporting error
|
||||
```
|
||||
|
||||
### Output Path Convention
|
||||
|
||||
Generated images from the same skill and source file MUST be grouped together:
|
||||
|
||||
**With source file** (e.g., `/path/to/project/content/my-article.md`):
|
||||
```
|
||||
/path/to/project/content/my-article/<skill-suffix>/
|
||||
```
|
||||
- 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/`
|
||||
|
||||
**Without source file**:
|
||||
```
|
||||
./<skill-suffix>/<source-slug>/
|
||||
```
|
||||
- Place under current project directory
|
||||
- Use descriptive slug for the content
|
||||
|
||||
**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`
|
||||
|
||||
### Image Naming Convention
|
||||
|
||||
Image filenames MUST include meaningful slugs for readability:
|
||||
|
||||
**Format**: `NN-{type}-[slug].png`
|
||||
- `NN`: Two-digit sequence number (01, 02, ...)
|
||||
- `{type}`: Image type (cover, content, page, slide, illustration, etc.)
|
||||
- `[slug]`: Descriptive kebab-case slug derived from content
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
01-cover-ai-future.png
|
||||
02-content-key-benefits.png
|
||||
03-page-enigma-machine.png
|
||||
04-slide-architecture-overview.png
|
||||
```
|
||||
|
||||
**Slug Rules**:
|
||||
- Derived from image purpose or content (kebab-case)
|
||||
- Must be unique within the output directory
|
||||
- 2-5 words, concise but descriptive
|
||||
- When content changes significantly, update slug accordingly
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Always read the image generation skill's SKILL.md before calling
|
||||
- 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`).
|
||||
|
||||
@@ -178,14 +178,28 @@ Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohms
|
||||
/baoyu-comic posts/turing-story/source.md --style dramatic
|
||||
/baoyu-comic posts/turing-story/source.md --style ohmsha
|
||||
|
||||
# Specify layout
|
||||
# Custom style (natural language)
|
||||
/baoyu-comic posts/turing-story/source.md --style "watercolor with soft edges"
|
||||
|
||||
# Specify layout and aspect ratio
|
||||
/baoyu-comic posts/turing-story/source.md --layout cinematic
|
||||
/baoyu-comic posts/turing-story/source.md --layout webtoon
|
||||
/baoyu-comic posts/turing-story/source.md --aspect 16:9
|
||||
|
||||
# Specify language
|
||||
/baoyu-comic posts/turing-story/source.md --lang zh
|
||||
|
||||
# Direct content input
|
||||
/baoyu-comic "The story of Alan Turing and the birth of computer science"
|
||||
```
|
||||
|
||||
**Options**:
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--style` | `classic` (default), `dramatic`, `warm`, `tech`, `sepia`, `vibrant`, `ohmsha`, `realistic`, 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. |
|
||||
|
||||
**Styles** (visual aesthetics):
|
||||
|
||||
| Style | Description | Best For |
|
||||
@@ -231,6 +245,34 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
|
||||
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
||||
|
||||
## 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
|
||||
|
||||
+44
-2
@@ -178,14 +178,28 @@ npx add-skill jimliu/baoyu-skills
|
||||
/baoyu-comic posts/turing-story/source.md --style dramatic
|
||||
/baoyu-comic posts/turing-story/source.md --style ohmsha
|
||||
|
||||
# 指定布局
|
||||
# 自定义风格(自然语言描述)
|
||||
/baoyu-comic posts/turing-story/source.md --style "水彩风格,边缘柔和"
|
||||
|
||||
# 指定布局和比例
|
||||
/baoyu-comic posts/turing-story/source.md --layout cinematic
|
||||
/baoyu-comic posts/turing-story/source.md --layout webtoon
|
||||
/baoyu-comic posts/turing-story/source.md --aspect 16:9
|
||||
|
||||
# 指定语言
|
||||
/baoyu-comic posts/turing-story/source.md --lang zh
|
||||
|
||||
# 直接输入内容
|
||||
/baoyu-comic "图灵的故事与计算机科学的诞生"
|
||||
```
|
||||
|
||||
**选项**:
|
||||
| 选项 | 取值 |
|
||||
|------|------|
|
||||
| `--style` | `classic`(默认)、`dramatic`、`warm`、`tech`、`sepia`、`vibrant`、`ohmsha`、`realistic`,或自然语言描述 |
|
||||
| `--layout` | `standard`(默认)、`cinematic`、`dense`、`splash`、`mixed`、`webtoon` |
|
||||
| `--aspect` | `3:4`(默认,竖版)、`4:3`(横版)、`16:9`(宽屏) |
|
||||
| `--lang` | `auto`(默认)、`zh`、`en`、`ja` 等 |
|
||||
|
||||
**风格**(视觉美学):
|
||||
|
||||
| 风格 | 描述 | 适用场景 |
|
||||
@@ -231,6 +245,34 @@ npx add-skill jimliu/baoyu-skills
|
||||
|
||||
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
|
||||
## 自定义扩展
|
||||
|
||||
所有技能支持通过 `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
|
||||
|
||||
@@ -102,22 +102,44 @@ When no `--style` is specified, analyze content to select the best style:
|
||||
|
||||
## File Management
|
||||
|
||||
Save illustrations to `imgs/` subdirectory in the same folder as the article:
|
||||
### With Article Path
|
||||
|
||||
Save illustrations to `[source-name-no-ext]/illustrations/` subdirectory in the same folder as the article:
|
||||
|
||||
```
|
||||
path/to/
|
||||
├── article.md
|
||||
└── imgs/
|
||||
└── article/
|
||||
└── illustrations/
|
||||
├── outline.md
|
||||
├── 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/`
|
||||
|
||||
### Without Article Path (Pasted Content)
|
||||
|
||||
Save to `./illustrations/[topic-slug]/`:
|
||||
|
||||
```
|
||||
illustrations/
|
||||
└── ai-future/
|
||||
├── source.md
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── illustration-concept-a.md
|
||||
│ ├── illustration-concept-b.md
|
||||
│ └── ...
|
||||
├── illustration-concept-a.png
|
||||
├── illustration-concept-b.png
|
||||
└── ...
|
||||
└── *.png
|
||||
```
|
||||
|
||||
### Directory Backup
|
||||
|
||||
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content & Select Style
|
||||
@@ -125,7 +147,11 @@ path/to/
|
||||
1. Read article content
|
||||
2. If `--style` specified, use that style
|
||||
3. Otherwise, scan for style signals and auto-select
|
||||
4. Extract key information:
|
||||
4. **Language detection**:
|
||||
- Detect **source language** from article content
|
||||
- Detect **user language** from conversation context
|
||||
- Note if source_language ≠ user_language (will ask in Step 4)
|
||||
5. Extract key information:
|
||||
- Main topic and themes
|
||||
- Core messages per section
|
||||
- Abstract concepts needing visualization
|
||||
@@ -173,10 +199,55 @@ path/to/
|
||||
...
|
||||
```
|
||||
|
||||
### Step 4: Create Prompt Files
|
||||
### Step 4: Review & Confirm
|
||||
|
||||
**Purpose**: Let user confirm all options in a single step before image generation.
|
||||
|
||||
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations.
|
||||
|
||||
1. **Generate 3 style variants**:
|
||||
- Analyze content to select 3 most suitable styles
|
||||
- Generate complete illustration plan for each style variant
|
||||
- Save as `outline-{style}.md` (e.g., `outline-notion.md`, `outline-tech.md`, `outline-warm.md`)
|
||||
|
||||
2. **Determine which questions to ask**:
|
||||
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Style variant | Always (required) |
|
||||
| Language | Only if `source_language ≠ user_language` |
|
||||
|
||||
3. **Present options** (use AskUserQuestion with all applicable questions):
|
||||
|
||||
**Question 1 (Style)** - always:
|
||||
- Style A (recommended): [style name] - [brief description]
|
||||
- Style B: [style name] - [brief description]
|
||||
- Style C: [style name] - [brief description]
|
||||
- Custom: Provide custom style reference
|
||||
|
||||
**Question 2 (Language)** - only if source ≠ user language:
|
||||
- [Source language] (matches article language)
|
||||
- [User language] (your preference)
|
||||
|
||||
**Language handling**:
|
||||
- If source language = user language: Just inform user (e.g., "Prompts will be in Chinese")
|
||||
- If different: Ask which language to use for prompts
|
||||
|
||||
4. **Apply selection**:
|
||||
- Copy selected `outline-{style}.md` to `outline.md`
|
||||
- If custom style provided, generate new plan with that style
|
||||
- If different language selected, regenerate outline in that language
|
||||
- User may edit `outline.md` directly for fine-tuning
|
||||
- If modified, reload plan before proceeding
|
||||
|
||||
5. **Proceed only after explicit user confirmation**
|
||||
|
||||
### Step 5: Create Prompt Files
|
||||
|
||||
Save prompts to `prompts/` directory with style-specific details.
|
||||
|
||||
**All prompts are written in the user's confirmed language preference.**
|
||||
|
||||
**Prompt Format**:
|
||||
|
||||
```markdown
|
||||
@@ -199,7 +270,7 @@ Text content (if any):
|
||||
Style notes: [specific style characteristics]
|
||||
```
|
||||
|
||||
### Step 5: Generate Images
|
||||
### Step 6: Generate Images
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
@@ -212,12 +283,12 @@ Style notes: [specific style characteristics]
|
||||
4. On failure, auto-retry once
|
||||
5. If retry fails, log reason, continue to next
|
||||
|
||||
### Step 6: Update Article
|
||||
### Step 7: Update Article
|
||||
|
||||
Insert generated images at corresponding positions:
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
|
||||
**Insertion Rules**:
|
||||
@@ -225,7 +296,7 @@ Insert generated images at corresponding positions:
|
||||
- Leave one blank line before and after image
|
||||
- Alt text uses concise description in article's language
|
||||
|
||||
### Step 7: Output Summary
|
||||
### Step 8: Output Summary
|
||||
|
||||
```
|
||||
Article Illustration Complete!
|
||||
@@ -244,6 +315,57 @@ Failed:
|
||||
- illustration-zzz.png: [failure reason]
|
||||
```
|
||||
|
||||
## Illustration Modification
|
||||
|
||||
Support for modifying individual illustrations after initial generation.
|
||||
|
||||
### Edit Single Illustration
|
||||
|
||||
Regenerate a specific illustration with modified prompt:
|
||||
|
||||
1. Identify illustration to edit (e.g., `illustration-concept-overview.png`)
|
||||
2. Update prompt in `prompts/illustration-concept-overview.md` if needed
|
||||
3. If content changes significantly, update slug in filename
|
||||
4. Regenerate image
|
||||
5. Update article if image reference changed
|
||||
|
||||
### Add New Illustration
|
||||
|
||||
Add a new illustration to the article:
|
||||
|
||||
1. Identify insertion position in article
|
||||
2. Create new prompt with appropriate slug (e.g., `illustration-new-concept.md`)
|
||||
3. Generate new illustration image
|
||||
4. Update `outline.md` with new illustration entry
|
||||
5. Insert image reference in article at the specified position
|
||||
|
||||
### Delete Illustration
|
||||
|
||||
Remove an illustration from the article:
|
||||
|
||||
1. Identify illustration to delete (e.g., `illustration-concept-overview.png`)
|
||||
2. Remove image file and prompt file
|
||||
3. Remove image reference from article
|
||||
4. Update `outline.md` to remove illustration entry
|
||||
|
||||
### File Naming Convention
|
||||
|
||||
Files use meaningful slugs for better readability:
|
||||
```
|
||||
illustration-[slug].png
|
||||
illustration-[slug].md (in prompts/)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `illustration-concept-overview.png`
|
||||
- `illustration-workflow-diagram.png`
|
||||
- `illustration-key-benefits.png`
|
||||
|
||||
**Slug rules**:
|
||||
- Derived from illustration purpose/content (kebab-case)
|
||||
- Must be unique within the article
|
||||
- When content changes significantly, update slug accordingly
|
||||
|
||||
## Style Reference Details
|
||||
|
||||
### elegant
|
||||
@@ -325,4 +447,15 @@ Typography: Clean hand-drawn lettering, simple sans-serif labels
|
||||
- Maintain selected style consistency across all illustrations in one article
|
||||
- Image generation typically takes 10-30 seconds per image
|
||||
- Sensitive figures should use cartoon alternatives
|
||||
- Prompt and illustration text language should match article language
|
||||
- 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.
|
||||
|
||||
+313
-58
@@ -11,7 +11,6 @@ Create original knowledge comics with multiple visual styles.
|
||||
|
||||
```bash
|
||||
/baoyu-comic posts/turing-story/source.md
|
||||
/baoyu-comic posts/turing-story/source.md --style dramatic --layout cinematic
|
||||
/baoyu-comic # then paste content
|
||||
```
|
||||
|
||||
@@ -19,10 +18,14 @@ Create original knowledge comics with multiple visual styles.
|
||||
|
||||
| Option | Values |
|
||||
|--------|--------|
|
||||
| `--style` | classic (default), dramatic, warm, tech, sepia, vibrant, ohmsha, realistic |
|
||||
| `--style` | classic (default), dramatic, warm, tech, sepia, vibrant, ohmsha, realistic, 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. |
|
||||
|
||||
Style × Layout can be freely combined.
|
||||
Style × Layout × Aspect can be freely combined. Custom styles can be described in natural language.
|
||||
|
||||
**Aspect ratio is consistent across all pages in a comic.**
|
||||
|
||||
## Auto Selection
|
||||
|
||||
@@ -36,100 +39,341 @@ Style × Layout can be freely combined.
|
||||
| Wine, food, business, lifestyle, professional | realistic | cinematic |
|
||||
| Biography, balanced | classic | mixed |
|
||||
|
||||
## 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/merge-to-pdf.ts` | Merge comic pages into PDF |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
[target]/
|
||||
├── outline.md
|
||||
├── characters/
|
||||
│ ├── characters.md # Character definitions
|
||||
│ └── characters.png # Character reference sheet
|
||||
├── source.md # Source content (if pasted, not file)
|
||||
├── analysis.md # Deep analysis results (YAML+MD)
|
||||
├── storyboard-chronological.md # Variant A (preserved)
|
||||
├── storyboard-thematic.md # Variant B (preserved)
|
||||
├── storyboard-character.md # Variant C (preserved)
|
||||
├── characters-chronological/ # Variant A chars (preserved)
|
||||
│ ├── characters.md
|
||||
│ └── characters.png
|
||||
├── characters-thematic/ # Variant B chars (preserved)
|
||||
│ ├── characters.md
|
||||
│ └── characters.png
|
||||
├── characters-character/ # Variant C chars (preserved)
|
||||
│ ├── characters.md
|
||||
│ └── characters.png
|
||||
├── storyboard.md # Final selected
|
||||
├── characters/ # Final selected
|
||||
│ ├── characters.md
|
||||
│ └── characters.png
|
||||
├── prompts/
|
||||
│ ├── 00-cover.md
|
||||
│ └── XX-page.md
|
||||
├── 00-cover.png
|
||||
└── XX-page.png
|
||||
│ ├── 00-cover-[slug].md
|
||||
│ └── NN-page-[slug].md
|
||||
├── 00-cover-[slug].png
|
||||
├── NN-page-[slug].png
|
||||
└── {topic-slug}.pdf
|
||||
```
|
||||
|
||||
**Target directory**:
|
||||
- With source path: `[source-dir]/comic/`
|
||||
- Without source: `comic-outputs/YYYY-MM-DD/[topic-slug]/`
|
||||
- With source path: `[source-dir]/[source-name-no-ext]/comic/`
|
||||
- Example: `/posts/turing-story.md` → `/posts/turing-story/comic/`
|
||||
- Without source: `./comic/[topic-slug]/`
|
||||
|
||||
**Directory backup**:
|
||||
- If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content
|
||||
### Step 1: Analyze Content → `analysis.md`
|
||||
|
||||
1. Read source content
|
||||
2. Select style (from `--style` or auto-detect)
|
||||
3. Select layout (from `--layout` or auto-detect per page)
|
||||
4. Determine page count:
|
||||
Read source content, save it if needed, and perform deep analysis.
|
||||
|
||||
**Actions**:
|
||||
1. **Save source content** (if not already a file):
|
||||
- If user provides a file path: use as-is
|
||||
- If user pastes content: save to `source.md` in target directory
|
||||
2. Read source content
|
||||
3. **Deep analysis** following `references/analysis-framework.md`:
|
||||
- Target audience identification
|
||||
- Value proposition for readers
|
||||
- Core themes and narrative potential
|
||||
- Key figures and their story arcs
|
||||
4. Detect source language
|
||||
5. Determine recommended page count:
|
||||
- Short story: 5-8 pages
|
||||
- Medium complexity: 9-15 pages
|
||||
- Full biography: 16-25 pages
|
||||
6. Analyze content signals for style/layout recommendations
|
||||
7. **Save to `analysis.md`**
|
||||
|
||||
### Step 2: Define Characters
|
||||
**analysis.md Format**:
|
||||
|
||||
**Purpose**: Establish visual consistency across all pages.
|
||||
```yaml
|
||||
---
|
||||
title: "Alan Turing: Father of Computing"
|
||||
topic: Biography
|
||||
time_span: 1912-1954
|
||||
source_language: en
|
||||
user_language: zh
|
||||
aspect_ratio: "3:4"
|
||||
recommended_page_count: 12
|
||||
---
|
||||
|
||||
1. Extract all characters from content (protagonist, supporting, antagonist, narrator)
|
||||
2. Create `characters/characters.md` with visual specs for each character
|
||||
3. Generate `characters/characters.png` (character reference sheet)
|
||||
## Target Audience
|
||||
|
||||
**Reference**: `references/character-template.md` for detailed format and examples.
|
||||
- **Primary**: Tech enthusiasts curious about computing history
|
||||
- **Secondary**: Students learning about scientific breakthroughs
|
||||
- **Tertiary**: General readers interested in biographical stories
|
||||
|
||||
### Step 3: Generate Outline
|
||||
## Value Proposition
|
||||
|
||||
Create `outline.md` with:
|
||||
- Metadata (title, style, layout, page count, character reference path)
|
||||
- Cover design
|
||||
- Each page: layout, panel breakdown, visual prompts
|
||||
What readers will gain:
|
||||
1. Understanding of how modern computing was born
|
||||
2. Emotional connection to a brilliant but tragic figure
|
||||
3. Appreciation for the human cost of innovation
|
||||
|
||||
**Reference**: `references/outline-template.md` for detailed format.
|
||||
## Core Themes
|
||||
|
||||
| Theme | Narrative Potential | Visual Opportunity |
|
||||
|-------|--------------------|--------------------|
|
||||
| Genius vs. Society | High conflict, dramatic arcs | Contrast scenes |
|
||||
| Code-breaking | Mystery, tension | Technical diagrams as art |
|
||||
| Personal tragedy | Emotional depth | Intimate, somber panels |
|
||||
|
||||
## Key Figures & Story Arcs
|
||||
|
||||
### Alan Turing (Protagonist)
|
||||
- **Arc**: Misunderstood genius → War hero → Tragic end
|
||||
- **Visual identity**: Disheveled academic, intense eyes
|
||||
- **Key moments**: Enigma breakthrough, arrest, final days
|
||||
|
||||
### Christopher Morcom (Catalyst)
|
||||
- **Role**: Early friend whose death shaped Turing
|
||||
- **Visual identity**: Youthful, bright
|
||||
- **Key moments**: School friendship, sudden death
|
||||
|
||||
## Content Signals
|
||||
|
||||
- "biography" → classic + mixed
|
||||
- "computing history" → tech + dense
|
||||
- "personal tragedy" → dramatic + splash
|
||||
|
||||
## Recommended Approaches
|
||||
|
||||
1. **Chronological** - follow life timeline (recommended for biography)
|
||||
2. **Thematic** - organize by contributions (good for educational focus)
|
||||
3. **Character-focused** - relationships drive narrative (good for emotional impact)
|
||||
```
|
||||
|
||||
### Step 2: Generate 3 Storyboard Variants
|
||||
|
||||
Create three distinct variants, each combining a narrative approach with a recommended style.
|
||||
|
||||
| Variant | Narrative Approach | Recommended Style | Layout |
|
||||
|---------|-------------------|-------------------|--------|
|
||||
| A | Chronological | sepia | cinematic |
|
||||
| B | Thematic | tech | dense |
|
||||
| C | Character-focused | warm | standard |
|
||||
|
||||
**For each variant**:
|
||||
|
||||
1. **Generate storyboard** (`storyboard-{approach}.md`):
|
||||
- YAML front matter with narrative_approach, recommended_style, recommended_layout, aspect_ratio
|
||||
- Cover design
|
||||
- Each page: layout, panel breakdown, visual prompts
|
||||
- **Written in user's preferred language**
|
||||
- Reference: `references/storyboard-template.md`
|
||||
|
||||
2. **Generate matching characters** (`characters-{approach}/`):
|
||||
- `characters.md` - visual specs matching the recommended style (in user's preferred language)
|
||||
- `characters.png` - character reference sheet
|
||||
- Reference: `references/character-template.md`
|
||||
|
||||
**All variants are preserved after selection for reference.**
|
||||
|
||||
### Step 3: User Confirms All Options
|
||||
|
||||
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations.
|
||||
|
||||
**Determine which questions to ask**:
|
||||
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Storyboard variant | Always (required) |
|
||||
| Visual style | Always (required) |
|
||||
| Language | Only if `source_language ≠ user_language` |
|
||||
| Aspect ratio | Only if user might prefer non-default (e.g., landscape content) |
|
||||
|
||||
**Language handling**:
|
||||
- If source language = user language: Just inform user (e.g., "Comic will be in Chinese")
|
||||
- If different: Ask which language to use
|
||||
|
||||
**All storyboards and prompts are generated in the user's selected/preferred language.**
|
||||
|
||||
**Aspect ratio handling**:
|
||||
- Default: 3:4 (portrait) - standard comic format
|
||||
- Offer 4:3 (landscape) if content suits it (e.g., panoramic scenes, technical diagrams)
|
||||
- Offer 16:9 (widescreen) for cinematic content
|
||||
|
||||
**AskUserQuestion format** (example with all questions):
|
||||
|
||||
```
|
||||
Question 1 (Storyboard): Which storyboard variant?
|
||||
- A: Chronological + sepia (Recommended)
|
||||
- B: Thematic + tech
|
||||
- C: Character-focused + warm
|
||||
- Custom
|
||||
|
||||
Question 2 (Style): Which visual style?
|
||||
- sepia (Recommended from variant)
|
||||
- classic / dramatic / warm / tech / vibrant / ohmsha / realistic
|
||||
- Custom description
|
||||
|
||||
Question 3 (Language) - only if mismatch:
|
||||
- Chinese (source material language)
|
||||
- English (your preference)
|
||||
|
||||
Question 4 (Aspect) - only if relevant:
|
||||
- 3:4 Portrait (Recommended)
|
||||
- 4:3 Landscape
|
||||
- 16:9 Widescreen
|
||||
```
|
||||
|
||||
**After confirmation**:
|
||||
1. Copy selected storyboard → `storyboard.md`
|
||||
2. Copy selected characters → `characters/`
|
||||
3. Update YAML front matter with confirmed style, language, aspect_ratio
|
||||
4. If style differs from variant's recommended: regenerate `characters/characters.png`
|
||||
5. User may edit files directly for fine-tuning
|
||||
|
||||
### Step 4: Generate Images
|
||||
|
||||
For each page (cover + pages):
|
||||
|
||||
1. Save prompt to `prompts/XX-page.md`
|
||||
2. Call image generation skill with:
|
||||
- Base prompt: `references/base-prompt.md`
|
||||
- Character reference (text or image, depending on skill capability)
|
||||
- Page prompt
|
||||
- Output path
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
- Check available image generation skills in the environment
|
||||
- Adapt parameters based on skill capabilities:
|
||||
- If supports `--promptfiles`: pass prompt files
|
||||
- If supports reference image: pass `characters/characters.png`
|
||||
- If text-only: concatenate prompts into single text
|
||||
- If multiple skills available, ask user preference
|
||||
|
||||
**Session Management**:
|
||||
If the image generation skill supports `--sessionId`:
|
||||
1. Generate a unique session ID at the start (e.g., `comic-{topic-slug}-{timestamp}`)
|
||||
2. Use the same session ID for character sheet and all pages
|
||||
3. This ensures visual consistency (character appearance, style) across all generated images
|
||||
With confirmed storyboard + style + aspect ratio:
|
||||
|
||||
**For each page (cover + pages)**:
|
||||
1. Save prompt to `prompts/NN-{cover|page}-[slug].md` (in user's preferred language)
|
||||
2. Generate image using confirmed style and aspect ratio
|
||||
3. Report progress after each generation
|
||||
|
||||
### Step 5: Completion Report
|
||||
**Image Generation Skill Selection**:
|
||||
- Check available image generation skills
|
||||
- If multiple skills available, ask user preference
|
||||
|
||||
**Character Reference Handling**:
|
||||
- If skill supports reference image: pass `characters/characters.png`
|
||||
- If skill does NOT support reference image: include `characters/characters.md` content in prompt
|
||||
|
||||
**Session Management**:
|
||||
If image generation skill supports `--sessionId`:
|
||||
1. Generate unique session ID: `comic-{topic-slug}-{timestamp}`
|
||||
2. Use same session ID for all pages
|
||||
3. Ensures visual consistency across generated images
|
||||
|
||||
### Step 5: Merge to PDF
|
||||
|
||||
After all images generated:
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/merge-to-pdf.ts <comic-dir>
|
||||
```
|
||||
|
||||
Creates `{topic-slug}.pdf` with all pages as full-page images.
|
||||
|
||||
### Step 6: Completion Report
|
||||
|
||||
```
|
||||
Comic Complete!
|
||||
Title: [title] | Style: [style] | Pages: [count]
|
||||
Title: [title] | Style: [style] | Pages: [count] | Aspect: [ratio] | Language: [lang]
|
||||
Location: [path]
|
||||
✓ analysis.md
|
||||
✓ characters.png
|
||||
✓ 00-cover.png ... XX-page.png
|
||||
✓ 00-cover-[slug].png ... NN-page-[slug].png
|
||||
✓ {topic-slug}.pdf
|
||||
```
|
||||
|
||||
## Page Modification
|
||||
|
||||
Support for modifying individual pages after initial generation.
|
||||
|
||||
### Edit Single Page
|
||||
|
||||
Regenerate a specific page with modified prompt:
|
||||
|
||||
1. Identify page to edit (e.g., `03-page-enigma-machine.png`)
|
||||
2. Update prompt in `prompts/03-page-enigma-machine.md` if needed
|
||||
3. If content changes significantly, update slug in filename
|
||||
4. Regenerate image using same session ID and aspect ratio
|
||||
5. Regenerate PDF
|
||||
|
||||
### Add New Page
|
||||
|
||||
Insert a new page at specified position:
|
||||
|
||||
1. Specify insertion position (e.g., after page 3)
|
||||
2. Create new prompt with appropriate slug (e.g., `04-page-bletchley-park.md`)
|
||||
3. Generate new page image (same aspect ratio)
|
||||
4. **Renumber files**: All subsequent pages increment NN by 1
|
||||
- `04-page-tragedy.png` → `05-page-tragedy.png`
|
||||
- Slugs remain unchanged
|
||||
5. Update `storyboard.md` with new page entry
|
||||
6. Regenerate PDF
|
||||
|
||||
### Delete Page
|
||||
|
||||
Remove a page and renumber:
|
||||
|
||||
1. Identify page to delete (e.g., `03-page-enigma-machine.png`)
|
||||
2. Remove image file and prompt file
|
||||
3. **Renumber files**: All subsequent pages decrement NN by 1
|
||||
- `04-page-tragedy.png` → `03-page-tragedy.png`
|
||||
- Slugs remain unchanged
|
||||
4. Update `storyboard.md` to remove page entry
|
||||
5. Regenerate PDF
|
||||
|
||||
### File Naming Convention
|
||||
|
||||
Files use meaningful slugs for better readability:
|
||||
```
|
||||
NN-cover-[slug].png / NN-page-[slug].png
|
||||
NN-cover-[slug].md / NN-page-[slug].md (in prompts/)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `00-cover-turing-story.png`
|
||||
- `01-page-early-life.png`
|
||||
- `02-page-cambridge-years.png`
|
||||
- `03-page-enigma-machine.png`
|
||||
|
||||
**Slug rules**:
|
||||
- Derived from page title/content (kebab-case)
|
||||
- Must be unique within the comic
|
||||
- When page content changes significantly, update slug accordingly
|
||||
|
||||
**Renumbering**:
|
||||
- After add/delete, update NN prefix for affected pages
|
||||
- Slug remains unchanged unless content changes
|
||||
- Maintain sequential numbering with no gaps
|
||||
|
||||
## Style-Specific Guidelines
|
||||
|
||||
### Ohmsha Style (`--style ohmsha`)
|
||||
|
||||
Additional requirements for educational manga:
|
||||
- Default characters: Student (大雄), Mentor (哆啦A梦), Antagonist (胖虎)
|
||||
- Custom: `--characters "Student:小明,Mentor:教授"`
|
||||
- **Default: Use Doraemon characters directly** - No need to create new characters
|
||||
- 大雄 (Nobita): Student role, curious learner
|
||||
- 哆啦A梦 (Doraemon): Mentor role, explains concepts with gadgets
|
||||
- 胖虎 (Gian): Antagonist/challenge role, represents obstacles or misconceptions
|
||||
- 静香 (Shizuka): Supporting role, asks clarifying questions
|
||||
- Custom characters only if explicitly requested: `--characters "Student:小明,Mentor:教授"`
|
||||
- Must use visual metaphors (gadgets, action scenes) - NO talking heads
|
||||
- Page titles: narrative style, not "Page X: Topic"
|
||||
|
||||
@@ -138,8 +382,19 @@ Additional requirements for educational manga:
|
||||
## References
|
||||
|
||||
Detailed templates and guidelines in `references/` directory:
|
||||
- `analysis-framework.md` - Deep content analysis for comic adaptation
|
||||
- `character-template.md` - Character definition format and examples
|
||||
- `outline-template.md` - Outline structure and panel breakdown
|
||||
- `storyboard-template.md` - Storyboard structure and panel breakdown
|
||||
- `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.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Comic Content Analysis Framework
|
||||
|
||||
Deep analysis framework for transforming source content into effective visual storytelling.
|
||||
|
||||
## Purpose
|
||||
|
||||
Before creating a comic, thoroughly analyze the source material to:
|
||||
- Identify the target audience and their needs
|
||||
- Determine what value the comic will deliver
|
||||
- Extract narrative potential for visual storytelling
|
||||
- Plan character arcs and key moments
|
||||
|
||||
## Analysis Dimensions
|
||||
|
||||
### 1. Core Content (Understanding "What")
|
||||
|
||||
**Central Message**
|
||||
- What is the single most important idea readers should take away?
|
||||
- Can you express it in one sentence?
|
||||
|
||||
**Key Concepts**
|
||||
- What are the essential concepts readers must understand?
|
||||
- How should these concepts be visualized?
|
||||
- Which concepts need simplified explanations?
|
||||
|
||||
**Content Structure**
|
||||
- How is the source material organized?
|
||||
- What is the natural narrative arc?
|
||||
- Where are the climax and turning points?
|
||||
|
||||
**Evidence & Examples**
|
||||
- What concrete examples, data, or stories support the main ideas?
|
||||
- Which examples translate well to visual panels?
|
||||
- What can be shown rather than told?
|
||||
|
||||
### 2. Context & Background (Understanding "Why")
|
||||
|
||||
**Source Origin**
|
||||
- Who created this content? What is their perspective?
|
||||
- What was the original purpose?
|
||||
- Is there bias to be aware of?
|
||||
|
||||
**Historical/Cultural Context**
|
||||
- When and where does the story take place?
|
||||
- What background knowledge do readers need?
|
||||
- What period-specific visual elements are required?
|
||||
|
||||
**Underlying Assumptions**
|
||||
- What does the source assume readers already know?
|
||||
- What implicit beliefs or values are present?
|
||||
- Should the comic challenge or reinforce these?
|
||||
|
||||
### 3. Audience Analysis
|
||||
|
||||
**Primary Audience**
|
||||
- Who will read this comic?
|
||||
- What is their existing knowledge level?
|
||||
- What are their interests and motivations?
|
||||
|
||||
**Secondary Audiences**
|
||||
- Who else might benefit from this comic?
|
||||
- How might their needs differ?
|
||||
|
||||
**Reader Questions**
|
||||
- What questions will readers have?
|
||||
- What misconceptions might they bring?
|
||||
- What "aha moments" can we create?
|
||||
|
||||
### 4. Value Proposition
|
||||
|
||||
**Knowledge Value**
|
||||
- What will readers learn?
|
||||
- What new perspectives will they gain?
|
||||
- How will this change their understanding?
|
||||
|
||||
**Emotional Value**
|
||||
- What emotions should readers feel?
|
||||
- What connections will they make with characters?
|
||||
- What will make this memorable?
|
||||
|
||||
**Practical Value**
|
||||
- Can readers apply what they learn?
|
||||
- What actions might this inspire?
|
||||
- What conversations might it spark?
|
||||
|
||||
### 5. Narrative Potential
|
||||
|
||||
**Story Arc Candidates**
|
||||
- What natural narratives exist in the content?
|
||||
- Where is the conflict or tension?
|
||||
- What transformations occur?
|
||||
|
||||
**Character Potential**
|
||||
- Who are the key figures?
|
||||
- What are their motivations and obstacles?
|
||||
- How do they change throughout?
|
||||
|
||||
**Visual Opportunities**
|
||||
- What scenes have strong visual potential?
|
||||
- Where can abstract concepts become concrete images?
|
||||
- What metaphors can be visualized?
|
||||
|
||||
**Dramatic Moments**
|
||||
- What are the breakthrough/revelation moments?
|
||||
- Where are the emotional peaks?
|
||||
- What creates tension and release?
|
||||
|
||||
### 6. Adaptation Considerations
|
||||
|
||||
**What to Keep**
|
||||
- Essential facts and ideas
|
||||
- Key quotes or moments
|
||||
- Core emotional beats
|
||||
|
||||
**What to Simplify**
|
||||
- Complex explanations
|
||||
- Dense technical details
|
||||
- Lengthy descriptions
|
||||
|
||||
**What to Expand**
|
||||
- Brief mentions that deserve more attention
|
||||
- Implied emotions or relationships
|
||||
- Visual details not in source
|
||||
|
||||
**What to Omit**
|
||||
- Tangential information
|
||||
- Redundant examples
|
||||
- Content that doesn't serve the narrative
|
||||
|
||||
## Output Format
|
||||
|
||||
Analysis results should be saved to `analysis.md` with:
|
||||
|
||||
1. **YAML Front Matter**: Metadata (title, topic, time_span, languages, aspect_ratio, page_count)
|
||||
2. **Target Audience**: Primary, secondary, tertiary audiences with their needs
|
||||
3. **Value Proposition**: What readers will gain (knowledge, emotional, practical)
|
||||
4. **Core Themes**: Table with theme, narrative potential, visual opportunity
|
||||
5. **Key Figures & Story Arcs**: Character profiles with arcs, visual identity, key moments
|
||||
6. **Content Signals**: Style and layout recommendations based on content type
|
||||
7. **Recommended Approaches**: Narrative approaches ranked by suitability
|
||||
|
||||
## Analysis Checklist
|
||||
|
||||
Before proceeding to storyboard:
|
||||
|
||||
- [ ] Can I state the core message in one sentence?
|
||||
- [ ] Do I know exactly who will read this comic?
|
||||
- [ ] Have I identified at least 3 ways this comic provides value?
|
||||
- [ ] Are there clear protagonists with compelling arcs?
|
||||
- [ ] Have I found at least 5 visually powerful moments?
|
||||
- [ ] Do I understand what to keep, simplify, expand, and omit?
|
||||
- [ ] Have I identified the emotional peaks and valleys?
|
||||
+18
-11
@@ -1,23 +1,30 @@
|
||||
# Outline Template
|
||||
# Storyboard Template
|
||||
|
||||
## Outline Document Format
|
||||
## Storyboard Document Format
|
||||
|
||||
```markdown
|
||||
# [Comic Title] - Knowledge Comic Outline
|
||||
---
|
||||
title: "[Comic Title]"
|
||||
topic: "[topic description]"
|
||||
time_span: "[e.g., 1912-1954]"
|
||||
narrative_approach: "[chronological/thematic/character-focused]"
|
||||
recommended_style: "[style name]"
|
||||
recommended_layout: "[layout name or varies]"
|
||||
aspect_ratio: "3:4" # 3:4 (portrait), 4:3 (landscape), 16:9 (widescreen)
|
||||
language: "[zh/en/ja/etc.]"
|
||||
page_count: [N]
|
||||
generated: "YYYY-MM-DD HH:mm"
|
||||
---
|
||||
|
||||
# [Comic Title] - Knowledge Comic Storyboard
|
||||
|
||||
**Topic**: [topic description]
|
||||
**Time Span**: [e.g., 1912-1954]
|
||||
**Style**: [selected style]
|
||||
**Default Layout**: [selected layout or "varies"]
|
||||
**Page Count**: Cover + N pages
|
||||
**Character Reference**: characters/characters.png
|
||||
**Generated**: YYYY-MM-DD HH:mm
|
||||
|
||||
---
|
||||
|
||||
## Cover
|
||||
|
||||
**Filename**: 00-cover.png
|
||||
**Filename**: 00-cover-[slug].png
|
||||
**Core Message**: [one-liner]
|
||||
|
||||
**Visual Design**:
|
||||
@@ -33,7 +40,7 @@
|
||||
|
||||
## Page 1 / N
|
||||
|
||||
**Filename**: 01-page.png
|
||||
**Filename**: 01-page-[slug].png
|
||||
**Layout**: [standard/cinematic/dense/splash/mixed]
|
||||
**Narrative Layer**: [Main narrative / Narrator layer / Mixed]
|
||||
**Core Message**: [What this page conveys]
|
||||
@@ -0,0 +1,116 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs";
|
||||
import { join, basename } from "path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
|
||||
interface PageInfo {
|
||||
filename: string;
|
||||
path: string;
|
||||
index: number;
|
||||
promptPath?: string;
|
||||
}
|
||||
|
||||
function parseArgs(): { dir: string; output?: string } {
|
||||
const args = process.argv.slice(2);
|
||||
let dir = "";
|
||||
let output: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--output" || args[i] === "-o") {
|
||||
output = args[++i];
|
||||
} else if (!args[i].startsWith("-")) {
|
||||
dir = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
console.error("Usage: bun merge-to-pdf.ts <comic-dir> [--output filename.pdf]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { dir, output };
|
||||
}
|
||||
|
||||
function findComicPages(dir: string): PageInfo[] {
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`Directory not found: ${dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(dir);
|
||||
const pagePattern = /^(\d+)-(cover|page)(-[\w-]+)?\.(png|jpg|jpeg)$/i;
|
||||
const promptsDir = join(dir, "prompts");
|
||||
const hasPrompts = existsSync(promptsDir);
|
||||
|
||||
const pages: PageInfo[] = files
|
||||
.filter((f) => pagePattern.test(f))
|
||||
.map((f) => {
|
||||
const match = f.match(pagePattern);
|
||||
const baseName = f.replace(/\.(png|jpg|jpeg)$/i, "");
|
||||
const promptPath = hasPrompts ? join(promptsDir, `${baseName}.md`) : undefined;
|
||||
|
||||
return {
|
||||
filename: f,
|
||||
path: join(dir, f),
|
||||
index: parseInt(match![1], 10),
|
||||
promptPath: promptPath && existsSync(promptPath) ? promptPath : undefined,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
if (pages.length === 0) {
|
||||
console.error(`No comic pages found in: ${dir}`);
|
||||
console.error("Expected format: 00-cover-slug.png, 01-page-slug.png, etc.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
async function createPdf(pages: PageInfo[], outputPath: string) {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
pdfDoc.setAuthor("baoyu-comic");
|
||||
pdfDoc.setSubject("Generated Comic");
|
||||
|
||||
for (const page of pages) {
|
||||
const imageData = readFileSync(page.path);
|
||||
const ext = page.filename.toLowerCase();
|
||||
const image = ext.endsWith(".png")
|
||||
? await pdfDoc.embedPng(imageData)
|
||||
: await pdfDoc.embedJpg(imageData);
|
||||
|
||||
const { width, height } = image;
|
||||
const pdfPage = pdfDoc.addPage([width, height]);
|
||||
|
||||
pdfPage.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
console.log(`Added: ${page.filename}${page.promptPath ? " (prompt available)" : ""}`);
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
await Bun.write(outputPath, pdfBytes);
|
||||
|
||||
console.log(`\nCreated: ${outputPath}`);
|
||||
console.log(`Total pages: ${pages.length}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { dir, output } = parseArgs();
|
||||
const pages = findComicPages(dir);
|
||||
|
||||
const dirName = basename(dir) === "comic" ? basename(join(dir, "..")) : basename(dir);
|
||||
const outputPath = output || join(dir, `${dirName}.pdf`);
|
||||
|
||||
console.log(`Found ${pages.length} pages in: ${dir}\n`);
|
||||
|
||||
await createPdf(pages, outputPath);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -38,6 +38,8 @@ Generate hand-drawn style cover images for articles with multiple style options.
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--style <name>` | Specify cover style (see Style Gallery below) |
|
||||
| `--aspect <ratio>` | Aspect ratio: 2.35:1 (cinematic, default), 16:9 (widescreen), 1:1 (social) |
|
||||
| `--lang <code>` | Output language for title text (en, zh, ja, etc.) |
|
||||
| `--no-title` | Generate cover without title text (visual only) |
|
||||
|
||||
## Style Gallery
|
||||
@@ -74,45 +76,103 @@ When no `--style` is specified, the system analyzes content to select the best s
|
||||
|
||||
### With Article Path
|
||||
|
||||
Save to `imgs/` subdirectory in the same folder as the article:
|
||||
Save to `[source-name-no-ext]/cover-image/` subdirectory in the same folder as the article:
|
||||
|
||||
```
|
||||
path/to/
|
||||
├── article.md
|
||||
└── imgs/
|
||||
└── article/
|
||||
└── cover-image/
|
||||
├── prompts/
|
||||
│ └── cover.md
|
||||
└── cover.png
|
||||
```
|
||||
|
||||
Example: `/posts/ai-future.md` → `/posts/ai-future/cover-image/`
|
||||
|
||||
### Without Article Path (Pasted Content)
|
||||
|
||||
Save to `./cover-image/[topic-slug]/`:
|
||||
|
||||
```
|
||||
cover-image/
|
||||
└── ai-future/
|
||||
├── source.md # Saved pasted content
|
||||
├── prompts/
|
||||
│ └── cover.md
|
||||
└── cover.png
|
||||
```
|
||||
|
||||
### Without Article Path
|
||||
### Directory Backup
|
||||
|
||||
Save to current working directory:
|
||||
|
||||
```
|
||||
./
|
||||
├── cover-prompt.md
|
||||
└── cover.png
|
||||
```
|
||||
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content
|
||||
|
||||
Extract key information:
|
||||
- **Main topic**: What is the article about?
|
||||
- **Core message**: What's the key takeaway?
|
||||
- **Tone**: Serious, playful, inspiring, educational?
|
||||
- **Keywords**: Identify style-signaling words
|
||||
1. **Save source content** (if not already a file):
|
||||
- If user provides a file path: use as-is
|
||||
- If user pastes content: save to `source.md` in target directory
|
||||
|
||||
### Step 2: Select Style
|
||||
2. **Extract key information**:
|
||||
- **Main topic**: What is the article about?
|
||||
- **Core message**: What's the key takeaway?
|
||||
- **Tone**: Serious, playful, inspiring, educational?
|
||||
- **Keywords**: Identify style-signaling words
|
||||
|
||||
If `--style` specified, use that style. Otherwise:
|
||||
1. Scan content for style signals (see Auto Style Selection table)
|
||||
2. Match signals to most appropriate style
|
||||
3. Default to `elegant` if no clear signals
|
||||
3. **Language detection**:
|
||||
- Detect **source language** from content
|
||||
- Detect **user language** from conversation context
|
||||
- Note if source_language ≠ user_language (will ask in Step 3)
|
||||
|
||||
### Step 3: Generate Cover Concept
|
||||
### Step 2: Determine Options
|
||||
|
||||
1. **Style selection**:
|
||||
- If `--style` specified, use that style
|
||||
- Otherwise, scan content for style signals and auto-select 3 candidates
|
||||
- Default to `elegant` if no clear signals
|
||||
|
||||
2. **Aspect ratio**:
|
||||
- If `--aspect` specified, use that ratio
|
||||
- Otherwise, prepare options: 2.35:1 (cinematic), 16:9 (widescreen), 1:1 (social)
|
||||
|
||||
### Step 3: Confirm Options
|
||||
|
||||
**Purpose**: Let user confirm all options in a single step before generation.
|
||||
|
||||
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations.
|
||||
|
||||
**Determine which questions to ask**:
|
||||
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Style | Always (required) |
|
||||
| Aspect ratio | Always (offer common options) |
|
||||
| Language | Only if `source_language ≠ user_language` |
|
||||
|
||||
**Present options** (use AskUserQuestion with all applicable questions):
|
||||
|
||||
**Question 1 (Style)** - always:
|
||||
- Style A (recommended): [style name] - [brief description]
|
||||
- Style B: [style name] - [brief description]
|
||||
- Style C: [style name] - [brief description]
|
||||
- Custom: Provide custom style reference
|
||||
|
||||
**Question 2 (Aspect)** - always:
|
||||
- 2.35:1 Cinematic (Recommended) - ultra-wide, dramatic
|
||||
- 16:9 Widescreen - standard video/presentation
|
||||
- 1:1 Square - social media optimized
|
||||
|
||||
**Question 3 (Language)** - only if source ≠ user language:
|
||||
- [Source language] (matches content)
|
||||
- [User language] (your preference)
|
||||
|
||||
**Language handling**:
|
||||
- If source language = user language: Just inform user (e.g., "Title will be in Chinese")
|
||||
- If different: Ask which language to use for title text
|
||||
|
||||
### Step 4: Generate Cover Concept
|
||||
|
||||
Create a cover image concept based on selected style:
|
||||
|
||||
@@ -126,21 +186,26 @@ Create a cover image concept based on selected style:
|
||||
- 1-2 symbolic elements representing the topic
|
||||
- Metaphors or analogies that fit the style
|
||||
|
||||
### Step 4: Create Prompt File
|
||||
### Step 5: Create Prompt File
|
||||
|
||||
Save prompt to `prompts/cover.md` with confirmed options.
|
||||
|
||||
**All prompts are written in the user's confirmed language preference.**
|
||||
|
||||
**Prompt Format**:
|
||||
|
||||
```markdown
|
||||
Cover theme: [topic in 2-3 words]
|
||||
Style: [selected style name]
|
||||
Aspect ratio: [confirmed aspect ratio]
|
||||
|
||||
[If title included:]
|
||||
Title text: [8 characters or less, in content language]
|
||||
Subtitle: [optional, in content language]
|
||||
Title text: [8 characters or less, in confirmed language]
|
||||
Subtitle: [optional, in confirmed language]
|
||||
|
||||
Visual composition:
|
||||
- Main visual: [description matching style]
|
||||
- Layout: [positioning based on title inclusion]
|
||||
- Layout: [positioning based on title inclusion and aspect ratio]
|
||||
- Decorative elements: [style-appropriate elements]
|
||||
|
||||
Color scheme:
|
||||
@@ -154,23 +219,25 @@ Style notes: [specific style characteristics to emphasize]
|
||||
Note: No title text, pure visual illustration only.
|
||||
```
|
||||
|
||||
### Step 5: Generate Image
|
||||
### Step 6: Generate Image
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
2. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation**:
|
||||
Call selected image generation skill with prompt file and output path.
|
||||
Call selected image generation skill with prompt file, output path, and confirmed aspect ratio.
|
||||
|
||||
### Step 6: Output Summary
|
||||
### Step 7: Output Summary
|
||||
|
||||
```
|
||||
Cover Image Generated!
|
||||
|
||||
Topic: [topic]
|
||||
Style: [style name]
|
||||
Aspect: [aspect ratio]
|
||||
Title: [cover title] (or "No title - visual only")
|
||||
Language: [confirmed language]
|
||||
Location: [output path]
|
||||
|
||||
Preview the image to verify it matches your expectations.
|
||||
@@ -183,4 +250,15 @@ Preview the image to verify it matches your expectations.
|
||||
- Visual metaphors work better than literal representations
|
||||
- Maintain style consistency throughout the cover
|
||||
- Image generation typically takes 10-30 seconds
|
||||
- Title text language should match content language
|
||||
- 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.
|
||||
|
||||
@@ -8,76 +8,87 @@ description: Image generation skill using Gemini Web. Generates images from text
|
||||
Supports:
|
||||
- Text generation
|
||||
- Image generation (download + save)
|
||||
- Reference image upload (attach images for vision tasks)
|
||||
- Multi-turn conversations within the same executor instance (`keepSession`)
|
||||
- Experimental video generation (`generateVideo`) — Gemini may return an async placeholder; download might require Gemini web UI
|
||||
- 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) |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "Hello, Gemini"
|
||||
npx -y bun scripts/main.ts --prompt "Explain quantum computing"
|
||||
npx -y bun scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||
npx -y bun scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
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 scripts/main.ts "Remember this: 42" --sessionId my-unique-id-123
|
||||
npx -y bun scripts/main.ts "What number?" --sessionId my-unique-id-123
|
||||
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
|
||||
```
|
||||
|
||||
## Executor options (programmatic)
|
||||
|
||||
This skill is typically consumed via `createGeminiWebExecutor(geminiOptions)` (see `scripts/executor.ts`).
|
||||
|
||||
Key options in `GeminiWebOptions`:
|
||||
- `referenceImages?: string | string[]` Upload local images as references (vision input).
|
||||
- `keepSession?: boolean` Reuse Gemini `chatMetadata` to continue the same conversation across calls (required if you want reference images to persist across multiple messages).
|
||||
- `generateVideo?: string` Generate a video and (best-effort) download to the given path. Gemini may return `video_gen_chip` (async); in that case you must open Gemini web UI to download the result.
|
||||
|
||||
Notes:
|
||||
- `generateVideo` cannot be combined with `generateImage` / `editImage`.
|
||||
- When `keepSession=true` and `referenceImages` is set, reference images are uploaded once per executor instance.
|
||||
|
||||
## Commands
|
||||
|
||||
### Text generation
|
||||
|
||||
```bash
|
||||
# Simple prompt (positional)
|
||||
npx -y bun scripts/main.ts "Your prompt here"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt here"
|
||||
|
||||
# Explicit prompt flag
|
||||
npx -y bun scripts/main.ts --prompt "Your prompt here"
|
||||
npx -y bun scripts/main.ts -p "Your prompt here"
|
||||
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 scripts/main.ts -p "Hello" -m gemini-2.5-pro
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Hello" -m gemini-2.5-pro
|
||||
|
||||
# Pipe from stdin
|
||||
echo "Summarize this" | npx -y bun scripts/main.ts
|
||||
echo "Summarize this" | npx -y bun ${SKILL_DIR}/scripts/main.ts
|
||||
```
|
||||
|
||||
### Image generation
|
||||
|
||||
```bash
|
||||
# Generate image with default path (./generated.png)
|
||||
npx -y bun scripts/main.ts --prompt "A sunset over mountains" --image
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A sunset over mountains" --image
|
||||
|
||||
# Generate image with custom path
|
||||
npx -y bun scripts/main.ts --prompt "A cute robot" --image robot.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute robot" --image robot.png
|
||||
|
||||
# Shorthand
|
||||
npx -y bun scripts/main.ts "A dragon" --image=dragon.png
|
||||
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 scripts/main.ts "Hello"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello"
|
||||
|
||||
# JSON output
|
||||
npx -y bun scripts/main.ts "Hello" --json
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
||||
```
|
||||
|
||||
## Options
|
||||
@@ -88,6 +99,7 @@ npx -y bun scripts/main.ts "Hello" --json
|
||||
| `--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 |
|
||||
@@ -96,7 +108,7 @@ npx -y bun scripts/main.ts "Hello" --json
|
||||
| `--profile-dir <path>` | Chrome profile directory |
|
||||
| `--help`, `-h` | Show help |
|
||||
|
||||
CLI note: `scripts/main.ts` supports text generation, image generation, and multi-turn conversations via `--sessionId`. Reference images and video generation are exposed via the executor API.
|
||||
CLI note: `scripts/main.ts` supports text generation, image generation, reference images (`--reference/--ref`), and multi-turn conversations via `--sessionId`.
|
||||
|
||||
## Models
|
||||
|
||||
@@ -110,7 +122,7 @@ First run opens Chrome to authenticate with Google. Cookies are cached for subse
|
||||
|
||||
```bash
|
||||
# Force cookie refresh
|
||||
npx -y bun scripts/main.ts --login
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --login
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
@@ -126,36 +138,36 @@ npx -y bun scripts/main.ts --login
|
||||
|
||||
### Generate text response
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "What is the capital of France?"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "What is the capital of France?"
|
||||
```
|
||||
|
||||
### Generate image
|
||||
```bash
|
||||
npx -y bun scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png
|
||||
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 scripts/main.ts "Hello" --json | jq '.text'
|
||||
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 scripts/main.ts --promptfiles system.md content.md --image output.png
|
||||
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 scripts/main.ts "You are a helpful math tutor." --sessionId task-abc123
|
||||
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 scripts/main.ts "What is 2+2?" --sessionId task-abc123
|
||||
npx -y bun scripts/main.ts "Now multiply that by 10" --sessionId task-abc123
|
||||
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 scripts/main.ts --list-sessions
|
||||
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:
|
||||
@@ -163,3 +175,13 @@ Session files are stored in `~/Library/Application Support/baoyu-skills/gemini-w
|
||||
- `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-gemini-web/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-gemini-web/EXTEND.md` (user)
|
||||
|
||||
If found, load before workflow. Extension content overrides defaults.
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import { spawn } 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 { fetchGeminiAccessToken } from './client.js';
|
||||
import type { GeminiWebLog } from './cookie-store.js';
|
||||
import { buildGeminiCookieMap, hasRequiredGeminiCookies } from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir } from './paths.js';
|
||||
|
||||
const GEMINI_URL = 'https://gemini.google.com/app';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Unable to allocate a free TCP port for Chrome debugging.')));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findChromeExecutable(): string | undefined {
|
||||
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',
|
||||
'/usr/bin/microsoft-edge-stable',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed: ${res.status} ${res.statusText} (${url})`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
): Promise<{ webSocketDebuggerUrl: string }> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
);
|
||||
if (version.webSocketDebuggerUrl) {
|
||||
return { webSocketDebuggerUrl: version.webSocketDebuggerUrl };
|
||||
}
|
||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Chrome debugging endpoint did not become ready within ${timeoutMs}ms: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
||||
);
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (value: unknown) => void; reject: (reason: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = (() => {
|
||||
if (typeof event.data === 'string') return event.data;
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
return new TextDecoder().decode(new Uint8Array(event.data));
|
||||
}
|
||||
if (ArrayBuffer.isView(event.data)) {
|
||||
return new TextDecoder().decode(event.data);
|
||||
}
|
||||
return String(event.data);
|
||||
})();
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (!msg.id) return;
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error('Chrome DevTools connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Timed out connecting to Chrome DevTools.')), timeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Failed to connect to Chrome DevTools.'));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: { sessionId?: string; timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const id = (this.nextId += 1);
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? 15_000;
|
||||
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP command timeout (${method}) after ${timeoutMs}ms.`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, {
|
||||
resolve,
|
||||
reject: (reason) => reject(reason),
|
||||
timer,
|
||||
});
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGeminiCookieMapViaChrome(options?: {
|
||||
timeoutMs?: number;
|
||||
debugConnectTimeoutMs?: number;
|
||||
tokenCheckTimeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
log?: GeminiWebLog;
|
||||
userDataDir?: string;
|
||||
chromePath?: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const log = options?.log;
|
||||
const timeoutMs = options?.timeoutMs ?? 5 * 60_000;
|
||||
const debugConnectTimeoutMs = options?.debugConnectTimeoutMs ?? 30_000;
|
||||
const tokenCheckTimeoutMs = options?.tokenCheckTimeoutMs ?? 30_000;
|
||||
const pollIntervalMs = options?.pollIntervalMs ?? 2_000;
|
||||
const userDataDir = options?.userDataDir ?? resolveGeminiWebChromeProfileDir();
|
||||
|
||||
const chromePath = options?.chromePath ?? findChromeExecutable();
|
||||
if (!chromePath) {
|
||||
throw new Error(
|
||||
'Unable to locate a Chrome/Chromium executable. Install Google Chrome or set GEMINI_WEB_CHROME_PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(userDataDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
log?.(`[gemini-web] Launching Chrome for cookie sync (profile: ${userDataDir})`);
|
||||
|
||||
const chrome = spawn(
|
||||
chromePath,
|
||||
[
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
GEMINI_URL,
|
||||
],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
try {
|
||||
const { webSocketDebuggerUrl } = await waitForChromeDebugPort(port, debugConnectTimeoutMs);
|
||||
cdp = await CdpConnection.connect(webSocketDebuggerUrl, debugConnectTimeoutMs);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: GEMINI_URL });
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', {
|
||||
targetId,
|
||||
flatten: true,
|
||||
});
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Network.enable', {}, { sessionId });
|
||||
|
||||
log?.('[gemini-web] Please log in to Gemini in the opened browser window.');
|
||||
log?.('[gemini-web] Waiting for cookies to become available...');
|
||||
|
||||
const start = Date.now();
|
||||
let lastTokenError: string | null = null;
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const response = await cdp.send<{ cookies?: unknown[] }>(
|
||||
'Network.getCookies',
|
||||
{ urls: [GEMINI_URL, 'https://google.com/'] },
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const rawCookies = Array.isArray(response.cookies) ? response.cookies : [];
|
||||
const cookieMap = buildGeminiCookieMap(
|
||||
rawCookies.filter(
|
||||
(cookie): cookie is { name?: string; value?: string; domain?: string; path?: string; url?: string } =>
|
||||
Boolean(cookie && typeof cookie === 'object'),
|
||||
),
|
||||
);
|
||||
|
||||
if (hasRequiredGeminiCookies(cookieMap)) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), tokenCheckTimeoutMs);
|
||||
try {
|
||||
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
log?.('[gemini-web] Gemini cookies detected.');
|
||||
return cookieMap;
|
||||
} catch (error) {
|
||||
lastTokenError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for Gemini cookies after ${timeoutMs}ms${lastTokenError ? ` (last error: ${lastTokenError})` : ''}.`,
|
||||
);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
const killTimer = setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, 2_000);
|
||||
killTimer.unref?.();
|
||||
try {
|
||||
chrome.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export type GeminiWebModelId = 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash';
|
||||
|
||||
export interface GeminiWebRunInput {
|
||||
prompt: string;
|
||||
files?: string[];
|
||||
model: GeminiWebModelId;
|
||||
cookieMap: Record<string, string>;
|
||||
chatMetadata?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface GeminiWebCandidateImage {
|
||||
url: string;
|
||||
title?: string;
|
||||
alt?: string;
|
||||
kind: 'web' | 'generated' | 'raw';
|
||||
}
|
||||
|
||||
export interface GeminiWebRunOutput {
|
||||
rawResponseText: string;
|
||||
text: string;
|
||||
thoughts: string | null;
|
||||
metadata: unknown;
|
||||
images: GeminiWebCandidateImage[];
|
||||
errorCode?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const 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';
|
||||
|
||||
const MODEL_HEADER_NAME = 'x-goog-ext-525001261-jspb';
|
||||
const MODEL_HEADERS: Record<GeminiWebModelId, string> = {
|
||||
'gemini-3-pro': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]',
|
||||
'gemini-2.5-pro': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]',
|
||||
'gemini-2.5-flash': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]',
|
||||
};
|
||||
|
||||
const GEMINI_APP_URL = 'https://gemini.google.com/app';
|
||||
const GEMINI_STREAM_GENERATE_URL =
|
||||
'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate';
|
||||
const GEMINI_UPLOAD_URL = 'https://content-push.googleapis.com/upload';
|
||||
const GEMINI_UPLOAD_PUSH_ID = 'feeds/mcudyrk2a4khkz';
|
||||
|
||||
function getNestedValue<T>(value: unknown, pathParts: Array<string | number>, fallback: T): T {
|
||||
let current: unknown = value;
|
||||
for (const part of pathParts) {
|
||||
if (current == null) return fallback;
|
||||
if (typeof part === 'number') {
|
||||
if (!Array.isArray(current)) return fallback;
|
||||
current = current[part];
|
||||
} else {
|
||||
if (typeof current !== 'object') return fallback;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
}
|
||||
return (current as T) ?? fallback;
|
||||
}
|
||||
|
||||
function buildCookieHeader(cookieMap: Record<string, string>): string {
|
||||
return Object.entries(cookieMap)
|
||||
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function getSetCookieHeaders(res: Response): string[] {
|
||||
const headers = res.headers as unknown as { getSetCookie?: () => string[] };
|
||||
if (typeof headers.getSetCookie === 'function') {
|
||||
try {
|
||||
return headers.getSetCookie();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const raw = res.headers.get('set-cookie');
|
||||
return raw ? [raw] : [];
|
||||
}
|
||||
|
||||
function applySetCookiesToMap(setCookies: string[], cookieMap: Record<string, string>): void {
|
||||
for (const raw of setCookies) {
|
||||
const first = raw.split(';')[0]?.trim();
|
||||
if (!first) continue;
|
||||
const idx = first.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
const name = first.slice(0, idx).trim();
|
||||
const value = first.slice(idx + 1).trim();
|
||||
if (!name) continue;
|
||||
cookieMap[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithCookieJar(
|
||||
url: string,
|
||||
init: Omit<RequestInit, 'redirect' | 'headers'> & { headers?: Record<string, string> },
|
||||
cookieMap: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
maxRedirects = 20,
|
||||
): Promise<Response> {
|
||||
let current = url;
|
||||
for (let i = 0; i <= maxRedirects; i += 1) {
|
||||
const cookieHeader = buildCookieHeader(cookieMap);
|
||||
const headers: Record<string, string> = {
|
||||
...(init.headers ?? {}),
|
||||
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
||||
'user-agent': USER_AGENT,
|
||||
};
|
||||
|
||||
const res = await fetch(current, { ...init, redirect: 'manual', signal, headers });
|
||||
applySetCookiesToMap(getSetCookieHeaders(res), cookieMap);
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get('location');
|
||||
if (!location) return res;
|
||||
current = new URL(location, current).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
throw new Error(`Too many redirects while fetching ${url} (>${maxRedirects}).`);
|
||||
}
|
||||
|
||||
export async function fetchGeminiAccessToken(
|
||||
cookieMap: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetchWithCookieJar(GEMINI_APP_URL, { method: 'GET' }, cookieMap, signal);
|
||||
const html = await res.text();
|
||||
|
||||
const tokens = ['SNlM0e', 'thykhd'] as const;
|
||||
for (const key of tokens) {
|
||||
const match = html.match(new RegExp(`"${key}":"(.*?)"`));
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
throw new Error(
|
||||
'Unable to locate Gemini access token on gemini.google.com/app (missing SNlM0e/thykhd).',
|
||||
);
|
||||
}
|
||||
|
||||
function trimGeminiJsonEnvelope(text: string): string {
|
||||
const start = text.indexOf('[');
|
||||
const end = text.lastIndexOf(']');
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
throw new Error('Gemini response did not contain a JSON payload.');
|
||||
}
|
||||
return text.slice(start, end + 1);
|
||||
}
|
||||
|
||||
function extractErrorCode(responseJson: unknown): number | undefined {
|
||||
const code = getNestedValue<number>(responseJson, [0, 5, 2, 0, 1, 0], -1);
|
||||
return typeof code === 'number' && code >= 0 ? code : undefined;
|
||||
}
|
||||
|
||||
function extractGgdlUrls(rawText: string): string[] {
|
||||
const matches =
|
||||
rawText.match(/https?:\/\/[^/\s"']*googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const match of matches) {
|
||||
if (seen.has(match)) continue;
|
||||
seen.add(match);
|
||||
urls.push(match);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function extractImageGenerationContentUrls(rawText: string): string[] {
|
||||
const matches =
|
||||
rawText.match(/https?:\/\/googleusercontent\.com\/image_generation_content\/\d+/g) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const match of matches) {
|
||||
if (seen.has(match)) continue;
|
||||
seen.add(match);
|
||||
urls.push(match);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function ensureFullSizeImageUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
let normalized = trimmed;
|
||||
const backslashIndex = normalized.indexOf('\\');
|
||||
if (backslashIndex >= 0) normalized = normalized.slice(0, backslashIndex);
|
||||
// Some Gemini responses embed a size suffix as "/=s2048" which breaks downloads.
|
||||
normalized = normalized.replace(/\/=s(?=\d+(?:$|[?#]))/, '=s');
|
||||
normalized = normalized.replace(/\/=s(?=$|[?#])/, '=s');
|
||||
if (normalized.endsWith('/')) normalized = normalized.slice(0, -1);
|
||||
if (normalized.includes('=s2048')) return normalized;
|
||||
if (normalized.includes('=s')) return normalized;
|
||||
return `${normalized}=s2048`;
|
||||
}
|
||||
|
||||
async function fetchWithCookiePreservingRedirects(
|
||||
url: string,
|
||||
init: Omit<RequestInit, 'redirect'>,
|
||||
signal?: AbortSignal,
|
||||
maxRedirects = 10,
|
||||
): Promise<Response> {
|
||||
let current = url;
|
||||
for (let i = 0; i <= maxRedirects; i += 1) {
|
||||
const res = await fetch(current, { ...init, redirect: 'manual', signal });
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get('location');
|
||||
if (!location) return res;
|
||||
current = new URL(location, current).toString();
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
throw new Error(`Too many redirects while downloading image (>${maxRedirects}).`);
|
||||
}
|
||||
|
||||
export async function downloadGeminiImage(
|
||||
url: string,
|
||||
cookieMap: Record<string, string>,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const cookieHeader = buildCookieHeader(cookieMap);
|
||||
const res = await fetchWithCookiePreservingRedirects(ensureFullSizeImageUrl(url), {
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
}, signal);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download image: ${res.status} ${res.statusText} (${res.url})`);
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, data);
|
||||
}
|
||||
|
||||
async function uploadGeminiFile(filePath: string, signal?: AbortSignal): Promise<{ id: string; name: string }> {
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
const data = await readFile(absPath);
|
||||
const fileName = path.basename(absPath);
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([data]), fileName);
|
||||
|
||||
const res = await fetch(GEMINI_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
signal,
|
||||
headers: {
|
||||
'push-id': GEMINI_UPLOAD_PUSH_ID,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`File upload failed: ${res.status} ${res.statusText} (${text.slice(0, 200)})`);
|
||||
}
|
||||
return { id: text, name: fileName };
|
||||
}
|
||||
|
||||
function guessMimeType(fileName: string): string {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.mov':
|
||||
return 'video/quicktime';
|
||||
case '.webm':
|
||||
return 'video/webm';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function buildGeminiFReqPayload(
|
||||
prompt: string,
|
||||
uploaded: Array<{ id: string; name: string }>,
|
||||
chatMetadata: unknown,
|
||||
): string {
|
||||
const promptPayload =
|
||||
uploaded.length > 0
|
||||
? [
|
||||
prompt,
|
||||
0,
|
||||
null,
|
||||
// Matches gemini-web payload format: [[[fileId, 1, null, mimeType], fileName]] for an attachment.
|
||||
uploaded.map((file) => [[file.id, 1, null, guessMimeType(file.name)], file.name]),
|
||||
]
|
||||
: [prompt];
|
||||
|
||||
const innerList: unknown[] = [promptPayload, null, chatMetadata ?? null];
|
||||
return JSON.stringify([null, JSON.stringify(innerList)]);
|
||||
}
|
||||
|
||||
export function parseGeminiStreamGenerateResponse(rawText: string): {
|
||||
metadata: unknown;
|
||||
text: string;
|
||||
thoughts: string | null;
|
||||
images: GeminiWebCandidateImage[];
|
||||
errorCode?: number;
|
||||
} {
|
||||
const responseJson = JSON.parse(trimGeminiJsonEnvelope(rawText)) as unknown;
|
||||
const errorCode = extractErrorCode(responseJson);
|
||||
|
||||
const parts = Array.isArray(responseJson) ? responseJson : [];
|
||||
let bodyIndex = 0;
|
||||
let body: unknown = null;
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const partBody = getNestedValue<string | null>(parts[i], [2], null);
|
||||
if (!partBody) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(partBody) as unknown;
|
||||
const candidateList = getNestedValue<unknown[]>(parsed, [4], []);
|
||||
if (Array.isArray(candidateList) && candidateList.length > 0) {
|
||||
bodyIndex = i;
|
||||
body = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const candidateList = getNestedValue<unknown[]>(body, [4], []);
|
||||
const firstCandidate = candidateList[0];
|
||||
const textRaw = getNestedValue<string>(firstCandidate, [1, 0], '');
|
||||
const cardContent = /^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(textRaw);
|
||||
const text = cardContent
|
||||
? (getNestedValue<string | null>(firstCandidate, [22, 0], null) ?? textRaw)
|
||||
: textRaw;
|
||||
const thoughts = getNestedValue<string | null>(firstCandidate, [37, 0, 0], null);
|
||||
const conversationMeta = getNestedValue<unknown[]>(body, [1], []);
|
||||
const conversationId =
|
||||
typeof conversationMeta[0] === 'string' && conversationMeta[0].length > 0
|
||||
? conversationMeta[0]
|
||||
: null;
|
||||
const responseId =
|
||||
typeof conversationMeta[1] === 'string' && conversationMeta[1].length > 0
|
||||
? conversationMeta[1]
|
||||
: null;
|
||||
const choiceIdRaw = getNestedValue<string | null>(firstCandidate, [0], null);
|
||||
const choiceId = typeof choiceIdRaw === 'string' && choiceIdRaw.length > 0 ? choiceIdRaw : null;
|
||||
const metadata =
|
||||
conversationId && responseId && choiceId ? [conversationId, responseId, choiceId] : conversationMeta;
|
||||
|
||||
const images: GeminiWebCandidateImage[] = [];
|
||||
|
||||
const webImages = getNestedValue<unknown[]>(firstCandidate, [12, 1], []);
|
||||
for (const webImage of webImages) {
|
||||
const url = getNestedValue<string | null>(webImage, [0, 0, 0], null);
|
||||
if (!url) continue;
|
||||
images.push({
|
||||
kind: 'web',
|
||||
url,
|
||||
title: getNestedValue<string | undefined>(webImage, [7, 0], undefined),
|
||||
alt: getNestedValue<string | undefined>(webImage, [0, 4], undefined),
|
||||
});
|
||||
}
|
||||
|
||||
const hasGenerated = Boolean(getNestedValue<unknown>(firstCandidate, [12, 7, 0], null));
|
||||
if (hasGenerated) {
|
||||
let imgBody: unknown = null;
|
||||
for (let i = bodyIndex; i < parts.length; i += 1) {
|
||||
const partBody = getNestedValue<string | null>(parts[i], [2], null);
|
||||
if (!partBody) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(partBody) as unknown;
|
||||
const candidateImages = getNestedValue<unknown | null>(parsed, [4, 0, 12, 7, 0], null);
|
||||
if (candidateImages != null) {
|
||||
imgBody = parsed;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const imgCandidate = getNestedValue<unknown>(imgBody ?? body, [4, 0], null);
|
||||
|
||||
const generated = getNestedValue<unknown[]>(imgCandidate, [12, 7, 0], []);
|
||||
for (const genImage of generated) {
|
||||
const url = getNestedValue<string | null>(genImage, [0, 3, 3], null);
|
||||
if (!url) continue;
|
||||
images.push({
|
||||
kind: 'generated',
|
||||
url,
|
||||
title: '[Generated Image]',
|
||||
alt: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { metadata, text, thoughts, images, errorCode };
|
||||
}
|
||||
|
||||
export function isGeminiModelUnavailable(errorCode: number | undefined): boolean {
|
||||
return errorCode === 1052;
|
||||
}
|
||||
|
||||
export async function runGeminiWebOnce(input: GeminiWebRunInput): Promise<GeminiWebRunOutput> {
|
||||
const at = await fetchGeminiAccessToken(input.cookieMap, input.signal);
|
||||
const cookieHeader = buildCookieHeader(input.cookieMap);
|
||||
|
||||
const uploaded: Array<{ id: string; name: string }> = [];
|
||||
for (const file of input.files ?? []) {
|
||||
if (input.signal?.aborted) {
|
||||
throw new Error('Gemini web run aborted before upload.');
|
||||
}
|
||||
uploaded.push(await uploadGeminiFile(file, input.signal));
|
||||
}
|
||||
|
||||
const fReq = buildGeminiFReqPayload(input.prompt, uploaded, input.chatMetadata ?? null);
|
||||
const params = new URLSearchParams();
|
||||
params.set('at', at);
|
||||
params.set('f.req', fReq);
|
||||
|
||||
const res = await fetch(GEMINI_STREAM_GENERATE_URL, {
|
||||
method: 'POST',
|
||||
redirect: 'follow',
|
||||
signal: input.signal,
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded;charset=utf-8',
|
||||
origin: 'https://gemini.google.com',
|
||||
referer: 'https://gemini.google.com/',
|
||||
'x-same-domain': '1',
|
||||
'user-agent': USER_AGENT,
|
||||
cookie: cookieHeader,
|
||||
[MODEL_HEADER_NAME]: MODEL_HEADERS[input.model],
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const rawResponseText = await res.text();
|
||||
if (!res.ok) {
|
||||
return {
|
||||
rawResponseText,
|
||||
text: '',
|
||||
thoughts: null,
|
||||
metadata: input.chatMetadata ?? null,
|
||||
images: [],
|
||||
errorMessage: `Gemini request failed: ${res.status} ${res.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseGeminiStreamGenerateResponse(rawResponseText);
|
||||
return {
|
||||
rawResponseText,
|
||||
text: parsed.text ?? '',
|
||||
thoughts: parsed.thoughts,
|
||||
metadata: parsed.metadata,
|
||||
images: parsed.images,
|
||||
errorCode: parsed.errorCode,
|
||||
};
|
||||
} catch (error) {
|
||||
let responseJson: unknown = null;
|
||||
try {
|
||||
responseJson = JSON.parse(trimGeminiJsonEnvelope(rawResponseText)) as unknown;
|
||||
} catch {
|
||||
responseJson = null;
|
||||
}
|
||||
const errorCode = extractErrorCode(responseJson);
|
||||
|
||||
return {
|
||||
rawResponseText,
|
||||
text: '',
|
||||
thoughts: null,
|
||||
metadata: input.chatMetadata ?? null,
|
||||
images: [],
|
||||
errorCode: typeof errorCode === 'number' ? errorCode : undefined,
|
||||
errorMessage: error instanceof Error ? error.message : String(error ?? ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runGeminiWebWithFallback(
|
||||
input: Omit<GeminiWebRunInput, 'model'> & { model: GeminiWebModelId },
|
||||
): Promise<GeminiWebRunOutput & { effectiveModel: GeminiWebModelId }> {
|
||||
const attempt = await runGeminiWebOnce(input);
|
||||
if (isGeminiModelUnavailable(attempt.errorCode) && input.model !== 'gemini-2.5-flash') {
|
||||
const fallback = await runGeminiWebOnce({ ...input, model: 'gemini-2.5-flash' });
|
||||
return { ...fallback, effectiveModel: 'gemini-2.5-flash' };
|
||||
}
|
||||
return { ...attempt, effectiveModel: input.model };
|
||||
}
|
||||
|
||||
export async function saveFirstGeminiImageFromOutput(
|
||||
output: GeminiWebRunOutput,
|
||||
cookieMap: Record<string, string>,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ saved: boolean; imageCount: number }> {
|
||||
const generatedOrWeb = output.images.find((img) => img.kind === 'generated') ?? output.images[0];
|
||||
if (generatedOrWeb?.url) {
|
||||
await downloadGeminiImage(generatedOrWeb.url, cookieMap, outputPath, signal);
|
||||
return { saved: true, imageCount: output.images.length };
|
||||
}
|
||||
|
||||
const ggdl = extractGgdlUrls(`${output.text}\n${output.rawResponseText}`);
|
||||
const preferred = ggdl.length > 0 ? ggdl[ggdl.length - 1] : null;
|
||||
if (preferred) {
|
||||
await downloadGeminiImage(preferred, cookieMap, outputPath, signal);
|
||||
return { saved: true, imageCount: ggdl.length };
|
||||
}
|
||||
|
||||
const imageGen = extractImageGenerationContentUrls(`${output.text}\n${output.rawResponseText}`);
|
||||
const imageGenPreferred = imageGen.length > 0 ? imageGen[imageGen.length - 1] : null;
|
||||
if (imageGenPreferred) {
|
||||
await downloadGeminiImage(imageGenPreferred, cookieMap, outputPath, signal);
|
||||
return { saved: true, imageCount: imageGen.length };
|
||||
}
|
||||
|
||||
return { saved: false, imageCount: 0 };
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
export type GeminiWebLog = (message: string) => void;
|
||||
|
||||
export const GEMINI_COOKIE_NAMES = [
|
||||
'__Secure-1PSID',
|
||||
'__Secure-1PSIDTS',
|
||||
'__Secure-1PSIDCC',
|
||||
'__Secure-1PAPISID',
|
||||
'NID',
|
||||
'AEC',
|
||||
'SOCS',
|
||||
'__Secure-BUCKET',
|
||||
'__Secure-ENID',
|
||||
'SID',
|
||||
'HSID',
|
||||
'SSID',
|
||||
'APISID',
|
||||
'SAPISID',
|
||||
'__Secure-3PSID',
|
||||
'__Secure-3PSIDTS',
|
||||
'__Secure-3PAPISID',
|
||||
'SIDCC',
|
||||
] as const;
|
||||
|
||||
export const GEMINI_REQUIRED_COOKIES = ['__Secure-1PSID', '__Secure-1PSIDTS'] as const;
|
||||
|
||||
export interface GeminiCookieFileV1 {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
cookieMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export function hasRequiredGeminiCookies(cookieMap: Record<string, string>): boolean {
|
||||
return GEMINI_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name]));
|
||||
}
|
||||
|
||||
function resolveCookieDomain(cookie: { domain?: string; url?: string }): string | null {
|
||||
const rawDomain = cookie.domain?.trim();
|
||||
if (rawDomain) {
|
||||
return rawDomain.startsWith('.') ? rawDomain.slice(1) : rawDomain;
|
||||
}
|
||||
const rawUrl = cookie.url?.trim();
|
||||
if (rawUrl) {
|
||||
try {
|
||||
return new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickCookieValue<T extends { name?: string; value?: string; domain?: string; path?: string; url?: string }>(
|
||||
cookies: T[],
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const matches = cookies.filter((cookie) => cookie.name === name && typeof cookie.value === 'string');
|
||||
if (matches.length === 0) return undefined;
|
||||
|
||||
const preferredDomain = matches.find((cookie) => {
|
||||
const domain = resolveCookieDomain(cookie);
|
||||
return domain === 'google.com' && (cookie.path ?? '/') === '/';
|
||||
});
|
||||
const googleDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? '').endsWith('google.com'));
|
||||
return (preferredDomain ?? googleDomain ?? matches[0])?.value;
|
||||
}
|
||||
|
||||
export function buildGeminiCookieMap<
|
||||
T extends { name?: string; value?: string; domain?: string; path?: string; url?: string },
|
||||
>(cookies: T[]): Record<string, string> {
|
||||
const cookieMap: Record<string, string> = {};
|
||||
for (const name of GEMINI_COOKIE_NAMES) {
|
||||
const value = pickCookieValue(cookies, name);
|
||||
if (value) cookieMap[name] = value;
|
||||
}
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
export async function readGeminiCookieMapFromDisk(options?: {
|
||||
cookiePath?: string;
|
||||
log?: GeminiWebLog;
|
||||
}): Promise<Record<string, string>> {
|
||||
const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
|
||||
try {
|
||||
const raw = await readFile(cookiePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<GeminiCookieFileV1> | Record<string, unknown>;
|
||||
|
||||
const cookieMap =
|
||||
(parsed as Partial<GeminiCookieFileV1>).version === 1
|
||||
? (parsed as Partial<GeminiCookieFileV1>).cookieMap
|
||||
: (parsed as Record<string, unknown>);
|
||||
|
||||
if (!cookieMap || typeof cookieMap !== 'object') return {};
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(cookieMap)) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(normalized).length > 0) {
|
||||
options?.log?.(`[gemini-web] Loaded cookies from ${cookiePath}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code === 'ENOENT') return {};
|
||||
options?.log?.(
|
||||
`[gemini-web] Failed to read cookies from ${cookiePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeGeminiCookieMapToDisk(
|
||||
cookieMap: Record<string, string>,
|
||||
options?: { cookiePath?: string; log?: GeminiWebLog },
|
||||
): Promise<void> {
|
||||
const cookiePath = options?.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
await mkdir(path.dirname(cookiePath), { recursive: true });
|
||||
|
||||
const payload: GeminiCookieFileV1 = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
cookieMap,
|
||||
};
|
||||
|
||||
await writeFile(cookiePath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
||||
try {
|
||||
await chmod(cookiePath, 0o600);
|
||||
} catch {
|
||||
// ignore chmod failures (e.g. on Windows)
|
||||
}
|
||||
options?.log?.(`[gemini-web] Saved cookies to ${cookiePath}`);
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { BrowserRunOptions, BrowserRunResult, BrowserLogger, CookieParam } from '../browser/types.js';
|
||||
import { runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
||||
import type { GeminiWebModelId, GeminiWebRunOutput } from './client.js';
|
||||
import {
|
||||
buildGeminiCookieMap,
|
||||
hasRequiredGeminiCookies,
|
||||
readGeminiCookieMapFromDisk,
|
||||
} from './cookie-store.js';
|
||||
import type { GeminiWebOptions, GeminiWebResponse } from './types.js';
|
||||
|
||||
export { hasRequiredGeminiCookies } from './cookie-store.js';
|
||||
|
||||
const 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';
|
||||
|
||||
function estimateTokenCount(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function resolveInvocationPath(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(process.cwd(), trimmed);
|
||||
}
|
||||
|
||||
function normalizePathList(value: string | string[] | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const raw = Array.isArray(value) ? value : [value];
|
||||
const out: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== 'string') continue;
|
||||
const resolved = resolveInvocationPath(entry);
|
||||
if (!resolved) continue;
|
||||
out.push(resolved);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dedupePaths(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const item of paths) {
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildCookieHeader(cookieMap: Record<string, string>): string {
|
||||
return Object.entries(cookieMap)
|
||||
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
async function fetchWithCookiePreservingRedirects(
|
||||
url: string,
|
||||
init: Omit<RequestInit, 'redirect'>,
|
||||
signal?: AbortSignal,
|
||||
maxRedirects = 10,
|
||||
): Promise<Response> {
|
||||
let current = url;
|
||||
for (let i = 0; i <= maxRedirects; i += 1) {
|
||||
const res = await fetch(current, { ...init, redirect: 'manual', signal });
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get('location');
|
||||
if (!location) return res;
|
||||
current = new URL(location, current).toString();
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
throw new Error(`Too many redirects while downloading media (>${maxRedirects}).`);
|
||||
}
|
||||
|
||||
async function downloadGeminiMedia(
|
||||
url: string,
|
||||
cookieMap: Record<string, string>,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const cookieHeader = buildCookieHeader(cookieMap);
|
||||
const res = await fetchWithCookiePreservingRedirects(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
'user-agent': USER_AGENT,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download media: ${res.status} ${res.statusText} (${res.url})`);
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, data);
|
||||
}
|
||||
|
||||
function extractGgdlUrls(rawText: string): string[] {
|
||||
const matches =
|
||||
rawText.match(/https?:\/\/[^/\s"']*googleusercontent\.com\/gg-dl\/[^\s"']+/g) ?? [];
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const match of matches) {
|
||||
if (seen.has(match)) continue;
|
||||
seen.add(match);
|
||||
urls.push(match);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
async function saveFirstGeminiVideoFromOutput(
|
||||
output: GeminiWebRunOutput,
|
||||
cookieMap: Record<string, string>,
|
||||
outputPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ saved: boolean; videoCount: number }> {
|
||||
const ggdl = extractGgdlUrls(output.rawResponseText);
|
||||
if (!ggdl[0]) return { saved: false, videoCount: 0 };
|
||||
|
||||
const videoCandidates = ggdl.filter((url) => /\.(mp4|webm|mov)(?:$|[?#])/i.test(url));
|
||||
const preferred =
|
||||
(videoCandidates.length > 0 ? videoCandidates[videoCandidates.length - 1] : null) ??
|
||||
ggdl.find((url) => /video/i.test(url)) ??
|
||||
ggdl[ggdl.length - 1];
|
||||
await downloadGeminiMedia(preferred, cookieMap, outputPath, signal);
|
||||
return { saved: true, videoCount: ggdl.length };
|
||||
}
|
||||
|
||||
function resolveGeminiWebModel(
|
||||
desiredModel: string | null | undefined,
|
||||
log?: BrowserLogger,
|
||||
): GeminiWebModelId {
|
||||
const desired = typeof desiredModel === 'string' ? desiredModel.trim() : '';
|
||||
if (!desired) return 'gemini-3-pro';
|
||||
|
||||
switch (desired) {
|
||||
case 'gemini-3-pro':
|
||||
case 'gemini-3.0-pro':
|
||||
return 'gemini-3-pro';
|
||||
case 'gemini-2.5-pro':
|
||||
return 'gemini-2.5-pro';
|
||||
case 'gemini-2.5-flash':
|
||||
return 'gemini-2.5-flash';
|
||||
default:
|
||||
if (desired.startsWith('gemini-')) {
|
||||
log?.(
|
||||
`[gemini-web] Unsupported Gemini web model "${desired}". Falling back to gemini-3-pro.`,
|
||||
);
|
||||
}
|
||||
return 'gemini-3-pro';
|
||||
}
|
||||
}
|
||||
|
||||
function buildInlineCookiesFromEnv(): CookieParam[] {
|
||||
const cookies: CookieParam[] = [];
|
||||
const psid = process.env.GEMINI_SECURE_1PSID?.trim();
|
||||
const psidts = process.env.GEMINI_SECURE_1PSIDTS?.trim();
|
||||
|
||||
if (psid) {
|
||||
cookies.push({ name: '__Secure-1PSID', value: psid, domain: 'google.com', path: '/' });
|
||||
}
|
||||
if (psidts) {
|
||||
cookies.push({ name: '__Secure-1PSIDTS', value: psidts, domain: 'google.com', path: '/' });
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function loadGeminiCookiesFromInline(
|
||||
browserConfig: BrowserRunOptions['config'],
|
||||
log?: BrowserLogger,
|
||||
): Promise<Record<string, string>> {
|
||||
const inline = browserConfig?.inlineCookies;
|
||||
if (!inline || inline.length === 0) return {};
|
||||
|
||||
const cookieMap = buildGeminiCookieMap(
|
||||
inline.filter((cookie): cookie is CookieParam => Boolean(cookie?.name && typeof cookie.value === 'string')),
|
||||
);
|
||||
|
||||
if (Object.keys(cookieMap).length > 0) {
|
||||
const source = browserConfig?.inlineCookiesSource ?? 'inline';
|
||||
log?.(`[gemini-web] Loaded Gemini cookies from inline payload (${source}): ${Object.keys(cookieMap).length} cookie(s).`);
|
||||
} else {
|
||||
log?.('[gemini-web] Inline cookie payload provided but no Gemini cookies matched.');
|
||||
}
|
||||
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
export async function loadGeminiCookies(
|
||||
browserConfig: BrowserRunOptions['config'],
|
||||
log?: BrowserLogger,
|
||||
): Promise<Record<string, string>> {
|
||||
const inlineMap = await loadGeminiCookiesFromInline(browserConfig, log);
|
||||
if (hasRequiredGeminiCookies(inlineMap)) return inlineMap;
|
||||
|
||||
const diskMap = await readGeminiCookieMapFromDisk({ log });
|
||||
const merged = { ...diskMap, ...inlineMap };
|
||||
if (hasRequiredGeminiCookies(merged)) return merged;
|
||||
|
||||
if (browserConfig?.cookieSync === false) {
|
||||
log?.('[gemini-web] Cookie sync disabled and inline cookies missing Gemini auth tokens.');
|
||||
return merged;
|
||||
}
|
||||
|
||||
log?.(
|
||||
'[gemini-web] Missing Gemini auth cookies. Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and refresh cookies.',
|
||||
);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function loadGeminiCookieMap(log?: BrowserLogger): Promise<Record<string, string>> {
|
||||
const diskMap = await readGeminiCookieMapFromDisk({ log });
|
||||
const inlineCookies = buildInlineCookiesFromEnv();
|
||||
const envMap = buildGeminiCookieMap(inlineCookies);
|
||||
return { ...diskMap, ...envMap };
|
||||
}
|
||||
|
||||
export function createGeminiWebExecutor(
|
||||
geminiOptions: GeminiWebOptions,
|
||||
): (runOptions: BrowserRunOptions) => Promise<BrowserRunResult> {
|
||||
let persistedChatMetadata: unknown | null = null;
|
||||
let referenceImagesUploaded = false;
|
||||
|
||||
return async (runOptions: BrowserRunOptions): Promise<BrowserRunResult> => {
|
||||
const startTime = Date.now();
|
||||
const log = runOptions.log;
|
||||
|
||||
log?.('[gemini-web] Starting Gemini web executor (TypeScript)');
|
||||
|
||||
const cookieMap = await loadGeminiCookies(runOptions.config, log);
|
||||
if (!hasRequiredGeminiCookies(cookieMap)) {
|
||||
throw new Error(
|
||||
'Gemini browser mode requires auth cookies (missing __Secure-1PSID/__Secure-1PSIDTS). Run `npx -y bun skills/baoyu-gemini-web/scripts/main.ts --login` to sign in and save cookies.',
|
||||
);
|
||||
}
|
||||
|
||||
const configTimeout =
|
||||
typeof runOptions.config?.timeoutMs === 'number' && Number.isFinite(runOptions.config.timeoutMs)
|
||||
? Math.max(1_000, runOptions.config.timeoutMs)
|
||||
: null;
|
||||
|
||||
const generateVideoPath = resolveInvocationPath(geminiOptions.generateVideo);
|
||||
|
||||
const defaultTimeoutMs = geminiOptions.youtube
|
||||
? 240_000
|
||||
: generateVideoPath
|
||||
? 900_000
|
||||
: geminiOptions.generateImage || geminiOptions.editImage
|
||||
? 300_000
|
||||
: 120_000;
|
||||
|
||||
const timeoutCapMs = generateVideoPath ? 1_800_000 : 600_000;
|
||||
const timeoutMs = Math.min(configTimeout ?? defaultTimeoutMs, timeoutCapMs);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const keepSession = geminiOptions.keepSession === true;
|
||||
|
||||
const generateImagePath = resolveInvocationPath(geminiOptions.generateImage);
|
||||
const editImagePath = resolveInvocationPath(geminiOptions.editImage);
|
||||
const outputPath = resolveInvocationPath(geminiOptions.outputPath);
|
||||
const attachmentPaths = (runOptions.attachments ?? []).map((attachment) => attachment.path);
|
||||
const referenceImagePaths = normalizePathList(geminiOptions.referenceImages);
|
||||
const requestFilePaths = dedupePaths(
|
||||
keepSession ? attachmentPaths : [...referenceImagePaths, ...attachmentPaths],
|
||||
);
|
||||
|
||||
if (generateVideoPath && (generateImagePath || editImagePath)) {
|
||||
throw new Error('Gemini web executor: generateVideo cannot be combined with generateImage/editImage options.');
|
||||
}
|
||||
|
||||
let prompt = runOptions.prompt;
|
||||
if (geminiOptions.aspectRatio && (generateImagePath || editImagePath || generateVideoPath)) {
|
||||
prompt = `${prompt} (aspect ratio: ${geminiOptions.aspectRatio})`;
|
||||
}
|
||||
if (geminiOptions.youtube) {
|
||||
prompt = `${prompt}\n\nYouTube video: ${geminiOptions.youtube}`;
|
||||
}
|
||||
if (generateImagePath && !editImagePath) {
|
||||
prompt = `Generate an image: ${prompt}`;
|
||||
}
|
||||
if (generateVideoPath) {
|
||||
prompt = `Generate a video: ${prompt}`;
|
||||
}
|
||||
|
||||
const model: GeminiWebModelId = resolveGeminiWebModel(runOptions.config?.desiredModel, log);
|
||||
let response: GeminiWebResponse;
|
||||
let videoSaveSummary: { saved: boolean; videoCount: number; outputPath: string } | null = null;
|
||||
|
||||
try {
|
||||
let chatMetadata: unknown = keepSession ? persistedChatMetadata : null;
|
||||
|
||||
if (keepSession && referenceImagePaths.length > 0 && !referenceImagesUploaded) {
|
||||
const intro = await runGeminiWebWithFallback({
|
||||
prompt: 'Here are reference images for future messages.',
|
||||
files: referenceImagePaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
chatMetadata = intro.metadata;
|
||||
persistedChatMetadata = intro.metadata;
|
||||
referenceImagesUploaded = true;
|
||||
}
|
||||
|
||||
if (editImagePath) {
|
||||
const intro = await runGeminiWebWithFallback({
|
||||
prompt: 'Here is an image to edit',
|
||||
files: [editImagePath],
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const editPrompt = `Use image generation tool to ${prompt}`;
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: editPrompt,
|
||||
files: requestFilePaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata: intro.metadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (keepSession) persistedChatMetadata = out.metadata;
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: false,
|
||||
image_count: 0,
|
||||
};
|
||||
|
||||
const resolvedOutputPath = outputPath ?? generateImagePath ?? 'generated.png';
|
||||
const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, resolvedOutputPath, controller.signal);
|
||||
response.has_images = imageSave.saved;
|
||||
response.image_count = imageSave.imageCount;
|
||||
if (!imageSave.saved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
} else if (generateImagePath) {
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt,
|
||||
files: requestFilePaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (keepSession) persistedChatMetadata = out.metadata;
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: false,
|
||||
image_count: 0,
|
||||
};
|
||||
const imageSave = await saveFirstGeminiImageFromOutput(out, cookieMap, generateImagePath, controller.signal);
|
||||
response.has_images = imageSave.saved;
|
||||
response.image_count = imageSave.imageCount;
|
||||
if (!imageSave.saved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
} else if (generateVideoPath) {
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt,
|
||||
files: requestFilePaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (keepSession) persistedChatMetadata = out.metadata;
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: false,
|
||||
image_count: 0,
|
||||
};
|
||||
|
||||
const resolvedOutputPath = generateVideoPath ?? outputPath ?? 'generated.mp4';
|
||||
const save = await saveFirstGeminiVideoFromOutput(out, cookieMap, resolvedOutputPath, controller.signal);
|
||||
videoSaveSummary = { ...save, outputPath: resolvedOutputPath };
|
||||
} else {
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt,
|
||||
files: requestFilePaths,
|
||||
model,
|
||||
cookieMap,
|
||||
chatMetadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (keepSession) persistedChatMetadata = out.metadata;
|
||||
response = {
|
||||
text: out.text ?? null,
|
||||
thoughts: geminiOptions.showThoughts ? out.thoughts : null,
|
||||
has_images: out.images.length > 0,
|
||||
image_count: out.images.length,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const answerText = response.text ?? '';
|
||||
let answerMarkdown = answerText;
|
||||
|
||||
if (geminiOptions.showThoughts && response.thoughts) {
|
||||
answerMarkdown = `## Thinking\n\n${response.thoughts}\n\n## Response\n\n${answerText}`;
|
||||
}
|
||||
|
||||
if (response.has_images && response.image_count > 0) {
|
||||
const imagePath = generateImagePath || outputPath || 'generated.png';
|
||||
answerMarkdown += `\n\n*Generated ${response.image_count} image(s). Saved to: ${imagePath}*`;
|
||||
}
|
||||
if (videoSaveSummary) {
|
||||
if (videoSaveSummary.saved) {
|
||||
answerMarkdown += `\n\n*Generated ${videoSaveSummary.videoCount || 1} video(s). Saved to: ${videoSaveSummary.outputPath}*`;
|
||||
} else if (/video_gen_chip/.test(answerMarkdown) || /video_gen_chip/.test(response.text ?? '')) {
|
||||
answerMarkdown += '\n\n*Video generation is asynchronous. Check Gemini web UI to download the result.*';
|
||||
} else {
|
||||
answerMarkdown += '\n\n*No downloadable video URL found in Gemini response.*';
|
||||
}
|
||||
}
|
||||
|
||||
const tookMs = Date.now() - startTime;
|
||||
log?.(`[gemini-web] Completed in ${tookMs}ms`);
|
||||
|
||||
return {
|
||||
answerText,
|
||||
answerMarkdown,
|
||||
tookMs,
|
||||
answerTokens: estimateTokenCount(answerText),
|
||||
answerChars: answerText.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,42 @@
|
||||
export type LogLevel = 'TRACE' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | number;
|
||||
|
||||
const lvl: Record<Exclude<LogLevel, number>, number> = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARNING: 3,
|
||||
ERROR: 4,
|
||||
CRITICAL: 5,
|
||||
};
|
||||
|
||||
let cur = lvl.INFO;
|
||||
|
||||
function toNum(level: LogLevel): number {
|
||||
if (typeof level === 'number') return level;
|
||||
return lvl[level] ?? lvl.INFO;
|
||||
}
|
||||
|
||||
export function set_log_level(level: LogLevel): void {
|
||||
cur = toNum(level);
|
||||
}
|
||||
|
||||
export const setLogLevel = set_log_level;
|
||||
|
||||
function emit(level: Exclude<LogLevel, number>, args: unknown[]): void {
|
||||
if (lvl[level] < cur) return;
|
||||
const prefix = `[gemini_webapi] ${level}:`;
|
||||
|
||||
if (level === 'WARNING') console.warn(prefix, ...args);
|
||||
else if (level === 'ERROR' || level === 'CRITICAL') console.error(prefix, ...args);
|
||||
else console.log(prefix, ...args);
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
trace: (...args: unknown[]) => emit('TRACE', args),
|
||||
debug: (...args: unknown[]) => emit('DEBUG', args),
|
||||
info: (...args: unknown[]) => emit('INFO', args),
|
||||
warning: (...args: unknown[]) => emit('WARNING', args),
|
||||
error: (...args: unknown[]) => emit('ERROR', args),
|
||||
success: (...args: unknown[]) => emit('INFO', args),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { logger } from './logger.js';
|
||||
|
||||
export function get_nested_value<T = unknown>(data: unknown, path: number[], def?: T): T {
|
||||
let cur: unknown = data;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const k = path[i]!;
|
||||
if (!Array.isArray(cur)) {
|
||||
logger.debug(`Safe navigation: path ${JSON.stringify(path)} ended at index ${i} (key '${k}'), returning default.`);
|
||||
return def as T;
|
||||
}
|
||||
cur = cur[k];
|
||||
if (cur === undefined) {
|
||||
logger.debug(`Safe navigation: path ${JSON.stringify(path)} ended at index ${i} (key '${k}'), returning default.`);
|
||||
return def as T;
|
||||
}
|
||||
}
|
||||
|
||||
if (cur == null && def !== undefined) return def as T;
|
||||
return cur as T;
|
||||
}
|
||||
|
||||
export function extract_json_from_response(text: string): unknown {
|
||||
if (typeof text !== 'string') {
|
||||
throw new TypeError(`Input text is expected to be a string, got ${typeof text} instead.`);
|
||||
}
|
||||
|
||||
let last: unknown = undefined;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
last = JSON.parse(trimmed) as unknown;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (last === undefined) {
|
||||
throw new Error('Could not find a valid JSON object or array in the response.');
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
export const extractJsonFromResponse = extract_json_from_response;
|
||||
export const getNestedValue = get_nested_value;
|
||||
+1
@@ -43,3 +43,4 @@ export function resolveGeminiWebSessionPath(name: string): string {
|
||||
const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
return path.join(resolveGeminiWebSessionsDir(), `${sanitized}.json`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
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 { resolveGeminiWebDataDir } from './paths.js';
|
||||
|
||||
export async function rotate_1psidts(cookies: Record<string, string>, _proxy?: string | null): Promise<string | null> {
|
||||
const p = resolveGeminiWebDataDir();
|
||||
await mkdir(p, { recursive: true });
|
||||
|
||||
const sid = cookies['__Secure-1PSID'];
|
||||
if (!sid) throw new Error('Missing __Secure-1PSID cookie.');
|
||||
|
||||
const cachePath = path.join(p, `.cached_1psidts_${sid}.txt`);
|
||||
|
||||
try {
|
||||
const st = fs.statSync(cachePath);
|
||||
if (Date.now() - st.mtimeMs <= 60_000) return null;
|
||||
} catch {}
|
||||
|
||||
const res = await fetch_with_timeout(Endpoint.ROTATE_COOKIES, {
|
||||
method: 'POST',
|
||||
headers: { ...Headers.ROTATE_COOKIES, Cookie: cookie_header(cookies) },
|
||||
body: '[000,"-0000000000000000000"]',
|
||||
redirect: 'follow',
|
||||
timeout_ms: 30_000,
|
||||
});
|
||||
|
||||
if (res.status === 401) throw new AuthError('Failed to refresh cookies (401).');
|
||||
if (!res.ok) throw new Error(`RotateCookies failed: ${res.status} ${res.statusText}`);
|
||||
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
const v = extract_set_cookie_value(setCookie, '__Secure-1PSIDTS');
|
||||
if (v) {
|
||||
await writeFile(cachePath, v, 'utf8');
|
||||
return v;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const rotate1psidts = rotate_1psidts;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { Endpoint, Headers } from '../constants.js';
|
||||
|
||||
export async function upload_file(file: string, _proxy?: string | null): Promise<string> {
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
throw new Error(`${file} is not a valid file.`);
|
||||
}
|
||||
|
||||
const filename = path.basename(file);
|
||||
const content = await readFile(file);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([content]), filename);
|
||||
|
||||
const res = await fetch(Endpoint.UPLOAD, {
|
||||
method: 'POST',
|
||||
headers: { ...Headers.UPLOAD },
|
||||
body: form,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export function parse_file_name(file: string): string {
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
throw new Error(`${file} is not a valid file.`);
|
||||
}
|
||||
return path.basename(file);
|
||||
}
|
||||
|
||||
export const uploadFile = upload_file;
|
||||
export const parseFileName = parse_file_name;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env -S npx -y bun
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
import { getGeminiCookieMapViaChrome } from './chrome-auth.js';
|
||||
import { writeGeminiCookieMapToDisk } from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cookiePath = resolveGeminiWebCookiePath();
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
|
||||
const log = (msg: string) => console.log(msg);
|
||||
const cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log });
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export { createGeminiWebExecutor } from './executor.js';
|
||||
export type { GeminiWebOptions, GeminiWebResponse } from './types.js';
|
||||
@@ -1,21 +1,65 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { fetchGeminiAccessToken, runGeminiWebWithFallback, saveFirstGeminiImageFromOutput } from './client.js';
|
||||
import { getGeminiCookieMapViaChrome } from './chrome-auth.js';
|
||||
import {
|
||||
hasRequiredGeminiCookies,
|
||||
readGeminiCookieMapFromDisk,
|
||||
writeGeminiCookieMapToDisk,
|
||||
} from './cookie-store.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
import { readSession, writeSession, listSessions } from './session-store.js';
|
||||
import { GeminiClient, GeneratedImage, Model, type ModelOutput } from './gemini-webapi/index.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath, resolveGeminiWebSessionPath, resolveGeminiWebSessionsDir } from './gemini-webapi/utils/index.js';
|
||||
|
||||
function printUsage(exitCode = 0): never {
|
||||
const cookiePath = resolveGeminiWebCookiePath();
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
type CliArgs = {
|
||||
prompt: string | null;
|
||||
promptFiles: string[];
|
||||
modelId: string;
|
||||
json: boolean;
|
||||
imagePath: string | null;
|
||||
referenceImages: string[];
|
||||
sessionId: string | null;
|
||||
listSessions: boolean;
|
||||
login: boolean;
|
||||
cookiePath: string | null;
|
||||
profileDir: string | null;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
id: string;
|
||||
metadata: Array<string | null>;
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string; timestamp: string; error?: string }>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type LegacySessionV1 = {
|
||||
version?: number;
|
||||
sessionId?: string;
|
||||
updatedAt?: string;
|
||||
conversationId?: string | null;
|
||||
responseId?: string | null;
|
||||
choiceId?: string | null;
|
||||
chatMetadata?: unknown;
|
||||
};
|
||||
|
||||
function normalizeSessionMetadata(input: unknown): Array<string | null> {
|
||||
if (Array.isArray(input)) {
|
||||
const out: Array<string | null> = [];
|
||||
for (const v of input.slice(0, 3)) out.push(typeof v === 'string' ? v : null);
|
||||
return out.length > 0 ? out : [null, null, null];
|
||||
}
|
||||
|
||||
if (input && typeof input === 'object') {
|
||||
const v1 = input as LegacySessionV1;
|
||||
if (Array.isArray(v1.chatMetadata)) return normalizeSessionMetadata(v1.chatMetadata);
|
||||
|
||||
const conv = typeof v1.conversationId === 'string' ? v1.conversationId : null;
|
||||
const rid = typeof v1.responseId === 'string' ? v1.responseId : null;
|
||||
const rcid = typeof v1.choiceId === 'string' ? v1.choiceId : null;
|
||||
if (conv || rid || rcid) return [conv, rid, rcid];
|
||||
}
|
||||
|
||||
return [null, null, null];
|
||||
}
|
||||
|
||||
function printUsage(cookiePath: string, profileDir: string): void {
|
||||
console.log(`Usage:
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "Hello"
|
||||
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello"
|
||||
@@ -33,6 +77,7 @@ Options:
|
||||
--json Output JSON
|
||||
--image [path] Generate an image and save it (default: ./generated.png)
|
||||
--reference <files...> Reference images for vision input
|
||||
--ref <files...> Alias for --reference
|
||||
--sessionId <id> Session ID for multi-turn conversation (agent should generate unique ID)
|
||||
--list-sessions List saved sessions (max 100, sorted by update time)
|
||||
--login Only refresh cookies, then exit
|
||||
@@ -41,405 +86,405 @@ Options:
|
||||
-h, --help Show help
|
||||
|
||||
Env overrides:
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH
|
||||
`);
|
||||
|
||||
process.exit(exitCode);
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH`);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
modelId: 'gemini-3-pro',
|
||||
json: false,
|
||||
imagePath: null,
|
||||
referenceImages: [],
|
||||
sessionId: null,
|
||||
listSessions: false,
|
||||
login: false,
|
||||
cookiePath: null,
|
||||
profileDir: null,
|
||||
help: false,
|
||||
};
|
||||
|
||||
async function readPromptFromStdin(): Promise<string | null> {
|
||||
if (process.stdin.isTTY) return null;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8').trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function readPromptFiles(filePaths: string[]): string {
|
||||
const contents: string[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new Error(`Prompt file not found: ${resolved}`);
|
||||
}
|
||||
const content = fs.readFileSync(resolved, 'utf8').trim();
|
||||
contents.push(content);
|
||||
}
|
||||
return contents.join('\n\n');
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
prompt?: string;
|
||||
promptFiles?: string[];
|
||||
model?: string;
|
||||
json?: boolean;
|
||||
imagePath?: string;
|
||||
loginOnly?: boolean;
|
||||
cookiePath?: string;
|
||||
profileDir?: string;
|
||||
referenceImages?: string[];
|
||||
sessionId?: string;
|
||||
listSessions?: boolean;
|
||||
} {
|
||||
const out: ReturnType<typeof parseArgs> = {};
|
||||
const positional: string[] = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i] ?? '';
|
||||
if (arg === '--help' || arg === '-h') printUsage(0);
|
||||
if (arg === '--json') {
|
||||
const takeMany = (i: number): { items: string[]; next: number } => {
|
||||
const items: string[] = [];
|
||||
let j = i + 1;
|
||||
while (j < argv.length) {
|
||||
const v = argv[j]!;
|
||||
if (v.startsWith('-')) break;
|
||||
items.push(v);
|
||||
j++;
|
||||
}
|
||||
return { items, next: j - 1 };
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
|
||||
if (a === '--help' || a === '-h') {
|
||||
out.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--json') {
|
||||
out.json = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--image' || arg === '--generate-image') {
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('-')) {
|
||||
out.imagePath = next;
|
||||
i += 1;
|
||||
} else {
|
||||
out.imagePath = 'generated.png';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--image=')) {
|
||||
out.imagePath = arg.slice('--image='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--generate-image=')) {
|
||||
out.imagePath = arg.slice('--generate-image='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--login') {
|
||||
out.loginOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--prompt' || arg === '-p') {
|
||||
out.prompt = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--prompt=')) {
|
||||
out.prompt = arg.slice('--prompt='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--promptfiles') {
|
||||
out.promptFiles = [];
|
||||
while (i + 1 < argv.length) {
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('-')) {
|
||||
out.promptFiles.push(next);
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg === '--model' || arg === '-m') {
|
||||
out.model = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--model=')) {
|
||||
out.model = arg.slice('--model='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--cookie-path') {
|
||||
out.cookiePath = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--cookie-path=')) {
|
||||
out.cookiePath = arg.slice('--cookie-path='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--profile-dir') {
|
||||
out.profileDir = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--profile-dir=')) {
|
||||
out.profileDir = arg.slice('--profile-dir='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--reference' || arg === '--ref') {
|
||||
out.referenceImages = [];
|
||||
while (i + 1 < argv.length) {
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('-')) {
|
||||
out.referenceImages.push(next);
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg === '--sessionId' || arg === '--session-id') {
|
||||
out.sessionId = argv[i + 1] ?? '';
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--sessionId=') || arg.startsWith('--session-id=')) {
|
||||
out.sessionId = arg.split('=')[1] ?? '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--list-sessions') {
|
||||
|
||||
if (a === '--list-sessions') {
|
||||
out.listSessions = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
if (a === '--login') {
|
||||
out.login = true;
|
||||
continue;
|
||||
}
|
||||
positional.push(arg);
|
||||
|
||||
if (a === '--prompt' || a === '-p') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error(`Missing value for ${a}`);
|
||||
out.prompt = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--promptfiles') {
|
||||
const { items, next } = takeMany(i);
|
||||
if (items.length === 0) throw new Error('Missing files for --promptfiles');
|
||||
out.promptFiles.push(...items);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--model' || a === '-m') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error(`Missing value for ${a}`);
|
||||
out.modelId = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--sessionId') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --sessionId');
|
||||
out.sessionId = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--cookie-path') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --cookie-path');
|
||||
out.cookiePath = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--profile-dir') {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error('Missing value for --profile-dir');
|
||||
out.profileDir = v;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--image' || a.startsWith('--image=')) {
|
||||
let v: string | null = null;
|
||||
if (a.startsWith('--image=')) {
|
||||
v = a.slice('--image='.length).trim();
|
||||
} else {
|
||||
const maybe = argv[i + 1];
|
||||
if (maybe && !maybe.startsWith('-')) {
|
||||
v = maybe;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
out.imagePath = v && v.length > 0 ? v : 'generated.png';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === '--reference' || a === '--ref') {
|
||||
const { items, next } = takeMany(i);
|
||||
if (items.length === 0) throw new Error(`Missing files for ${a}`);
|
||||
out.referenceImages.push(...items);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${a}`);
|
||||
}
|
||||
|
||||
positional.push(a);
|
||||
}
|
||||
|
||||
if (!out.prompt && positional.length > 0) {
|
||||
out.prompt = positional.join(' ').trim();
|
||||
if (!out.prompt && out.promptFiles.length === 0 && positional.length > 0) {
|
||||
out.prompt = positional.join(' ');
|
||||
}
|
||||
|
||||
if (out.prompt != null) out.prompt = out.prompt.trim();
|
||||
if (out.model != null) out.model = out.model.trim();
|
||||
if (out.imagePath != null) out.imagePath = out.imagePath.trim();
|
||||
if (out.cookiePath != null) out.cookiePath = out.cookiePath.trim();
|
||||
if (out.profileDir != null) out.profileDir = out.profileDir.trim();
|
||||
|
||||
if (out.imagePath === '') delete out.imagePath;
|
||||
if (out.cookiePath === '') delete out.cookiePath;
|
||||
if (out.profileDir === '') delete out.profileDir;
|
||||
if (out.promptFiles?.length === 0) delete out.promptFiles;
|
||||
if (out.referenceImages?.length === 0) delete out.referenceImages;
|
||||
if (out.sessionId != null) out.sessionId = out.sessionId.trim();
|
||||
if (out.sessionId === '') delete out.sessionId;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function isCookieMapValid(cookieMap: Record<string, string>): Promise<boolean> {
|
||||
if (!hasRequiredGeminiCookies(cookieMap)) return false;
|
||||
function resolveModel(id: string): Model {
|
||||
const k = id.trim();
|
||||
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
|
||||
if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO;
|
||||
if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO;
|
||||
if (k === 'gemini-2.5-flash') return Model.G_2_5_FLASH;
|
||||
return Model.from_name(k);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30_000);
|
||||
async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
for (const f of files) {
|
||||
parts.push(await readFile(f, 'utf8'));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
async function readPromptFromStdin(): Promise<string | null> {
|
||||
if (process.stdin.isTTY) return null;
|
||||
try {
|
||||
await fetchGeminiAccessToken(cookieMap, controller.signal);
|
||||
return true;
|
||||
// Bun provides Bun.stdin; Node-compatible read can be flaky across runtimes.
|
||||
const t = await Bun.stdin.text();
|
||||
const v = t.trim();
|
||||
return v.length > 0 ? v : null;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGeminiCookieMap(options: {
|
||||
cookiePath: string;
|
||||
profileDir: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const log = (msg: string) => console.error(msg);
|
||||
|
||||
let cookieMap = await readGeminiCookieMapFromDisk({ cookiePath: options.cookiePath, log });
|
||||
if (await isCookieMapValid(cookieMap)) return cookieMap;
|
||||
|
||||
log('[gemini-web] No valid cookies found. Opening browser to sync Gemini cookies...');
|
||||
cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: options.profileDir, log });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath: options.cookiePath, log });
|
||||
return cookieMap;
|
||||
function normalizeOutputImagePath(p: string): string {
|
||||
const full = path.resolve(p);
|
||||
const ext = path.extname(full);
|
||||
if (ext) return full;
|
||||
return `${full}.png`;
|
||||
}
|
||||
|
||||
function resolveModel(value: string): 'gemini-3-pro' | 'gemini-2.5-pro' | 'gemini-2.5-flash' {
|
||||
const desired = value.trim();
|
||||
if (!desired) return 'gemini-3-pro';
|
||||
switch (desired) {
|
||||
case 'gemini-3-pro':
|
||||
case 'gemini-3.0-pro':
|
||||
return 'gemini-3-pro';
|
||||
case 'gemini-2.5-pro':
|
||||
return 'gemini-2.5-pro';
|
||||
case 'gemini-2.5-flash':
|
||||
return 'gemini-2.5-flash';
|
||||
default:
|
||||
console.error(`[gemini-web] Unsupported model "${desired}", falling back to gemini-3-pro.`);
|
||||
return 'gemini-3-pro';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImageOutputPath(value: string | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
const raw = trimmed || 'generated.png';
|
||||
const resolved = path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
|
||||
|
||||
if (resolved.endsWith(path.sep)) return path.join(resolved, 'generated.png');
|
||||
async function loadSession(id: string): Promise<SessionRecord | null> {
|
||||
const p = resolveGeminiWebSessionPath(id);
|
||||
try {
|
||||
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
||||
return path.join(resolved, 'generated.png');
|
||||
const raw = await readFile(p, 'utf8');
|
||||
const j = JSON.parse(raw) as unknown;
|
||||
if (!j || typeof j !== 'object') return null;
|
||||
|
||||
const sid = (typeof (j as any).id === 'string' && (j as any).id.trim()) || (typeof (j as any).sessionId === 'string' && (j as any).sessionId.trim()) || id;
|
||||
const metadata = normalizeSessionMetadata((j as any).metadata ?? (j as any).chatMetadata ?? j);
|
||||
const messages = Array.isArray((j as any).messages) ? ((j as any).messages as SessionRecord['messages']) : [];
|
||||
const createdAt =
|
||||
typeof (j as any).createdAt === 'string'
|
||||
? ((j as any).createdAt as string)
|
||||
: typeof (j as any).updatedAt === 'string'
|
||||
? ((j as any).updatedAt as string)
|
||||
: new Date().toISOString();
|
||||
const updatedAt = typeof (j as any).updatedAt === 'string' ? ((j as any).updatedAt as string) : createdAt;
|
||||
|
||||
return {
|
||||
id: sid,
|
||||
metadata,
|
||||
messages,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSession(rec: SessionRecord): Promise<void> {
|
||||
const dir = resolveGeminiWebSessionsDir();
|
||||
await mkdir(dir, { recursive: true });
|
||||
const p = resolveGeminiWebSessionPath(rec.id);
|
||||
const tmp = `${p}.tmp.${Date.now()}`;
|
||||
await writeFile(tmp, JSON.stringify(rec, null, 2), 'utf8');
|
||||
await fs.promises.rename(tmp, p);
|
||||
}
|
||||
|
||||
async function listSessions(): Promise<SessionRecord[]> {
|
||||
const dir = resolveGeminiWebSessionsDir();
|
||||
try {
|
||||
const names = await readdir(dir);
|
||||
const items: Array<{ path: string; st: number }> = [];
|
||||
for (const n of names) {
|
||||
if (!n.endsWith('.json')) continue;
|
||||
const p = path.join(dir, n);
|
||||
try {
|
||||
const s = await stat(p);
|
||||
items.push({ path: p, st: s.mtimeMs });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.st - a.st);
|
||||
const out: SessionRecord[] = [];
|
||||
for (const it of items.slice(0, 100)) {
|
||||
try {
|
||||
const raw = await readFile(it.path, 'utf8');
|
||||
const j = JSON.parse(raw) as any;
|
||||
const id =
|
||||
(typeof j?.id === 'string' && j.id.trim()) ||
|
||||
(typeof j?.sessionId === 'string' && j.sessionId.trim()) ||
|
||||
path.basename(it.path, '.json');
|
||||
out.push({
|
||||
id,
|
||||
metadata: normalizeSessionMetadata(j?.metadata ?? j?.chatMetadata ?? j),
|
||||
messages: Array.isArray(j?.messages) ? j.messages : [],
|
||||
createdAt:
|
||||
typeof j?.createdAt === 'string'
|
||||
? j.createdAt
|
||||
: typeof j?.updatedAt === 'string'
|
||||
? j.updatedAt
|
||||
: new Date(it.st).toISOString(),
|
||||
updatedAt: typeof j?.updatedAt === 'string' ? j.updatedAt : new Date(it.st).toISOString(),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
out.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
||||
return out.slice(0, 100);
|
||||
} catch {
|
||||
// ignore
|
||||
return [];
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function formatJson(out: ModelOutput, extra?: Record<string, unknown>): string {
|
||||
const candidates = out.candidates.map((c) => ({
|
||||
rcid: c.rcid,
|
||||
text: c.text,
|
||||
thoughts: c.thoughts,
|
||||
images: c.images.map((img) => ({
|
||||
url: img.url,
|
||||
title: img.title,
|
||||
alt: img.alt,
|
||||
kind: img instanceof GeneratedImage ? 'generated' : 'web',
|
||||
})),
|
||||
}));
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
text: out.text,
|
||||
thoughts: out.thoughts,
|
||||
metadata: out.metadata,
|
||||
chosen: out.chosen,
|
||||
candidates,
|
||||
...extra,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cookiePath = args.cookiePath ?? resolveGeminiWebCookiePath();
|
||||
const profileDir = args.profileDir ?? resolveGeminiWebChromeProfileDir();
|
||||
|
||||
if (args.cookiePath) process.env.GEMINI_WEB_COOKIE_PATH = args.cookiePath;
|
||||
if (args.profileDir) process.env.GEMINI_WEB_CHROME_PROFILE_DIR = args.profileDir;
|
||||
|
||||
const cookiePath = resolveGeminiWebCookiePath();
|
||||
const profileDir = resolveGeminiWebChromeProfileDir();
|
||||
|
||||
if (args.help) {
|
||||
printUsage(cookiePath, profileDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.listSessions) {
|
||||
const sessions = await listSessions();
|
||||
if (sessions.length === 0) {
|
||||
console.log('No saved sessions.');
|
||||
} else {
|
||||
for (const { id, updatedAt } of sessions) {
|
||||
console.log(`${id}\t${updatedAt}`);
|
||||
}
|
||||
const ss = await listSessions();
|
||||
for (const s of ss) {
|
||||
const n = s.messages.length;
|
||||
const last = s.messages.slice(-1)[0];
|
||||
const lastLine = last?.content ? String(last.content).split('\n')[0] : '';
|
||||
console.log(`${s.id}\t${s.updatedAt}\t${n}\t${lastLine}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.loginOnly) {
|
||||
await ensureGeminiCookieMap({ cookiePath, profileDir });
|
||||
if (args.login) {
|
||||
process.env.GEMINI_WEB_LOGIN = '1';
|
||||
const c = new GeminiClient();
|
||||
await c.init({ verbose: true });
|
||||
await c.close();
|
||||
if (!args.json) console.log(`Cookie refreshed: ${cookiePath}`);
|
||||
else console.log(JSON.stringify({ ok: true, cookiePath }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const promptFromFiles = args.promptFiles ? readPromptFiles(args.promptFiles) : null;
|
||||
const promptFromArgs = promptFromFiles || args.prompt;
|
||||
const prompt = promptFromArgs || (await readPromptFromStdin());
|
||||
if (!prompt) printUsage(1);
|
||||
let prompt: string | null = args.prompt;
|
||||
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
|
||||
if (!prompt) prompt = await readPromptFromStdin();
|
||||
|
||||
const sessionData = args.sessionId ? await readSession(args.sessionId) : null;
|
||||
const chatMetadata = sessionData?.metadata ?? null;
|
||||
if (!prompt) {
|
||||
printUsage(cookiePath, profileDir);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let cookieMap = await ensureGeminiCookieMap({ cookiePath, profileDir });
|
||||
|
||||
const desiredModel = resolveModel(args.model || 'gemini-3-pro');
|
||||
const imagePath = resolveImageOutputPath(args.imagePath);
|
||||
const referenceImages = (args.referenceImages ?? []).map((p) =>
|
||||
path.isAbsolute(p) ? p : path.resolve(process.cwd(), p),
|
||||
);
|
||||
const model = resolveModel(args.modelId);
|
||||
|
||||
const c = new GeminiClient();
|
||||
await c.init({ verbose: false });
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = imagePath ? 300_000 : 120_000;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let sess: SessionRecord | null = null;
|
||||
let chat = null as any;
|
||||
|
||||
try {
|
||||
const effectivePrompt = imagePath ? `Generate an image: ${prompt}` : prompt;
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: effectivePrompt,
|
||||
files: referenceImages,
|
||||
model: desiredModel,
|
||||
cookieMap,
|
||||
chatMetadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (args.sessionId && out.metadata) {
|
||||
await writeSession(args.sessionId, out.metadata, prompt, out.text ?? '', out.errorMessage);
|
||||
}
|
||||
|
||||
let imageSaved = false;
|
||||
let imageCount = 0;
|
||||
if (imagePath) {
|
||||
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath, controller.signal);
|
||||
imageSaved = save.saved;
|
||||
imageCount = save.imageCount;
|
||||
if (!imageSaved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
const jsonOut = { ...out, ...(imagePath && { imageSaved, imageCount, imagePath }), ...(args.sessionId && { sessionId: args.sessionId }) };
|
||||
process.stdout.write(`${JSON.stringify(jsonOut, null, 2)}\n`);
|
||||
if (out.errorMessage) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (out.errorMessage) {
|
||||
throw new Error(out.errorMessage);
|
||||
}
|
||||
|
||||
process.stdout.write(out.text ?? '');
|
||||
if (!out.text?.endsWith('\n')) process.stdout.write('\n');
|
||||
if (imagePath) {
|
||||
process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`);
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (args.sessionId) {
|
||||
sess = (await loadSession(args.sessionId)) ?? {
|
||||
id: args.sessionId,
|
||||
metadata: [null, null, null],
|
||||
messages: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
chat = c.start_chat({ metadata: sess.metadata, model });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (message.includes('Unable to locate Gemini access token')) {
|
||||
console.error('[gemini-web] Cookies may be expired. Re-opening browser to refresh cookies...');
|
||||
await sleep(500);
|
||||
cookieMap = await getGeminiCookieMapViaChrome({ userDataDir: profileDir, log: (m) => console.error(m) });
|
||||
await writeGeminiCookieMapToDisk(cookieMap, { cookiePath, log: (m) => console.error(m) });
|
||||
const files = args.referenceImages.length > 0 ? args.referenceImages : null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = imagePath ? 300_000 : 120_000;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
let out: ModelOutput;
|
||||
if (chat) out = await chat.send_message(prompt, files);
|
||||
else out = await c.generate_content(prompt, files, model);
|
||||
|
||||
try {
|
||||
const out = await runGeminiWebWithFallback({
|
||||
prompt: imagePath ? `Generate an image: ${prompt}` : prompt,
|
||||
files: referenceImages,
|
||||
model: desiredModel,
|
||||
cookieMap,
|
||||
chatMetadata,
|
||||
signal: controller.signal,
|
||||
});
|
||||
let savedImage: string | null = null;
|
||||
if (args.imagePath) {
|
||||
const p = normalizeOutputImagePath(args.imagePath);
|
||||
const dir = path.dirname(p);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
if (args.sessionId && out.metadata) {
|
||||
await writeSession(args.sessionId, out.metadata, prompt, out.text ?? '', out.errorMessage);
|
||||
}
|
||||
const img = out.images[0];
|
||||
if (!img) {
|
||||
throw new Error('No image returned in response.');
|
||||
}
|
||||
|
||||
let imageSaved = false;
|
||||
let imageCount = 0;
|
||||
if (imagePath) {
|
||||
const save = await saveFirstGeminiImageFromOutput(out, cookieMap, imagePath, controller.signal);
|
||||
imageSaved = save.saved;
|
||||
imageCount = save.imageCount;
|
||||
if (!imageSaved) {
|
||||
throw new Error(`No images generated. Response text:\n${out.text || '(empty response)'}`);
|
||||
}
|
||||
}
|
||||
const fn = path.basename(p);
|
||||
const dp = dir;
|
||||
|
||||
if (args.json) {
|
||||
const jsonOut = { ...out, ...(imagePath && { imageSaved, imageCount, imagePath }), ...(args.sessionId && { sessionId: args.sessionId }) };
|
||||
process.stdout.write(`${JSON.stringify(jsonOut, null, 2)}\n`);
|
||||
if (out.errorMessage) process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (out.errorMessage) {
|
||||
throw new Error(out.errorMessage);
|
||||
}
|
||||
|
||||
process.stdout.write(out.text ?? '');
|
||||
if (!out.text?.endsWith('\n')) process.stdout.write('\n');
|
||||
if (imagePath) {
|
||||
process.stdout.write(`Saved image (${imageCount || 1}) to: ${imagePath}\n`);
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (img instanceof GeneratedImage) {
|
||||
savedImage = await img.save(dp, fn, undefined, false, false, true);
|
||||
} else {
|
||||
savedImage = await img.save(dp, fn, c.cookies, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
if (sess && args.sessionId) {
|
||||
const now = new Date().toISOString();
|
||||
sess.updatedAt = now;
|
||||
sess.metadata = (chat?.metadata ?? sess.metadata).slice(0, 3);
|
||||
sess.messages.push({ role: 'user', content: prompt, timestamp: now });
|
||||
sess.messages.push({ role: 'assistant', content: out.text ?? '', timestamp: now });
|
||||
await saveSession(sess);
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(formatJson(out, { savedImage, sessionId: args.sessionId, model: model.model_name }));
|
||||
} else if (args.imagePath) {
|
||||
console.log(savedImage ?? '');
|
||||
} else {
|
||||
console.log(out.text);
|
||||
}
|
||||
} finally {
|
||||
await c.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
main().catch((e) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { mkdir, readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { resolveGeminiWebSessionsDir, resolveGeminiWebSessionPath } from './paths.js';
|
||||
|
||||
export interface SessionMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
id: string;
|
||||
metadata: unknown;
|
||||
messages: SessionMessage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function readSession(id: string): Promise<SessionData | null> {
|
||||
const sessionPath = resolveGeminiWebSessionPath(id);
|
||||
try {
|
||||
const content = await readFile(sessionPath, 'utf8');
|
||||
return JSON.parse(content) as SessionData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSession(
|
||||
id: string,
|
||||
metadata: unknown,
|
||||
userMessage: string,
|
||||
assistantMessage: string,
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const sessionPath = resolveGeminiWebSessionPath(id);
|
||||
const sessionsDir = resolveGeminiWebSessionsDir();
|
||||
await mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const existing = await readSession(id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newMessages: SessionMessage[] = [
|
||||
{ role: 'user', content: userMessage, timestamp: now },
|
||||
{ role: 'assistant', content: assistantMessage, timestamp: now, ...(error && { error }) },
|
||||
];
|
||||
|
||||
const data: SessionData = {
|
||||
id,
|
||||
metadata,
|
||||
messages: [...(existing?.messages ?? []), ...newMessages],
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await writeFile(sessionPath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export async function listSessions(limit = 100): Promise<SessionListItem[]> {
|
||||
const sessionsDir = resolveGeminiWebSessionsDir();
|
||||
try {
|
||||
const files = await readdir(sessionsDir);
|
||||
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
||||
|
||||
const items: { id: string; updatedAt: string; mtime: number }[] = [];
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = path.join(sessionsDir, file);
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
items.push({
|
||||
id: file.slice(0, -5),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
mtime: stats.mtime.getTime(),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.mtime - a.mtime);
|
||||
return items.slice(0, limit).map(({ id, updatedAt }) => ({ id, updatedAt }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface GeminiWebOptions {
|
||||
youtube?: string;
|
||||
generateImage?: string;
|
||||
editImage?: string;
|
||||
generateVideo?: string;
|
||||
outputPath?: string;
|
||||
showThoughts?: boolean;
|
||||
aspectRatio?: string;
|
||||
/**
|
||||
* One or more local image paths to upload as persistent reference images.
|
||||
* - If `keepSession` is enabled, they are uploaded once per executor session.
|
||||
* - Otherwise, they are attached to each request.
|
||||
*/
|
||||
referenceImages?: string | string[];
|
||||
/** Preserve Gemini chat metadata to continue multi-turn conversations within the same executor instance. */
|
||||
keepSession?: boolean;
|
||||
}
|
||||
|
||||
export interface GeminiWebResponse {
|
||||
text: string | null;
|
||||
thoughts: string | null;
|
||||
has_images: boolean;
|
||||
image_count: number;
|
||||
error?: string;
|
||||
}
|
||||
@@ -72,3 +72,13 @@ npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --markdown article.md --theme
|
||||
- **Not logged in**: First run opens browser - scan QR code to log in, session is preserved
|
||||
- **Chrome not found**: Set `WECHAT_BROWSER_CHROME_PATH` environment variable
|
||||
- **Paste fails**: Check system clipboard permissions
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md` (user)
|
||||
|
||||
If found, load before workflow. Extension content overrides defaults.
|
||||
|
||||
@@ -101,3 +101,13 @@ cover_image: /path/to/cover.jpg
|
||||
- Always preview before using `--submit`
|
||||
- Browser closes automatically after operation
|
||||
- Supports macOS, Linux, and Windows
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-post-to-x/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-post-to-x/EXTEND.md` (user)
|
||||
|
||||
If found, load before workflow. Extension content overrides defaults.
|
||||
|
||||
+114
-264
@@ -10,40 +10,37 @@ Transform content into professional slide deck images with flexible style option
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Auto-select style based on content
|
||||
/baoyu-slide-deck path/to/content.md
|
||||
|
||||
# Specify a style
|
||||
/baoyu-slide-deck path/to/content.md --style sketch-notes
|
||||
/baoyu-slide-deck path/to/content.md --style minimal
|
||||
|
||||
# Target specific audience
|
||||
/baoyu-slide-deck path/to/content.md --audience executives
|
||||
|
||||
# Set output language
|
||||
/baoyu-slide-deck path/to/content.md --lang zh
|
||||
|
||||
# Limit slide count
|
||||
/baoyu-slide-deck path/to/content.md --slides 10
|
||||
|
||||
# Generate outline only (skip image generation)
|
||||
/baoyu-slide-deck path/to/content.md --outline-only
|
||||
|
||||
# Direct content input
|
||||
/baoyu-slide-deck
|
||||
[paste content]
|
||||
|
||||
# Combine options
|
||||
/baoyu-slide-deck path/to/content.md --style storytelling --audience experts --slides 15
|
||||
/baoyu-slide-deck # Then paste content
|
||||
```
|
||||
|
||||
## 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/merge-to-pptx.ts` | Merge slides into PowerPoint |
|
||||
| `scripts/merge-to-pdf.ts` | Merge slides into PDF |
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--style <name>` | Visual style (see Style Gallery) |
|
||||
| `--audience <type>` | Target audience: beginners, intermediate, experts, executives, general |
|
||||
| `--lang <code>` | Output language for prompts and slides (en, zh, ja, etc.) |
|
||||
| `--lang <code>` | Output language (en, zh, ja, etc.) |
|
||||
| `--slides <number>` | Target slide count |
|
||||
| `--outline-only` | Generate outline only, skip image generation |
|
||||
|
||||
@@ -51,33 +48,31 @@ Transform content into professional slide deck images with flexible style option
|
||||
|
||||
| Style | Description | Best For |
|
||||
|-------|-------------|----------|
|
||||
| `sketch-notes` | Hand-drawn sketch notes, warm & friendly | Educational, tutorials, knowledge sharing |
|
||||
| `blueprint` | Technical blueprint, precise & analytical | Architecture, system design, data analysis |
|
||||
| `bold-editorial` | Magazine editorial, high-impact & dynamic | Product launches, marketing, keynotes |
|
||||
| `vector-illustration` | Flat vector with black outlines, retro & cute | Creative proposals, children's content, brand showcases |
|
||||
| `minimal` | Ultra-clean, maximum whitespace | Executive briefings, keynotes, premium brands |
|
||||
| `storytelling` | Cinematic, full-bleed visuals | Narratives, case studies, emotional impact |
|
||||
| `warm` | Soft gradients, wellness aesthetic | Lifestyle, wellness, personal development |
|
||||
| `notion` (Default) | SaaS dashboard, clean data focus | Product demos, SaaS, productivity tools |
|
||||
| `corporate` | Navy/gold, professional business | Investor decks, client proposals, quarterly reports |
|
||||
| `playful` | Vibrant colors, dynamic rounded shapes | Workshops, training, creative pitches |
|
||||
|
||||
Detailed style definitions: `references/styles/<style>.md`
|
||||
| `sketch-notes` | Hand-drawn, warm & friendly | Educational, tutorials |
|
||||
| `blueprint` | Technical, precise & analytical | Architecture, system design |
|
||||
| `bold-editorial` | Magazine, high-impact & dynamic | Product launches, keynotes |
|
||||
| `vector-illustration` | Flat vector, retro & cute | Creative, children's content |
|
||||
| `minimal` | Ultra-clean, maximum whitespace | Executive briefings, premium |
|
||||
| `storytelling` | Cinematic, full-bleed visuals | Narratives, case studies |
|
||||
| `warm` | Soft gradients, wellness aesthetic | Lifestyle, personal development |
|
||||
| `notion` (Default) | SaaS dashboard, clean data focus | Product demos, productivity |
|
||||
| `corporate` | Navy/gold, professional | Investor decks, proposals |
|
||||
| `playful` | Vibrant, dynamic shapes | Workshops, training |
|
||||
|
||||
## Auto Style Selection
|
||||
|
||||
| Content Signals | Selected Style |
|
||||
|-----------------|----------------|
|
||||
| tutorial, learn, education, guide, intro, beginner | `sketch-notes` |
|
||||
| architecture, system, data, analysis, technical, engineering | `blueprint` |
|
||||
| launch, marketing, brand, keynote, impact, showcase | `bold-editorial` |
|
||||
| creative, children, kids, cute, illustration, retro | `vector-illustration` |
|
||||
| architecture, system, data, analysis, technical | `blueprint` |
|
||||
| launch, marketing, brand, keynote, impact | `bold-editorial` |
|
||||
| creative, children, kids, cute, illustration | `vector-illustration` |
|
||||
| executive, minimal, clean, simple, elegant | `minimal` |
|
||||
| story, journey, case study, narrative, emotional | `storytelling` |
|
||||
| wellness, lifestyle, personal, growth, mindfulness | `warm` |
|
||||
| saas, product, dashboard, metrics, productivity | `notion` |
|
||||
| investor, quarterly, business, corporate, proposal, client | `corporate` |
|
||||
| workshop, training, fun, playful, energetic, team | `playful` |
|
||||
| investor, quarterly, business, corporate, proposal | `corporate` |
|
||||
| workshop, training, fun, playful, energetic | `playful` |
|
||||
| Default | `notion` |
|
||||
|
||||
## Design Philosophy
|
||||
@@ -91,209 +86,89 @@ This deck is designed for **reading and sharing**, not live presentation:
|
||||
## File Management
|
||||
|
||||
### With Content Path
|
||||
|
||||
Save to `slide-deck/` subdirectory in the same folder as the content:
|
||||
|
||||
```
|
||||
content-dir/
|
||||
├── source-content.md
|
||||
└── slide-deck/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── 01-slide-cover.md
|
||||
│ ├── 02-slide-{slug}.md
|
||||
│ └── ...
|
||||
├── 01-slide-cover.png
|
||||
├── 02-slide-{slug}.png
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Without Content Path
|
||||
|
||||
Save to `slide-outputs/YYYY-MM-DD/[topic-slug]/`:
|
||||
|
||||
```
|
||||
slide-outputs/
|
||||
└── 2026-01-17/
|
||||
└── ai-future-trends/
|
||||
└── source-content/
|
||||
└── slide-deck/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── 01-slide-cover.md
|
||||
│ └── ...
|
||||
├── 01-slide-cover.png
|
||||
└── ...
|
||||
│ └── 01-slide-cover.md, 02-slide-{slug}.md, ...
|
||||
├── 01-slide-cover.png, 02-slide-{slug}.png, ...
|
||||
├── {topic-slug}.pptx
|
||||
└── {topic-slug}.pdf
|
||||
```
|
||||
|
||||
Example: `/posts/ai-intro.md` → `/posts/ai-intro/slide-deck/`
|
||||
|
||||
### Without Content Path (Pasted Content)
|
||||
```
|
||||
slide-deck/{topic-slug}/
|
||||
├── source.md
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
├── *.png
|
||||
├── {topic-slug}.pptx
|
||||
└── {topic-slug}.pdf
|
||||
```
|
||||
|
||||
### Directory Backup
|
||||
|
||||
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content & Determine Settings
|
||||
### Step 1: Analyze Content
|
||||
|
||||
1. Read source material
|
||||
2. **Style selection**:
|
||||
- If `--style` specified, use that style
|
||||
- Otherwise, scan for style signals and auto-select
|
||||
3. **Language detection**:
|
||||
- If `--lang` specified, use that language for all prompts and slide text
|
||||
- Otherwise, detect language from source material
|
||||
- If uncertain (mixed languages or unclear), ask user to confirm
|
||||
4. **Slide count**:
|
||||
- If `--slides` specified, use that count
|
||||
- Otherwise, dynamic based on content structure
|
||||
1. Save source content (if pasted, save as `source.md`)
|
||||
2. Follow `references/analysis-framework.md` for deep content analysis
|
||||
3. Determine style (use `--style` or auto-select from signals)
|
||||
4. Detect languages (source vs. user preference)
|
||||
5. Plan slide count (`--slides` or dynamic)
|
||||
|
||||
### Step 2: Generate Outline with Style Instructions
|
||||
### Step 2: Generate Outline Variants
|
||||
|
||||
Create outline with structured STYLE_INSTRUCTIONS block:
|
||||
1. Generate 3 style variant outlines based on content analysis
|
||||
2. Follow `references/outline-template.md` for structure
|
||||
3. Save as `outline-{style}.md` for each variant
|
||||
|
||||
```markdown
|
||||
# Slide Deck Outline
|
||||
### Step 3: User Confirmation
|
||||
|
||||
**Topic**: [topic description]
|
||||
**Style**: [selected style]
|
||||
**Audience**: [target audience]
|
||||
**Language**: [output language]
|
||||
**Slide Count**: N slides
|
||||
**Generated**: YYYY-MM-DD HH:mm
|
||||
**Single AskUserQuestion with all applicable options:**
|
||||
|
||||
---
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Style variant | Always (3 options + custom) |
|
||||
| Language | Only if source ≠ user language |
|
||||
|
||||
<STYLE_INSTRUCTIONS>
|
||||
Design Aesthetic: [2-3 sentence description from style file]
|
||||
After selection:
|
||||
- Copy selected `outline-{style}.md` to `outline.md`
|
||||
- Regenerate in different language if requested
|
||||
- User may edit `outline.md` for fine-tuning
|
||||
|
||||
Background:
|
||||
Color: [Name] ([Hex])
|
||||
Texture: [description]
|
||||
|
||||
Typography:
|
||||
Primary Font: [detailed description for image generation]
|
||||
Secondary Font: [detailed description for image generation]
|
||||
|
||||
Color Palette:
|
||||
Primary Text: [Name] ([Hex]) - [usage]
|
||||
Background: [Name] ([Hex]) - [usage]
|
||||
Accent 1: [Name] ([Hex]) - [usage]
|
||||
Accent 2: [Name] ([Hex]) - [usage]
|
||||
|
||||
Visual Elements:
|
||||
- [element 1 with rendering guidance]
|
||||
- [element 2 with rendering guidance]
|
||||
- ...
|
||||
|
||||
Style Rules:
|
||||
Do: [guidelines from style file]
|
||||
Don't: [anti-patterns from style file]
|
||||
</STYLE_INSTRUCTIONS>
|
||||
|
||||
---
|
||||
|
||||
## Slide 1 of N
|
||||
|
||||
**Type**: Cover
|
||||
**Filename**: 01-slide-cover.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[What this slide achieves in the story arc]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [main title]
|
||||
Sub-headline: [supporting tagline]
|
||||
|
||||
// VISUAL
|
||||
[Detailed visual description - specific elements, composition, mood]
|
||||
|
||||
// LAYOUT
|
||||
[Composition, hierarchy, spatial arrangement]
|
||||
|
||||
---
|
||||
|
||||
## Slide 2 of N
|
||||
|
||||
**Type**: Content
|
||||
**Filename**: 02-slide-{slug}.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[What this slide achieves in the story arc]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [main message - narrative, not label]
|
||||
Sub-headline: [supporting context]
|
||||
Body:
|
||||
- [point 1 with specific detail]
|
||||
- [point 2 with specific detail]
|
||||
- [point 3 with specific detail]
|
||||
|
||||
// VISUAL
|
||||
[Detailed visual description]
|
||||
|
||||
// LAYOUT
|
||||
[Composition, hierarchy, spatial arrangement]
|
||||
|
||||
---
|
||||
...
|
||||
|
||||
## Slide N of N
|
||||
|
||||
**Type**: Back Cover
|
||||
**Filename**: {NN}-slide-back-cover.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[Meaningful closing - not just "thank you"]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [memorable closing statement or call-to-action]
|
||||
Body: [optional summary points or next steps]
|
||||
|
||||
// VISUAL
|
||||
[Visual that reinforces the core message]
|
||||
|
||||
// LAYOUT
|
||||
[Clean, impactful composition]
|
||||
```
|
||||
|
||||
### Step 3: Save Outline
|
||||
|
||||
Save outline as `outline.md` in the output directory.
|
||||
|
||||
If `--outline-only` is specified, stop here.
|
||||
If `--outline-only`, stop here.
|
||||
|
||||
### Step 4: Generate Prompts
|
||||
|
||||
Create prompt file per slide in `prompts/` directory:
|
||||
|
||||
1. Read `references/base-prompt.md`
|
||||
2. Combine with style-specific instructions from outline
|
||||
3. Add slide-specific content from outline
|
||||
4. Save as `01-slide-cover.md`, `02-slide-{slug}.md`, etc.
|
||||
2. Combine with style instructions from outline
|
||||
3. Add slide-specific content
|
||||
4. Save to `prompts/` directory
|
||||
|
||||
### Step 5: Generate Images
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
2. If multiple skills available, ask user to choose
|
||||
1. Select available image generation skill
|
||||
2. Generate session ID: `slides-{topic-slug}-{timestamp}`
|
||||
3. Generate each slide with same session ID
|
||||
4. Report progress: "Generated X/N"
|
||||
|
||||
**Session Management**:
|
||||
If the image generation skill supports `--sessionId`:
|
||||
1. Generate a unique session ID at the start (e.g., `slides-{topic-slug}-{timestamp}`)
|
||||
2. Use the same session ID for all slides in the series
|
||||
3. This ensures style consistency across all generated slides
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call selected image generation skill with prompt file, output path, and session ID
|
||||
2. Confirm generation success
|
||||
3. Report progress: "Generated X/N"
|
||||
4. Continue to next
|
||||
|
||||
### Step 6: Merge to PPTX
|
||||
|
||||
After all images are generated, merge them into a PowerPoint file:
|
||||
### Step 6: Merge to PPTX and PDF
|
||||
|
||||
```bash
|
||||
npx -y bun skills/baoyu-slide-deck/scripts/merge-to-pptx.ts <slide-deck-dir>
|
||||
npx -y bun ${SKILL_DIR}/scripts/merge-to-pptx.ts <slide-deck-dir>
|
||||
npx -y bun ${SKILL_DIR}/scripts/merge-to-pdf.ts <slide-deck-dir>
|
||||
```
|
||||
|
||||
This creates `{topic-slug}.pptx` in the slide deck directory with:
|
||||
- All images as full-bleed 16:9 slides
|
||||
- Prompt content added as speaker notes (from `prompts/` directory)
|
||||
|
||||
### Step 7: Output Summary
|
||||
|
||||
```
|
||||
@@ -301,76 +176,51 @@ Slide Deck Complete!
|
||||
|
||||
Topic: [topic]
|
||||
Style: [style name]
|
||||
Audience: [audience type]
|
||||
Location: [directory path]
|
||||
Slides: N total
|
||||
|
||||
- 01-slide-cover.png ✓ Cover
|
||||
- 02-slide-intro.png ✓ Content
|
||||
- 03-slide-main-point.png ✓ Content
|
||||
- ...
|
||||
- {NN}-slide-back-cover.png ✓ Back Cover
|
||||
|
||||
Outline: outline.md
|
||||
PPTX: {topic-slug}.pptx
|
||||
PDF: {topic-slug}.pdf
|
||||
```
|
||||
|
||||
## Content Rules
|
||||
## Slide Modification
|
||||
|
||||
1. **Respect reader attention** -
|
||||
2. **Data traceability** - All statistics must include source attribution
|
||||
3. **Self-contained prompts** - Every detail in the image prompt, no external references
|
||||
4. **No placeholders** - Every element must be fully specified
|
||||
See `references/modification-guide.md` for:
|
||||
- Edit single slide workflow
|
||||
- Add new slide (with renumbering)
|
||||
- Delete slide (with renumbering)
|
||||
- File naming conventions
|
||||
|
||||
## Style Rules
|
||||
## References
|
||||
|
||||
1. **Narrative headlines** - Headlines tell the story, not label the content
|
||||
- Bad: "Key Statistics"
|
||||
- Good: "Usage doubled in 6 months"
|
||||
|
||||
2. **Avoid AI clichés** - No "dive into", "explore", "journey", "let's"
|
||||
|
||||
3. **Meaningful back cover** - Not just "Thank you"
|
||||
- Include call-to-action, key takeaway, or memorable closing
|
||||
|
||||
4. **Consistent visual language** - Same icons, colors, layouts throughout
|
||||
|
||||
## Slide Structure
|
||||
|
||||
1. **Cover (Slide 1)**: Title, visual hook, topic introduction
|
||||
2. **Content (Middle)**: Key points, data, explanations - dynamic count based on content
|
||||
3. **Back Cover (Final)**: Summary, call-to-action, or memorable closing
|
||||
|
||||
## Key Specifications
|
||||
|
||||
- **Aspect Ratio**: 16:9 (landscape)
|
||||
- **Slide Count**: Dynamic based on content
|
||||
- **Required Slides**: Cover + Back Cover minimum
|
||||
- **No slide numbers, footers, or logos**
|
||||
- **Language**: Priority order: `--lang` option → source material language → ask user if uncertain
|
||||
- **Tone**: Direct, confident language (avoid AI-sounding phrases)
|
||||
|
||||
## Style Reference Details
|
||||
|
||||
| Style | Description |
|
||||
|-------|-------------|
|
||||
| `sketch-notes` | Hand-drawn feel, soft brush strokes, warm off-white background, conceptual icons |
|
||||
| `blueprint` | Technical schematics, grid texture, precise lines, engineering blue tones |
|
||||
| `bold-editorial` | High contrast, bold typography, dark backgrounds, magazine-level impact |
|
||||
| `vector-illustration` | Flat vector, black outlines, retro colors, toy model aesthetic |
|
||||
| `minimal` | Maximum whitespace, single accent color, clean sans-serif, zen-like |
|
||||
| `storytelling` | Full-bleed imagery, cinematic compositions, emotional photography |
|
||||
| `warm` | Soft gradients, rounded shapes, wellness palette, approachable |
|
||||
| `notion` | Dashboard aesthetic, clean data viz, SaaS-inspired, productivity focus |
|
||||
| `corporate` | Navy/gold palette, structured layouts, professional iconography, business polish |
|
||||
| `playful` | Vibrant coral/teal/yellow, rounded shapes, dynamic layouts, energetic |
|
||||
|
||||
Full style specifications: `references/styles/<style>.md`
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `references/analysis-framework.md` | Deep content analysis for presentations |
|
||||
| `references/outline-template.md` | Outline structure and STYLE_INSTRUCTIONS format |
|
||||
| `references/modification-guide.md` | Edit, add, delete slide workflows |
|
||||
| `references/content-rules.md` | Content and style guidelines |
|
||||
| `references/base-prompt.md` | Base prompt for image generation |
|
||||
| `references/styles/<style>.md` | Full style specifications |
|
||||
|
||||
## Notes
|
||||
|
||||
- Image generation typically takes 10-30 seconds per slide
|
||||
- Image generation: 10-30 seconds per slide
|
||||
- Auto-retry once on generation failure
|
||||
- Use stylized alternatives for sensitive public figures
|
||||
- Output language matches input content language (or `--lang`)
|
||||
- Maintain style consistency across all slides in deck
|
||||
- Maintain style consistency via session ID
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom styles and configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-slide-deck/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-slide-deck/EXTEND.md` (user)
|
||||
|
||||
If found, load before Step 1. Extension content overrides defaults.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Presentation Analysis Framework
|
||||
|
||||
Deep content analysis for effective slide deck creation.
|
||||
|
||||
## 1. Message Hierarchy
|
||||
|
||||
Identify the core message structure before designing slides.
|
||||
|
||||
### Core Message (One Sentence)
|
||||
- What is the single most important takeaway?
|
||||
- If the audience remembers only one thing, what should it be?
|
||||
- Can you state it in ≤15 words?
|
||||
|
||||
### Supporting Points (3-5 Maximum)
|
||||
- What evidence supports the core message?
|
||||
- What sub-topics must be covered?
|
||||
- Prioritize by audience relevance, not source order
|
||||
|
||||
### Call-to-Action
|
||||
- What should the audience DO after viewing?
|
||||
- Is it clear, specific, and achievable?
|
||||
- Where does it appear (slide position)?
|
||||
|
||||
## 2. Audience Decision Matrix
|
||||
|
||||
| Question | Analysis |
|
||||
|----------|----------|
|
||||
| Who is the primary audience? | [Role, expertise level, relationship to topic] |
|
||||
| What do they currently believe? | [Existing knowledge, assumptions, biases] |
|
||||
| What decision do we want them to make? | [Specific action or conclusion] |
|
||||
| What barriers exist? | [Objections, concerns, missing information] |
|
||||
| What evidence will convince them? | [Data types, credibility sources, emotional hooks] |
|
||||
|
||||
### Audience Adaptation
|
||||
|
||||
| Audience Type | Content Focus | Visual Treatment |
|
||||
|---------------|---------------|------------------|
|
||||
| Executives | Outcomes, ROI, strategic impact | High-level, clean, data highlights |
|
||||
| Technical | Architecture, implementation, specs | Detailed diagrams, code, schematics |
|
||||
| General | Benefits, stories, relatability | Visual metaphors, simple charts |
|
||||
| Investors | Market size, traction, team | Growth charts, milestones, comparisons |
|
||||
| Learners | Step-by-step, examples, practice | Progressive reveals, exercises |
|
||||
|
||||
## 3. Visual Opportunity Map
|
||||
|
||||
Identify which content benefits from visualization.
|
||||
|
||||
### Content-to-Visual Mapping
|
||||
|
||||
| Content Type | Visual Treatment | Example |
|
||||
|--------------|------------------|---------|
|
||||
| Comparisons | Side-by-side, before/after | Feature comparison table |
|
||||
| Processes | Flow diagrams, numbered steps | Workflow illustration |
|
||||
| Hierarchies | Org charts, pyramids, trees | Organizational structure |
|
||||
| Timelines | Horizontal/vertical timelines | Project milestones |
|
||||
| Statistics | Charts, highlighted numbers | Key metrics with context |
|
||||
| Concepts | Icons, metaphors, illustrations | Abstract idea visualization |
|
||||
| Relationships | Venn diagrams, networks | Ecosystem or dependencies |
|
||||
| Lists | Structured grids, icon rows | Feature bullets with icons |
|
||||
|
||||
### Visual Priority
|
||||
|
||||
Rate each piece of content:
|
||||
- **Must Visualize**: Complex data, key differentiators, memorable moments
|
||||
- **Should Visualize**: Supporting evidence, secondary points
|
||||
- **Text Only**: Simple statements, transitions, minor details
|
||||
|
||||
## 4. Presentation Flow
|
||||
|
||||
Structure for impact and retention.
|
||||
|
||||
### Opening (First 2-3 Slides)
|
||||
|
||||
| Element | Purpose |
|
||||
|---------|---------|
|
||||
| Hook | Capture attention (surprising stat, question, story) |
|
||||
| Context | Why this matters now |
|
||||
| Preview | What audience will learn/gain |
|
||||
|
||||
### Middle (Content Slides)
|
||||
|
||||
| Pattern | When to Use |
|
||||
|---------|-------------|
|
||||
| Problem → Solution | Introducing new products/ideas |
|
||||
| Situation → Complication → Resolution | Complex business cases |
|
||||
| What → Why → How | Educational content |
|
||||
| Past → Present → Future | Transformation stories |
|
||||
| Claim → Evidence → Implication | Data-driven arguments |
|
||||
|
||||
### Closing (Final 2-3 Slides)
|
||||
|
||||
| Element | Purpose |
|
||||
|---------|---------|
|
||||
| Synthesis | Tie back to core message |
|
||||
| Call-to-Action | Clear next steps |
|
||||
| Memorable Close | Resonant quote, image, or statement |
|
||||
|
||||
### Transitions
|
||||
|
||||
- Each slide should answer: "What comes next?"
|
||||
- Use narrative connectors between sections
|
||||
- Build logical progression, not topic jumps
|
||||
|
||||
## 5. Content Adaptation
|
||||
|
||||
Decide what to keep, transform, or omit.
|
||||
|
||||
### Keep (High Value)
|
||||
- Core arguments and evidence
|
||||
- Unique insights or data
|
||||
- Audience-relevant examples
|
||||
- Memorable quotes or statistics
|
||||
|
||||
### Simplify (Medium Value)
|
||||
- Technical details → Visual summaries
|
||||
- Long explanations → Bullet hierarchies
|
||||
- Multiple examples → Best 1-2 examples
|
||||
- Background context → Brief framing
|
||||
|
||||
### Visualize (Transform)
|
||||
- Data tables → Charts or highlighted numbers
|
||||
- Process descriptions → Flow diagrams
|
||||
- Comparisons in text → Side-by-side visuals
|
||||
- Abstract concepts → Concrete metaphors
|
||||
|
||||
### Omit (Low Value)
|
||||
- Tangential information
|
||||
- Redundant examples
|
||||
- Excessive caveats
|
||||
- Background the audience already knows
|
||||
|
||||
## 6. Analysis Checklist
|
||||
|
||||
Before outline creation, confirm:
|
||||
|
||||
### Message Clarity
|
||||
- [ ] Core message stated in one sentence
|
||||
- [ ] 3-5 supporting points identified
|
||||
- [ ] Call-to-action defined
|
||||
|
||||
### Audience Fit
|
||||
- [ ] Primary audience identified
|
||||
- [ ] Existing beliefs mapped
|
||||
- [ ] Desired decision clear
|
||||
- [ ] Evidence matches audience needs
|
||||
|
||||
### Visual Planning
|
||||
- [ ] Key visualizations identified
|
||||
- [ ] Chart/diagram types selected
|
||||
- [ ] Visual priority assigned
|
||||
|
||||
### Flow Design
|
||||
- [ ] Opening hook defined
|
||||
- [ ] Middle pattern selected
|
||||
- [ ] Closing approach planned
|
||||
- [ ] Transitions considered
|
||||
|
||||
### Content Decisions
|
||||
- [ ] Keep/simplify/visualize/omit applied
|
||||
- [ ] Source material fully processed
|
||||
- [ ] No important content overlooked
|
||||
@@ -0,0 +1,95 @@
|
||||
# Content & Style Rules
|
||||
|
||||
Guidelines for slide deck content quality and style consistency.
|
||||
|
||||
## Content Rules
|
||||
|
||||
### 1. Respect Reader Attention
|
||||
- Each slide should communicate ONE main idea
|
||||
- Remove redundant information
|
||||
- Prioritize clarity over comprehensiveness
|
||||
|
||||
### 2. Data Traceability
|
||||
- All statistics must include source attribution
|
||||
- Cite sources directly on slides with data
|
||||
- Use specific numbers over vague claims
|
||||
|
||||
### 3. Self-Contained Prompts
|
||||
- Every detail must be in the image prompt
|
||||
- No external references (e.g., "like slide 2")
|
||||
- Include all colors, layouts, and content explicitly
|
||||
|
||||
### 4. No Placeholders
|
||||
- Every element must be fully specified
|
||||
- No "[insert data here]" or "TBD"
|
||||
- All text content finalized before generation
|
||||
|
||||
## Style Rules
|
||||
|
||||
### 1. Narrative Headlines
|
||||
Headlines tell the story, not label the content.
|
||||
|
||||
| Bad | Good |
|
||||
|-----|------|
|
||||
| "Key Statistics" | "Usage doubled in 6 months" |
|
||||
| "Our Solution" | "One platform replaces five tools" |
|
||||
| "Benefits" | "Teams save 10 hours weekly" |
|
||||
|
||||
### 2. Avoid AI Clichés
|
||||
Remove these patterns:
|
||||
- "Dive into", "explore", "journey"
|
||||
- "Let's look at", "let me show you"
|
||||
- "Exciting", "amazing", "revolutionary"
|
||||
- "In conclusion", "to summarize"
|
||||
|
||||
### 3. Meaningful Back Cover
|
||||
Not just "Thank you" or "Questions?"
|
||||
|
||||
Include one of:
|
||||
- Clear call-to-action
|
||||
- Memorable key takeaway
|
||||
- Thought-provoking closing statement
|
||||
- Contact information with purpose
|
||||
|
||||
### 4. Consistent Visual Language
|
||||
Throughout the deck:
|
||||
- Same icon style
|
||||
- Same color usage patterns
|
||||
- Same layout grid system
|
||||
- Same typography hierarchy
|
||||
|
||||
## Slide Structure
|
||||
|
||||
| Position | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| 1 | Cover | Title, visual hook, topic introduction |
|
||||
| 2 to N-1 | Content | Key points, data, explanations |
|
||||
| N | Back Cover | Summary, call-to-action, memorable close |
|
||||
|
||||
## Key Specifications
|
||||
|
||||
| Specification | Value |
|
||||
|---------------|-------|
|
||||
| Aspect Ratio | 16:9 (landscape) |
|
||||
| Slide Count | Dynamic based on content |
|
||||
| Required Slides | Cover + Back Cover minimum |
|
||||
| Footers | None (no slide numbers, logos) |
|
||||
| Language Priority | `--lang` → source language → ask user |
|
||||
| Tone | Direct, confident (avoid AI phrases) |
|
||||
|
||||
## Style Quick Reference
|
||||
|
||||
| Style | Visual Summary |
|
||||
|-------|----------------|
|
||||
| `sketch-notes` | Hand-drawn, warm off-white, conceptual icons |
|
||||
| `blueprint` | Technical schematics, grid texture, blue tones |
|
||||
| `bold-editorial` | High contrast, dark backgrounds, magazine impact |
|
||||
| `vector-illustration` | Flat vector, black outlines, retro colors |
|
||||
| `minimal` | Maximum whitespace, single accent, zen-like |
|
||||
| `storytelling` | Full-bleed imagery, cinematic, emotional |
|
||||
| `warm` | Soft gradients, rounded shapes, wellness palette |
|
||||
| `notion` | Dashboard aesthetic, clean data viz, SaaS-inspired |
|
||||
| `corporate` | Navy/gold, structured layouts, business polish |
|
||||
| `playful` | Vibrant coral/teal/yellow, dynamic, energetic |
|
||||
|
||||
Full style specifications: `references/styles/<style>.md`
|
||||
@@ -0,0 +1,85 @@
|
||||
# Slide Modification Guide
|
||||
|
||||
Workflows for modifying individual slides after initial generation.
|
||||
|
||||
## Edit Single Slide
|
||||
|
||||
Regenerate a specific slide with modified content:
|
||||
|
||||
1. Identify slide to edit (e.g., `03-slide-key-findings.png`)
|
||||
2. Update prompt in `prompts/03-slide-key-findings.md`
|
||||
3. If content changes significantly, update slug in filename
|
||||
4. Regenerate image using same session ID
|
||||
5. Regenerate PPTX and PDF
|
||||
|
||||
## Add New Slide
|
||||
|
||||
Insert a new slide at specified position:
|
||||
|
||||
1. Specify insertion position (e.g., after slide 3)
|
||||
2. Create new prompt with appropriate slug (e.g., `04-slide-new-section.md`)
|
||||
3. Generate new slide image
|
||||
4. **Renumber files**: All subsequent slides increment NN by 1
|
||||
- `04-slide-conclusion.png` → `05-slide-conclusion.png`
|
||||
- Slugs remain unchanged
|
||||
5. Update `outline.md` with new slide entry
|
||||
6. Regenerate PPTX and PDF
|
||||
|
||||
## Delete Slide
|
||||
|
||||
Remove a slide and renumber:
|
||||
|
||||
1. Identify slide to delete (e.g., `03-slide-key-findings.png`)
|
||||
2. Remove image file and prompt file
|
||||
3. **Renumber files**: All subsequent slides decrement NN by 1
|
||||
- `04-slide-conclusion.png` → `03-slide-conclusion.png`
|
||||
- Slugs remain unchanged
|
||||
4. Update `outline.md` to remove slide entry
|
||||
5. Regenerate PPTX and PDF
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
Files use meaningful slugs for better readability:
|
||||
|
||||
```
|
||||
NN-slide-[slug].png
|
||||
NN-slide-[slug].md (in prompts/)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `01-slide-cover.png`
|
||||
- `02-slide-problem-statement.png`
|
||||
- `03-slide-key-findings.png`
|
||||
- `04-slide-back-cover.png`
|
||||
|
||||
## Slug Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Format | Kebab-case (lowercase, hyphens) |
|
||||
| Source | Derived from slide title/content |
|
||||
| Uniqueness | Must be unique within the deck |
|
||||
| Updates | Change slug when content changes significantly |
|
||||
|
||||
## Renumbering Rules
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Add slide | Increment NN for all subsequent slides |
|
||||
| Delete slide | Decrement NN for all subsequent slides |
|
||||
| Reorder slides | Update NN to match new positions |
|
||||
| Edit slide | NN unchanged, update slug if needed |
|
||||
|
||||
**Important**: Slugs remain unchanged during renumbering. Only the NN prefix changes.
|
||||
|
||||
## Post-Modification Checklist
|
||||
|
||||
After any modification:
|
||||
|
||||
- [ ] Image file renamed/created correctly
|
||||
- [ ] Prompt file renamed/created correctly
|
||||
- [ ] Subsequent files renumbered (if add/delete)
|
||||
- [ ] `outline.md` updated to reflect changes
|
||||
- [ ] PPTX regenerated
|
||||
- [ ] PDF regenerated
|
||||
- [ ] Slide count in outline header updated
|
||||
@@ -0,0 +1,164 @@
|
||||
# Outline Template
|
||||
|
||||
Standard structure for slide deck outlines with style instructions.
|
||||
|
||||
## Outline Format
|
||||
|
||||
```markdown
|
||||
# Slide Deck Outline
|
||||
|
||||
**Topic**: [topic description]
|
||||
**Style**: [selected style]
|
||||
**Audience**: [target audience]
|
||||
**Language**: [output language]
|
||||
**Slide Count**: N slides
|
||||
**Generated**: YYYY-MM-DD HH:mm
|
||||
|
||||
---
|
||||
|
||||
<STYLE_INSTRUCTIONS>
|
||||
Design Aesthetic: [2-3 sentence description from style file]
|
||||
|
||||
Background:
|
||||
Color: [Name] ([Hex])
|
||||
Texture: [description]
|
||||
|
||||
Typography:
|
||||
Primary Font: [detailed description for image generation]
|
||||
Secondary Font: [detailed description for image generation]
|
||||
|
||||
Color Palette:
|
||||
Primary Text: [Name] ([Hex]) - [usage]
|
||||
Background: [Name] ([Hex]) - [usage]
|
||||
Accent 1: [Name] ([Hex]) - [usage]
|
||||
Accent 2: [Name] ([Hex]) - [usage]
|
||||
|
||||
Visual Elements:
|
||||
- [element 1 with rendering guidance]
|
||||
- [element 2 with rendering guidance]
|
||||
- ...
|
||||
|
||||
Style Rules:
|
||||
Do: [guidelines from style file]
|
||||
Don't: [anti-patterns from style file]
|
||||
</STYLE_INSTRUCTIONS>
|
||||
|
||||
---
|
||||
|
||||
[Slide entries follow...]
|
||||
```
|
||||
|
||||
## Cover Slide Template
|
||||
|
||||
```markdown
|
||||
## Slide 1 of N
|
||||
|
||||
**Type**: Cover
|
||||
**Filename**: 01-slide-cover.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[What this slide achieves in the story arc]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [main title]
|
||||
Sub-headline: [supporting tagline]
|
||||
|
||||
// VISUAL
|
||||
[Detailed visual description - specific elements, composition, mood]
|
||||
|
||||
// LAYOUT
|
||||
[Composition, hierarchy, spatial arrangement]
|
||||
```
|
||||
|
||||
## Content Slide Template
|
||||
|
||||
```markdown
|
||||
## Slide X of N
|
||||
|
||||
**Type**: Content
|
||||
**Filename**: {NN}-slide-{slug}.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[What this slide achieves in the story arc]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [main message - narrative, not label]
|
||||
Sub-headline: [supporting context]
|
||||
Body:
|
||||
- [point 1 with specific detail]
|
||||
- [point 2 with specific detail]
|
||||
- [point 3 with specific detail]
|
||||
|
||||
// VISUAL
|
||||
[Detailed visual description]
|
||||
|
||||
// LAYOUT
|
||||
[Composition, hierarchy, spatial arrangement]
|
||||
```
|
||||
|
||||
## Back Cover Slide Template
|
||||
|
||||
```markdown
|
||||
## Slide N of N
|
||||
|
||||
**Type**: Back Cover
|
||||
**Filename**: {NN}-slide-back-cover.png
|
||||
|
||||
// NARRATIVE GOAL
|
||||
[Meaningful closing - not just "thank you"]
|
||||
|
||||
// KEY CONTENT
|
||||
Headline: [memorable closing statement or call-to-action]
|
||||
Body: [optional summary points or next steps]
|
||||
|
||||
// VISUAL
|
||||
[Visual that reinforces the core message]
|
||||
|
||||
// LAYOUT
|
||||
[Clean, impactful composition]
|
||||
```
|
||||
|
||||
## STYLE_INSTRUCTIONS Block
|
||||
|
||||
The `<STYLE_INSTRUCTIONS>` block contains all style-specific guidance for image generation:
|
||||
|
||||
| Section | Content |
|
||||
|---------|---------|
|
||||
| Design Aesthetic | Overall visual direction from style file |
|
||||
| Background | Base color and texture details |
|
||||
| Typography | Font descriptions for Gemini (no font names, describe appearance) |
|
||||
| Color Palette | Named colors with hex codes and usage guidance |
|
||||
| Visual Elements | Specific graphic elements with rendering instructions |
|
||||
| Style Rules | Do/Don't guidelines from style file |
|
||||
|
||||
**Important**: Typography descriptions must describe the visual appearance (e.g., "rounded sans-serif", "bold geometric") since image generators cannot use font names.
|
||||
|
||||
## Section Dividers
|
||||
|
||||
Use `---` (horizontal rule) between:
|
||||
- Header metadata and STYLE_INSTRUCTIONS
|
||||
- STYLE_INSTRUCTIONS and first slide
|
||||
- Each slide entry
|
||||
|
||||
## Slide Numbering
|
||||
|
||||
- Cover is always Slide 1
|
||||
- Content slides use sequential numbers
|
||||
- Back Cover is always final slide (N)
|
||||
- Filename prefix matches slide position: `01-`, `02-`, etc.
|
||||
|
||||
## Filename Slugs
|
||||
|
||||
Generate meaningful slugs from slide content:
|
||||
|
||||
| Slide Type | Slug Pattern | Example |
|
||||
|------------|--------------|---------|
|
||||
| Cover | `cover` | `01-slide-cover.png` |
|
||||
| Content | `{topic-slug}` | `02-slide-problem-statement.png` |
|
||||
| Back Cover | `back-cover` | `10-slide-back-cover.png` |
|
||||
|
||||
Slug rules:
|
||||
- Kebab-case (lowercase, hyphens)
|
||||
- Derived from headline or main topic
|
||||
- Maximum 30 characters
|
||||
- Unique within deck
|
||||
@@ -0,0 +1,116 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs";
|
||||
import { join, basename } from "path";
|
||||
import { PDFDocument, rgb } from "pdf-lib";
|
||||
|
||||
interface SlideInfo {
|
||||
filename: string;
|
||||
path: string;
|
||||
index: number;
|
||||
promptPath?: string;
|
||||
}
|
||||
|
||||
function parseArgs(): { dir: string; output?: string } {
|
||||
const args = process.argv.slice(2);
|
||||
let dir = "";
|
||||
let output: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--output" || args[i] === "-o") {
|
||||
output = args[++i];
|
||||
} else if (!args[i].startsWith("-")) {
|
||||
dir = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir) {
|
||||
console.error("Usage: bun merge-to-pdf.ts <slide-deck-dir> [--output filename.pdf]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { dir, output };
|
||||
}
|
||||
|
||||
function findSlideImages(dir: string): SlideInfo[] {
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`Directory not found: ${dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(dir);
|
||||
const slidePattern = /^(\d+)-slide-.*\.(png|jpg|jpeg)$/i;
|
||||
const promptsDir = join(dir, "prompts");
|
||||
const hasPrompts = existsSync(promptsDir);
|
||||
|
||||
const slides: SlideInfo[] = files
|
||||
.filter((f) => slidePattern.test(f))
|
||||
.map((f) => {
|
||||
const match = f.match(slidePattern);
|
||||
const baseName = f.replace(/\.(png|jpg|jpeg)$/i, "");
|
||||
const promptPath = hasPrompts ? join(promptsDir, `${baseName}.md`) : undefined;
|
||||
|
||||
return {
|
||||
filename: f,
|
||||
path: join(dir, f),
|
||||
index: parseInt(match![1], 10),
|
||||
promptPath: promptPath && existsSync(promptPath) ? promptPath : undefined,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
if (slides.length === 0) {
|
||||
console.error(`No slide images found in: ${dir}`);
|
||||
console.error("Expected format: 01-slide-*.png, 02-slide-*.png, etc.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return slides;
|
||||
}
|
||||
|
||||
async function createPdf(slides: SlideInfo[], outputPath: string) {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
pdfDoc.setAuthor("baoyu-slide-deck");
|
||||
pdfDoc.setSubject("Generated Slide Deck");
|
||||
|
||||
for (const slide of slides) {
|
||||
const imageData = readFileSync(slide.path);
|
||||
const ext = slide.filename.toLowerCase();
|
||||
const image = ext.endsWith(".png")
|
||||
? await pdfDoc.embedPng(imageData)
|
||||
: await pdfDoc.embedJpg(imageData);
|
||||
|
||||
const { width, height } = image;
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
|
||||
page.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
console.log(`Added: ${slide.filename}${slide.promptPath ? " (prompt available)" : ""}`);
|
||||
}
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
await Bun.write(outputPath, pdfBytes);
|
||||
|
||||
console.log(`\nCreated: ${outputPath}`);
|
||||
console.log(`Total pages: ${slides.length}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { dir, output } = parseArgs();
|
||||
const slides = findSlideImages(dir);
|
||||
|
||||
const dirName = basename(dir) === "slide-deck" ? basename(join(dir, "..")) : basename(dir);
|
||||
const outputPath = output || join(dir, `${dirName}.pdf`);
|
||||
|
||||
console.log(`Found ${slides.length} slides in: ${dir}\n`);
|
||||
|
||||
await createPdf(slides, outputPath);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+163
-173
@@ -76,202 +76,148 @@ Detailed style definitions: `references/styles/<style>.md`
|
||||
|
||||
Detailed layout definitions: `references/layouts/<layout>.md`
|
||||
|
||||
## Auto Style Selection
|
||||
## Auto Selection
|
||||
|
||||
When no `--style` is specified, analyze content to select:
|
||||
| Content Signals | Style | Layout |
|
||||
|-----------------|-------|--------|
|
||||
| Beauty, fashion, cute, girl, pink | `cute` | sparse/balanced |
|
||||
| Health, nature, clean, fresh, organic | `fresh` | balanced/flow |
|
||||
| Tech, AI, code, digital, app, tool | `tech` | dense/list |
|
||||
| Life, story, emotion, feeling, warm | `warm` | balanced |
|
||||
| Warning, important, must, critical | `bold` | list/comparison |
|
||||
| Professional, business, elegant, simple | `minimal` | sparse/balanced |
|
||||
| Classic, vintage, old, traditional | `retro` | balanced |
|
||||
| Fun, exciting, wow, amazing | `pop` | sparse/list |
|
||||
| Knowledge, concept, productivity, SaaS | `notion` | dense/list |
|
||||
|
||||
| Content Signals | Selected Style |
|
||||
|----------------|----------------|
|
||||
| Beauty, fashion, cute, girl, pink | `cute` |
|
||||
| Health, nature, clean, fresh, organic | `fresh` |
|
||||
| Tech, AI, code, digital, app, tool | `tech` |
|
||||
| Life, story, emotion, feeling, warm | `warm` |
|
||||
| Warning, important, must, critical | `bold` |
|
||||
| Professional, business, elegant, simple | `minimal` |
|
||||
| Classic, vintage, old, traditional | `retro` |
|
||||
| Fun, exciting, wow, amazing | `pop` |
|
||||
| Knowledge, concept, productivity, SaaS, notion | `notion` |
|
||||
|
||||
## Auto Layout Selection
|
||||
|
||||
When no `--layout` is specified, analyze content structure to select:
|
||||
|
||||
| Content Signals | Selected Layout |
|
||||
|----------------|-----------------|
|
||||
| Single quote, one key point, cover | `sparse` |
|
||||
| 3-4 points, explanation, tutorial | `balanced` |
|
||||
| 5+ points, summary, cheat sheet, 干货 | `dense` |
|
||||
| Numbered items, top N, checklist, steps | `list` |
|
||||
| vs, compare, before/after, pros/cons | `comparison` |
|
||||
| Process, flow, timeline, steps with order | `flow` |
|
||||
|
||||
**Layout by Position**:
|
||||
| Position | Recommended Layout |
|
||||
|----------|-------------------|
|
||||
| Cover | `sparse` |
|
||||
| Content | `balanced` or content-appropriate |
|
||||
| Ending | `sparse` or `balanced` |
|
||||
|
||||
## File Management
|
||||
|
||||
### With Article Path
|
||||
|
||||
Save to `xhs-images/` subdirectory in the same folder as the article:
|
||||
## File Structure
|
||||
|
||||
```
|
||||
posts/ai-future/
|
||||
├── article.md
|
||||
└── xhs-images/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── 01-cover.md
|
||||
│ ├── 02-content-1.md
|
||||
│ └── ...
|
||||
├── 01-cover.png
|
||||
├── 02-content-1.png
|
||||
└── 03-ending.png
|
||||
[target]/
|
||||
├── source.md # Source content (if pasted)
|
||||
├── analysis.md # Deep analysis results
|
||||
├── outline-style-[slug].md # Variant A (e.g., outline-style-tech.md)
|
||||
├── outline-style-[slug].md # Variant B (e.g., outline-style-notion.md)
|
||||
├── outline-style-[slug].md # Variant C (e.g., outline-style-minimal.md)
|
||||
├── outline.md # Final selected
|
||||
├── prompts/
|
||||
│ ├── 01-cover-[slug].md
|
||||
│ ├── 02-content-[slug].md
|
||||
│ └── ...
|
||||
├── 01-cover-[slug].png
|
||||
├── 02-content-[slug].png
|
||||
└── NN-ending-[slug].png
|
||||
```
|
||||
|
||||
### Without Article Path
|
||||
**Target directory**:
|
||||
- With source path: `[source-dir]/[source-name-no-ext]/xhs-images/`
|
||||
- Example: `/tests-data/article.md` → `/tests-data/article/xhs-images/`
|
||||
- Without source: `./xhs-images/[topic-slug]/`
|
||||
|
||||
Save to `xhs-outputs/YYYY-MM-DD/[topic-slug]/`:
|
||||
|
||||
```
|
||||
xhs-outputs/
|
||||
└── 2026-01-13/
|
||||
└── ai-agent-guide/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ ├── 01-cover.md
|
||||
│ └── ...
|
||||
├── 01-cover.png
|
||||
└── 02-ending.png
|
||||
```
|
||||
**Directory backup**:
|
||||
- If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Analyze Content & Select Style/Layout
|
||||
### Step 1: Analyze Content → `analysis.md`
|
||||
|
||||
1. Read content
|
||||
2. If `--style` specified, use that style; otherwise auto-select
|
||||
3. If `--layout` specified, use that layout; otherwise auto-select per image
|
||||
4. Determine image count based on content complexity:
|
||||
Read source content, save it if needed, and perform deep analysis.
|
||||
|
||||
| Content Type | Image Count |
|
||||
|-------------|-------------|
|
||||
| Simple opinion / single topic | 2-3 |
|
||||
| Medium complexity / tutorial | 4-6 |
|
||||
| Deep dive / multi-dimensional | 7-10 |
|
||||
**Actions**:
|
||||
1. **Save source content** (if not already a file):
|
||||
- If user provides a file path: use as-is
|
||||
- If user pastes content: save to `source.md` in target directory
|
||||
2. Read source content
|
||||
3. **Deep analysis** following `references/analysis-framework.md`:
|
||||
- Content type classification (种草/干货/测评/教程/避坑...)
|
||||
- Hook analysis (爆款标题潜力)
|
||||
- Target audience identification
|
||||
- Engagement potential (收藏/分享/评论)
|
||||
- Visual opportunity mapping
|
||||
- Swipe flow design
|
||||
4. Detect source language
|
||||
5. Determine recommended image count (2-10)
|
||||
6. Select 3 style+layout combinations
|
||||
7. **Save to `analysis.md`**
|
||||
|
||||
**Note**: Layout can vary per image in a series. Cover typically uses `sparse`, content pages use `balanced`/`dense`/`list` as appropriate.
|
||||
### Step 2: Generate 3 Outline Variants
|
||||
|
||||
### Step 2: Generate Outline
|
||||
Based on analysis, create three distinct style variants.
|
||||
|
||||
Plan for each image with style and layout specifications:
|
||||
**For each variant**:
|
||||
1. **Generate outline** (`outline-style-[slug].md`):
|
||||
- YAML front matter with style, layout, image_count
|
||||
- Cover design with hook
|
||||
- Each image: layout, core message, text content, visual concept
|
||||
- **Written in user's preferred language**
|
||||
- Reference: `references/outline-template.md`
|
||||
|
||||
```markdown
|
||||
# Xiaohongshu Infographic Series Outline
|
||||
| Variant | Selection Logic | Example Filename |
|
||||
|---------|-----------------|------------------|
|
||||
| A | Primary recommendation | `outline-style-tech.md` |
|
||||
| B | Alternative style | `outline-style-notion.md` |
|
||||
| C | Different audience/mood | `outline-style-minimal.md` |
|
||||
|
||||
**Topic**: [topic description]
|
||||
**Style**: [selected style]
|
||||
**Default Layout**: [selected layout or "varies"]
|
||||
**Image Count**: N
|
||||
**Generated**: YYYY-MM-DD HH:mm
|
||||
**All variants are preserved after selection for reference.**
|
||||
|
||||
---
|
||||
### Step 3: User Confirms All Options
|
||||
|
||||
## Image 1 of N
|
||||
**IMPORTANT**: Present ALL options in a single confirmation step using AskUserQuestion. Do NOT interrupt workflow with multiple separate confirmations.
|
||||
|
||||
**Position**: Cover
|
||||
**Layout**: sparse
|
||||
**Core Message**: [one-liner]
|
||||
**Filename**: 01-cover.png
|
||||
**Determine which questions to ask**:
|
||||
|
||||
**Text Content**:
|
||||
- Title: xxx
|
||||
- Subtitle: xxx
|
||||
| Question | When to Ask |
|
||||
|----------|-------------|
|
||||
| Style variant | Always (required) |
|
||||
| Default layout | Only if user might want to override |
|
||||
| Language | Only if `source_language ≠ user_language` |
|
||||
|
||||
**Visual Concept**: [style + layout appropriate description]
|
||||
**Language handling**:
|
||||
- If source language = user language: Just inform user (e.g., "Images will be in Chinese")
|
||||
- If different: Ask which language to use
|
||||
|
||||
---
|
||||
**AskUserQuestion format**:
|
||||
|
||||
## Image 2 of N
|
||||
```
|
||||
Question 1 (Style): Which style variant?
|
||||
- A: tech + dense (Recommended) - 专业科技感,适合干货
|
||||
- B: notion + list - 清爽知识卡片
|
||||
- C: minimal + balanced - 简约高端风格
|
||||
- Custom: 自定义风格描述
|
||||
|
||||
**Position**: Content
|
||||
**Layout**: [balanced/dense/list/comparison/flow]
|
||||
**Core Message**: [one-liner]
|
||||
**Filename**: 02-xxx.png
|
||||
Question 2 (Layout) - only if relevant:
|
||||
- Keep variant default (Recommended)
|
||||
- sparse / balanced / dense / list / comparison / flow
|
||||
|
||||
**Text Content**:
|
||||
- Title: xxx
|
||||
- Points: [list based on layout density]
|
||||
|
||||
**Visual Concept**: [description matching style + layout]
|
||||
|
||||
---
|
||||
...
|
||||
Question 3 (Language) - only if mismatch:
|
||||
- 中文 (匹配原文)
|
||||
- English (your preference)
|
||||
```
|
||||
|
||||
### Step 3: Save Outline
|
||||
**After confirmation**:
|
||||
1. Copy selected `outline-style-[slug].md` → `outline.md`
|
||||
2. Update YAML front matter with confirmed options
|
||||
3. If custom style: regenerate outline with that style
|
||||
4. User may edit `outline.md` directly for fine-tuning
|
||||
|
||||
Save outline as `outline.md`.
|
||||
### Step 4: Generate Images
|
||||
|
||||
### Step 4: Generate Images One by One
|
||||
With confirmed outline + style + layout:
|
||||
|
||||
For each image, create a prompt file with style and layout specifications.
|
||||
|
||||
**Prompt Format**:
|
||||
|
||||
```markdown
|
||||
Infographic theme: [topic]
|
||||
Style: [style name]
|
||||
Layout: [layout name]
|
||||
Position: [cover/content/ending]
|
||||
|
||||
Visual composition:
|
||||
- Main visual: [style-appropriate description]
|
||||
- Arrangement: [layout-specific structure]
|
||||
- Decorative elements: [style-specific decorations]
|
||||
|
||||
Color scheme:
|
||||
- Primary: [style primary color]
|
||||
- Background: [style background color]
|
||||
- Accent: [style accent color]
|
||||
|
||||
Text content:
|
||||
- Title: 「xxx」(large, prominent)
|
||||
- Key points: [based on layout density]
|
||||
|
||||
Layout instructions: [layout-specific guidance]
|
||||
Style notes: [style-specific characteristics]
|
||||
```
|
||||
|
||||
**Layout-Specific Instructions**:
|
||||
|
||||
| Layout | Arrangement Instructions |
|
||||
|--------|-------------------------|
|
||||
| `sparse` | Single focal point centered, 1-2 text elements, maximum breathing room |
|
||||
| `balanced` | Title at top, 3-4 points in clear sections, moderate spacing |
|
||||
| `dense` | Grid or multi-section layout, 5-8 points, compact but organized |
|
||||
| `list` | Vertical numbered/bulleted list, consistent item spacing, clear hierarchy |
|
||||
| `comparison` | Two-column split, clear divider, mirrored structure left/right |
|
||||
| `flow` | Horizontal or vertical flow with arrows, connected nodes/steps |
|
||||
**For each image (cover + content + ending)**:
|
||||
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
||||
2. Generate image using confirmed style and layout
|
||||
3. Report progress after each generation
|
||||
|
||||
**Image Generation Skill Selection**:
|
||||
1. Check available image generation skills
|
||||
2. If multiple skills available, ask user to choose
|
||||
- Check available image generation skills
|
||||
- If multiple skills available, ask user preference
|
||||
|
||||
**Session Management**:
|
||||
If the image generation skill supports `--sessionId`:
|
||||
1. Generate a unique session ID at the start (e.g., `xhs-{topic-slug}-{timestamp}`)
|
||||
2. Use the same session ID for all images in the series
|
||||
3. This ensures style consistency across all generated images
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call selected image generation skill with prompt file, output path, and session ID
|
||||
2. Confirm generation success
|
||||
3. Report progress: "Generated X/N"
|
||||
4. Continue to next
|
||||
If image generation skill supports `--sessionId`:
|
||||
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
|
||||
2. Use same session ID for all images
|
||||
3. Ensures visual consistency across generated images
|
||||
|
||||
### Step 5: Completion Report
|
||||
|
||||
@@ -284,21 +230,48 @@ Layout: [layout name or "varies"]
|
||||
Location: [directory path]
|
||||
Images: N total
|
||||
|
||||
- 01-cover.png ✓ Cover (sparse)
|
||||
- 02-content-1.png ✓ Content (balanced)
|
||||
- 03-content-2.png ✓ Content (dense)
|
||||
- 04-ending.png ✓ Ending (sparse)
|
||||
✓ analysis.md
|
||||
✓ outline-style-tech.md
|
||||
✓ outline-style-notion.md
|
||||
✓ outline-style-minimal.md
|
||||
✓ outline.md (selected: tech + dense)
|
||||
|
||||
Outline: outline.md
|
||||
Files:
|
||||
- 01-cover-[slug].png ✓ Cover (sparse)
|
||||
- 02-content-[slug].png ✓ Content (balanced)
|
||||
- 03-content-[slug].png ✓ Content (dense)
|
||||
- 04-ending-[slug].png ✓ Ending (sparse)
|
||||
```
|
||||
|
||||
## Image Modification
|
||||
|
||||
### Edit Single Image
|
||||
|
||||
1. Identify image to edit (e.g., `03-content-chatgpt.png`)
|
||||
2. Update prompt in `prompts/03-content-chatgpt.md` if needed
|
||||
3. Regenerate image using same session ID
|
||||
|
||||
### Add New Image
|
||||
|
||||
1. Specify insertion position (e.g., after image 3)
|
||||
2. Create new prompt with appropriate slug
|
||||
3. Generate new image
|
||||
4. **Renumber files**: All subsequent images increment NN by 1
|
||||
5. Update `outline.md` with new image entry
|
||||
|
||||
### Delete Image
|
||||
|
||||
1. Remove image file and prompt file
|
||||
2. **Renumber files**: All subsequent images decrement NN by 1
|
||||
3. Update `outline.md` to remove image entry
|
||||
|
||||
## Content Breakdown Principles
|
||||
|
||||
1. **Cover (Image 1)**: Strong visual impact, core title, attention hook → `sparse` layout
|
||||
2. **Content (Middle)**: Core points per image, density varies by content → `balanced`/`dense`/`list`/`comparison`/`flow`
|
||||
3. **Ending (Last)**: Summary / call-to-action / memorable quote → `sparse` or `balanced`
|
||||
1. **Cover (Image 1)**: Hook + visual impact → `sparse` layout
|
||||
2. **Content (Middle)**: Core value per image → `balanced`/`dense`/`list`/`comparison`/`flow`
|
||||
3. **Ending (Last)**: CTA / summary → `sparse` or `balanced`
|
||||
|
||||
**Style × Layout Matrix** (recommended combinations):
|
||||
**Style × Layout Matrix** (✓✓ = highly recommended, ✓ = works well):
|
||||
|
||||
| | sparse | balanced | dense | list | comparison | flow |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
@@ -312,12 +285,29 @@ Outline: outline.md
|
||||
| pop | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| notion | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
|
||||
✓✓ = highly recommended, ✓ = works well
|
||||
## References
|
||||
|
||||
Detailed templates and guidelines in `references/` directory:
|
||||
- `analysis-framework.md` - XHS-specific content analysis
|
||||
- `outline-template.md` - Outline format and examples
|
||||
- `styles/<style>.md` - Detailed style definitions
|
||||
- `layouts/<layout>.md` - Detailed layout definitions
|
||||
- `base-prompt.md` - Base prompt template
|
||||
|
||||
## Notes
|
||||
|
||||
- Image generation typically takes 10-30 seconds per image
|
||||
- Auto-retry once on generation failure
|
||||
- Use cartoon alternatives for sensitive public figures
|
||||
- Output language matches input content language
|
||||
- Maintain selected style consistency across all images in series
|
||||
- All prompts and text use confirmed language preference
|
||||
- Maintain style consistency across all images in series
|
||||
|
||||
## Extension Support
|
||||
|
||||
Custom styles and configurations via EXTEND.md.
|
||||
|
||||
**Check paths** (priority order):
|
||||
1. `.baoyu-skills/baoyu-xhs-images/EXTEND.md` (project)
|
||||
2. `~/.baoyu-skills/baoyu-xhs-images/EXTEND.md` (user)
|
||||
|
||||
If found, load before Step 1. Extension content overrides defaults.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# Xiaohongshu Content Analysis Framework
|
||||
|
||||
Deep analysis framework tailored for Xiaohongshu's unique engagement patterns.
|
||||
|
||||
## Purpose
|
||||
|
||||
Before creating infographics, thoroughly analyze the source material to:
|
||||
- Maximize hook power and swipe motivation
|
||||
- Identify save-worthy and share-worthy elements
|
||||
- Plan the visual narrative arc
|
||||
- Match content to optimal style/layout
|
||||
|
||||
## Platform Characteristics
|
||||
|
||||
Unlike other platforms, Xiaohongshu content must prioritize:
|
||||
- **Hook Power**: First image decides 90% of engagement
|
||||
- **Swipe Motivation**: Each image must compel users to continue
|
||||
- **Save Value**: Content worth bookmarking for later
|
||||
- **Share Triggers**: Emotional resonance that drives sharing
|
||||
|
||||
## Analysis Dimensions
|
||||
|
||||
### 1. Content Type Classification
|
||||
|
||||
| Type | Characteristics | Best Style | Best Layout |
|
||||
|------|----------------|------------|-------------|
|
||||
| 种草/安利 | Product recommendation, benefits focus | cute/fresh | balanced/list |
|
||||
| 干货分享 | Knowledge, tips, how-to | notion/tech | dense/list |
|
||||
| 个人故事 | Personal experience, emotional | warm | balanced |
|
||||
| 测评对比 | Review, comparison, pros/cons | tech/bold | comparison |
|
||||
| 教程步骤 | Step-by-step guide | fresh/notion | flow/list |
|
||||
| 避坑指南 | Warnings, mistakes to avoid | bold | list/comparison |
|
||||
| 清单合集 | Collections, recommendations | cute/minimal | list/dense |
|
||||
|
||||
### 2. Hook Analysis (爆款标题潜力)
|
||||
|
||||
Evaluate title/hook potential using these patterns:
|
||||
|
||||
**Hook Types**:
|
||||
- **数字钩子**: "5个方法", "3分钟学会", "99%的人不知道"
|
||||
- **痛点钩子**: "踩过的坑", "后悔没早知道", "别再..."
|
||||
- **好奇钩子**: "原来...", "竟然...", "没想到..."
|
||||
- **利益钩子**: "省钱", "变美", "效率翻倍"
|
||||
- **身份钩子**: "打工人必看", "学生党", "新手妈妈"
|
||||
|
||||
**Rating Scale**:
|
||||
- ⭐⭐⭐⭐⭐ (5/5): Multiple strong hooks combined
|
||||
- ⭐⭐⭐⭐ (4/5): Clear hook with room for enhancement
|
||||
- ⭐⭐⭐ (3/5): Basic hook, needs strengthening
|
||||
- ⭐⭐ (2/5): Weak hook, requires significant improvement
|
||||
- ⭐ (1/5): No clear hook
|
||||
|
||||
### 3. Target Audience (用户画像)
|
||||
|
||||
| Audience | Interests | Preferred Style | Content Focus |
|
||||
|----------|-----------|-----------------|---------------|
|
||||
| 学生党 | 省钱、学习、校园 | cute/fresh | 平价、教程、学习方法 |
|
||||
| 打工人 | 效率、职场、减压 | minimal/tech | 工具、技巧、摸鱼 |
|
||||
| 宝妈 | 育儿、家居、省心 | warm/fresh | 实用、安全、经验 |
|
||||
| 精致女孩 | 美妆、穿搭、仪式感 | cute/retro | 好看、氛围、品质 |
|
||||
| 技术宅 | 工具、效率、极客 | tech/notion | 深度、专业、新奇 |
|
||||
| 美食爱好者 | 探店、食谱、测评 | warm/pop | 好吃、简单、颜值 |
|
||||
| 旅行达人 | 攻略、打卡、小众 | fresh/retro | 省钱、避坑、拍照 |
|
||||
|
||||
### 4. Engagement Potential
|
||||
|
||||
**Save Value (收藏价值)**:
|
||||
- Is it reference material? ✓ High save potential
|
||||
- Is it a checklist or list? ✓ High save potential
|
||||
- Is it a tutorial? ✓ High save potential
|
||||
- Is it time-sensitive news? ✗ Low save potential
|
||||
|
||||
**Share Triggers (分享冲动)**:
|
||||
- "我朋友也需要看这个" → High share potential
|
||||
- "这说的就是我" → Identity resonance
|
||||
- "太有用了必须分享" → Utility sharing
|
||||
- "笑死,给朋友看看" → Entertainment sharing
|
||||
|
||||
**Comment Inducement (评论诱导)**:
|
||||
- Open-ended questions: "你是哪种类型?"
|
||||
- Experience sharing: "评论区说说你的经历"
|
||||
- Debate triggers: "你觉得呢?"
|
||||
- Help requests: "有更好的推荐吗?"
|
||||
|
||||
**Interaction Design (互动设计)**:
|
||||
- Polls: "A还是B?"
|
||||
- Challenges: "你能做到几个?"
|
||||
- Tags: "@你那个需要的朋友"
|
||||
|
||||
### 5. Visual Opportunity Map
|
||||
|
||||
| Content Element | Visual Treatment | Example |
|
||||
|-----------------|------------------|---------|
|
||||
| 数据/统计 | Highlighted numbers, simple charts | "节省80%时间" 大字突出 |
|
||||
| 对比 | Before/after, side-by-side | 左右分屏对比图 |
|
||||
| 步骤 | Numbered flow, arrows | 1→2→3 流程图 |
|
||||
| 清单 | Checklist with icons | ✓/✗ 列表配图标 |
|
||||
| 情感 | Character expressions, scenes | 卡通人物表情包 |
|
||||
| 产品 | Product showcase, lifestyle | 产品实拍+使用场景 |
|
||||
| 引用 | Quote cards, speech bubbles | 金句卡片设计 |
|
||||
|
||||
### 6. Swipe Flow Design
|
||||
|
||||
Plan the narrative arc across images:
|
||||
|
||||
| Position | Purpose | Hook Strategy |
|
||||
|----------|---------|---------------|
|
||||
| **Cover (封面)** | Stop scrolling | 最强视觉冲击 + 核心标题 |
|
||||
| **Setup (铺垫)** | Build context | 痛点共鸣 / 好奇心 |
|
||||
| **Core (核心)** | Deliver value | 干货内容,每页1-2个要点 |
|
||||
| **Payoff (收获)** | Practical takeaway | 可执行的行动建议 |
|
||||
| **Ending (结尾)** | Drive action | CTA + 互动引导 |
|
||||
|
||||
**Swipe Motivation Between Images**:
|
||||
- End each image with a hook for the next
|
||||
- Use "下一页更精彩" type transitions
|
||||
- Create information gaps that require swiping
|
||||
- Build anticipation through numbering ("第3个最重要")
|
||||
|
||||
## Output Format
|
||||
|
||||
Analysis results should be saved to `analysis.md` with:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "5个让你效率翻倍的AI工具"
|
||||
topic: 干货分享
|
||||
content_type: 工具推荐
|
||||
source_language: zh
|
||||
user_language: zh
|
||||
recommended_image_count: 6
|
||||
---
|
||||
|
||||
## Target Audience
|
||||
|
||||
- **Primary**: 打工人、自由职业者 - 追求效率提升
|
||||
- **Secondary**: 学生党 - 写论文、做作业需要
|
||||
- **Tertiary**: 内容创作者 - 需要AI辅助
|
||||
|
||||
## Hook Analysis
|
||||
|
||||
**标题钩子评分**: ⭐⭐⭐⭐ (4/5)
|
||||
- ✓ 数字钩子: "5个"
|
||||
- ✓ 利益钩子: "效率翻倍"
|
||||
- △ 可增强: 加入身份标签 "打工人必看"
|
||||
|
||||
**建议优化**:
|
||||
- 原标题: "5个让你效率翻倍的AI工具"
|
||||
- 优化: "打工人必看!5个让我效率翻倍的AI神器"
|
||||
|
||||
## Value Proposition
|
||||
|
||||
**为什么用户要看?**
|
||||
1. **实用价值**: 直接可用的工具推荐
|
||||
2. **省时省力**: 不用自己筛选,直接抄作业
|
||||
3. **FOMO**: 别人都在用,我不能落后
|
||||
|
||||
**收藏理由**: 工具清单,需要时可以回来查
|
||||
|
||||
## Engagement Design
|
||||
|
||||
- **互动点**: 结尾问"你最常用哪个?"
|
||||
- **评论诱导**: "还有什么好用的工具评论区分享"
|
||||
- **分享触发**: 打工人会转发给同事
|
||||
|
||||
## Content Signals
|
||||
|
||||
- "AI工具" → tech + dense
|
||||
- "效率" → notion + list
|
||||
- "干货" → minimal + dense
|
||||
|
||||
## Swipe Flow
|
||||
|
||||
| Image | Position | Purpose | Hook |
|
||||
|-------|----------|---------|------|
|
||||
| 1 | Cover | 吸引停留 | 标题+视觉冲击 |
|
||||
| 2 | Setup | 建立共鸣 | 为什么需要AI工具 |
|
||||
| 3-5 | Core | 核心价值 | 每页1-2个工具详解 |
|
||||
| 6 | Ending | 行动引导 | 总结+互动引导 |
|
||||
|
||||
## Recommended Approaches
|
||||
|
||||
1. **Tech + Dense** - 专业科技感,适合干货分享 (recommended)
|
||||
2. **Notion + List** - 清爽知识卡片风格
|
||||
3. **Minimal + Balanced** - 简约高端,适合职场人群
|
||||
```
|
||||
|
||||
## Analysis Checklist
|
||||
|
||||
Before proceeding to outline generation:
|
||||
|
||||
- [ ] Can I identify the content type?
|
||||
- [ ] Is the hook strong enough? (≥3 stars)
|
||||
- [ ] Do I know the primary audience?
|
||||
- [ ] Have I identified save/share triggers?
|
||||
- [ ] Are there clear visual opportunities?
|
||||
- [ ] Is the swipe flow planned?
|
||||
- [ ] Have I selected 3 style+layout combinations?
|
||||
@@ -0,0 +1,228 @@
|
||||
# Xiaohongshu Outline Template
|
||||
|
||||
Template for generating infographic series outlines.
|
||||
|
||||
## File Naming
|
||||
|
||||
Outline files use style slug in the name:
|
||||
- `outline-style-tech.md` - Tech style variant
|
||||
- `outline-style-notion.md` - Notion style variant
|
||||
- `outline-style-minimal.md` - Minimal style variant
|
||||
- `outline.md` - Final selected (copied from chosen variant)
|
||||
|
||||
## Image File Naming
|
||||
|
||||
Images use meaningful slugs for readability:
|
||||
```
|
||||
NN-{type}-[slug].png
|
||||
NN-{type}-[slug].md (in prompts/)
|
||||
```
|
||||
|
||||
| Type | Usage |
|
||||
|------|-------|
|
||||
| `cover` | First image (cover) |
|
||||
| `content` | Middle content images |
|
||||
| `ending` | Last image |
|
||||
|
||||
**Examples**:
|
||||
- `01-cover-ai-tools.png`
|
||||
- `02-content-why-ai.png`
|
||||
- `03-content-chatgpt.png`
|
||||
- `04-content-midjourney.png`
|
||||
- `05-content-notion-ai.png`
|
||||
- `06-ending-summary.png`
|
||||
|
||||
**Slug rules**:
|
||||
- Derived from image content (kebab-case)
|
||||
- Must be unique within the series
|
||||
- Keep short but descriptive (2-4 words)
|
||||
|
||||
## Outline Format
|
||||
|
||||
```markdown
|
||||
# Xiaohongshu Infographic Series Outline
|
||||
|
||||
---
|
||||
style: tech
|
||||
default_layout: dense
|
||||
image_count: 6
|
||||
generated: YYYY-MM-DD HH:mm
|
||||
---
|
||||
|
||||
## Image 1 of 6
|
||||
|
||||
**Position**: Cover
|
||||
**Layout**: sparse
|
||||
**Hook**: 打工人必看!
|
||||
**Slug**: ai-tools
|
||||
**Filename**: 01-cover-ai-tools.png
|
||||
|
||||
**Text Content**:
|
||||
- Title: 「5个AI神器让你效率翻倍」
|
||||
- Subtitle: 亲测好用,建议收藏
|
||||
|
||||
**Visual Concept**:
|
||||
科技感背景,多个AI工具图标环绕,中心大标题,
|
||||
霓虹蓝+深色背景,未来感十足
|
||||
|
||||
**Swipe Hook**: 第一个就很强大👇
|
||||
|
||||
---
|
||||
|
||||
## Image 2 of 6
|
||||
|
||||
**Position**: Content
|
||||
**Layout**: balanced
|
||||
**Core Message**: 为什么你需要AI工具
|
||||
**Slug**: why-ai
|
||||
**Filename**: 02-content-why-ai.png
|
||||
|
||||
**Text Content**:
|
||||
- Title: 「为什么要用AI?」
|
||||
- Points:
|
||||
- 重复工作自动化
|
||||
- 创意辅助不卡壳
|
||||
- 效率提升10倍
|
||||
|
||||
**Visual Concept**:
|
||||
对比图:左边疲惫打工人,右边轻松使用AI的人
|
||||
科技线条装饰,简洁有力
|
||||
|
||||
**Swipe Hook**: 接下来是具体工具推荐👇
|
||||
|
||||
---
|
||||
|
||||
## Image 3 of 6
|
||||
|
||||
**Position**: Content
|
||||
**Layout**: dense
|
||||
**Core Message**: ChatGPT使用技巧
|
||||
**Slug**: chatgpt
|
||||
**Filename**: 03-content-chatgpt.png
|
||||
|
||||
**Text Content**:
|
||||
- Title: 「ChatGPT」
|
||||
- Subtitle: 最强AI助手
|
||||
- Points:
|
||||
- 写文案:给出框架,秒出初稿
|
||||
- 改文章:润色、翻译、总结
|
||||
- 编程:写代码、找bug
|
||||
- 学习:解释概念、出题练习
|
||||
|
||||
**Visual Concept**:
|
||||
ChatGPT logo居中,四周放射状展示功能点
|
||||
深色科技背景,霓虹绿点缀
|
||||
|
||||
**Swipe Hook**: 下一个更适合创意工作者👇
|
||||
|
||||
---
|
||||
|
||||
## Image 4 of 6
|
||||
|
||||
**Position**: Content
|
||||
**Layout**: dense
|
||||
**Core Message**: Midjourney绘图
|
||||
**Slug**: midjourney
|
||||
**Filename**: 04-content-midjourney.png
|
||||
|
||||
**Text Content**:
|
||||
- Title: 「Midjourney」
|
||||
- Subtitle: AI绘画神器
|
||||
- Points:
|
||||
- 输入描述,秒出图片
|
||||
- 风格多样:写实/插画/3D
|
||||
- 做封面、做头像、做素材
|
||||
- 不会画画也能当设计师
|
||||
|
||||
**Visual Concept**:
|
||||
展示几张MJ生成的不同风格图片
|
||||
画框/画布元素装饰
|
||||
|
||||
**Swipe Hook**: 还有一个效率神器👇
|
||||
|
||||
---
|
||||
|
||||
## Image 5 of 6
|
||||
|
||||
**Position**: Content
|
||||
**Layout**: balanced
|
||||
**Core Message**: Notion AI笔记
|
||||
**Slug**: notion-ai
|
||||
**Filename**: 05-content-notion-ai.png
|
||||
|
||||
**Text Content**:
|
||||
- Title: 「Notion AI」
|
||||
- Subtitle: 智能笔记助手
|
||||
- Points:
|
||||
- 自动总结长文
|
||||
- 头脑风暴出点子
|
||||
- 整理会议记录
|
||||
|
||||
**Visual Concept**:
|
||||
Notion界面风格,简洁黑白配色
|
||||
展示笔记整理前后对比
|
||||
|
||||
**Swipe Hook**: 最后总结一下👇
|
||||
|
||||
---
|
||||
|
||||
## Image 6 of 6
|
||||
|
||||
**Position**: Ending
|
||||
**Layout**: sparse
|
||||
**Core Message**: 总结与互动
|
||||
**Slug**: summary
|
||||
**Filename**: 06-ending-summary.png
|
||||
|
||||
**Text Content**:
|
||||
- Title: 「工具只是工具」
|
||||
- Subtitle: 关键是用起来!
|
||||
- CTA: 收藏备用 | 转发给需要的朋友
|
||||
- Interaction: 你最常用哪个?评论区见👇
|
||||
|
||||
**Visual Concept**:
|
||||
简洁背景,大字标题
|
||||
底部互动引导文字
|
||||
收藏/分享图标
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
## Layout Guidelines by Position
|
||||
|
||||
| Position | Recommended Layout | Why |
|
||||
|----------|-------------------|-----|
|
||||
| Cover | `sparse` | Maximum visual impact, clear title |
|
||||
| Setup | `balanced` | Context without overwhelming |
|
||||
| Core | `balanced`/`dense`/`list` | Based on content density |
|
||||
| Payoff | `balanced`/`list` | Clear takeaways |
|
||||
| Ending | `sparse` | Clean CTA, memorable close |
|
||||
|
||||
## Swipe Hook Strategies
|
||||
|
||||
Each image should end with a hook for the next:
|
||||
|
||||
| Strategy | Example |
|
||||
|----------|---------|
|
||||
| Teaser | "第一个就很强大👇" |
|
||||
| Numbering | "接下来是第2个👇" |
|
||||
| Superlative | "下一个更厉害👇" |
|
||||
| Question | "猜猜下一个是什么?👇" |
|
||||
| Promise | "最后一个最实用👇" |
|
||||
| Urgency | "最重要的来了👇" |
|
||||
|
||||
## Variant Differentiation
|
||||
|
||||
Three variants should differ meaningfully:
|
||||
|
||||
| Aspect | Variant A | Variant B | Variant C |
|
||||
|--------|-----------|-----------|-----------|
|
||||
| Style | Primary match | Alternative | Different mood |
|
||||
| Layout | Content-optimized | Different density | Different structure |
|
||||
| Tone | Professional | Casual | Playful |
|
||||
| Audience | Primary target | Secondary target | Broader appeal |
|
||||
|
||||
**Example for "AI工具推荐"**:
|
||||
- `outline-style-tech.md`: Tech + Dense - 专业极客风
|
||||
- `outline-style-notion.md`: Notion + List - 清爽知识卡片
|
||||
- `outline-style-cute.md`: Cute + Balanced - 可爱易读风
|
||||
Reference in New Issue
Block a user