Compare commits

...

27 Commits

Author SHA1 Message Date
Jim Liu 宝玉 464edf0656 chore: release v1.0.1 2026-01-18 19:15:34 -06:00
Jim Liu 宝玉 43c2589a38 Refactor code structure for improved readability and maintainability 2026-01-18 18:18:54 -06:00
Jim Liu 宝玉 4c82884722 chore: release v1.0.0
- Add baoyu-danger-x-to-markdown skill to marketplace
- Update README files with new skill documentation
- Bump version to 1.0.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 18:00:58 -06:00
Jim Liu 宝玉 56d0485412 chore: rename baoyu-gemini-web to baoyu-danger-gemini-web 2026-01-18 17:58:12 -06:00
Jim Liu 宝玉 4998eaf8c2 feat: add baoyu-danger-x-to-markdown skill
Ignore generated x-to-markdown output directory.
2026-01-18 17:42:25 -06:00
Jim Liu 宝玉 5993b6969d chore: release v0.11.0 2026-01-18 09:53:48 -06:00
Jim Liu 宝玉 dc0201b63f chore: release v0.10.0 2026-01-18 00:01:06 -06:00
Jim Liu 宝玉 3811512750 docs: add Customization section to README files 2026-01-17 22:12:01 -06:00
Jim Liu 宝玉 9a9f6a42cd chore: release v0.9.0 2026-01-17 22:02:40 -06:00
Jim Liu 宝玉 3b13b25f1a chore: release v0.8.2 2026-01-17 19:00:27 -06:00
Jim Liu 宝玉 688d1760ed chore: release v0.8.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 16:44:50 -06:00
Jim Liu 宝玉 a701be873b update Image Generation Guidelines 2026-01-17 16:24:23 -06:00
Jim Liu 宝玉 1df5e4974d feat: add analysis framework for Xiaohongshu content 2026-01-17 15:52:00 -06:00
Jim Liu 宝玉 2677e730b9 Update release-skills to update readme 2026-01-17 15:11:50 -06:00
Jim Liu 宝玉 080f2eff48 chore: release v0.7.0
- baoyu-comic: adds --aspect (3:4, 4:3, 16:9) and --lang options; multi-variant storyboard workflow
- baoyu-comic/baoyu-slide-deck: adds analysis-framework and template references
- Multiple skills: restructured SKILL.md, moved details to references/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 15:03:43 -06:00
Jim Liu 宝玉 bb4f0dc52c feat: add PDF export for slide-deck and comic skills 2026-01-17 12:08:08 -06:00
Jim Liu 宝玉 c731faea8f feat: update slide deck 2026-01-17 04:28:16 -06:00
Jim Liu 宝玉 fa155da15d fix: update version to 0.5.3 and enhance placeholder selection logic in publishArticle function 2026-01-17 01:29:10 -06:00
Jim Liu 宝玉 6194f71378 bump version 2026-01-16 21:44:14 -06:00
Jim Liu 宝玉 8e88cf4a8b feat: implement session management for image generation skills and add session handling functions 2026-01-16 20:20:07 -06:00
Jim Liu 宝玉 259baff413 update gemeni-web to support add image as reference 2026-01-16 19:06:14 -06:00
Jim Liu 宝玉 a42137ff13 feat: update version to 0.5.1 and enhance baoyu-comic documentation with character and outline templates 2026-01-16 01:43:37 -06:00
Jim Liu 宝玉 c6d96d4134 add new skill baoyu-comic 2026-01-16 01:12:21 -06:00
Jim Liu 宝玉 e174d642df feat: add quick install instructions to README files 2026-01-15 22:06:38 -06:00
Jim Liu 宝玉 db7eaa2847 feat: update version number to 0.4.2 and enhance description for baoyu-gemini-web skill 2026-01-15 17:05:58 -06:00
Jim Liu 宝玉 e3d2f5d03f Add clipboard paste functionality and documentation for X Articles and regular posts
- Implemented `paste-from-clipboard.ts` script to send real paste keystrokes across macOS, Linux, and Windows.
- Created detailed guides for publishing articles to X Articles and posting regular content, including usage examples and troubleshooting tips.
- Added support for Markdown formatting and image handling in X Articles.
- Documented manual workflows for image pasting and browser interactions using Playwright MCP.
2026-01-15 15:38:03 -06:00
Jim Liu 宝玉 da920bb830 feat: add baoyu- prefix 2026-01-15 14:18:56 -06:00
187 changed files with 13556 additions and 3541 deletions
+10 -8
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "0.3.1"
"version": "1.0.1"
},
"plugins": [
{
@@ -15,13 +15,15 @@
"source": "./",
"strict": false,
"skills": [
"./skills/gemini-web",
"./skills/xhs-images",
"./skills/post-to-x",
"./skills/post-to-wechat",
"./skills/article-illustrator",
"./skills/cover-image",
"./skills/slide-deck"
"./skills/baoyu-danger-gemini-web",
"./skills/baoyu-danger-x-to-markdown",
"./skills/baoyu-xhs-images",
"./skills/baoyu-post-to-x",
"./skills/baoyu-post-to-wechat",
"./skills/baoyu-article-illustrator",
"./skills/baoyu-cover-image",
"./skills/baoyu-slide-deck",
"./skills/baoyu-comic"
]
}
]
+203
View File
@@ -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}
```
+4
View File
@@ -140,3 +140,7 @@ vite.config.ts.timestamp-*
tests-data/
.DS_Store
# Skill extensions (user customization)
.baoyu-skills/
x-to-markdown/
+154
View File
@@ -0,0 +1,154 @@
# Changelog
English | [中文](./CHANGELOG.zh.md)
## 1.0.1 - 2026-01-18
### Refactor
- Code structure improvements for better readability and maintainability.
- `baoyu-slide-deck`: unified style reference file formats.
### Other
- Screenshots: converted from PNG to WebP format for smaller file sizes; added screenshots for new styles.
## 1.0.0 - 2026-01-18
### Features
- `baoyu-danger-x-to-markdown`: new skill to convert X/Twitter posts and threads to Markdown format.
### Breaking Changes
- `baoyu-gemini-web` renamed to `baoyu-danger-gemini-web` to indicate potential risks of using reverse-engineered APIs.
## 0.11.0 - 2026-01-18
### Features
- `baoyu-danger-gemini-web`: adds disclaimer consent check flow—requires user acceptance before first use, with persistent consent storage per platform.
## 0.10.0 - 2026-01-18
### Features
- `baoyu-slide-deck`: expands style library from 10 to 15 styles with 8 new additions—`dark-atmospheric`, `editorial-infographic`, `fantasy-animation`, `intuition-machine`, `pixel-art`, `scientific`, `vintage`, `watercolor`.
### Breaking Changes
- `baoyu-slide-deck`: removes 3 styles (`playful`, `storytelling`, `warm`); changes default style from `notion` to `blueprint`.
## 0.9.0 - 2026-01-17
### Features
- Extension support: all skills now support customization via `EXTEND.md` files. Check `.baoyu-skills/<skill-name>/EXTEND.md` (project) or `~/.baoyu-skills/<skill-name>/EXTEND.md` (user) for custom styles and configurations.
### Other
- `.gitignore`: adds `.baoyu-skills/` directory for user extension files.
## 0.8.2 - 2026-01-17
### Refactor
- `baoyu-danger-gemini-web`: reorganizes script architecture—moves modular files into `gemini-webapi/` subdirectory and updates SKILL.md with `${SKILL_DIR}` path references.
## 0.8.1 - 2026-01-17
### Refactor
- `baoyu-danger-gemini-web`: refactors script architecture—consolidates 10 separate files into a structured `gemini-webapi/` module (TypeScript port of gemini_webapi Python library).
## 0.8.0 - 2026-01-17
### Features
- `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-danger-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` docs to use `${SKILL_DIR}` in commands so agents can run scripts from any install location.
## 0.6.0 - 2026-01-17
- `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-danger-gemini-web`: adds `--sessionId` (local persisted sessions, plus `--list-sessions`) for multi-turn conversations and consistent multi-image generation.
- `baoyu-danger-gemini-web`: adds `--reference/--ref` for reference images (vision input), plus stronger timeout handling and cookie refresh recovery.
- Docs: `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic` document session usage (reuse one `sessionId` per set) to improve visual consistency.
## 0.5.1 - 2026-01-16
- `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-danger-gemini-web`: updates description to clarify it as the image-generation backend for other skills (e.g. `cover-image`, `xhs-images`, `article-illustrator`).
## 0.4.1 - 2026-01-15
- `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).
+153
View File
@@ -0,0 +1,153 @@
# Changelog
[English](./CHANGELOG.md) | 中文
## 1.0.1 - 2026-01-18
### 重构
- 代码结构优化,提升可读性和可维护性。
- `baoyu-slide-deck`:统一风格参考文件格式。
### 其他
- 截图:从 PNG 转换为 WebP 格式,减小文件体积;新增新风格的截图。
## 1.0.0 - 2026-01-18
### 新功能
- `baoyu-danger-x-to-markdown`:新增技能,将 X/Twitter 帖子和线程转换为 Markdown 格式。
### 破坏性变更
- `baoyu-gemini-web` 重命名为 `baoyu-danger-gemini-web`,以提示使用逆向工程 API 的潜在风险。
## 0.11.0 - 2026-01-18
### 新功能
- `baoyu-danger-gemini-web`:新增 Disclaimer 同意检查流程——首次使用前需用户确认接受,同意状态按平台持久化存储。
## 0.10.0 - 2026-01-18
### 新功能
- `baoyu-slide-deck`:风格库从 10 个扩展至 15 个,新增 8 种风格——`dark-atmospheric`(暗黑氛围)、`editorial-infographic`(杂志信息图)、`fantasy-animation`(奇幻动画)、`intuition-machine`(技术简报)、`pixel-art`(像素艺术)、`scientific`(科学图解)、`vintage`(复古文献)、`watercolor`(水彩手绘)。
### 破坏性变更
- `baoyu-slide-deck`:移除 3 种风格(`playful``storytelling``warm`);默认风格从 `notion` 改为 `blueprint`
## 0.9.0 - 2026-01-17
### 新功能
- 扩展支持:所有技能现支持通过 `EXTEND.md` 文件自定义。检查 `.baoyu-skills/<skill-name>/EXTEND.md`(项目级)或 `~/.baoyu-skills/<skill-name>/EXTEND.md`(用户级)配置自定义样式与设置。
### 其他
- `.gitignore`:添加 `.baoyu-skills/` 目录忽略,存放用户扩展文件。
## 0.8.2 - 2026-01-17
### 重构
- `baoyu-danger-gemini-web`:重组脚本架构——将模块文件移至 `gemini-webapi/` 子目录,并更新 SKILL.md 使用 `${SKILL_DIR}` 路径引用。
## 0.8.1 - 2026-01-17
### 重构
- `baoyu-danger-gemini-web`:重构脚本架构——将 10 个分散的脚本文件整合为结构化的 `gemini-webapi/` 模块(gemini_webapi Python 库的 TypeScript 移植版)。
## 0.8.0 - 2026-01-17
### 新功能
- `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-danger-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-danger-gemini-web`:新增 `--sessionId`(本地持久化会话,支持 `--list-sessions`),用于多轮对话/多图生成保持上下文一致。
- `baoyu-danger-gemini-web`:新增 `--reference/--ref` 传入参考图片(vision 输入),并增强超时与 cookie 失效自动恢复逻辑。
- `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic`:文档补充 session 约定(整套图使用同一 `sessionId`,增强风格一致性)。
## 0.5.1 - 2026-01-16
- `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-danger-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 登录与缓存流程)。
+148 -15
View File
@@ -10,12 +10,13 @@ Claude Code marketplace plugin providing AI-powered content generation skills. S
```
skills/
├── gemini-web/ # Core: Gemini API wrapper (text + image gen)
├── xhs-images/ # Xiaohongshu infographic series (1-10 images)
├── cover-image/ # Article cover images (2.35:1 aspect)
├── slide-deck/ # Presentation slides with outlines
├── article-illustrator/ # Smart illustration placement
── post-to-x/ # X/Twitter posting automation
├── baoyu-danger-gemini-web/ # Core: Gemini API wrapper (text + image gen)
├── baoyu-xhs-images/ # Xiaohongshu infographic series (1-10 images)
├── baoyu-cover-image/ # Article cover images (2.35:1 aspect)
├── baoyu-slide-deck/ # Presentation slides with outlines
├── baoyu-article-illustrator/ # Smart illustration placement
── baoyu-post-to-x/ # X/Twitter posting automation
└── baoyu-post-to-wechat/ # WeChat Official Account posting
```
Each skill contains:
@@ -34,24 +35,24 @@ npx -y bun skills/<skill>/scripts/main.ts [options]
Examples:
```bash
# Text generation
npx -y bun skills/gemini-web/scripts/main.ts "Hello"
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "Hello"
# Image generation
npx -y bun skills/gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
# From prompt files
npx -y bun skills/gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
```
## Key Dependencies
- **Bun**: TypeScript runtime (via `npx -y bun`)
- **Chrome**: Required for `gemini-web` auth and `post-to-x` automation
- **Chrome**: Required for `baoyu-danger-gemini-web` auth and `baoyu-post-to-x` automation
- **No npm packages**: Self-contained TypeScript, no external dependencies
## Authentication
`gemini-web` uses browser cookies for Google auth:
`baoyu-danger-gemini-web` uses browser cookies for Google auth:
- First run opens Chrome for login
- Cookies cached in data directory
- Force refresh: `--login` flag
@@ -62,10 +63,38 @@ npx -y bun skills/gemini-web/scripts/main.ts --promptfiles system.md content.md
## Adding New Skills
1. Create `skills/<name>/SKILL.md` with YAML front matter
2. Add TypeScript in `skills/<name>/scripts/`
3. Add prompt templates in `skills/<name>/prompts/` if needed
4. Register in `marketplace.json` plugins[0].skills array
**IMPORTANT**: All skills MUST use `baoyu-` prefix to avoid conflicts when users import this plugin.
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
- Directory name: `baoyu-<name>`
- SKILL.md `name` field: `baoyu-<name>`
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
4. Register in `marketplace.json` plugins[0].skills array as `./skills/baoyu-<name>`
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
@@ -73,3 +102,107 @@ npx -y bun skills/gemini-web/scripts/main.ts --promptfiles system.md content.md
- 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-danger-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`).
+209 -31
View File
@@ -11,6 +11,12 @@ Skills shared by Baoyu for improving daily work efficiency with Claude Code.
## Installation
### Quick Install (Recommended)
```bash
npx add-skill jimliu/baoyu-skills
```
### Register as Plugin Marketplace
Run the following command in Claude Code:
@@ -34,45 +40,86 @@ Run the following command in Claude Code:
/plugin install content-skills@baoyu-skills
```
**Option 3: Ask the Agent**
Simply tell Claude Code:
> Please install Skills from github.com/JimLiu/baoyu-skills
## Update Skills
To update skills to the latest version:
1. Run `/plugin` in Claude Code
2. Switch to **Marketplaces** tab (use arrow keys or Tab)
3. Select **baoyu-skills**
4. Choose **Update marketplace**
You can also **Enable auto-update** to get the latest versions automatically.
![Update Skills](./screenshots/update-plugins.png)
## Available Skills
### gemini-web
### baoyu-danger-gemini-web
Interacts with Gemini Web to generate text and images.
**Text Generation:**
```bash
/gemini-web "Hello, Gemini"
/gemini-web --prompt "Explain quantum computing"
/baoyu-danger-gemini-web "Hello, Gemini"
/baoyu-danger-gemini-web --prompt "Explain quantum computing"
```
**Image Generation:**
```bash
/gemini-web --prompt "A cute cat" --image cat.png
/gemini-web --promptfiles system.md content.md --image out.png
/baoyu-danger-gemini-web --prompt "A cute cat" --image cat.png
/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png
```
### xhs-images
### baoyu-danger-x-to-markdown
Converts X (Twitter) content to markdown format. Supports tweet threads and X Articles.
```bash
# Convert tweet to markdown
/baoyu-danger-x-to-markdown https://x.com/username/status/123456
# Save to specific file
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md
# JSON output
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
```
**Supported URLs:**
- `https://x.com/<user>/status/<id>`
- `https://twitter.com/<user>/status/<id>`
- `https://x.com/i/article/<id>`
**Authentication:** Uses environment variables (`X_AUTH_TOKEN`, `X_CT0`) or Chrome login for cookie-based auth.
### baoyu-xhs-images
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics with **Style × Layout** two-dimensional system.
```bash
# Auto-select style and layout
/xhs-images posts/ai-future/article.md
/baoyu-xhs-images posts/ai-future/article.md
# Specify style
/xhs-images posts/ai-future/article.md --style notion
/baoyu-xhs-images posts/ai-future/article.md --style notion
# Specify layout
/xhs-images posts/ai-future/article.md --layout dense
/baoyu-xhs-images posts/ai-future/article.md --layout dense
# Combine style and layout
/xhs-images posts/ai-future/article.md --style tech --layout list
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list
# Direct content input
/xhs-images 今日星座运势
/baoyu-xhs-images 今日星座运势
```
**Styles** (visual aesthetics): `cute` (default), `fresh`, `tech`, `warm`, `bold`, `minimal`, `retro`, `pop`, `notion`
@@ -87,70 +134,190 @@ Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-1
| `comparison` | 2 sides | Before/after, pros/cons |
| `flow` | 3-6 steps | Processes, timelines |
### cover-image
### baoyu-cover-image
Generate hand-drawn style cover images for articles with multiple style options.
```bash
# From markdown file (auto-select style)
/cover-image path/to/article.md
/baoyu-cover-image path/to/article.md
# Specify a style
/cover-image path/to/article.md --style tech
/cover-image path/to/article.md --style warm
/baoyu-cover-image path/to/article.md --style tech
/baoyu-cover-image path/to/article.md --style warm
# Without title text
/cover-image path/to/article.md --no-title
/baoyu-cover-image path/to/article.md --no-title
```
Available styles: `elegant` (default), `tech`, `warm`, `bold`, `minimal`, `playful`, `nature`, `retro`
### slide-deck
### baoyu-slide-deck
Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images.
```bash
# From markdown file
/slide-deck path/to/article.md
/baoyu-slide-deck path/to/article.md
# With style and audience
/slide-deck path/to/article.md --style corporate
/slide-deck path/to/article.md --audience executives
/baoyu-slide-deck path/to/article.md --style corporate
/baoyu-slide-deck path/to/article.md --audience executives
# Outline only (no image generation)
/slide-deck path/to/article.md --outline-only
/baoyu-slide-deck path/to/article.md --outline-only
# With language
/slide-deck path/to/article.md --lang zh
/baoyu-slide-deck path/to/article.md --lang zh
```
Available styles: `editorial` (default), `corporate`, `technical`, `playful`, `minimal`, `storytelling`, `warm`, `retro-flat`, `notion`
**Styles** (visual aesthetics):
### post-to-wechat
| Style | Description | Best For |
|-------|-------------|----------|
| `blueprint` (default) | Technical schematics, grid texture, engineering precision | Architecture, system design |
| `notion` | SaaS dashboard aesthetic, card-based layouts, clean data focus | Product demos, SaaS, B2B |
| `bold-editorial` | High-impact magazine style, bold typography, dark backgrounds | Product launches, keynotes |
| `corporate` | Navy/gold palette, structured layouts, professional icons | Investor decks, proposals |
| `dark-atmospheric` | Cinematic dark mode, glowing accents, atmospheric depth | Entertainment, gaming, creative |
| `editorial-infographic` | Magazine-style explainers, flat illustrations | Tech explainers, research |
| `fantasy-animation` | Whimsical Ghibli/Disney style, hand-drawn animation | Educational, storytelling |
| `intuition-machine` | Technical briefing, bilingual labels, aged paper texture | Technical docs, bilingual |
| `minimal` | Ultra-clean, maximum whitespace, single accent color | Executive briefings, premium |
| `pixel-art` | Retro 8-bit aesthetic, chunky pixels, nostalgic gaming | Gaming, developer talks |
| `scientific` | Academic diagrams, biological pathways, precise labeling | Biology, chemistry, medical |
| `sketch-notes` | Hand-drawn feel, soft brush strokes, warm background | Educational, tutorials |
| `vector-illustration` | Flat vector, black outlines, retro soft colors | Creative proposals, explainers |
| `vintage` | Aged-paper aesthetic, historical document styling | Historical, heritage, biography |
| `watercolor` | Soft hand-painted textures, natural warmth | Lifestyle, wellness, travel |
**Style Previews**:
| | | |
|:---:|:---:|:---:|
| ![blueprint](./screenshots/slide-deck-styles/blueprint.webp) | ![bold-editorial](./screenshots/slide-deck-styles/bold-editorial.webp) | ![corporate](./screenshots/slide-deck-styles/corporate.webp) |
| blueprint | bold-editorial | corporate |
| ![dark-atmospheric](./screenshots/slide-deck-styles/dark-atmospheric.webp) | ![editorial-infographic](./screenshots/slide-deck-styles/editorial-infographic.webp) | ![fantasy-animation](./screenshots/slide-deck-styles/fantasy-animation.webp) |
| dark-atmospheric | editorial-infographic | fantasy-animation |
| ![intuition-machine](./screenshots/slide-deck-styles/intuition-machine.webp) | ![minimal](./screenshots/slide-deck-styles/minimal.webp) | ![notion](./screenshots/slide-deck-styles/notion.webp) |
| intuition-machine | minimal | notion |
| ![pixel-art](./screenshots/slide-deck-styles/pixel-art.webp) | ![scientific](./screenshots/slide-deck-styles/scientific.webp) | ![sketch-notes](./screenshots/slide-deck-styles/sketch-notes.webp) |
| pixel-art | scientific | sketch-notes |
| ![vector-illustration](./screenshots/slide-deck-styles/vector-illustration.webp) | ![vintage](./screenshots/slide-deck-styles/vintage.webp) | ![watercolor](./screenshots/slide-deck-styles/watercolor.webp) |
| vector-illustration | vintage | watercolor |
After generation, slides are automatically merged into a `.pptx` file for easy sharing.
### baoyu-comic
Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation.
```bash
# From source material
/baoyu-comic posts/turing-story/source.md
# Specify style
/baoyu-comic posts/turing-story/source.md --style dramatic
/baoyu-comic posts/turing-story/source.md --style ohmsha
# 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 --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 |
|-------|-------------|----------|
| `classic` (default) | Traditional Ligne Claire with clean uniform outlines, flat colors, detailed backgrounds | Biographies, balanced narratives, educational content |
| `dramatic` | High contrast with heavy shadows, intense expressions, angular compositions | Pivotal discoveries, conflicts, climactic scenes |
| `warm` | Soft edges, golden tones, cozy interiors with nostalgic feel | Personal stories, childhood scenes, mentorship |
| `tech` | Precise geometric lines, circuit motifs, neon accents on dark backgrounds | Computing history, AI stories, modern tech |
| `sepia` | Vintage illustration style with aged paper effect, period-accurate details | Pre-1950s stories, classical science, historical figures |
| `vibrant` | Energetic lines with weight variation, bright colors, dynamic poses | Science explanations, "aha" moments, young audience |
| `ohmsha` | Manga guide style with visual metaphors, gadgets, student/mentor dynamic | Technical tutorials, complex concepts (ML, physics) |
| `realistic` | Full-color realistic manga with digital painting, smooth gradients, accurate proportions | Wine, food, business, lifestyle, professional topics |
**Layouts** (panel arrangement):
| Layout | Panels/Page | Best for |
|--------|-------------|----------|
| `standard` | 4-6 | Dialogue, narrative flow |
| `cinematic` | 2-4 | Dramatic moments, establishing shots |
| `dense` | 6-9 | Technical explanations, timelines |
| `splash` | 1-2 large | Key moments, revelations |
| `mixed` | 3-7 varies | Complex narratives, emotional arcs |
| `webtoon` | 3-5 vertical | Ohmsha tutorials, mobile reading |
### baoyu-post-to-wechat
Post content to WeChat Official Account (微信公众号). Two modes available:
**Image-Text (图文)** - Multiple images with short title/content:
```bash
/post-to-wechat 图文 --markdown article.md --images ./photos/
/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
```
**Article (文章)** - Full markdown/HTML with rich formatting:
```bash
/post-to-wechat 文章 --markdown article.md
/post-to-wechat 文章 --markdown article.md --theme grace
/post-to-wechat 文章 --html article.html
/baoyu-post-to-wechat 文章 --markdown article.md
/baoyu-post-to-wechat 文章 --markdown article.md --theme grace
/baoyu-post-to-wechat 文章 --html article.html
```
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
### gemini-web
### baoyu-danger-gemini-web
This skill uses the Gemini Web API (reverse-engineered).
@@ -160,6 +327,17 @@ This skill uses the Gemini Web API (reverse-engineered).
- Cookies are cached for subsequent runs
- No guarantees on API stability or availability
### baoyu-danger-x-to-markdown
This skill uses a reverse-engineered X (Twitter) API.
**Warning:** This is NOT an official API. Use at your own risk.
- May break without notice if X changes their API
- Account restrictions possible if API usage detected
- First use requires consent acknowledgment
- Authentication via environment variables or Chrome login
## License
MIT
+209 -31
View File
@@ -11,6 +11,12 @@
## 安装
### 快速安装(推荐)
```bash
npx add-skill jimliu/baoyu-skills
```
### 注册插件市场
在 Claude Code 中运行:
@@ -34,45 +40,86 @@
/plugin install content-skills@baoyu-skills
```
**方式三:告诉 Agent**
直接告诉 Claude Code
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
## 更新技能
更新技能到最新版本:
1. 在 Claude Code 中运行 `/plugin`
2. 切换到 **Marketplaces** 标签页(使用方向键或 Tab
3. 选择 **baoyu-skills**
4. 选择 **Update marketplace**
也可以选择 **Enable auto-update** 启用自动更新,每次启动时自动获取最新版本。
![更新技能](./screenshots/update-plugins.png)
## 可用技能
### gemini-web
### baoyu-danger-gemini-web
与 Gemini Web 交互,生成文本和图片。
**文本生成:**
```bash
/gemini-web "你好,Gemini"
/gemini-web --prompt "解释量子计算"
/baoyu-danger-gemini-web "你好,Gemini"
/baoyu-danger-gemini-web --prompt "解释量子计算"
```
**图片生成:**
```bash
/gemini-web --prompt "一只可爱的猫" --image cat.png
/gemini-web --promptfiles system.md content.md --image out.png
/baoyu-danger-gemini-web --prompt "一只可爱的猫" --image cat.png
/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png
```
### xhs-images
### baoyu-danger-x-to-markdown
将 X (Twitter) 内容转换为 markdown 格式。支持推文串和 X 文章。
```bash
# 将推文转换为 markdown
/baoyu-danger-x-to-markdown https://x.com/username/status/123456
# 保存到指定文件
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md
# JSON 输出
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
```
**支持的 URL**
- `https://x.com/<user>/status/<id>`
- `https://twitter.com/<user>/status/<id>`
- `https://x.com/i/article/<id>`
**身份验证:** 使用环境变量(`X_AUTH_TOKEN``X_CT0`)或 Chrome 登录进行 cookie 认证。
### baoyu-xhs-images
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图,支持 **风格 × 布局** 二维系统。
```bash
# 自动选择风格和布局
/xhs-images posts/ai-future/article.md
/baoyu-xhs-images posts/ai-future/article.md
# 指定风格
/xhs-images posts/ai-future/article.md --style notion
/baoyu-xhs-images posts/ai-future/article.md --style notion
# 指定布局
/xhs-images posts/ai-future/article.md --layout dense
/baoyu-xhs-images posts/ai-future/article.md --layout dense
# 组合风格和布局
/xhs-images posts/ai-future/article.md --style tech --layout list
/baoyu-xhs-images posts/ai-future/article.md --style tech --layout list
# 直接输入内容
/xhs-images 今日星座运势
/baoyu-xhs-images 今日星座运势
```
**风格**(视觉美学):`cute`(默认)、`fresh``tech``warm``bold``minimal``retro``pop``notion`
@@ -87,70 +134,190 @@
| `comparison` | 双栏 | 对比、优劣 |
| `flow` | 3-6 步 | 流程、时间线 |
### cover-image
### baoyu-cover-image
为文章生成手绘风格封面图,支持多种风格选项。
```bash
# 从 markdown 文件生成(自动选择风格)
/cover-image path/to/article.md
/baoyu-cover-image path/to/article.md
# 指定风格
/cover-image path/to/article.md --style tech
/cover-image path/to/article.md --style warm
/baoyu-cover-image path/to/article.md --style tech
/baoyu-cover-image path/to/article.md --style warm
# 不包含标题文字
/cover-image path/to/article.md --no-title
/baoyu-cover-image path/to/article.md --no-title
```
可用风格:`elegant`(默认)、`tech``warm``bold``minimal``playful``nature``retro`
### slide-deck
### baoyu-slide-deck
从内容生成专业的幻灯片图片。先创建包含样式说明的完整大纲,然后逐页生成幻灯片图片。
```bash
# 从 markdown 文件生成
/slide-deck path/to/article.md
/baoyu-slide-deck path/to/article.md
# 指定风格和受众
/slide-deck path/to/article.md --style corporate
/slide-deck path/to/article.md --audience executives
/baoyu-slide-deck path/to/article.md --style corporate
/baoyu-slide-deck path/to/article.md --audience executives
# 仅生成大纲(不生成图片)
/slide-deck path/to/article.md --outline-only
/baoyu-slide-deck path/to/article.md --outline-only
# 指定语言
/slide-deck path/to/article.md --lang zh
/baoyu-slide-deck path/to/article.md --lang zh
```
可用风格:`editorial`(默认)、`corporate``technical``playful``minimal``storytelling``warm``retro-flat``notion`
**风格**(视觉美学):
### post-to-wechat
| 风格 | 描述 | 适用场景 |
|------|------|----------|
| `blueprint`(默认) | 技术蓝图风格,网格纹理,工程精度 | 架构设计、系统设计 |
| `notion` | SaaS 仪表盘美学,卡片式布局,数据清晰 | 产品演示、SaaS、B2B |
| `bold-editorial` | 杂志社论风格,粗体排版,深色背景 | 产品发布、主题演讲 |
| `corporate` | 海军蓝/金色配色,结构化布局,专业图标 | 投资者演示、客户提案 |
| `dark-atmospheric` | 电影级暗色调,发光效果,氛围感 | 娱乐、游戏、创意 |
| `editorial-infographic` | 杂志风格信息图,扁平插画 | 科技解说、研究报告 |
| `fantasy-animation` | 吉卜力/迪士尼风格,手绘动画 | 教育、故事讲述 |
| `intuition-machine` | 技术简报,双语标签,做旧纸张纹理 | 技术文档、双语内容 |
| `minimal` | 极简风格,大量留白,单一强调色 | 高管简报、高端品牌 |
| `pixel-art` | 复古 8-bit 像素风,怀旧游戏感 | 游戏、开发者分享 |
| `scientific` | 学术图表,生物通路,精确标注 | 生物、化学、医学 |
| `sketch-notes` | 手绘风格,柔和笔触,暖白色背景 | 教育、教程、知识分享 |
| `vector-illustration` | 扁平矢量风格,黑色轮廓线,复古柔和配色 | 创意提案、说明性内容 |
| `vintage` | 做旧纸张美学,历史文档风格 | 历史、传记、人文 |
| `watercolor` | 柔和手绘水彩纹理,自然温暖 | 生活方式、健康、旅行 |
**风格预览**
| | | |
|:---:|:---:|:---:|
| ![blueprint](./screenshots/slide-deck-styles/blueprint.webp) | ![bold-editorial](./screenshots/slide-deck-styles/bold-editorial.webp) | ![corporate](./screenshots/slide-deck-styles/corporate.webp) |
| blueprint | bold-editorial | corporate |
| ![dark-atmospheric](./screenshots/slide-deck-styles/dark-atmospheric.webp) | ![editorial-infographic](./screenshots/slide-deck-styles/editorial-infographic.webp) | ![fantasy-animation](./screenshots/slide-deck-styles/fantasy-animation.webp) |
| dark-atmospheric | editorial-infographic | fantasy-animation |
| ![intuition-machine](./screenshots/slide-deck-styles/intuition-machine.webp) | ![minimal](./screenshots/slide-deck-styles/minimal.webp) | ![notion](./screenshots/slide-deck-styles/notion.webp) |
| intuition-machine | minimal | notion |
| ![pixel-art](./screenshots/slide-deck-styles/pixel-art.webp) | ![scientific](./screenshots/slide-deck-styles/scientific.webp) | ![sketch-notes](./screenshots/slide-deck-styles/sketch-notes.webp) |
| pixel-art | scientific | sketch-notes |
| ![vector-illustration](./screenshots/slide-deck-styles/vector-illustration.webp) | ![vintage](./screenshots/slide-deck-styles/vintage.webp) | ![watercolor](./screenshots/slide-deck-styles/watercolor.webp) |
| vector-illustration | vintage | watercolor |
生成完成后,所有幻灯片会自动合并为 `.pptx` 文件,方便分享。
### baoyu-comic
知识漫画创作器,支持多种风格(Logicomix/清线风格、欧姆社漫画教程风格)。创作带有详细分镜布局的原创教育漫画,逐页生成图片。
```bash
# 从素材文件生成
/baoyu-comic posts/turing-story/source.md
# 指定风格
/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 --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` 等 |
**风格**(视觉美学):
| 风格 | 描述 | 适用场景 |
|------|------|----------|
| `classic`(默认) | 传统清线风格,统一线条、平涂色彩、精细背景 | 传记、平衡叙事、教育内容 |
| `dramatic` | 高对比度,重阴影、紧张表情、棱角分明的构图 | 重大发现、冲突、高潮场景 |
| `warm` | 柔和边缘、金色调、温馨室内、怀旧感 | 个人故事、童年场景、师生情 |
| `tech` | 精确几何线条、电路纹理、深色背景配霓虹色 | 计算机史、AI 故事、现代科技 |
| `sepia` | 复古插画风格、做旧纸张效果、时代准确细节 | 1950 年前故事、古典科学、历史人物 |
| `vibrant` | 富有活力的线条、明亮色彩、动感姿态 | 科学解说、"顿悟"时刻、青少年读者 |
| `ohmsha` | 欧姆社漫画风格,视觉比喻、道具、学生/导师互动 | 技术教程、复杂概念(机器学习、物理) |
| `realistic` | 全彩写实日漫风格,数字绘画、平滑渐变、准确人体比例 | 红酒、美食、商业、生活方式、专业话题 |
**布局**(分镜排列):
| 布局 | 每页分镜数 | 适用场景 |
|------|-----------|----------|
| `standard` | 4-6 | 对话、叙事推进 |
| `cinematic` | 2-4 | 戏剧性时刻、建立镜头 |
| `dense` | 6-9 | 技术说明、时间线 |
| `splash` | 1-2 大图 | 关键时刻、揭示 |
| `mixed` | 3-7 不等 | 复杂叙事、情感弧线 |
| `webtoon` | 3-5 竖向 | 欧姆社教程、手机阅读 |
### baoyu-post-to-wechat
发布内容到微信公众号,支持两种模式:
**图文模式** - 多图配短标题和正文:
```bash
/post-to-wechat 图文 --markdown article.md --images ./photos/
/post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
/post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
/baoyu-post-to-wechat 图文 --markdown article.md --images ./photos/
/baoyu-post-to-wechat 图文 --markdown article.md --image img1.png --image img2.png --image img3.png
/baoyu-post-to-wechat 图文 --title "标题" --content "内容" --image img1.png --submit
```
**文章模式** - 完整 markdown/HTML 富文本格式:
```bash
/post-to-wechat 文章 --markdown article.md
/post-to-wechat 文章 --markdown article.md --theme grace
/post-to-wechat 文章 --html article.html
/baoyu-post-to-wechat 文章 --markdown article.md
/baoyu-post-to-wechat 文章 --markdown article.md --theme grace
/baoyu-post-to-wechat 文章 --html article.html
```
前置要求:已安装 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 水印
```
扩展内容会在技能执行前加载,并覆盖默认设置。
## 免责声明
### gemini-web
### baoyu-danger-gemini-web
此技能使用 Gemini Web API(逆向工程)。
@@ -160,6 +327,17 @@
- Cookies 会被缓存供后续使用
- 不保证 API 的稳定性或可用性
### baoyu-danger-x-to-markdown
此技能使用逆向工程的 X (Twitter) API。
**警告:** 这不是官方 API。使用风险自负。
- 如果 X 更改其 API,可能会无预警失效
- 如检测到 API 使用,账号可能受限
- 首次使用需确认免责声明
- 通过环境变量或 Chrome 登录进行身份验证
## 许可证
MIT
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

@@ -1,5 +1,5 @@
---
name: article-illustrator
name: baoyu-article-illustrator
description: Smart article illustration skill. Analyzes article content and generates illustrations at positions requiring visual aids with multiple style options. Use when user asks to "add illustrations to article", "generate images for article", or "illustrate article".
---
@@ -11,15 +11,15 @@ Analyze article structure and content, identify positions requiring visual aids,
```bash
# Auto-select style based on content
/article-illustrator path/to/article.md
/baoyu-article-illustrator path/to/article.md
# Specify a style
/article-illustrator path/to/article.md --style tech
/article-illustrator path/to/article.md --style warm
/article-illustrator path/to/article.md --style minimal
/baoyu-article-illustrator path/to/article.md --style tech
/baoyu-article-illustrator path/to/article.md --style warm
/baoyu-article-illustrator path/to/article.md --style minimal
# Combine with other options
/article-illustrator path/to/article.md --style playful
/baoyu-article-illustrator path/to/article.md --style playful
```
## Options
@@ -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
![illustration description](imgs/illustration-[slug].png)
![illustration description]([article-name]/illustrations/illustration-[slug].png)
```
**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.
+400
View File
@@ -0,0 +1,400 @@
---
name: baoyu-comic
description: Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
---
# Knowledge Comic Creator
Create original knowledge comics with multiple visual styles.
## Usage
```bash
/baoyu-comic posts/turing-story/source.md
/baoyu-comic # then paste content
```
## 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. |
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
| Content Signals | Style | Layout |
|-----------------|-------|--------|
| Tutorial, how-to, beginner | ohmsha | webtoon |
| Computing, AI, programming | tech | dense |
| Pre-1950, classical, ancient | sepia | cinematic |
| Personal story, mentor | warm | standard |
| Conflict, breakthrough | dramatic | splash |
| 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]/
├── 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-[slug].md
│ └── NN-page-[slug].md
├── 00-cover-[slug].png
├── NN-page-[slug].png
└── {topic-slug}.pdf
```
**Target directory**:
- With source path: `[source-dir]/[source-name-no-ext]/comic/`
- Example: `/posts/turing-story.md``/posts/turing-story/comic/`
- Without source: `./comic/[topic-slug]/`
**Directory backup**:
- If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
## Workflow
### Step 1: Analyze Content → `analysis.md`
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`**
**analysis.md Format**:
```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
---
## Target Audience
- **Primary**: Tech enthusiasts curious about computing history
- **Secondary**: Students learning about scientific breakthroughs
- **Tertiary**: General readers interested in biographical stories
## Value Proposition
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
## 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
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
**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] | Aspect: [ratio] | Language: [lang]
Location: [path]
✓ analysis.md
✓ characters.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: 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"
**Reference**: `references/ohmsha-guide.md` for detailed guidelines.
## 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
- `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?
@@ -0,0 +1,98 @@
Create a knowledge biography comic page following these guidelines:
## Image Specifications
- **Type**: Comic book page with multiple panels
- **Orientation**: Portrait (vertical)
- **Aspect Ratio**: 2:3
- **Style**: See style-specific reference for visual guidelines
## Panel Structure
### Panel Borders
- Clean black lines (1-2px) around each panel
- White gutters between panels (8-12px)
- Panels arranged for clear reading flow
- Variety in panel sizes for visual rhythm
### Panel Composition
- Clear focal points in each panel
- Proper use of foreground, midground, background
- Camera angles vary: eye level, bird's eye, low angle, close-up, wide shot
- Action flows logically between panels
- Negative space used intentionally
## Text Elements
### Speech Bubbles
- **Dialogue**: Oval/elliptical bubbles with pointed tails
- White fill with thin black outline
- Tail points clearly to speaker
- Hand-lettered style font (not computer-generated)
### Narrator Boxes
- **Fourth Wall/Narrator**: Rectangular boxes
- Often positioned at panel edges (top or bottom)
- Slightly different fill color (cream or light yellow)
- Used for commentary, time jumps, explanations
### Thought Bubbles
- Cloud-shaped with bubble trail leading to thinker
- Softer outline than speech bubbles
- For internal monologue
### Caption Bars
- Rectangular bars at panel edges
- Time and place information
- "Meanwhile...", "Three years later..." type transitions
- Darker fill with white text, or vice versa
### Typography
- Hand-drawn lettering style throughout
- Bold for emphasis and key terms
- Consistent letter sizing
- Chinese text: use full-width punctuation "",。!
- Clear hierarchy: titles > dialogue > captions
## Scientific/Concept Visualization
When depicting abstract concepts:
| Concept | Visual Metaphor |
|---------|----------------|
| Neural networks | Glowing nodes connected by clean lines |
| Data flow | Luminous particles along simple paths |
| Algorithms | Geometric patterns, building blocks |
| Logic/proof | Interlocking puzzle pieces |
| Discovery | Light breaking through darkness |
| Uncertainty | Forking paths, question marks |
| Time | Clock motifs, calendar pages |
- Integrate diagrams naturally into narrative panels
- Use inset panels or thought-bubble style for explanations
- Simplified iconography over realistic depiction
## Fourth Wall / Narrator Character
When depicting narrator characters addressing the reader:
- Character may look directly out of panel
- Can appear in "present day" framing scenes
- Distinct visual treatment from main timeline
- Often at page edges or in dedicated panels
- May comment on or question the events shown
## Historical Accuracy
- Research period-specific details: costumes, technology, architecture
- Show aging naturally for characters across time periods
- Iconic items and locations rendered recognizably
- Balance accuracy with stylization
## Language
- All text in Chinese (中文) unless source material is in another language
- Use Chinese full-width punctuation: "",。!
---
Please generate the comic page based on the content provided below:
@@ -0,0 +1,180 @@
# Character Definition Template
## Character Document Format
Create `characters/characters.md` with the following structure:
```markdown
# Character Definitions - [Comic Title]
**Style**: [selected style]
**Art Direction**: [Ligne Claire / Manga / etc.]
---
## Character 1: [Name]
**Role**: [Protagonist / Mentor / Antagonist / Narrator]
**Age**: [approximate age or age range in story]
**Appearance**:
- Face shape: [oval/square/round]
- Hair: [color, style, length]
- Eyes: [color, shape, distinctive features]
- Build: [height, body type]
- Distinguishing features: [glasses, beard, scar, etc.]
**Costume**:
- Default outfit: [detailed description]
- Color palette: [primary colors for this character]
- Accessories: [hat, bag, tools, etc.]
**Expression Range**:
- Neutral: [description]
- Happy/Excited: [description]
- Thinking/Confused: [description]
- Determined: [description]
**Visual Reference Notes**:
[Any specific artistic direction]
---
## Character 2: [Name]
...
```
## Reference Sheet Image Prompt
After character definitions, include a prompt for generating the reference sheet:
```markdown
## Reference Sheet Prompt
Character reference sheet in [style] style, clean lines, flat colors:
[ROW 1 - Character Name]:
- Front view: [detailed description]
- 3/4 view: [description]
- Expression sheet: Neutral | Happy | Focused | Worried
[ROW 2 - Character Name]:
...
COLOR PALETTE:
- [Character 1]: [colors]
- [Character 2]: [colors]
White background, clear labels under each character.
```
## Example: Turing Biography
```markdown
# Character Definitions - The Imitation Game
**Style**: classic (Ligne Claire)
**Art Direction**: Clean lines, muted colors, period-accurate details
---
## Character 1: Alan Turing
**Role**: Protagonist
**Age**: 25-40 (varies across story)
**Appearance**:
- Face shape: Oval, slightly angular
- Hair: Dark brown, wavy, slightly disheveled
- Eyes: Deep-set, intense gaze
- Build: Tall, lean, slightly awkward posture
- Distinguishing features: Prominent brow, thoughtful expression
**Costume**:
- Default outfit: Tweed jacket with elbow patches, white shirt, no tie
- Color palette: Muted browns, navy blue, cream
- Accessories: Occasionally a pipe, papers/notebooks
**Expression Range**:
- Neutral: Thoughtful, slightly distant
- Happy/Excited: Eureka moment, eyes bright, subtle smile
- Thinking/Confused: Furrowed brow, looking at abstract space
- Determined: Jaw set, focused eyes
---
## Character 2: The Bombe Machine
**Role**: Supporting (anthropomorphized)
**Appearance**:
- Large brass and wood cabinet
- Dial "eyes" that can express states
- Paper tape "mouth"
- Indicator lights for emotions
**Expression Range**:
- Processing: Spinning dials, humming
- Success: Lights up warmly
- Stuck: Smoke wisps, stuttering
---
## Reference Sheet Prompt
Character reference sheet in Ligne Claire style, clean lines, flat colors:
TOP ROW - Alan Turing:
- Front view: Young man, 30s, short dark wavy hair, thoughtful expression, wearing tweed jacket with elbow patches, white shirt
- 3/4 view: Same character, slight smile, showing profile of nose
- Expression sheet: Neutral | Excited (eureka moment) | Focused (working) | Worried
BOTTOM ROW - The Bombe Machine (anthropomorphized):
- Bombe machine as character: Large, brass and wood, dial "eyes", paper tape "mouth"
- Expressions: Processing (spinning dials) | Success (lights up) | Stuck (smoke wisps)
COLOR PALETTE:
- Turing: Muted browns (#8B7355), navy blue (#2C3E50), cream (#F5F5DC)
- Machine: Brass (#B5A642), mahogany (#4E2728), emerald indicators (#2ECC71)
White background, clear labels under each character.
```
## Handling Age Variants
For biographies spanning many years, define age variants:
```markdown
## Alan Turing - Age Variants
### Young (1920s, age 10-18)
- Boyish features, round face
- School uniform (Sherborne)
- Curious, eager expression
### Adult (1930s-40s, age 25-35)
- Angular face, defined jaw
- Tweed jacket, rumpled appearance
- Intense, focused expression
### Later (1950s, age 40+)
- Slightly weathered
- More casual dress
- Thoughtful, sometimes melancholic
```
## Best Practices
| Practice | Description |
|----------|-------------|
| Be specific | "Short dark wavy hair, parted left" not just "dark hair" |
| Use distinguishing features | Glasses, scars, accessories that identify character |
| Define color codes | Use specific color names or hex codes |
| Include age markers | Wrinkles, posture, clothing style matching era |
| Reference real people | For historical figures, note "based on 1940s photographs" |
## Why Character Reference Matters
Without unified character definition, AI generates inconsistent appearances. The reference sheet provides:
1. Visual anchors for consistent features
2. Color palettes for consistent coloring
3. Expression documentation for emotional portrayals
@@ -0,0 +1,23 @@
# cinematic
Wide panels, filmic feel
## Panel Structure
- **Panels per page**: 2-4
- **Structure**: Horizontal emphasis, wide aspect panels
- **Gutters**: Generous spacing (12-15px)
## Grid Configuration
- 1-2 columns, horizontal emphasis
- Panel sizes: Wide aspect ratios (3:1, 4:1)
- Reading flow: Horizontal sweep, filmic rhythm
## Best For
Establishing shots, dramatic moments, landscapes
## Best Style Pairings
dramatic, classic, sepia
@@ -0,0 +1,23 @@
# dense
Information-rich, educational focus
## Panel Structure
- **Panels per page**: 6-9
- **Structure**: Compact grid, smaller panels
- **Gutters**: Tight spacing (4-6px)
## Grid Configuration
- 3 columns × 3 rows
- Panel sizes: Compact, uniform
- Reading flow: Rapid progression, information-rich
## Best For
Technical explanations, complex narratives, timelines
## Best Style Pairings
tech, ohmsha, vibrant
@@ -0,0 +1,23 @@
# mixed
Dynamic, varied rhythm
## Panel Structure
- **Panels per page**: 3-7 (varies)
- **Structure**: Intentionally varied for pacing
- **Gutters**: Dynamic spacing
## Grid Configuration
- Intentionally irregular
- Panel sizes: Varied for pacing and emphasis
- Reading flow: Guides eye through varied rhythm
## Best For
Action sequences, emotional arcs, complex stories
## Best Style Pairings
dramatic, vibrant, ohmsha
@@ -0,0 +1,23 @@
# splash
Impact-focused, key moments
## Panel Structure
- **Panels per page**: 1-2 large + 2-3 small
- **Structure**: Dominant splash with supporting panels
- **Gutters**: Varied for emphasis
## Grid Configuration
- 1 dominant panel + 2-3 supporting
- Panel sizes: 50-70% splash, remainder small
- Reading flow: Splash dominates, supporting panels accent
## Best For
Revelations, breakthroughs, chapter openings
## Best Style Pairings
dramatic, classic, vibrant
@@ -0,0 +1,23 @@
# standard
Classic comic grid, versatile
## Panel Structure
- **Panels per page**: 4-6
- **Structure**: Regular grid with occasional variation
- **Gutters**: Consistent white space (8-10px)
## Grid Configuration
- 2-3 columns × 2-3 rows
- Panel sizes: Mostly equal, occasional variation
- Reading flow: Left→right, top→bottom (Z-pattern)
## Best For
Narrative flow, dialogue scenes
## Best Style Pairings
classic, warm, sepia
@@ -0,0 +1,30 @@
# webtoon
Vertical scrolling comic (竖版条漫)
## Panel Structure
- **Panels per page**: 3-5 vertically stacked
- **Structure**: Single column, vertical flow optimized for scrolling
- **Gutters**: Generous vertical spacing (20-40px), panels often bleed horizontally
## Grid Configuration
- Single column, vertical stack
- Panel sizes: Full width, variable height (1:1 to 1:2 aspect)
- Reading flow: Top→bottom continuous scroll
## Special Features
- Panels can extend beyond frame for dramatic effect
- Generous whitespace between beats
- Character close-ups alternate with wide explanation panels
- "Float" effect - elements can exist between panels
## Best For
Ohmsha-style tutorials, mobile reading, step-by-step guides
## Best Style Pairings
ohmsha, tech, vibrant
@@ -0,0 +1,85 @@
# Ohmsha Manga Guide Style
Guidelines for `--style ohmsha` educational manga comics.
## Character Setup
| Role | Default | Traits |
|------|---------|--------|
| Student (Role A) | 大雄 | Confused, asks basic but crucial questions, represents reader |
| Mentor (Role B) | 哆啦A梦 | Knowledgeable, patient, uses gadgets as technical metaphors |
| Antagonist (Role C, optional) | 胖虎 | Represents misunderstanding, or "noise" in the data |
Custom characters: `--characters "Student:小明,Mentor:教授,Antagonist:Bug怪"`
## Character Reference Sheet Style
For Ohmsha style, use manga/anime style with:
- Exaggerated expressions for educational clarity
- Simple, distinctive silhouettes
- Bright, saturated color palettes
- Chibi/SD (super-deformed) variants for comedic reactions
## Outline Spec Block
Every ohmsha outline must start with:
```markdown
【漫画规格单】
- Language: [Same as input content]
- Style: Ohmsha (Manga Guide), Full Color
- Layout: Vertical Scrolling Comic (竖版条漫)
- Characters: [List character names and roles]
- Character Reference: characters/characters.png
- Page Limit: ≤20 pages
```
## Visual Metaphor Rules (Critical)
**NEVER** create "talking heads" panels. Every technical concept must become:
1. **A tangible gadget/prop** - Something characters can hold, use, demonstrate
2. **An action scene** - Characters doing something that illustrates the concept
3. **A visual environment** - Stepping into a metaphorical space
### Examples
| Concept | Bad (Talking Heads) | Good (Visual Metaphor) |
|---------|---------------------|------------------------|
| Word embeddings | Characters discussing vectors | 哆啦A梦拿出"词向量压缩机",把书本压缩成彩色小球 |
| Gradient descent | Explaining math formula | 大雄在山谷地形上滚球,寻找最低点 |
| Neural network | Diagram on whiteboard | 角色走进由发光节点组成的网络迷宫 |
## Page Title Convention
Avoid AI-style "Title: Subtitle" format. Use narrative descriptions:
- ❌ "Page 3: Introduction to Neural Networks"
- ✓ "Page 3: 大雄被海量单词淹没,哆啦A梦拿出'词向量压缩机'"
## Ending Requirements
- NO generic endings ("What will you choose?", "Thanks for reading")
- End with: Technical summary moment OR character achieving a small goal
- Final panel: Sense of accomplishment, not open-ended question
### Good Endings
- Student successfully applies learned concept
- Visual callback to opening problem, now solved
- Mentor gives summary while student demonstrates understanding
### Bad Endings
- "What do you think?" open questions
- "Thanks for reading this tutorial"
- Cliffhanger without resolution
## Layout Preference
Ohmsha style typically uses:
- `webtoon` (vertical scrolling) - Primary choice
- `dense` - For information-heavy sections
- `mixed` - For varied pacing
Avoid `cinematic` and `splash` for educational content.
@@ -0,0 +1,143 @@
# Storyboard Template
## Storyboard Document Format
```markdown
---
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
**Character Reference**: characters/characters.png
---
## Cover
**Filename**: 00-cover-[slug].png
**Core Message**: [one-liner]
**Visual Design**:
- Title typography style
- Main visual composition
- Color scheme
- Subtitle / time span notation
**Visual Prompt**:
[Detailed image generation prompt]
---
## Page 1 / N
**Filename**: 01-page-[slug].png
**Layout**: [standard/cinematic/dense/splash/mixed]
**Narrative Layer**: [Main narrative / Narrator layer / Mixed]
**Core Message**: [What this page conveys]
### Panel Layout
**Panel Count**: X
**Layout Type**: [grid/irregular/splash]
#### Panel 1 (Size: 1/3 page, Position: Top)
**Scene**: [Time, location]
**Image Description**:
- Camera angle: [bird's eye / low angle / eye level / close-up / wide shot]
- Characters: [pose, expression, action]
- Environment: [scene details, period markers]
- Lighting: [atmosphere description]
- Color tone: [palette reference]
**Text Elements**:
- Dialogue bubble (oval): "Character line"
- Narrator box (rectangular): 「Narrator commentary」
- Caption bar: [Background info text]
#### Panel 2...
**Page Hook**: [Cliffhanger or transition at page end]
**Visual Prompt**:
[Full page image generation prompt]
---
## Page 2 / N
...
```
## Cover Design Principles
- Academic gravitas with visual appeal
- Title typography reflecting knowledge/science theme
- Composition hinting at core theme (character silhouette, iconic symbol, concept diagram)
- Subtitle or time span for epic scope
## Panel Composition Guidelines
| Panel Type | Recommended Count | Usage |
|-----------|-------------------|-------|
| Main narrative | 3-5 per page | Story progression |
| Concept diagram | 1-2 per page | Visualize abstractions |
| Narrator panel | 0-1 per page | Commentary, transition |
| Splash (full/half) | Occasional | Major moments |
## Panel Size Reference
- **Full page (Splash)**: Major moments, key breakthroughs
- **Half page**: Important scenes, turning points
- **1/3 page**: Standard narrative panels
- **1/4 or smaller**: Quick progression, sequential action
## Concept Visualization Techniques
Transform abstract concepts into concrete visuals:
| Abstract Concept | Visual Approach |
|-----------------|-----------------|
| Neural network | Glowing nodes with connecting lines |
| Gradient descent | Ball rolling down valley terrain |
| Data flow | Luminous particles flowing through pipes |
| Algorithm iteration | Ascending spiral staircase |
| Breakthrough moment | Shattering barrier, piercing light |
| Logical proof | Building blocks assembling |
| Uncertainty | Forking paths, fog, multiple shadows |
## Text Element Design
| Text Type | Style | Usage |
|-----------|-------|-------|
| Character dialogue | Oval speech bubble | Main narrative speech |
| Narrator commentary | Rectangular box | Explanation, commentary |
| Caption bar | Edge-mounted rectangle | Time, location info |
| Thought bubble | Cloud shape | Character inner monologue |
| Term label | Bold / special color | First appearance of technical terms |
## Prompt Structure for Consistency
Each page prompt should include character reference:
```
[CHARACTER REFERENCE]
(Key details from characters.md for characters in this page)
[PAGE CONTENT]
(Specific scene, panel layout, and visual elements)
[CONSISTENCY REMINDER]
Maintain exact character appearances as defined in character reference.
- [Character A]: [key identifying features]
- [Character B]: [key identifying features]
```
@@ -0,0 +1,54 @@
# classic
Traditional Ligne Claire, balanced and timeless
## Style Guidelines
### Line Work
- Uniform, clean outlines with consistent weight (approximately 2px)
- No hatching or cross-hatching for shading
- Sharp, precise edges on all elements
- Black ink outlines on all figures and objects
- Shadows indicated through flat color areas, not line techniques
### Character Design
- Slightly stylized/cartoonish characters with realistic proportions
- Distinctive, recognizable facial features
- Expressive faces with clear emotions
- Period-appropriate clothing with attention to detail
- Consistent character appearance across panels
### Background Treatment
- Detailed, realistic backgrounds with architectural accuracy
- Period-specific props and technology
- Clear spatial depth and perspective
- Environmental storytelling through details
- Contrast between simplified characters and detailed backgrounds
## Color Palette
- Primary: Clean blue (#3182CE), red (#E53E3E), yellow (#ECC94B)
- Skin: Warm tan (#F7CFAE)
- Background: Light cream (#FFFAF0), sky blue (#BEE3F8)
## Color Approach
- Flat colors without gradients (true to Ligne Claire tradition)
- Limited palette per page for cohesion
- Colors support narrative mood
- Consistent lighting logic within scenes
## Quality Markers
A good Ligne Claire comic page exhibits:
- ✓ Clean, uniform line weight throughout
- ✓ Flat colors without gradients
- ✓ Detailed backgrounds, stylized characters
- ✓ Clear panel borders and reading flow
- ✓ Hand-drawn text style
- ✓ Period-appropriate details
- ✓ Expressive but consistent characters
- ✓ Proper perspective in environments
## Best For
Educational content, balanced narratives, biography comics
@@ -0,0 +1,34 @@
# dramatic
High contrast, intense moments
## Style Guidelines
### Line Work
- 2-3px outlines, heavier on shadows
- Dramatic angles and perspectives
- Strong contrast between light and dark areas
### Character Design
- Intense expressions, dynamic poses
- Dramatic lighting on faces
- Sharp angular features emphasized
### Background Treatment
- High contrast, angular shadows
- Dramatic lighting effects
- Silhouettes and stark compositions
## Color Palette
- Primary: Deep navy (#1A365D), crimson (#9B2C2C), stark white
- Shadows: Heavy blacks, dramatic contrast
- Highlights: Sharp whites, limited mid-tones
## Mood
Tension, breakthrough moments, conflict
## Best For
Pivotal discoveries, conflicts, climactic scenes
@@ -0,0 +1,107 @@
# ohmsha
Ohmsha Manga Guide style - educational manga with visual metaphors
## Core Philosophy
Educational manga where every concept becomes a visual metaphor or action. NO talking heads - characters must DO things, not just explain.
## Visual Style
- **Type**: Manga-style educational comic
- **Orientation**: Portrait (vertical), optimized for scrolling
- **Colors**: Full color, bright and clean anime/manga aesthetic
- **Lines**: Clean manga lines (1.5-2px), smooth curves, expressive
## Character Design (Manga Style)
- Anime/manga proportions: slightly larger eyes, expressive faces
- **Student character**: Confused expressions, question marks (), sweat drops, represents reader
- **Mentor character**: Confident poses, explanatory gestures, produces gadgets/tools
- Clear emotional indicators using manga conventions (, , sweat drops, sparkles)
- Consistent character designs across all panels
## Background Treatment
- Simplified backgrounds during dialogue/explanation
- Detailed "imagination spaces" for concept visualization
- Technical diagrams styled as holographic displays or magical blueprints
- Screen tone effects for atmosphere
## Visual Metaphor Requirements (CRITICAL)
Every technical concept MUST be visualized as:
| Concept Type | Visualization Approach |
|-------------|----------------------|
| Algorithm | Gadget/machine that demonstrates the process |
| Data structure | Physical space characters can enter/explore |
| Mathematical formula | Transformation visible in environment |
| Abstract process | Tangible flow of particles/objects |
**Wrong approach**: Character points at blackboard explaining
**Right approach**: Character uses "Concept Visualizer" gadget, steps into metaphorical space
### Visual Metaphor Examples
| Concept | Wrong (Talking Head) | Right (Visual Metaphor) |
|---------|---------------------|------------------------|
| Attention mechanism | Character points at formula on blackboard | "Attention Flashlight" gadget illuminates key words in dark room |
| Gradient descent | "The algorithm minimizes loss" | Character rides ball rolling down mountain valley |
| Neural network | Diagram with arrows | Living network of glowing creatures passing messages |
| Overfitting | "The model memorized the data" | Character wearing clothes that fit only one specific pose |
## Panel Layout for Ohmsha
- Vertical scroll optimized (webtoon style)
- Single column, panels stack vertically
- Generous whitespace between major beats
- Panels can bleed to edges for impact
- "Float" elements between panels for emphasis
## Special Visual Elements
- **Gadget reveals**: Dramatic unveiling with sparkle effects
- **Concept spaces**: Rounded borders, glowing edges for "imagination mode"
- **Information displays**: Holographic UI style for technical details
- **Aha moments**: Radial lines, light burst effects
- **Confusion**: Spiral eyes, question marks floating above head
## Text Elements (Ohmsha)
- Hand-lettered manga style
- Sound effects integrated visually (ドキドキ, ピカーン, etc.)
- Speech bubbles: rounded for normal, spiky for excitement/shock
- Thought bubbles for internal process visualization
- Technical terms in bold with furigana-style annotations if needed
## Color Palette
- Primary: Bright blue (#4299E1), warm orange (#ED8936), soft green (#68D391)
- Skin: Anime-style warm (#FEEBC8)
- Background: Clean white, soft gradients
- Gadgets: Metallic accents (#FFD700, #C0C0C0), vibrant highlights
- Concept spaces: Pastel backgrounds, glowing accents
## Quality Markers (Ohmsha)
- ✓ Every concept is a visual metaphor, not just explained
- ✓ Characters are DOING things, not just talking
- ✓ Clear student/mentor dynamic
- ✓ Gadgets and props drive the explanation
- ✓ Expressive manga-style emotions
- ✓ Information density through visual design, not text walls
## Character Setup (Required)
Define characters before generating:
| Role | Default | Traits |
|------|---------|--------|
| Student (Role A) | 大雄 | Confused, asks basic but crucial questions, represents reader |
| Mentor (Role B) | 哆啦A梦 | Knowledgeable, patient, uses gadgets as technical metaphors |
| Antagonist (Role C, optional) | 胖虎 | Represents misunderstanding, or "noise" in the data |
## Best For
Technical tutorials, complex concepts (ML, physics, math), self-study material
@@ -0,0 +1,66 @@
# realistic
Full-color realistic manga style with digital painting techniques
## Style Guidelines
### Line Work
- Clean, precise outlines with clear contours
- Uniform line weight for character definition
- No excessive hatching - rely on color for depth
- Smooth curves and realistic anatomical lines
- Ligne Claire influence: clean but not overly simplified
### Character Design
- Realistic human proportions (7-8 head heights)
- Anatomically accurate features and expressions
- Detailed facial structure without exaggeration
- Natural poses and body language
- Consistent character appearance across all panels
- Subtle expressions rather than manga-style exaggeration
### Rendering Style
- Full-color digital painting with rich gradients
- Soft shadow transitions on skin and fabric
- Realistic material textures (glass, liquid, fabric, wood)
- Detailed hair with natural shine and volume
- Environmental lighting affects all elements
- NOT flat cel-shading - use smooth color blending
### Background Treatment
- Highly detailed, realistic environments
- Accurate perspective and spatial depth
- Atmospheric lighting (warm indoor, cool outdoor)
- Professional settings rendered with precision
- Props and objects with realistic textures
## Color Palette
- Skin: Natural warm tones (#F5D6C6, #E8C4B0)
- Hair: Rich browns and blacks with highlights
- Environment: Warm wood tones (#8B7355), cool stone (#9CA3AF)
- Accent: Wine red (#722F37), gold (#D4AF37)
- Lighting: Warm amber (#FFB347), cool blue (#B0C4DE)
## Color Approach
- Rich gradients for depth and volume
- Realistic lighting with warm/cool contrast
- Material-specific rendering (glass transparency, liquid reflection)
- Subtle color temperature shifts for atmosphere
- Professional, sophisticated palette
## Quality Markers
A good realistic manga page exhibits:
- ✓ Anatomically accurate character proportions
- ✓ Smooth color gradients (not flat fills)
- ✓ Realistic material textures
- ✓ Detailed, atmospheric backgrounds
- ✓ Natural lighting with soft shadows
- ✓ Expressive but subtle facial expressions
- ✓ Professional, sophisticated aesthetic
- ✓ Clean speech bubbles with clear typography
## Best For
Professional topics (wine, food, business), lifestyle content, adult-oriented narratives, educational guides for mature audiences, documentary-style storytelling
@@ -0,0 +1,34 @@
# sepia
Historical, archival feel
## Style Guidelines
### Line Work
- 2px, classic weight with aged texture
- Vintage illustration style
- Period-appropriate techniques
### Character Design
- Period-accurate attire, formal poses
- Historical accuracy emphasized
- Classical proportions
### Background Treatment
- Historical settings, vintage details
- Aged paper effect
- Period architecture and props
## Color Palette
- Primary: Sepia brown (#8B7355), aged paper (#F5E6D3)
- Accents: Faded teal, muted burgundy
- Background: Yellowed paper, vintage tones
## Mood
Historical distance, period authenticity
## Best For
Pre-1950s stories, classical science, historical figures
@@ -0,0 +1,34 @@
# tech
Modern, digital-age narratives
## Style Guidelines
### Line Work
- 2px, precise geometric undertones
- Clean, technical precision
- Circuit-like patterns in backgrounds
### Character Design
- Contemporary clothing, focused expressions
- Modern tech accessories
- Clean, precise features
### Background Treatment
- Digital elements, screens, circuit motifs
- Grid patterns
- Glowing interface elements
## Color Palette
- Primary: Cyan (#00D4FF), deep blue (#1A365D), white
- Accents: Neon green (#00FF88), electric purple (#805AD5)
- Background: Dark gray (#1A202C), grid patterns
## Mood
Innovation, digital, contemporary
## Best For
Computing history, AI stories, modern tech
@@ -0,0 +1,34 @@
# vibrant
Energetic, engaging, educational
## Style Guidelines
### Line Work
- 2-2.5px, expressive weight variation
- Dynamic, energetic lines
- Emphasis on movement and action
### Character Design
- Expressive, animated, approachable
- Wide eyes, big reactions
- Dynamic poses
### Background Treatment
- Simplified, focus on action
- Bright, clean compositions
- Energy effects and motion lines
## Color Palette
- Primary: Bright red (#F56565), sunny yellow (#F6E05E), sky blue (#63B3ED)
- Accents: Magenta, lime green
- Background: Clean white, bright pastels
## Mood
Excitement, discovery, wonder
## Best For
Science explanations, "aha" moments, young audience
@@ -0,0 +1,34 @@
# warm
Nostalgic, personal storytelling
## Style Guidelines
### Line Work
- 1.5-2px, slightly softer edges
- Gentle curves, friendly feel
- Less rigid than classic style
### Character Design
- Friendly expressions, relaxed poses
- Warm, inviting character designs
- Approachable proportions
### Background Treatment
- Cozy interiors, warm lighting
- Nostalgic feel
- Soft focus on backgrounds
## Color Palette
- Primary: Golden (#D69E2E), orange (#DD6B20), soft brown (#8B6F47)
- Skin: Warm golden (#FEEBC8)
- Background: Warm cream (#FEF3C7), sunset tones
## Mood
Memory, personal journey, reflection
## Best For
Personal stories, childhood scenes, mentorship
+116
View File
@@ -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);
});
+264
View File
@@ -0,0 +1,264 @@
---
name: baoyu-cover-image
description: Generate elegant cover images for articles. Analyzes content and creates eye-catching hand-drawn style cover images with multiple style options. Use when user asks to "generate cover image", "create article cover", or "make a cover for article".
---
# Cover Image Generator
Generate hand-drawn style cover images for articles with multiple style options.
## Usage
```bash
# From markdown file (auto-select style based on content)
/baoyu-cover-image path/to/article.md
# Specify a style
/baoyu-cover-image path/to/article.md --style tech
/baoyu-cover-image path/to/article.md --style warm
/baoyu-cover-image path/to/article.md --style bold
# Without title text
/baoyu-cover-image path/to/article.md --no-title
# Combine options
/baoyu-cover-image path/to/article.md --style minimal --no-title
# From direct text input
/baoyu-cover-image
[paste content or describe the topic]
# Direct input with style
/baoyu-cover-image --style playful
[paste content]
```
## 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
| Style | Description |
|-------|-------------|
| `elegant` (Default) | Refined, sophisticated, understated |
| `tech` | Modern, clean, futuristic |
| `warm` | Friendly, approachable, human-centered |
| `bold` | High contrast, attention-grabbing, energetic |
| `minimal` | Ultra-clean, zen-like, focused |
| `playful` | Fun, creative, whimsical |
| `nature` | Organic, calm, earthy |
| `retro` | Vintage, nostalgic, classic |
Detailed style definitions: `references/styles/<style>.md`
## Auto Style Selection
When no `--style` is specified, the system analyzes content to select the best style:
| Content Signals | Selected Style |
|----------------|----------------|
| AI, coding, tech, digital, algorithm | `tech` |
| Personal story, emotion, growth, life | `warm` |
| Controversial, urgent, must-read, warning | `bold` |
| Simple, zen, focus, essential | `minimal` |
| Fun, easy, beginner, casual, tutorial | `playful` |
| Nature, eco, wellness, health, organic | `nature` |
| History, classic, vintage, old, traditional | `retro` |
| Business, professional, strategy, analysis | `elegant` |
## File Management
### With Article Path
Save to `[source-name-no-ext]/cover-image/` subdirectory in the same folder as the article:
```
path/to/
├── article.md
└── 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
```
### Directory Backup
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
## Workflow
### Step 1: Analyze Content
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. **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
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 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:
**Title** (if included, max 8 characters):
- Distill the core message into a punchy headline
- Use hooks: numbers, questions, contrasts, pain points
- Skip if `--no-title` flag is used
**Visual Elements**:
- Style-appropriate imagery and icons
- 1-2 symbolic elements representing the topic
- Metaphors or analogies that fit the style
### 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 confirmed language]
Subtitle: [optional, in confirmed language]
Visual composition:
- Main visual: [description matching style]
- Layout: [positioning based on title inclusion and aspect ratio]
- Decorative elements: [style-appropriate elements]
Color scheme:
- Primary: [style primary color]
- Background: [style background color]
- Accent: [style accent color]
Style notes: [specific style characteristics to emphasize]
[If no title:]
Note: No title text, pure visual illustration only.
```
### 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, output path, and confirmed aspect ratio.
### 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.
```
## Notes
- Cover should be instantly understandable at small preview sizes
- Title (if included) must be readable and impactful
- Visual metaphors work better than literal representations
- Maintain style consistency throughout the cover
- Image generation typically takes 10-30 seconds
- Title text uses user's confirmed language preference
- Aspect ratio: 2.35:1 for cinematic/dramatic, 16:9 for widescreen, 1:1 for social media
## Extension Support
Custom styles and configurations via EXTEND.md.
**Check paths** (priority order):
1. `.baoyu-skills/baoyu-cover-image/EXTEND.md` (project)
2. `~/.baoyu-skills/baoyu-cover-image/EXTEND.md` (user)
If found, load before Step 1. Extension content overrides defaults.
@@ -0,0 +1,23 @@
# bold
High contrast, attention-grabbing, energetic
## Color Palette
- Primary: Vibrant red (#E53E3E), bright orange (#DD6B20), electric yellow (#F6E05E)
- Background: Deep black (#000000), dark charcoal
- Accents: White, neon highlights
## Visual Elements
- Exclamation marks, lightning bolts
- Arrows, strong shapes
- Dramatic compositions
## Typography
- Bold, impactful, large hand lettering with shadows
## Best For
Opinion pieces, controversial takes, urgent topics
@@ -0,0 +1,23 @@
# elegant
Refined, sophisticated, understated
## Color Palette
- Primary: Soft coral (#E8A598), muted teal (#5B8A8A), dusty rose (#D4A5A5)
- Background: Warm cream (#F5F0E6), soft beige
- Accents: Gold (#C9A962), copper
## Visual Elements
- Delicate lines, refined icons
- Subtle gradients
- Balanced composition
## Typography
- Elegant serif-style hand lettering
## Best For
Professional content, thought leadership, business topics
@@ -0,0 +1,23 @@
# minimal
Ultra-clean, zen-like, focused
## Color Palette
- Primary: Pure black (#000000), white (#FFFFFF)
- Background: White or off-white (#FAFAFA)
- Accents: Single color (user's choice or content-derived)
## Visual Elements
- Single focal point
- Maximum negative space
- Thin lines
## Typography
- Clean, simple hand lettering, lots of breathing room
## Best For
Philosophy, minimalism, focused concepts
@@ -0,0 +1,22 @@
# nature
Organic, calm, earthy
## Color Palette
- Primary: Forest green (#276749), sage (#9AE6B4), earth brown (#744210)
- Background: Sand beige (#F5E6D3), sky blue (#E0F2FE)
- Accents: Sunset orange, water blue
## Visual Elements
- Leaves, trees, mountains
- Sun, clouds, organic flowing lines
## Typography
- Organic, flowing hand lettering with natural textures
## Best For
Sustainability, wellness, outdoor topics, slow living
@@ -0,0 +1,23 @@
# playful
Fun, creative, whimsical
## Color Palette
- Primary: Pastel pink (#FED7E2), mint (#C6F6D5), lavender (#E9D8FD), sky blue (#BEE3F8)
- Background: Light cream (#FFFBEB), soft white
- Accents: Bright pops - yellow, coral, turquoise
## Visual Elements
- Doodles, stars, swirls
- Cute characters, emoji-style icons
- Playful compositions
## Typography
- Bouncy, irregular hand lettering, playful angles
## Best For
Casual content, tutorials, beginner guides, fun topics
@@ -0,0 +1,22 @@
# retro
Vintage, nostalgic, classic
## Color Palette
- Primary: Muted orange (#ED8936 at 70%), dusty pink (#FED7E2 at 80%), faded teal
- Background: Aged paper (#F5E6D3), sepia tones
- Accents: Faded red, vintage gold
## Visual Elements
- Halftone dots, vintage badges
- Classic icons, aged textures
## Typography
- Vintage-style hand lettering, classic serif influence
## Best For
History, retrospectives, classic topics, throwback content
@@ -0,0 +1,23 @@
# tech
Modern, clean, futuristic
## Color Palette
- Primary: Deep blue (#1A365D), electric cyan (#00D4FF), purple (#6B46C1)
- Background: Dark gray (#1A202C), near-black (#0D1117)
- Accents: Neon green (#00FF88), bright white
## Visual Elements
- Circuit patterns, data nodes
- Geometric grids, code snippets
- Glowing effects
## Typography
- Monospace-style hand lettering, glowing effects
## Best For
AI, programming, technology, digital transformation
@@ -0,0 +1,22 @@
# warm
Friendly, approachable, human-centered
## Color Palette
- Primary: Warm orange (#ED8936), golden yellow (#F6AD55), terracotta (#C05621)
- Background: Cream (#FFFAF0), soft peach (#FED7AA)
- Accents: Deep brown (#744210), soft red
## Visual Elements
- Rounded shapes, smiling faces
- Sun rays, hearts, warm lighting
## Typography
- Friendly rounded hand lettering
## Best For
Personal growth, lifestyle, education, human stories
+267
View File
@@ -0,0 +1,267 @@
---
name: baoyu-danger-gemini-web
description: Image generation skill using Gemini Web. Generates images from text prompts via Google Gemini. Also supports text generation. Use as the image generation backend for other skills like cover-image, xhs-images, article-illustrator.
---
# Gemini Web Client
Supports:
- Text generation
- Image generation (download + save)
- Reference images for vision input (attach local images)
- Multi-turn conversations via persisted `--sessionId`
## Script Directory
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
**Agent Execution Instructions**:
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
3. Replace all `${SKILL_DIR}` in this document with the actual path
**Script Reference**:
| Script | Purpose |
|--------|---------|
| `scripts/main.ts` | CLI entry point for text/image generation |
| `scripts/gemini-webapi/*` | TypeScript port of `gemini_webapi` (GeminiClient, types, utils) |
## ⚠️ Disclaimer (REQUIRED)
**Before using this skill**, the consent check MUST be performed.
### Consent Check Flow
**Step 1**: Check consent file
```bash
# macOS
cat ~/Library/Application\ Support/baoyu-skills/gemini-web/consent.json 2>/dev/null
# Linux
cat ~/.local/share/baoyu-skills/gemini-web/consent.json 2>/dev/null
# Windows (PowerShell)
Get-Content "$env:APPDATA\baoyu-skills\gemini-web\consent.json" 2>$null
```
**Step 2**: If consent exists and `accepted: true` with matching `disclaimerVersion: "1.0"`:
Print warning and proceed:
```
⚠️ Warning: Using reverse-engineered Gemini Web API (not official). Accepted on: <acceptedAt date>
```
**Step 3**: If consent file doesn't exist or `disclaimerVersion` mismatch:
Display disclaimer and ask user:
```
⚠️ DISCLAIMER
This tool uses a reverse-engineered Gemini Web API, NOT an official Google API.
Risks:
- May break without notice if Google changes their API
- No official support or guarantees
- Use at your own risk
Do you accept these terms and wish to continue?
```
Use `AskUserQuestion` tool with options:
- **Yes, I accept** - Continue and save consent
- **No, I decline** - Exit immediately
**Step 4**: On acceptance, create consent file:
```bash
# macOS
mkdir -p ~/Library/Application\ Support/baoyu-skills/gemini-web
cat > ~/Library/Application\ Support/baoyu-skills/gemini-web/consent.json << 'EOF'
{
"version": 1,
"accepted": true,
"acceptedAt": "<ISO timestamp>",
"disclaimerVersion": "1.0"
}
EOF
# Linux
mkdir -p ~/.local/share/baoyu-skills/gemini-web
cat > ~/.local/share/baoyu-skills/gemini-web/consent.json << 'EOF'
{
"version": 1,
"accepted": true,
"acceptedAt": "<ISO timestamp>",
"disclaimerVersion": "1.0"
}
EOF
```
**Step 5**: On decline, output message and stop:
```
User declined the disclaimer. Exiting.
```
---
## Quick start
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello, Gemini"
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Explain quantum computing"
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
# Multi-turn conversation (agent generates unique sessionId)
npx -y bun ${SKILL_DIR}/scripts/main.ts "Remember this: 42" --sessionId my-unique-id-123
npx -y bun ${SKILL_DIR}/scripts/main.ts "What number?" --sessionId my-unique-id-123
```
## Commands
### Text generation
```bash
# Simple prompt (positional)
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt here"
# Explicit prompt flag
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt here"
npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Your prompt here"
# With model selection
npx -y bun ${SKILL_DIR}/scripts/main.ts -p "Hello" -m gemini-2.5-pro
# Pipe from stdin
echo "Summarize this" | npx -y bun ${SKILL_DIR}/scripts/main.ts
```
### Image generation
```bash
# Generate image with default path (./generated.png)
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A sunset over mountains" --image
# Generate image with custom path
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute robot" --image robot.png
# Shorthand
npx -y bun ${SKILL_DIR}/scripts/main.ts "A dragon" --image=dragon.png
```
### Vision input (reference images)
```bash
# Text + image -> text
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Describe this image" --reference a.png
# Text + image -> image
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Generate a variation" --reference a.png --image out.png
```
### Output formats
```bash
# Plain text (default)
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello"
# JSON output
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
```
## Options
| Option | Description |
|--------|-------------|
| `--prompt <text>`, `-p` | Prompt text |
| `--promptfiles <files...>` | Read prompt from files (concatenated in order) |
| `--model <id>`, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
| `--image [path]` | Generate image, save to path (default: generated.png) |
| `--reference <files...>`, `--ref <files...>` | Reference images for vision input |
| `--sessionId <id>` | Session ID for multi-turn conversation (agent generates unique ID) |
| `--list-sessions` | List saved sessions (max 100, sorted by update time) |
| `--json` | Output as JSON |
| `--login` | Refresh cookies only, then exit |
| `--cookie-path <path>` | Custom cookie file path |
| `--profile-dir <path>` | Chrome profile directory |
| `--help`, `-h` | Show help |
CLI note: `scripts/main.ts` supports text generation, image generation, reference images (`--reference/--ref`), and multi-turn conversations via `--sessionId`.
## Models
- `gemini-3-pro` - Default, latest model
- `gemini-2.5-pro` - Previous generation pro
- `gemini-2.5-flash` - Fast, lightweight
## Authentication
First run opens Chrome to authenticate with Google. Cookies are cached for subsequent runs.
```bash
# Force cookie refresh
npx -y bun ${SKILL_DIR}/scripts/main.ts --login
```
## Environment variables
| Variable | Description |
|----------|-------------|
| `GEMINI_WEB_DATA_DIR` | Data directory |
| `GEMINI_WEB_COOKIE_PATH` | Cookie file path |
| `GEMINI_WEB_CHROME_PROFILE_DIR` | Chrome profile directory |
| `GEMINI_WEB_CHROME_PATH` | Chrome executable path |
## Examples
### Generate text response
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts "What is the capital of France?"
```
### Generate image
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts "A photorealistic image of a golden retriever puppy" --image puppy.png
```
### Get JSON output for parsing
```bash
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json | jq '.text'
```
### Generate image from prompt files
```bash
# Concatenate system.md + content.md as prompt
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image output.png
```
### Multi-turn conversation
```bash
# Start a session with unique ID (agent generates this)
npx -y bun ${SKILL_DIR}/scripts/main.ts "You are a helpful math tutor." --sessionId task-abc123
# Continue the conversation (remembers context)
npx -y bun ${SKILL_DIR}/scripts/main.ts "What is 2+2?" --sessionId task-abc123
npx -y bun ${SKILL_DIR}/scripts/main.ts "Now multiply that by 10" --sessionId task-abc123
# List recent sessions (max 100, sorted by update time)
npx -y bun ${SKILL_DIR}/scripts/main.ts --list-sessions
```
Session files are stored in `~/Library/Application Support/baoyu-skills/gemini-web/sessions/<id>.json` and contain:
- `id`: Session ID
- `metadata`: Gemini chat metadata for continuation
- `messages`: Array of `{role, content, timestamp, error?}`
- `createdAt`, `updatedAt`: Timestamps
## Extension Support
Custom configurations via EXTEND.md.
**Check paths** (priority order):
1. `.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md` (project)
2. `~/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md` (user)
If found, load before workflow. Extension content overrides defaults.
@@ -0,0 +1,627 @@
import { Endpoint, ErrorCode, Headers, Model } from './constants.js';
import { GemMixin } from './components/gem-mixin.js';
import {
APIError,
AuthError,
GeminiError,
ImageGenerationError,
ModelInvalid,
TemporarilyBlocked,
TimeoutError,
UsageLimitExceeded,
} from './exceptions.js';
import { Candidate, Gem, GeneratedImage, ModelOutput, RPCData, WebImage } from './types/index.js';
import {
extract_json_from_response,
get_access_token,
get_nested_value,
logger,
parse_file_name,
rotate_1psidts,
rotate_tasks,
fetch_with_timeout,
sleep,
upload_file,
write_cookie_file,
resolveGeminiWebCookiePath,
} from './utils/index.js';
type InitOptions = {
timeout?: number;
auto_close?: boolean;
close_delay?: number;
auto_refresh?: boolean;
refresh_interval?: number;
verbose?: boolean;
};
type RequestKwargs = RequestInit & { timeout_ms?: number };
function normalize_headers(h?: HeadersInit): Record<string, string> {
if (!h) return {};
if (Array.isArray(h)) return Object.fromEntries(h.map(([k, v]) => [k, v]));
if (h instanceof Headers) {
const out: Record<string, string> = {};
h.forEach((v, k) => {
out[k] = v;
});
return out;
}
return { ...(h as Record<string, string>) };
}
function collect_strings(root: unknown, accept: (s: string) => boolean, limit: number = 20): string[] {
const out: string[] = [];
const seen = new Set<string>();
const stack: unknown[] = [root];
while (stack.length > 0 && out.length < limit) {
const v = stack.pop();
if (typeof v === 'string') {
if (accept(v) && !seen.has(v)) {
seen.add(v);
out.push(v);
}
continue;
}
if (Array.isArray(v)) {
for (let i = 0; i < v.length; i++) stack.push(v[i]);
continue;
}
if (v && typeof v === 'object') {
for (const val of Object.values(v as Record<string, unknown>)) stack.push(val);
}
}
return out;
}
export class GeminiClient extends GemMixin {
public cookies: Record<string, string> = {};
public proxy: string | null = null;
public _running: boolean = false;
public access_token: string | null = null;
public timeout: number = 300;
public auto_close: boolean = false;
public close_delay: number = 300;
public auto_refresh: boolean = true;
public refresh_interval: number = 540;
public kwargs: RequestInit;
private close_timer: ReturnType<typeof setTimeout> | null = null;
private refresh_abort: AbortController | null = null;
constructor(
secure_1psid: string | null = null,
secure_1psidts: string | null = null,
proxy: string | null = null,
kwargs: RequestInit = {},
) {
super();
this.proxy = proxy;
this.kwargs = kwargs;
if (secure_1psid) {
this.cookies['__Secure-1PSID'] = secure_1psid;
if (secure_1psidts) this.cookies['__Secure-1PSIDTS'] = secure_1psidts;
}
}
async init(
timeoutOrOpts: number | InitOptions = 300,
auto_close: boolean = false,
close_delay: number = 300,
auto_refresh: boolean = true,
refresh_interval: number = 540,
verbose: boolean = true,
): Promise<void> {
const opts: InitOptions =
typeof timeoutOrOpts === 'object'
? timeoutOrOpts
: { timeout: timeoutOrOpts, auto_close, close_delay, auto_refresh, refresh_interval, verbose };
const timeout = opts.timeout ?? 300;
const ac = opts.auto_close ?? false;
const cd = opts.close_delay ?? 300;
const ar = opts.auto_refresh ?? true;
const ri = opts.refresh_interval ?? 540;
const vb = opts.verbose ?? true;
try {
const [token, valid] = await get_access_token(this.cookies, this.proxy, vb);
this.access_token = token;
this.cookies = valid;
this._running = true;
this.timeout = timeout;
this.auto_close = ac;
this.close_delay = cd;
if (this.auto_close) await this.reset_close_task();
this.auto_refresh = ar;
this.refresh_interval = ri;
const sid = this.cookies['__Secure-1PSID'];
if (sid) {
const existing = rotate_tasks.get(sid);
if (existing && existing instanceof AbortController) existing.abort();
rotate_tasks.delete(sid);
}
if (this.auto_refresh && sid) {
const ctl = new AbortController();
this.refresh_abort?.abort();
this.refresh_abort = ctl;
rotate_tasks.set(sid, ctl);
void this.start_auto_refresh(ctl.signal);
}
await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'client').catch(() => {});
if (vb) logger.success('Gemini client initialized successfully.');
} catch (e) {
await this.close();
throw e;
}
}
async close(delay: number = 0): Promise<void> {
if (delay > 0) await sleep(delay * 1000);
this._running = false;
if (this.close_timer) {
clearTimeout(this.close_timer);
this.close_timer = null;
}
this.refresh_abort?.abort();
this.refresh_abort = null;
const sid = this.cookies['__Secure-1PSID'];
const t = sid ? rotate_tasks.get(sid) : null;
if (t && t instanceof AbortController) t.abort();
if (sid) rotate_tasks.delete(sid);
}
async reset_close_task(): Promise<void> {
if (this.close_timer) {
clearTimeout(this.close_timer);
this.close_timer = null;
}
this.close_timer = setTimeout(() => {
void this.close(0);
}, this.close_delay * 1000);
this.close_timer.unref?.();
}
async start_auto_refresh(signal: AbortSignal): Promise<void> {
while (!signal.aborted) {
let newTs: string | null = null;
try {
newTs = await rotate_1psidts(this.cookies, this.proxy);
} catch (e) {
if (e instanceof AuthError) {
logger.warning('AuthError: Failed to refresh cookies. Auto refresh task canceled.');
return;
}
logger.warning(`Unexpected error while refreshing cookies: ${e instanceof Error ? e.message : String(e)}`);
}
if (newTs) {
this.cookies['__Secure-1PSIDTS'] = newTs;
await write_cookie_file(this.cookies, resolveGeminiWebCookiePath(), 'refresh').catch(() => {});
logger.debug('Cookies refreshed. New __Secure-1PSIDTS applied.');
}
await sleep(this.refresh_interval * 1000, signal);
}
}
protected async _run<T>(fn: () => Promise<T>, retry: number): Promise<T> {
try {
if (!this._running) {
await this.init({
timeout: this.timeout,
auto_close: this.auto_close,
close_delay: this.close_delay,
auto_refresh: this.auto_refresh,
refresh_interval: this.refresh_interval,
verbose: false,
});
if (!this._running) {
throw new APIError('Client initialization failed.');
}
}
return await fn();
} catch (e) {
let r = retry;
if (e instanceof ImageGenerationError) r = Math.min(1, r);
if (e instanceof APIError && r > 0) {
await sleep(1000);
return await this._run(fn, r - 1);
}
throw e;
}
}
async generate_content(
prompt: string,
files: string[] | null = null,
model: Model | string | Record<string, unknown> = Model.UNSPECIFIED,
gem: Gem | string | null = null,
chat: ChatSession | null = null,
kwargs: RequestKwargs = {},
): Promise<ModelOutput> {
return await this._run(async () => {
if (!prompt) throw new Error('Prompt cannot be empty.');
let mdl: Model;
if (typeof model === 'string') mdl = Model.from_name(model);
else if (model instanceof Model) mdl = model;
else if (model && typeof model === 'object') mdl = Model.from_dict(model);
else throw new TypeError(`'model' must be a Model instance, string, or dictionary; got ${typeof model}`);
const gem_id = gem instanceof Gem ? gem.id : gem;
if (this.auto_close) await this.reset_close_task();
if (!this.access_token) throw new APIError('Missing access token.');
const f = files?.length ? files : null;
const uploaded =
f &&
(await Promise.all(
f.map(async (p) => [[await upload_file(p, this.proxy)], parse_file_name(p)] as [string[], string]),
));
const first = uploaded ? [prompt, 0, null, uploaded] : [prompt];
const inner: unknown[] = [first, null, chat ? chat.metadata : null];
if (gem_id) {
for (let i = 0; i < 16; i++) inner.push(null);
inner.push(gem_id);
}
const f_req = JSON.stringify([null, JSON.stringify(inner)]);
const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();
const h0 = { ...Headers.GEMINI, ...mdl.model_header, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };
const h1 = { ...h0, ...normalize_headers(kwargs.headers) };
let res: Response;
try {
const timeout_ms = typeof kwargs.timeout_ms === 'number' ? kwargs.timeout_ms : this.timeout * 1000;
const { timeout_ms: _t, ...rest } = kwargs;
res = await fetch_with_timeout(Endpoint.GENERATE, {
method: 'POST',
headers: h1,
body,
redirect: 'follow',
...this.kwargs,
...rest,
timeout_ms,
});
} catch (e) {
throw new TimeoutError(
`Generate content request timed out, please try again. If the problem persists, consider setting a higher 'timeout' value when initializing GeminiClient. (${e instanceof Error ? e.message : String(e)})`,
);
}
if (res.status !== 200) {
await this.close();
throw new APIError(`Failed to generate contents. Request failed with status code ${res.status}`);
}
const txt = await res.text();
const response_json = extract_json_from_response(txt);
let body_json: unknown[] | null = null;
let body_index = 0;
try {
if (!Array.isArray(response_json)) throw new Error('Invalid JSON');
for (let part_index = 0; part_index < response_json.length; part_index++) {
const part = response_json[part_index];
if (!Array.isArray(part)) continue;
const part_body = get_nested_value<string | null>(part, [2], null);
if (!part_body) continue;
try {
const part_json = JSON.parse(part_body) as unknown[];
if (get_nested_value(part_json, [4], null)) {
body_index = part_index;
body_json = part_json;
break;
}
} catch {}
}
if (!body_json) throw new Error('No body');
} catch {
await this.close();
try {
const code = get_nested_value<number>(response_json, [0, 5, 2, 0, 1, 0], -1);
if (code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
throw new UsageLimitExceeded(
`Failed to generate contents. Usage limit of ${mdl.model_name} model has exceeded. Please try switching to another model.`,
);
}
if (code === ErrorCode.MODEL_INCONSISTENT) {
throw new ModelInvalid(
'Failed to generate contents. The specified model is inconsistent with the chat history. Please make sure to pass the same `model` parameter when starting a chat session with previous metadata.',
);
}
if (code === ErrorCode.MODEL_HEADER_INVALID) {
throw new ModelInvalid(
'Failed to generate contents. The specified model is not available. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
);
}
if (code === ErrorCode.IP_TEMPORARILY_BLOCKED) {
throw new TemporarilyBlocked(
'Failed to generate contents. Your IP address is temporarily blocked by Google. Please try using a proxy or waiting for a while.',
);
}
} catch (e) {
if (e instanceof GeminiError) throw e;
}
logger.debug(`Invalid response: ${txt.slice(0, 500)}`);
throw new APIError('Failed to generate contents. Invalid response data received. Client will try to re-initialize on next request.');
}
try {
const candidate_list = get_nested_value<unknown[]>(body_json, [4], []);
const out: Candidate[] = [];
for (let candidate_index = 0; candidate_index < candidate_list.length; candidate_index++) {
const candidate = candidate_list[candidate_index];
if (!Array.isArray(candidate)) continue;
const rcid = get_nested_value<string | null>(candidate, [0], null);
if (!rcid) continue;
let text = String(get_nested_value(candidate, [1, 0], ''));
if (/^http:\/\/googleusercontent\.com\/card_content\/\d+/.test(text)) {
text = String(get_nested_value(candidate, [22, 0], text));
}
const thoughts = get_nested_value<string | null>(candidate, [37, 0, 0], null);
const web_images: WebImage[] = [];
for (const w of get_nested_value<unknown[]>(candidate, [12, 1], [])) {
if (!Array.isArray(w)) continue;
const url = get_nested_value<string | null>(w, [0, 0, 0], null);
if (!url) continue;
web_images.push(new WebImage(url, String(get_nested_value(w, [7, 0], '')), String(get_nested_value(w, [0, 4], '')), this.proxy));
}
const generated_images: GeneratedImage[] = [];
const wants_generated =
get_nested_value(candidate, [12, 7, 0], null) != null ||
/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/.test(text);
if (wants_generated) {
let img_body: unknown[] | null = null;
for (let part_index = body_index; part_index < (response_json as unknown[]).length; part_index++) {
const part = (response_json as unknown[])[part_index];
if (!Array.isArray(part)) continue;
const part_body = get_nested_value<string | null>(part, [2], null);
if (!part_body) continue;
try {
const part_json = JSON.parse(part_body) as unknown[];
const cand = get_nested_value<unknown>(part_json, [4, candidate_index], null);
if (!cand) continue;
const urls = collect_strings(cand, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 1);
if (urls.length > 0) {
img_body = part_json;
break;
}
} catch {}
}
if (!img_body) {
throw new ImageGenerationError(
'Failed to parse generated images. Please update gemini_webapi to the latest version. If the error persists and is caused by the package, please report it on GitHub.',
);
}
const img_candidate = get_nested_value<unknown[]>(img_body, [4, candidate_index], []);
const finished = get_nested_value<string | null>(img_candidate, [1, 0], null);
if (finished) {
text = finished.replace(/http:\/\/googleusercontent\.com\/image_generation_content\/\d+/g, '').trimEnd();
}
const gen = get_nested_value<unknown[]>(img_candidate, [12, 7, 0], []);
for (let img_index = 0; img_index < gen.length; img_index++) {
const g = gen[img_index];
if (!Array.isArray(g)) continue;
const url = get_nested_value<string | null>(g, [0, 3, 3], null);
if (!url) continue;
const img_num = get_nested_value<number | null>(g, [3, 6], null);
const title = img_num ? `[Generated Image ${img_num}]` : '[Generated Image]';
const alt_list = get_nested_value<unknown[]>(g, [3, 5], []);
const alt =
(typeof alt_list[img_index] === 'string' ? (alt_list[img_index] as string) : null) ??
(typeof alt_list[0] === 'string' ? (alt_list[0] as string) : '') ??
'';
generated_images.push(new GeneratedImage(url, title, alt, this.proxy, this.cookies));
}
if (generated_images.length === 0) {
const urls = collect_strings(img_candidate, (s) => s.startsWith('https://lh3.googleusercontent.com/gg-dl/'), 4);
for (const url of urls) {
generated_images.push(new GeneratedImage(url, '[Generated Image]', '', this.proxy, this.cookies));
}
}
}
out.push(new Candidate({ rcid, text, thoughts, web_images, generated_images }));
}
if (out.length === 0) {
throw new GeminiError('Failed to generate contents. No output data found in response.');
}
const metadata = get_nested_value<string[]>(body_json, [1], []);
const output = new ModelOutput({ metadata, candidates: out });
if (chat instanceof ChatSession) chat.last_output = output;
return output;
} catch (e) {
if (e instanceof GeminiError || e instanceof APIError) throw e;
throw new APIError('Failed to parse response body. Data structure is invalid.');
}
}, 2);
}
async generateContent(
prompt: string,
files?: string[] | null,
model?: Model | string | Record<string, unknown>,
gem?: Gem | string | null,
chat?: ChatSession | null,
kwargs?: RequestKwargs,
): Promise<ModelOutput> {
return await this.generate_content(prompt, files ?? null, model ?? Model.UNSPECIFIED, gem ?? null, chat ?? null, kwargs ?? {});
}
start_chat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
return new ChatSession(this, opts);
}
startChat(opts?: ConstructorParameters<typeof ChatSession>[1]): ChatSession {
return this.start_chat(opts);
}
protected async _batch_execute(payloads: RPCData[], opts: RequestInit = {}): Promise<Response> {
if (!this.access_token) throw new APIError('Missing access token.');
const f_req = JSON.stringify([payloads.map((p) => p.serialize())]);
const body = new URLSearchParams({ at: this.access_token, 'f.req': f_req }).toString();
const h0 = { ...Headers.GEMINI, Cookie: Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ') };
const h1 = { ...h0, ...normalize_headers(opts.headers) };
const res = await fetch_with_timeout(Endpoint.BATCH_EXEC, {
method: 'POST',
headers: h1,
body,
redirect: 'follow',
...this.kwargs,
...opts,
timeout_ms: this.timeout * 1000,
});
if (res.status !== 200) {
await this.close();
throw new APIError(`Batch execution failed with status code ${res.status}`);
}
return res;
}
}
export class ChatSession {
private __metadata: Array<string | null> = [null, null, null];
public geminiclient: GeminiClient;
private _last_output: ModelOutput | null = null;
public model: Model | string | Record<string, unknown>;
public gem: Gem | string | null;
constructor(
geminiclient: GeminiClient,
opts: {
metadata?: Array<string | null>;
cid?: string | null;
rid?: string | null;
rcid?: string | null;
model?: Model | string | Record<string, unknown>;
gem?: Gem | string | null;
} = {},
) {
this.geminiclient = geminiclient;
this.model = opts.model ?? Model.UNSPECIFIED;
this.gem = opts.gem ?? null;
if (opts.metadata) this.metadata = opts.metadata;
if (opts.cid) this.cid = opts.cid;
if (opts.rid) this.rid = opts.rid;
if (opts.rcid) this.rcid = opts.rcid;
}
toString(): string {
return `ChatSession(cid='${this.cid}', rid='${this.rid}', rcid='${this.rcid}')`;
}
get last_output(): ModelOutput | null {
return this._last_output;
}
set last_output(v: ModelOutput | null) {
this._last_output = v;
if (v) {
this.metadata = (v.metadata ?? []) as Array<string | null>;
this.rcid = v.rcid;
}
}
async send_message(prompt: string, files: string[] | null = null, kwargs: RequestKwargs = {}): Promise<ModelOutput> {
return await this.geminiclient.generate_content(prompt, files, this.model, this.gem, this, kwargs);
}
async sendMessage(prompt: string, files?: string[] | null, kwargs?: RequestKwargs): Promise<ModelOutput> {
return await this.send_message(prompt, files ?? null, kwargs ?? {});
}
choose_candidate(index: number): ModelOutput {
if (!this.last_output) throw new Error('No previous output data found in this chat session.');
if (index >= this.last_output.candidates.length) {
throw new Error(`Index ${index} exceeds the number of candidates in last model output.`);
}
this.last_output.chosen = index;
this.rcid = this.last_output.rcid;
return this.last_output;
}
chooseCandidate(index: number): ModelOutput {
return this.choose_candidate(index);
}
get metadata(): Array<string | null> {
return this.__metadata;
}
set metadata(v: Array<string | null>) {
if (v.length > 3) throw new Error('metadata cannot exceed 3 elements');
this.__metadata = [null, null, null];
for (let i = 0; i < v.length; i++) this.__metadata[i] = v[i] ?? null;
}
get cid(): string | null {
return this.__metadata[0];
}
set cid(v: string | null) {
this.__metadata[0] = v;
}
get rid(): string | null {
return this.__metadata[1];
}
set rid(v: string | null) {
this.__metadata[1] = v;
}
get rcid(): string | null {
return this.__metadata[2];
}
set rcid(v: string | null) {
this.__metadata[2] = v;
}
}
@@ -0,0 +1,179 @@
import { GRPC } from '../constants.js';
import { APIError } from '../exceptions.js';
import { Gem, GemJar, RPCData } from '../types/index.js';
import { logger } from '../utils/logger.js';
import { extract_json_from_response, get_nested_value } from '../utils/parsing.js';
export abstract class GemMixin {
protected _gems: GemJar | null = null;
protected abstract _run<T>(fn: () => Promise<T>, retry: number): Promise<T>;
protected abstract _batch_execute(payloads: RPCData[], opts?: RequestInit): Promise<Response>;
protected abstract close(delay?: number): Promise<void>;
get gems(): GemJar {
if (this._gems == null) {
throw new Error(
'Gems not fetched yet. Call `GeminiClient.fetch_gems()` method to fetch gems from gemini.google.com.',
);
}
return this._gems;
}
async fetch_gems(include_hidden: boolean = false, opts?: RequestInit): Promise<GemJar> {
return await this._run(async () => {
const res = await this._batch_execute(
[
new RPCData(GRPC.LIST_GEMS, include_hidden ? '[4]' : '[3]', 'system'),
new RPCData(GRPC.LIST_GEMS, '[2]', 'custom'),
],
opts,
);
let response_json: unknown;
try {
response_json = extract_json_from_response(await res.text());
if (!Array.isArray(response_json)) throw new Error('Invalid response');
} catch {
await this.close();
throw new APIError('Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request.');
}
let predefined: unknown[] = [];
let custom: unknown[] = [];
try {
for (const part of response_json as unknown[]) {
if (!Array.isArray(part)) continue;
const ident = part[part.length - 1];
const body = get_nested_value<string | null>(part, [2], null);
if (!body) continue;
if (ident === 'system') {
const parsed = JSON.parse(body) as unknown[];
predefined = (Array.isArray(parsed) ? (parsed[2] as unknown[]) : []) ?? [];
} else if (ident === 'custom') {
const parsed = JSON.parse(body) as unknown[] | null;
if (parsed) custom = (parsed[2] as unknown[]) ?? [];
}
}
if (predefined.length === 0 && custom.length === 0) throw new Error('No gems');
} catch {
await this.close();
logger.debug('Invalid response while parsing gems');
throw new APIError('Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request.');
}
const entries: [string, Gem][] = [];
for (const gem of predefined) {
if (!Array.isArray(gem)) continue;
const id = String(get_nested_value(gem, [0], ''));
if (!id) continue;
entries.push([
id,
new Gem(
id,
String(get_nested_value(gem, [1, 0], '')),
get_nested_value<string | null>(gem, [1, 1], null),
get_nested_value<string | null>(gem, [2, 0], null),
true,
),
]);
}
for (const gem of custom) {
if (!Array.isArray(gem)) continue;
const id = String(get_nested_value(gem, [0], ''));
if (!id) continue;
entries.push([
id,
new Gem(
id,
String(get_nested_value(gem, [1, 0], '')),
get_nested_value<string | null>(gem, [1, 1], null),
get_nested_value<string | null>(gem, [2, 0], null),
false,
),
]);
}
this._gems = new GemJar(entries);
return this._gems;
}, 2);
}
async create_gem(name: string, prompt: string, description: string = ''): Promise<Gem> {
return await this._run(async () => {
const payload = JSON.stringify([
[
name,
description,
prompt,
null,
null,
null,
null,
null,
0,
null,
1,
null,
null,
null,
[],
],
]);
const res = await this._batch_execute([new RPCData(GRPC.CREATE_GEM, payload)]);
try {
const response_json = extract_json_from_response(await res.text()) as unknown[];
const gem_id = JSON.parse(String((response_json[0] as unknown[])[2]))[0] as string;
return new Gem(gem_id, name, description, prompt, false);
} catch {
await this.close();
throw new APIError('Failed to create gem. Invalid response data received. Client will try to re-initialize on next request.');
}
}, 2);
}
async update_gem(gem: Gem | string, name: string, prompt: string, description: string = ''): Promise<Gem> {
return await this._run(async () => {
const gem_id = typeof gem === 'string' ? gem : gem.id;
const payload = JSON.stringify([
gem_id,
[
name,
description,
prompt,
null,
null,
null,
null,
null,
0,
null,
1,
null,
null,
null,
[],
0,
],
]);
await this._batch_execute([new RPCData(GRPC.UPDATE_GEM, payload)]);
return new Gem(gem_id, name, description, prompt, false);
}, 2);
}
async delete_gem(gem: Gem | string, opts?: RequestInit): Promise<void> {
return await this._run(async () => {
const gem_id = typeof gem === 'string' ? gem : gem.id;
const payload = JSON.stringify([gem_id]);
await this._batch_execute([new RPCData(GRPC.DELETE_GEM, payload)], opts);
}, 2);
}
}
@@ -0,0 +1,2 @@
export { GemMixin } from './gem-mixin.js';
@@ -0,0 +1,107 @@
export const Endpoint = {
GOOGLE: 'https://www.google.com',
INIT: 'https://gemini.google.com/app',
GENERATE:
'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate',
ROTATE_COOKIES: 'https://accounts.google.com/RotateCookies',
UPLOAD: 'https://content-push.googleapis.com/upload',
BATCH_EXEC: 'https://gemini.google.com/_/BardChatUi/data/batchexecute',
} as const;
export const GRPC = {
LIST_CHATS: 'MaZiqc',
READ_CHAT: 'hNvQHb',
LIST_GEMS: 'CNgdBe',
CREATE_GEM: 'oMH3Zd',
UPDATE_GEM: 'kHv0Vd',
DELETE_GEM: 'UXcSJb',
} as const;
export const Headers = {
GEMINI: {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
Host: 'gemini.google.com',
Origin: 'https://gemini.google.com',
Referer: 'https://gemini.google.com/',
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'X-Same-Domain': '1',
},
ROTATE_COOKIES: {
'Content-Type': 'application/json',
},
UPLOAD: {
'Push-ID': 'feeds/mcudyrk2a4khkz',
},
} as const;
export const ErrorCode = {
TEMPORARY_ERROR_1013: 1013,
USAGE_LIMIT_EXCEEDED: 1037,
MODEL_INCONSISTENT: 1050,
MODEL_HEADER_INVALID: 1052,
IP_TEMPORARILY_BLOCKED: 1060,
} as const;
export class Model {
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
static readonly G_3_0_PRO = new Model(
'gemini-3.0-pro',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' },
false,
);
static readonly G_2_5_PRO = new Model(
'gemini-2.5-pro',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
false,
);
static readonly G_2_5_FLASH = new Model(
'gemini-2.5-flash',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
false,
);
constructor(
public readonly model_name: string,
public readonly model_header: Record<string, string>,
public readonly advanced_only: boolean,
) {}
static from_name(name: string): Model {
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) {
if (model.model_name === name) return model;
}
throw new Error(
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]
.map((m) => m.model_name)
.join(', ')}`,
);
}
static from_dict(model_dict: { model_name?: unknown; model_header?: unknown }): Model {
if (!model_dict || typeof model_dict !== 'object') {
throw new Error("When passing a custom model as a dictionary, 'model_name' and 'model_header' keys must be provided.");
}
if (!('model_name' in model_dict) || !('model_header' in model_dict)) {
throw new Error("When passing a custom model as a dictionary, 'model_name' and 'model_header' keys must be provided.");
}
if (typeof model_dict.model_name !== 'string' || !model_dict.model_name.trim()) {
throw new Error("When passing a custom model as a dictionary, 'model_name' must be a non-empty string.");
}
if (!model_dict.model_header || typeof model_dict.model_header !== 'object') {
throw new Error("When passing a custom model as a dictionary, 'model_header' must be a dictionary containing valid header strings.");
}
const header: Record<string, string> = {};
for (const [k, v] of Object.entries(model_dict.model_header as Record<string, unknown>)) {
if (typeof v === 'string') header[k] = v;
}
return new Model(model_dict.model_name, header, false);
}
}
@@ -0,0 +1,56 @@
export class AuthError extends Error {
constructor(message = 'AuthError') {
super(message);
this.name = 'AuthError';
}
}
export class APIError extends Error {
constructor(message = 'APIError') {
super(message);
this.name = 'APIError';
}
}
export class ImageGenerationError extends APIError {
constructor(message = 'ImageGenerationError') {
super(message);
this.name = 'ImageGenerationError';
}
}
export class GeminiError extends Error {
constructor(message = 'GeminiError') {
super(message);
this.name = 'GeminiError';
}
}
export class TimeoutError extends GeminiError {
constructor(message = 'TimeoutError') {
super(message);
this.name = 'TimeoutError';
}
}
export class UsageLimitExceeded extends GeminiError {
constructor(message = 'UsageLimitExceeded') {
super(message);
this.name = 'UsageLimitExceeded';
}
}
export class ModelInvalid extends GeminiError {
constructor(message = 'ModelInvalid') {
super(message);
this.name = 'ModelInvalid';
}
}
export class TemporarilyBlocked extends GeminiError {
constructor(message = 'TemporarilyBlocked') {
super(message);
this.name = 'TemporarilyBlocked';
}
}
@@ -0,0 +1,8 @@
export { GeminiClient, ChatSession } from './client.js';
export * from './exceptions.js';
export * from './types/index.js';
export * from './constants.js';
export { logger, set_log_level, setLogLevel } from './utils/logger.js';
export * as utils from './utils/index.js';
@@ -0,0 +1,45 @@
import { GeneratedImage, type Image, WebImage } from './image.js';
function decode_html(s: string | null | undefined): string | null | undefined {
if (s == null) return s;
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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;
@@ -34,3 +34,13 @@ export function resolveGeminiWebChromeProfileDir(): string {
if (override) return path.resolve(override);
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
}
export function resolveGeminiWebSessionsDir(): string {
return path.join(resolveGeminiWebDataDir(), 'sessions');
}
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;
@@ -0,0 +1,490 @@
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 { GeminiClient, GeneratedImage, Model, type ModelOutput } from './gemini-webapi/index.js';
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath, resolveGeminiWebSessionPath, resolveGeminiWebSessionsDir } from './gemini-webapi/utils/index.js';
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-danger-gemini-web/scripts/main.ts --prompt "Hello"
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "Hello"
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
Multi-turn conversation (agent generates unique sessionId):
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "Remember 42" --sessionId abc123
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "What number?" --sessionId abc123
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
--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
--cookie-path <path> Cookie file path (default: ${cookiePath})
--profile-dir <path> Chrome profile dir (default: ${profileDir})
-h, --help Show help
Env overrides:
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH`);
}
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,
};
const positional: string[] = [];
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 (a === '--list-sessions') {
out.listSessions = true;
continue;
}
if (a === '--login') {
out.login = true;
continue;
}
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 && out.promptFiles.length === 0 && positional.length > 0) {
out.prompt = positional.join(' ');
}
return out;
}
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);
}
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 {
// 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 null;
}
}
function normalizeOutputImagePath(p: string): string {
const full = path.resolve(p);
const ext = path.extname(full);
if (ext) return full;
return `${full}.png`;
}
async function loadSession(id: string): Promise<SessionRecord | null> {
const p = resolveGeminiWebSessionPath(id);
try {
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 {
return [];
}
}
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));
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 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.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;
}
let prompt: string | null = args.prompt;
if (!prompt && args.promptFiles.length > 0) prompt = await readPromptFromFiles(args.promptFiles);
if (!prompt) prompt = await readPromptFromStdin();
if (!prompt) {
printUsage(cookiePath, profileDir);
process.exitCode = 1;
return;
}
const model = resolveModel(args.modelId);
const c = new GeminiClient();
await c.init({ verbose: false });
try {
let sess: SessionRecord | null = null;
let chat = null as any;
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 });
}
const files = args.referenceImages.length > 0 ? args.referenceImages : null;
let out: ModelOutput;
if (chat) out = await chat.send_message(prompt, files);
else out = await c.generate_content(prompt, files, model);
let savedImage: string | null = null;
if (args.imagePath) {
const p = normalizeOutputImagePath(args.imagePath);
const dir = path.dirname(p);
await mkdir(dir, { recursive: true });
const img = out.images[0];
if (!img) {
throw new Error('No image returned in response.');
}
const fn = path.basename(p);
const dp = dir;
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);
}
}
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((e) => {
const msg = e instanceof Error ? e.message : String(e);
console.error(msg);
process.exit(1);
});
+177
View File
@@ -0,0 +1,177 @@
---
name: baoyu-danger-x-to-markdown
description: Convert X (Twitter) tweet or article URL to markdown. Uses reverse-engineered X API (private). Requires user consent before use.
---
# X to Markdown
Converts X (Twitter) content to markdown format:
- Tweet threads → Markdown with YAML front matter
- X Articles → Full article content extraction
## 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 URL conversion |
## ⚠️ Disclaimer (REQUIRED)
**Before using this skill**, the consent check MUST be performed.
### Consent Check Flow
**Step 1**: Check consent file
```bash
# macOS
cat ~/Library/Application\ Support/baoyu-skills/x-to-markdown/consent.json 2>/dev/null
# Linux
cat ~/.local/share/baoyu-skills/x-to-markdown/consent.json 2>/dev/null
# Windows (PowerShell)
Get-Content "$env:APPDATA\baoyu-skills\x-to-markdown\consent.json" 2>$null
```
**Step 2**: If consent exists and `accepted: true` with matching `disclaimerVersion: "1.0"`:
Print warning and proceed:
```
⚠️ Warning: Using reverse-engineered X API (not official). Accepted on: <acceptedAt date>
```
**Step 3**: If consent file doesn't exist or `disclaimerVersion` mismatch:
Display disclaimer and ask user:
```
⚠️ DISCLAIMER
This tool uses a reverse-engineered X (Twitter) API, NOT an official API.
Risks:
- May break without notice if X changes their API
- No official support or guarantees
- Account restrictions possible if API usage detected
- Use at your own risk
Do you accept these terms and wish to continue?
```
Use `AskUserQuestion` tool with options:
- **Yes, I accept** - Continue and save consent
- **No, I decline** - Exit immediately
**Step 4**: On acceptance, create consent file:
```bash
# macOS
mkdir -p ~/Library/Application\ Support/baoyu-skills/x-to-markdown
cat > ~/Library/Application\ Support/baoyu-skills/x-to-markdown/consent.json << 'EOF'
{
"version": 1,
"accepted": true,
"acceptedAt": "<ISO timestamp>",
"disclaimerVersion": "1.0"
}
EOF
# Linux
mkdir -p ~/.local/share/baoyu-skills/x-to-markdown
cat > ~/.local/share/baoyu-skills/x-to-markdown/consent.json << 'EOF'
{
"version": 1,
"accepted": true,
"acceptedAt": "<ISO timestamp>",
"disclaimerVersion": "1.0"
}
EOF
```
**Step 5**: On decline, output message and stop:
```
User declined the disclaimer. Exiting.
```
---
## Usage
```bash
# Convert tweet (outputs markdown path)
npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
# Save to specific file
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md
# JSON output
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
```
## Options
| Option | Description |
|--------|-------------|
| `<url>` | Tweet or article URL |
| `-o <path>` | Output path (file or dir) |
| `--json` | Output as JSON |
| `--login` | Refresh cookies only |
## File Structure
```
x-to-markdown/
└── {username}/
└── {tweet-id}.md
```
## Supported URLs
- `https://x.com/<user>/status/<id>`
- `https://twitter.com/<user>/status/<id>`
- `https://x.com/i/article/<id>`
## Output Format
```markdown
---
url: https://x.com/username/status/123
author: "Display Name (@username)"
tweet_count: 3
---
Tweet content...
---
Thread continuation...
```
## Authentication
**Option 1**: Environment variables (recommended)
- `X_AUTH_TOKEN` - auth_token cookie
- `X_CT0` - ct0 cookie
**Option 2**: Chrome login (auto if env vars not set)
- First run opens Chrome for login
- Cookies cached locally
## Extension Support
Custom configurations via EXTEND.md.
**Check paths** (priority order):
1. `.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md` (project)
2. `~/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md` (user)
If found, load before workflow. Extension content overrides defaults.
@@ -0,0 +1,143 @@
import { resolveXToMarkdownChromeProfileDir } from "./paths.js";
export const DEFAULT_BEARER_TOKEN =
"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
export const DEFAULT_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
export const X_LOGIN_URL = "https://x.com/home";
export const X_USER_DATA_DIR = resolveXToMarkdownChromeProfileDir();
export const X_COOKIE_NAMES = ["auth_token", "ct0", "gt", "twid"] as const;
export const X_REQUIRED_COOKIES = ["auth_token", "ct0"] as const;
export const FALLBACK_QUERY_ID = "id8pHQbQi7eZ6P9mA1th1Q";
export const FALLBACK_FEATURE_SWITCHES = [
"profile_label_improvements_pcf_label_in_post_enabled",
"responsive_web_profile_redirect_enabled",
"rweb_tipjar_consumption_enabled",
"verified_phone_label_enabled",
"responsive_web_graphql_skip_user_profile_image_extensions_enabled",
"responsive_web_graphql_timeline_navigation_enabled",
];
export const FALLBACK_FIELD_TOGGLES = ["withPayments", "withAuxiliaryUserLabels"];
export const FALLBACK_TWEET_QUERY_ID = "HJ9lpOL-ZlOk5CkCw0JW6Q";
export const FALLBACK_TWEET_FEATURE_SWITCHES = [
"creator_subscriptions_tweet_preview_api_enabled",
"premium_content_api_read_enabled",
"communities_web_enable_tweet_community_results_fetch",
"c9s_tweet_anatomy_moderator_badge_enabled",
"responsive_web_grok_analyze_button_fetch_trends_enabled",
"responsive_web_grok_analyze_post_followups_enabled",
"responsive_web_jetfuel_frame",
"responsive_web_grok_share_attachment_enabled",
"responsive_web_grok_annotations_enabled",
"articles_preview_enabled",
"responsive_web_edit_tweet_api_enabled",
"graphql_is_translatable_rweb_tweet_is_translatable_enabled",
"view_counts_everywhere_api_enabled",
"longform_notetweets_consumption_enabled",
"responsive_web_twitter_article_tweet_consumption_enabled",
"tweet_awards_web_tipping_enabled",
"responsive_web_grok_show_grok_translated_post",
"responsive_web_grok_analysis_button_from_backend",
"post_ctas_fetch_enabled",
"creator_subscriptions_quote_tweet_preview_enabled",
"freedom_of_speech_not_reach_fetch_enabled",
"standardized_nudges_misinfo",
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled",
"longform_notetweets_rich_text_read_enabled",
"longform_notetweets_inline_media_enabled",
"profile_label_improvements_pcf_label_in_post_enabled",
"responsive_web_profile_redirect_enabled",
"rweb_tipjar_consumption_enabled",
"verified_phone_label_enabled",
"responsive_web_grok_image_annotation_enabled",
"responsive_web_grok_imagine_annotation_enabled",
"responsive_web_grok_community_note_auto_translation_is_enabled",
"responsive_web_graphql_skip_user_profile_image_extensions_enabled",
"responsive_web_graphql_timeline_navigation_enabled",
"responsive_web_enhance_cards_enabled",
];
export const FALLBACK_TWEET_FIELD_TOGGLES = [
"withArticleRichContentState",
"withArticlePlainText",
"withGrokAnalyze",
"withDisallowedReplyControls",
"withPayments",
"withAuxiliaryUserLabels",
];
export const FALLBACK_TWEET_DETAIL_QUERY_ID = "_8aYOgEDz35BrBcBal1-_w";
export const FALLBACK_TWEET_DETAIL_FEATURE_SWITCHES = [
"rweb_video_screen_enabled",
"profile_label_improvements_pcf_label_in_post_enabled",
"rweb_tipjar_consumption_enabled",
"verified_phone_label_enabled",
"creator_subscriptions_tweet_preview_api_enabled",
"responsive_web_graphql_timeline_navigation_enabled",
"responsive_web_graphql_skip_user_profile_image_extensions_enabled",
"premium_content_api_read_enabled",
"communities_web_enable_tweet_community_results_fetch",
"c9s_tweet_anatomy_moderator_badge_enabled",
"responsive_web_grok_analyze_button_fetch_trends_enabled",
"responsive_web_grok_analyze_post_followups_enabled",
"responsive_web_jetfuel_frame",
"responsive_web_grok_share_attachment_enabled",
"articles_preview_enabled",
"responsive_web_edit_tweet_api_enabled",
"graphql_is_translatable_rweb_tweet_is_translatable_enabled",
"view_counts_everywhere_api_enabled",
"longform_notetweets_consumption_enabled",
"responsive_web_twitter_article_tweet_consumption_enabled",
"tweet_awards_web_tipping_enabled",
"responsive_web_grok_show_grok_translated_post",
"responsive_web_grok_analysis_button_from_backend",
"creator_subscriptions_quote_tweet_preview_enabled",
"freedom_of_speech_not_reach_fetch_enabled",
"standardized_nudges_misinfo",
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled",
"longform_notetweets_rich_text_read_enabled",
"longform_notetweets_inline_media_enabled",
"responsive_web_grok_image_annotation_enabled",
"responsive_web_enhance_cards_enabled",
];
export const FALLBACK_TWEET_DETAIL_FEATURE_DEFAULTS: Record<string, boolean> = {
rweb_video_screen_enabled: false,
profile_label_improvements_pcf_label_in_post_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: true,
responsive_web_jetfuel_frame: false,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
export const FALLBACK_TWEET_DETAIL_FIELD_TOGGLES = [
"withArticleRichContentState",
"withArticlePlainText",
"withGrokAnalyze",
"withDisallowedReplyControls",
];
@@ -0,0 +1,85 @@
import fs from "node:fs";
import path from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { resolveXToMarkdownCookiePath } 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 = resolveXToMarkdownCookiePath()
): 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 Object.keys(out).length > 0 ? out : null;
}
}
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 = resolveXToMarkdownCookiePath(),
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,423 @@
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 { read_cookie_file, write_cookie_file } from "./cookie-file.js";
import { resolveXToMarkdownCookiePath } from "./paths.js";
import { X_COOKIE_NAMES, X_REQUIRED_COOKIES, X_LOGIN_URL, X_USER_DATA_DIR } from "./constants.js";
import type { CookieLike } from "./types.js";
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithTimeout(
url: string,
init: RequestInit & { timeoutMs?: number } = {}
): Promise<Response> {
const { timeoutMs, ...rest } = init;
if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest);
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), timeoutMs);
try {
return await fetch(url, { ...rest, signal: ctl.signal });
} finally {
clearTimeout(t);
}
}
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 getFreePort(): 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 findChromeExecutable(): string | null {
const override = process.env.X_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 waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 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 launchChrome(profileDir: string, port: number): Promise<ChildProcess> {
const chrome = findChromeExecutable();
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",
X_LOGIN_URL,
];
return spawn(chrome, args, { stdio: "ignore" });
}
async function fetchXCookiesViaCdp(
profileDir: string,
timeoutMs: number,
verbose: boolean,
log?: (message: string) => void
): Promise<Record<string, string>> {
await mkdir(profileDir, { recursive: true });
const port = await getFreePort();
const chrome = await launchChrome(profileDir, port);
let cdp: CdpConnection | null = null;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000);
cdp = await CdpConnection.connect(wsUrl, 15_000);
const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", {
url: X_LOGIN_URL,
newWindow: true,
});
const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Network.enable", {}, { sessionId });
if (verbose) {
log?.("[x-cookies] Chrome opened. If needed, complete X login in the window. Waiting for cookies...");
}
const start = Date.now();
let last: Record<string, string> = {};
while (Date.now() - start < timeoutMs) {
const { cookies } = await cdp.send<{ cookies: CookieLike[] }>(
"Network.getCookies",
{ urls: ["https://x.com/", "https://twitter.com/"] },
{ sessionId, timeoutMs: 10_000 }
);
const m = buildXCookieMap((cookies ?? []).filter(Boolean));
last = m;
if (hasRequiredXCookies(m)) {
return m;
}
await sleep(1000);
}
throw new Error(`Timed out waiting for X 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?.();
}
}
function resolveCookieDomain(cookie: CookieLike): 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 CookieLike>(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 preferred = matches.find((cookie) => {
const domain = resolveCookieDomain(cookie);
return domain === "x.com" && (cookie.path ?? "/") === "/";
});
const xDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? "").endsWith("x.com"));
const twitterDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? "").endsWith("twitter.com"));
return (preferred ?? xDomain ?? twitterDomain ?? matches[0])?.value;
}
function buildXCookieMap<T extends CookieLike>(cookies: T[]): Record<string, string> {
const cookieMap: Record<string, string> = {};
for (const name of X_COOKIE_NAMES) {
const value = pickCookieValue(cookies, name);
if (value) cookieMap[name] = value;
}
return cookieMap;
}
export function hasRequiredXCookies(cookieMap: Record<string, string>): boolean {
return X_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name]));
}
function filterXCookieMap(cookieMap: Record<string, string>): Record<string, string> {
const filtered: Record<string, string> = {};
for (const name of X_COOKIE_NAMES) {
const value = cookieMap[name];
if (value) filtered[name] = value;
}
return filtered;
}
function buildInlineCookiesFromEnv(): CookieLike[] {
const cookies: CookieLike[] = [];
const authToken = process.env.X_AUTH_TOKEN?.trim();
const ct0 = process.env.X_CT0?.trim();
const gt = process.env.X_GUEST_TOKEN?.trim();
const twid = process.env.X_TWID?.trim();
if (authToken) {
cookies.push({ name: "auth_token", value: authToken, domain: "x.com", path: "/" });
}
if (ct0) {
cookies.push({ name: "ct0", value: ct0, domain: "x.com", path: "/" });
}
if (gt) {
cookies.push({ name: "gt", value: gt, domain: "x.com", path: "/" });
}
if (twid) {
cookies.push({ name: "twid", value: twid, domain: "x.com", path: "/" });
}
return cookies;
}
async function loadXCookiesFromInline(log?: (message: string) => void): Promise<Record<string, string>> {
const inline = buildInlineCookiesFromEnv();
if (inline.length === 0) return {};
const cookieMap = buildXCookieMap(
inline.filter((cookie): cookie is CookieLike => Boolean(cookie?.name && typeof cookie.value === "string"))
);
if (Object.keys(cookieMap).length > 0) {
log?.(`[x-cookies] Loaded X cookies from env: ${Object.keys(cookieMap).length} cookie(s).`);
} else {
log?.("[x-cookies] Env cookies provided but no X cookies matched.");
}
return cookieMap;
}
async function loadXCookiesFromFile(log?: (message: string) => void): Promise<Record<string, string>> {
const cookiePath = resolveXToMarkdownCookiePath();
const fileMap = filterXCookieMap((await read_cookie_file(cookiePath)) ?? {});
if (Object.keys(fileMap).length > 0) {
log?.(`[x-cookies] Loaded X cookies from file: ${cookiePath} (${Object.keys(fileMap).length} cookie(s))`);
}
return fileMap;
}
async function loadXCookiesFromCdp(log?: (message: string) => void): Promise<Record<string, string>> {
try {
const cookieMap = await fetchXCookiesViaCdp(X_USER_DATA_DIR, 5 * 60 * 1000, true, log);
if (!hasRequiredXCookies(cookieMap)) return cookieMap;
const cookiePath = resolveXToMarkdownCookiePath();
try {
await write_cookie_file(cookieMap, cookiePath, "cdp");
log?.(`[x-cookies] Cookies saved to ${cookiePath}`);
} catch (error) {
log?.(
`[x-cookies] Failed to write cookie file (${cookiePath}): ${
error instanceof Error ? error.message : String(error ?? "")
}`
);
}
if (cookieMap.auth_token) log?.(`[x-cookies] auth_token: ${cookieMap.auth_token.slice(0, 20)}...`);
if (cookieMap.ct0) log?.(`[x-cookies] ct0: ${cookieMap.ct0.slice(0, 20)}...`);
return cookieMap;
} catch (error) {
log?.(
`[x-cookies] Failed to load cookies via Chrome DevTools Protocol: ${
error instanceof Error ? error.message : String(error ?? "")
}`
);
return {};
}
}
export async function loadXCookies(log?: (message: string) => void): Promise<Record<string, string>> {
const inlineMap = await loadXCookiesFromInline(log);
const fileMap = await loadXCookiesFromFile(log);
const combined = { ...fileMap, ...inlineMap };
if (hasRequiredXCookies(combined)) return combined;
const cdpMap = await loadXCookiesFromCdp(log);
return { ...fileMap, ...cdpMap, ...inlineMap };
}
export async function refreshXCookies(log?: (message: string) => void): Promise<Record<string, string>> {
return loadXCookiesFromCdp(log);
}
export function buildCookieHeader(cookieMap: Record<string, string>): string | undefined {
const entries = Object.entries(cookieMap).filter(([, value]) => value);
if (entries.length === 0) return undefined;
return entries.map(([key, value]) => `${key}=${value}`).join("; ");
}
@@ -0,0 +1,389 @@
import {
DEFAULT_BEARER_TOKEN,
DEFAULT_USER_AGENT,
FALLBACK_FEATURE_SWITCHES,
FALLBACK_FIELD_TOGGLES,
FALLBACK_QUERY_ID,
FALLBACK_TWEET_DETAIL_FEATURE_DEFAULTS,
FALLBACK_TWEET_DETAIL_FEATURE_SWITCHES,
FALLBACK_TWEET_DETAIL_FIELD_TOGGLES,
FALLBACK_TWEET_DETAIL_QUERY_ID,
FALLBACK_TWEET_FEATURE_SWITCHES,
FALLBACK_TWEET_FIELD_TOGGLES,
FALLBACK_TWEET_QUERY_ID,
} from "./constants.js";
import {
buildFeatureMap,
buildFieldToggleMap,
buildRequestHeaders,
buildTweetFieldToggleMap,
fetchHomeHtml,
fetchText,
parseStringList,
} from "./http.js";
import type { ArticleQueryInfo } from "./types.js";
function isNonEmptyObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && Object.keys(value as Record<string, unknown>).length > 0);
}
function unwrapTweetResult(result: any): any {
if (!result) return null;
if (result.__typename === "TweetWithVisibilityResults" && result.tweet) {
return result.tweet;
}
return result;
}
function extractArticleFromTweet(payload: unknown): unknown {
const root = (payload as { data?: any }).data ?? payload;
const result = root?.tweetResult?.result ?? root?.tweet_result?.result ?? root?.tweet_result;
const tweet = unwrapTweetResult(result);
const legacy = tweet?.legacy ?? {};
const article = legacy?.article ?? tweet?.article;
return (
article?.article_results?.result ??
legacy?.article_results?.result ??
tweet?.article_results?.result ??
null
);
}
function extractTweetFromPayload(payload: unknown): unknown {
const root = (payload as { data?: any }).data ?? payload;
const result = root?.tweetResult?.result ?? root?.tweet_result?.result ?? root?.tweet_result;
return unwrapTweetResult(result);
}
function extractArticleFromEntity(payload: unknown): unknown {
const root = (payload as { data?: any }).data ?? payload;
return (
root?.article_result_by_rest_id?.result ??
root?.article_result_by_rest_id ??
root?.article_entity_result?.result ??
null
);
}
async function resolveArticleQueryInfo(userAgent: string): Promise<ArticleQueryInfo> {
const html = await fetchHomeHtml(userAgent);
const bundleMatch = html.match(/"bundle\\.TwitterArticles":"([a-z0-9]+)"/);
if (!bundleMatch) {
return {
queryId: FALLBACK_QUERY_ID,
featureSwitches: FALLBACK_FEATURE_SWITCHES,
fieldToggles: FALLBACK_FIELD_TOGGLES,
html,
};
}
const bundleHash = bundleMatch[1];
const chunkUrl = `https://abs.twimg.com/responsive-web/client-web/bundle.TwitterArticles.${bundleHash}a.js`;
const chunk = await fetchText(chunkUrl, {
headers: {
"user-agent": userAgent,
},
});
const queryIdMatch = chunk.match(/queryId:\"([^\"]+)\",operationName:\"ArticleEntityResultByRestId\"/);
const featureMatch = chunk.match(
/operationName:\"ArticleEntityResultByRestId\"[\s\S]*?featureSwitches:\[(.*?)\]/
);
const fieldToggleMatch = chunk.match(
/operationName:\"ArticleEntityResultByRestId\"[\s\S]*?fieldToggles:\[(.*?)\]/
);
const featureSwitches = parseStringList(featureMatch?.[1]);
const fieldToggles = parseStringList(fieldToggleMatch?.[1]);
return {
queryId: queryIdMatch?.[1] ?? FALLBACK_QUERY_ID,
featureSwitches: featureSwitches.length > 0 ? featureSwitches : FALLBACK_FEATURE_SWITCHES,
fieldToggles: fieldToggles.length > 0 ? fieldToggles : FALLBACK_FIELD_TOGGLES,
html,
};
}
function resolveMainChunkHash(html: string): string | null {
const match = html.match(/main\\.([a-z0-9]+)\\.js/);
return match?.[1] ?? null;
}
function resolveApiChunkHash(html: string): string | null {
const match = html.match(/api:\"([a-zA-Z0-9_-]+)\"/);
return match?.[1] ?? null;
}
async function resolveTweetDetailQueryInfo(userAgent: string): Promise<ArticleQueryInfo> {
const html = await fetchHomeHtml(userAgent);
const apiHash = resolveApiChunkHash(html);
if (!apiHash) {
return {
queryId: FALLBACK_TWEET_DETAIL_QUERY_ID,
featureSwitches: FALLBACK_TWEET_DETAIL_FEATURE_SWITCHES,
fieldToggles: FALLBACK_TWEET_DETAIL_FIELD_TOGGLES,
html,
};
}
const chunkUrl = `https://abs.twimg.com/responsive-web/client-web/api.${apiHash}a.js`;
const chunk = await fetchText(chunkUrl, {
headers: {
"user-agent": userAgent,
},
});
const queryIdMatch = chunk.match(/queryId:\"([^\"]+)\",operationName:\"TweetDetail\"/);
const featureMatch = chunk.match(
/operationName:\"TweetDetail\"[\s\S]*?featureSwitches:\[(.*?)\]/
);
const fieldToggleMatch = chunk.match(
/operationName:\"TweetDetail\"[\s\S]*?fieldToggles:\[(.*?)\]/
);
const featureSwitches = parseStringList(featureMatch?.[1]);
const fieldToggles = parseStringList(fieldToggleMatch?.[1]);
return {
queryId: queryIdMatch?.[1] ?? FALLBACK_TWEET_DETAIL_QUERY_ID,
featureSwitches: featureSwitches.length > 0 ? featureSwitches : FALLBACK_TWEET_DETAIL_FEATURE_SWITCHES,
fieldToggles: fieldToggles.length > 0 ? fieldToggles : FALLBACK_TWEET_DETAIL_FIELD_TOGGLES,
html,
};
}
function buildTweetDetailFieldToggleMap(keys: string[]): Record<string, boolean> {
const toggles = buildFieldToggleMap(keys);
if (Object.prototype.hasOwnProperty.call(toggles, "withArticlePlainText")) {
toggles.withArticlePlainText = false;
}
if (Object.prototype.hasOwnProperty.call(toggles, "withGrokAnalyze")) {
toggles.withGrokAnalyze = false;
}
if (Object.prototype.hasOwnProperty.call(toggles, "withDisallowedReplyControls")) {
toggles.withDisallowedReplyControls = false;
}
return toggles;
}
async function resolveTweetQueryInfo(userAgent: string): Promise<ArticleQueryInfo> {
const html = await fetchHomeHtml(userAgent);
const mainHash = resolveMainChunkHash(html);
if (!mainHash) {
return {
queryId: FALLBACK_TWEET_QUERY_ID,
featureSwitches: FALLBACK_TWEET_FEATURE_SWITCHES,
fieldToggles: FALLBACK_TWEET_FIELD_TOGGLES,
html,
};
}
const chunkUrl = `https://abs.twimg.com/responsive-web/client-web/main.${mainHash}.js`;
const chunk = await fetchText(chunkUrl, {
headers: {
"user-agent": userAgent,
},
});
const queryIdMatch = chunk.match(/queryId:\"([^\"]+)\",operationName:\"TweetResultByRestId\"/);
const featureMatch = chunk.match(
/operationName:\"TweetResultByRestId\"[\s\S]*?featureSwitches:\[(.*?)\]/
);
const fieldToggleMatch = chunk.match(
/operationName:\"TweetResultByRestId\"[\s\S]*?fieldToggles:\[(.*?)\]/
);
const featureSwitches = parseStringList(featureMatch?.[1]);
const fieldToggles = parseStringList(fieldToggleMatch?.[1]);
return {
queryId: queryIdMatch?.[1] ?? FALLBACK_TWEET_QUERY_ID,
featureSwitches: featureSwitches.length > 0 ? featureSwitches : FALLBACK_TWEET_FEATURE_SWITCHES,
fieldToggles: fieldToggles.length > 0 ? fieldToggles : FALLBACK_TWEET_FIELD_TOGGLES,
html,
};
}
async function fetchTweetResult(
tweetId: string,
cookieMap: Record<string, string>,
userAgent: string,
bearerToken: string
): Promise<unknown> {
const queryInfo = await resolveTweetQueryInfo(userAgent);
const features = buildFeatureMap(queryInfo.html, queryInfo.featureSwitches);
const fieldToggles = buildTweetFieldToggleMap(queryInfo.fieldToggles);
const url = new URL(`https://x.com/i/api/graphql/${queryInfo.queryId}/TweetResultByRestId`);
url.searchParams.set(
"variables",
JSON.stringify({
tweetId,
withCommunity: false,
includePromotedContent: false,
withVoice: true,
})
);
if (Object.keys(features).length > 0) {
url.searchParams.set("features", JSON.stringify(features));
}
if (Object.keys(fieldToggles).length > 0) {
url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
}
const response = await fetch(url.toString(), {
headers: buildRequestHeaders(cookieMap, userAgent, bearerToken),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`X API error (${response.status}): ${text.slice(0, 400)}`);
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`Failed to parse response JSON: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function fetchTweetDetail(
tweetId: string,
cookieMap: Record<string, string>,
cursor?: string
): Promise<unknown> {
const userAgent = process.env.X_USER_AGENT?.trim() || DEFAULT_USER_AGENT;
const bearerToken = process.env.X_BEARER_TOKEN?.trim() || DEFAULT_BEARER_TOKEN;
const queryInfo = await resolveTweetDetailQueryInfo(userAgent);
const features = buildFeatureMap(
queryInfo.html,
queryInfo.featureSwitches,
FALLBACK_TWEET_DETAIL_FEATURE_DEFAULTS
);
const fieldToggles = buildTweetDetailFieldToggleMap(queryInfo.fieldToggles);
const url = new URL(`https://x.com/i/api/graphql/${queryInfo.queryId}/TweetDetail`);
url.searchParams.set(
"variables",
JSON.stringify({
focalTweetId: tweetId,
cursor,
referrer: cursor ? "tweet" : undefined,
with_rux_injections: false,
includePromotedContent: true,
withCommunity: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withVoice: true,
withV2Timeline: true,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
withSuperFollowsTweetFields: false,
withSuperFollowsUserFields: false,
})
);
if (Object.keys(features).length > 0) {
url.searchParams.set("features", JSON.stringify(features));
}
if (Object.keys(fieldToggles).length > 0) {
url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
}
const response = await fetch(url.toString(), {
headers: buildRequestHeaders(cookieMap, userAgent, bearerToken),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`X API error (${response.status}): ${text.slice(0, 400)}`);
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`Failed to parse response JSON: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function fetchArticleEntityById(
articleEntityId: string,
cookieMap: Record<string, string>,
userAgent: string,
bearerToken: string
): Promise<unknown> {
const queryInfo = await resolveArticleQueryInfo(userAgent);
const features = buildFeatureMap(queryInfo.html, queryInfo.featureSwitches);
const fieldToggles = buildFieldToggleMap(queryInfo.fieldToggles);
const url = new URL(`https://x.com/i/api/graphql/${queryInfo.queryId}/ArticleEntityResultByRestId`);
url.searchParams.set("variables", JSON.stringify({ articleEntityId }));
if (Object.keys(features).length > 0) {
url.searchParams.set("features", JSON.stringify(features));
}
if (Object.keys(fieldToggles).length > 0) {
url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
}
const response = await fetch(url.toString(), {
headers: buildRequestHeaders(cookieMap, userAgent, bearerToken),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`X API error (${response.status}): ${text.slice(0, 400)}`);
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(`Failed to parse response JSON: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function fetchXArticle(
articleId: string,
cookieMap: Record<string, string>,
raw: boolean
): Promise<unknown> {
const userAgent = process.env.X_USER_AGENT?.trim() || DEFAULT_USER_AGENT;
const bearerToken = process.env.X_BEARER_TOKEN?.trim() || DEFAULT_BEARER_TOKEN;
const tweetPayload = await fetchTweetResult(articleId, cookieMap, userAgent, bearerToken);
if (raw) {
return tweetPayload;
}
const articleFromTweet = extractArticleFromTweet(tweetPayload);
if (isNonEmptyObject(articleFromTweet)) {
return articleFromTweet;
}
const articlePayload = await fetchArticleEntityById(articleId, cookieMap, userAgent, bearerToken);
const articleFromEntity = extractArticleFromEntity(articlePayload);
if (isNonEmptyObject(articleFromEntity)) {
return articleFromEntity;
}
return articleFromEntity ?? articlePayload;
}
export async function fetchXTweet(
tweetId: string,
cookieMap: Record<string, string>,
raw: boolean
): Promise<unknown> {
const userAgent = process.env.X_USER_AGENT?.trim() || DEFAULT_USER_AGENT;
const bearerToken = process.env.X_BEARER_TOKEN?.trim() || DEFAULT_BEARER_TOKEN;
const tweetPayload = await fetchTweetResult(tweetId, cookieMap, userAgent, bearerToken);
if (raw) {
return tweetPayload;
}
const tweet = extractTweetFromPayload(tweetPayload);
if (isNonEmptyObject(tweet)) {
return tweet;
}
return tweet ?? tweetPayload;
}
@@ -0,0 +1,117 @@
import { buildCookieHeader } from "./cookies.js";
let cachedHomeHtml: { userAgent: string; html: string } | null = null;
export async function fetchText(url: string, init?: RequestInit): Promise<string> {
const response = await fetch(url, init);
const text = await response.text();
if (!response.ok) {
throw new Error(`Request failed (${response.status}) for ${url}: ${text.slice(0, 200)}`);
}
return text;
}
export async function fetchHomeHtml(userAgent: string): Promise<string> {
if (cachedHomeHtml?.userAgent === userAgent) {
return cachedHomeHtml.html;
}
const html = await fetchText("https://x.com", {
headers: {
"user-agent": userAgent,
},
});
cachedHomeHtml = { userAgent, html };
return html;
}
export function parseStringList(raw: string | undefined): string[] {
if (!raw) return [];
return raw
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => item.replace(/^\"|\"$/g, ""));
}
export function resolveFeatureValue(html: string, key: string): boolean | undefined {
const keyPattern = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const unescaped = new RegExp(`"${keyPattern}"\\s*:\\s*\\{"value"\\s*:\\s*(true|false)`);
const escaped = new RegExp(`\\\\"${keyPattern}\\\\"\\s*:\\s*\\\\{\\\\"value\\\\"\\s*:\\s*(true|false)`);
const match = html.match(unescaped) ?? html.match(escaped);
if (!match) return undefined;
return match[1] === "true";
}
export function buildFeatureMap(
html: string,
keys: string[],
defaults?: Record<string, boolean>
): Record<string, boolean> {
const features: Record<string, boolean> = {};
for (const key of keys) {
const value = resolveFeatureValue(html, key);
if (value !== undefined) {
features[key] = value;
} else if (defaults && Object.prototype.hasOwnProperty.call(defaults, key)) {
features[key] = defaults[key] ?? true;
} else {
features[key] = true;
}
}
if (!Object.prototype.hasOwnProperty.call(features, "responsive_web_graphql_exclude_directive_enabled")) {
features.responsive_web_graphql_exclude_directive_enabled = true;
}
return features;
}
export function buildFieldToggleMap(keys: string[]): Record<string, boolean> {
const toggles: Record<string, boolean> = {};
for (const key of keys) {
toggles[key] = true;
}
return toggles;
}
export function buildTweetFieldToggleMap(keys: string[]): Record<string, boolean> {
const toggles: Record<string, boolean> = {};
for (const key of keys) {
if (key === "withGrokAnalyze" || key === "withDisallowedReplyControls") {
toggles[key] = false;
} else {
toggles[key] = true;
}
}
return toggles;
}
export function buildRequestHeaders(
cookieMap: Record<string, string>,
userAgent: string,
bearerToken: string
): Record<string, string> {
const headers: Record<string, string> = {
authorization: bearerToken,
"user-agent": userAgent,
accept: "application/json",
"x-twitter-active-user": "yes",
"x-twitter-client-language": "en",
"accept-language": "en",
};
if (cookieMap.auth_token) {
headers["x-twitter-auth-type"] = "OAuth2Session";
}
const cookieHeader = buildCookieHeader(cookieMap);
if (cookieHeader) {
headers.cookie = cookieHeader;
}
if (cookieMap.ct0) {
headers["x-csrf-token"] = cookieMap.ct0;
}
if (process.env.X_CLIENT_TRANSACTION_ID?.trim()) {
headers["x-client-transaction-id"] = process.env.X_CLIENT_TRANSACTION_ID.trim();
}
return headers;
}
@@ -0,0 +1,419 @@
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import process from "node:process";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { fetchXArticle } from "./graphql.js";
import { formatArticleMarkdown } from "./markdown.js";
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
import { resolveXToMarkdownConsentPath } from "./paths.js";
import { tweetToMarkdown } from "./tweet-to-markdown.js";
type CliArgs = {
url: string | null;
output: string | null;
json: boolean;
login: boolean;
help: boolean;
};
type ConsentRecord = {
version: number;
accepted: boolean;
acceptedAt: string;
disclaimerVersion: string;
};
const DISCLAIMER_VERSION = "1.0";
function printUsage(exitCode: number): never {
const cmd = "npx -y bun skills/baoyu-danger-x-to-markdown/scripts/main.ts";
console.log(`X (Twitter) to Markdown
Usage:
${cmd} <url>
${cmd} --url <url>
Options:
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
--json Output as JSON
--login Refresh cookies only, then exit
--help, -h Show help
Examples:
${cmd} https://x.com/username/status/1234567890
${cmd} https://x.com/i/article/1234567890 -o ./article.md
${cmd} https://x.com/username/status/1234567890 -o ./out/
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
${cmd} --login
`);
process.exit(exitCode);
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = {
url: null,
output: null,
json: false,
login: false,
help: false,
};
const positional: string[] = [];
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 (a === "--login") {
out.login = true;
continue;
}
if (a === "--url") {
const v = argv[++i];
if (!v) throw new Error("Missing value for --url");
out.url = v;
continue;
}
if (a === "--output" || a === "-o") {
const v = argv[++i];
if (!v) throw new Error(`Missing value for ${a}`);
out.output = v;
continue;
}
if (a.startsWith("-")) {
throw new Error(`Unknown option: ${a}`);
}
positional.push(a);
}
if (!out.url && positional.length > 0) {
out.url = positional[0]!;
}
return out;
}
function normalizeInputUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
try {
return new URL(trimmed).toString();
} catch {
return trimmed;
}
}
function parseArticleId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
const match = parsed.pathname.match(/\/(?:i\/)?article\/(\d+)/);
if (match?.[1]) return match[1];
} catch {
return null;
}
return null;
}
function parseTweetId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed);
const match = parsed.pathname.match(/\/status(?:es)?\/(\d+)/);
if (match?.[1]) return match[1];
} catch {
return null;
}
return null;
}
function parseTweetUsername(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
const match = parsed.pathname.match(/^\/([^/]+)\/status(?:es)?\/\d+/);
if (match?.[1]) return match[1];
} catch {
return null;
}
return null;
}
function sanitizeSlug(input: string): string {
return input
.trim()
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "")
.slice(0, 120);
}
function formatBackupTimestamp(date: Date = new Date()): string {
const pad2 = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(
date.getMinutes()
)}${pad2(date.getSeconds())}`;
}
async function backupDirIfExists(dir: string, log: (message: string) => void): Promise<void> {
try {
if (!fs.existsSync(dir)) return;
const stat = fs.statSync(dir);
if (!stat.isDirectory()) return;
const backup = `${dir}-backup-${formatBackupTimestamp()}`;
await rename(dir, backup);
log(`[x-to-markdown] Existing directory moved to: ${backup}`);
} catch (error) {
throw new Error(
`Failed to backup existing directory (${dir}): ${error instanceof Error ? error.message : String(error ?? "")}`
);
}
}
function resolveDefaultOutputDir(slug: string): string {
return path.resolve(process.cwd(), "x-to-markdown", slug);
}
async function resolveOutputPath(
normalizedUrl: string,
kind: "tweet" | "article",
argsOutput: string | null,
log: (message: string) => void
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
const tweetId = kind === "tweet" ? parseTweetId(normalizedUrl) : null;
const username = kind === "tweet" ? parseTweetUsername(normalizedUrl) : null;
const userSlug = username ? sanitizeSlug(username) : null;
const idPart = articleId ?? tweetId ?? String(Date.now());
const slug = userSlug ?? idPart;
const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`;
if (argsOutput) {
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
const resolved = path.resolve(argsOutput);
try {
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
const outputDir = path.join(resolved, slug);
await backupDirIfExists(outputDir, log);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
}
} catch {
// treat as file path
}
const outputDir = path.dirname(resolved);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: resolved, slug };
}
const outputDir = resolveDefaultOutputDir(slug);
await backupDirIfExists(outputDir, log);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
}
function formatMetaMarkdown(meta: Record<string, string | number | null | undefined>): string {
const lines = ["---"];
for (const [key, value] of Object.entries(meta)) {
if (value === undefined || value === null || value === "") continue;
if (typeof value === "number") {
lines.push(`${key}: ${value}`);
} else {
lines.push(`${key}: ${JSON.stringify(value)}`);
}
}
lines.push("---");
return lines.join("\n");
}
async function promptYesNo(question: string): Promise<boolean> {
if (!process.stdin.isTTY) return false;
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
});
try {
const answer = await new Promise<string>((resolve) => rl.question(question, resolve));
const normalized = answer.trim().toLowerCase();
return normalized === "y" || normalized === "yes";
} finally {
rl.close();
}
}
function isValidConsent(value: unknown): value is ConsentRecord {
if (!value || typeof value !== "object") return false;
const record = value as Partial<ConsentRecord>;
return (
record.accepted === true &&
record.disclaimerVersion === DISCLAIMER_VERSION &&
typeof record.acceptedAt === "string" &&
record.acceptedAt.length > 0
);
}
async function ensureConsent(log: (message: string) => void): Promise<void> {
const consentPath = resolveXToMarkdownConsentPath();
try {
if (fs.existsSync(consentPath) && fs.statSync(consentPath).isFile()) {
const raw = await readFile(consentPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (isValidConsent(parsed)) {
log(
`⚠️ Warning: Using reverse-engineered X API (not official). Accepted on: ${(parsed as ConsentRecord).acceptedAt}`
);
return;
}
}
} catch {
// fall through to prompt
}
log(`⚠️ DISCLAIMER
This tool uses a reverse-engineered X (Twitter) API, NOT an official API.
Risks:
- May break without notice if X changes their API
- No official support or guarantees
- Account restrictions possible if API usage detected
- Use at your own risk
`);
if (!process.stdin.isTTY) {
throw new Error(
`Consent required. Run in a TTY or create ${consentPath} with accepted: true and disclaimerVersion: ${DISCLAIMER_VERSION}`
);
}
const accepted = await promptYesNo("Do you accept these terms and wish to continue? (y/N): ");
if (!accepted) {
throw new Error("User declined the disclaimer. Exiting.");
}
await mkdir(path.dirname(consentPath), { recursive: true });
const payload: ConsentRecord = {
version: 1,
accepted: true,
acceptedAt: new Date().toISOString(),
disclaimerVersion: DISCLAIMER_VERSION,
};
await writeFile(consentPath, JSON.stringify(payload, null, 2), "utf8");
log(`[x-to-markdown] Consent saved to: ${consentPath}`);
}
async function convertArticleToMarkdown(
inputUrl: string,
articleId: string,
log: (message: string) => void
): Promise<string> {
log("[x-to-markdown] Loading cookies...");
const cookieMap = await loadXCookies(log);
if (!hasRequiredXCookies(cookieMap)) {
throw new Error("Missing auth cookies. Provide X_AUTH_TOKEN and X_CT0 or log in via Chrome.");
}
log(`[x-to-markdown] Fetching article ${articleId}...`);
const article = await fetchXArticle(articleId, cookieMap, false);
const body = formatArticleMarkdown(article).trimEnd();
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
const meta = formatMetaMarkdown({
url: `https://x.com/i/article/${articleId}`,
requested_url: inputUrl,
title: title || null,
});
return [meta, body].filter(Boolean).join("\n\n").trimEnd();
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) printUsage(0);
if (!args.login && !args.url) printUsage(1);
const log = (message: string) => console.error(message);
await ensureConsent(log);
if (args.login) {
log("[x-to-markdown] Refreshing cookies via browser login...");
const cookieMap = await refreshXCookies(log);
if (!hasRequiredXCookies(cookieMap)) {
throw new Error("Missing auth cookies after login. Please ensure you are logged in to X.");
}
log("[x-to-markdown] Cookies refreshed.");
return;
}
const normalizedUrl = normalizeInputUrl(args.url ?? "");
const articleId = parseArticleId(normalizedUrl);
const tweetId = parseTweetId(normalizedUrl);
if (!articleId && !tweetId) {
throw new Error("Invalid X url. Examples: https://x.com/<user>/status/<id> or https://x.com/i/article/<id>");
}
const kind = articleId ? ("article" as const) : ("tweet" as const);
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
const markdown =
kind === "article" && articleId
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
: await tweetToMarkdown(normalizedUrl, { log });
await writeFile(markdownPath, markdown, "utf8");
log(`[x-to-markdown] Saved: ${markdownPath}`);
if (args.json) {
console.log(
JSON.stringify(
{
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
requested_url: normalizedUrl,
type: kind,
slug,
outputDir,
markdownPath,
},
null,
2
)
);
} else {
console.log(markdownPath);
}
}
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error ?? ""));
process.exit(1);
});
@@ -0,0 +1,306 @@
import type {
ArticleBlock,
ArticleContentState,
ArticleEntity,
ArticleMediaInfo,
} from "./types.js";
function coerceArticleEntity(value: unknown): ArticleEntity | null {
if (!value || typeof value !== "object") return null;
const candidate = value as ArticleEntity;
if (
typeof candidate.title === "string" ||
typeof candidate.plain_text === "string" ||
typeof candidate.preview_text === "string" ||
candidate.content_state
) {
return candidate;
}
return null;
}
function escapeMarkdownAlt(text: string): string {
return text.replace(/[\[\]]/g, "\\$&");
}
function normalizeCaption(caption?: string): string {
const trimmed = caption?.trim();
if (!trimmed) return "";
return trimmed.replace(/\s+/g, " ");
}
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
if (!info) return undefined;
if (info.original_img_url) return info.original_img_url;
if (info.preview_image?.original_img_url) return info.preview_image.original_img_url;
const variants = info.variants ?? [];
const mp4 = variants
.filter((variant) => variant?.content_type?.includes("video"))
.sort((a, b) => (b.bit_rate ?? 0) - (a.bit_rate ?? 0))[0];
return mp4?.url ?? variants[0]?.url;
}
function buildMediaById(article: ArticleEntity): Map<string, string> {
const map = new Map<string, string>();
for (const entity of article.media_entities ?? []) {
if (!entity?.media_id) continue;
const url = resolveMediaUrl(entity.media_info);
if (url) {
map.set(entity.media_id, url);
}
}
return map;
}
function collectMediaUrls(
article: ArticleEntity,
usedUrls: Set<string>,
excludeUrl?: string
): string[] {
const urls: string[] = [];
const addUrl = (url?: string) => {
if (!url) return;
if (excludeUrl && url === excludeUrl) {
usedUrls.add(url);
return;
}
if (usedUrls.has(url)) return;
usedUrls.add(url);
urls.push(url);
};
for (const entity of article.media_entities ?? []) {
addUrl(resolveMediaUrl(entity?.media_info));
}
return urls;
}
function resolveEntityMediaLines(
entityKey: number | undefined,
entityMap: ArticleContentState["entityMap"] | undefined,
mediaById: Map<string, string>,
usedUrls: Set<string>
): string[] {
if (entityKey === undefined || !entityMap) return [];
const entry = entityMap[String(entityKey)];
const value = entry?.value;
if (!value) return [];
const type = value.type;
if (type !== "MEDIA" && type !== "IMAGE") return [];
const caption = normalizeCaption(value.data?.caption);
const altText = caption ? escapeMarkdownAlt(caption) : "";
const lines: string[] = [];
const mediaItems = value.data?.mediaItems ?? [];
for (const item of mediaItems) {
const mediaId =
typeof item?.mediaId === "string"
? item.mediaId
: typeof item?.media_id === "string"
? item.media_id
: undefined;
const url = mediaId ? mediaById.get(mediaId) : undefined;
if (url && !usedUrls.has(url)) {
usedUrls.add(url);
lines.push(`![${altText}](${url})`);
}
}
const fallbackUrl = typeof value.data?.url === "string" ? value.data.url : undefined;
if (fallbackUrl && !usedUrls.has(fallbackUrl)) {
usedUrls.add(fallbackUrl);
lines.push(`![${altText}](${fallbackUrl})`);
}
return lines;
}
function renderContentBlocks(
blocks: ArticleBlock[],
entityMap: ArticleContentState["entityMap"] | undefined,
mediaById: Map<string, string>,
usedUrls: Set<string>
): string[] {
const lines: string[] = [];
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
let listKind: "ordered" | "unordered" | null = null;
let orderedIndex = 0;
let inCodeBlock = false;
const pushBlock = (
blockLines: string[],
kind: "list" | "quote" | "heading" | "text" | "media"
) => {
if (blockLines.length === 0) return;
if (
lines.length > 0 &&
previousKind &&
!(previousKind === kind && (kind === "list" || kind === "quote" || kind === "media"))
) {
lines.push("");
}
lines.push(...blockLines);
previousKind = kind;
};
const collectMediaLines = (block: ArticleBlock): string[] => {
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const mediaLines: string[] = [];
for (const range of ranges) {
if (typeof range?.key !== "number") continue;
mediaLines.push(...resolveEntityMediaLines(range.key, entityMap, mediaById, usedUrls));
}
return mediaLines;
};
for (const block of blocks) {
const type = typeof block?.type === "string" ? block.type : "unstyled";
const text = typeof block?.text === "string" ? block.text : "";
if (type === "code-block") {
if (!inCodeBlock) {
if (lines.length > 0) {
lines.push("");
}
lines.push("```");
inCodeBlock = true;
}
lines.push(text);
previousKind = "code";
listKind = null;
orderedIndex = 0;
continue;
}
if (type === "atomic") {
if (inCodeBlock) {
lines.push("```");
inCodeBlock = false;
previousKind = "code";
}
listKind = null;
orderedIndex = 0;
const mediaLines = collectMediaLines(block);
if (mediaLines.length > 0) {
pushBlock(mediaLines, "media");
}
continue;
}
if (inCodeBlock) {
lines.push("```");
inCodeBlock = false;
previousKind = "code";
}
if (type === "unordered-list-item") {
listKind = "unordered";
orderedIndex = 0;
pushBlock([`- ${text}`], "list");
continue;
}
if (type === "ordered-list-item") {
if (listKind !== "ordered") {
orderedIndex = 0;
}
listKind = "ordered";
orderedIndex += 1;
pushBlock([`${orderedIndex}. ${text}`], "list");
continue;
}
listKind = null;
orderedIndex = 0;
switch (type) {
case "header-one":
pushBlock([`# ${text}`], "heading");
break;
case "header-two":
pushBlock([`## ${text}`], "heading");
break;
case "header-three":
pushBlock([`### ${text}`], "heading");
break;
case "header-four":
pushBlock([`#### ${text}`], "heading");
break;
case "header-five":
pushBlock([`##### ${text}`], "heading");
break;
case "header-six":
pushBlock([`###### ${text}`], "heading");
break;
case "blockquote": {
const quoteLines = text.length > 0 ? text.split("\n") : [""];
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
break;
}
default:
pushBlock([text], "text");
break;
}
const trailingMediaLines = collectMediaLines(block);
if (trailingMediaLines.length > 0) {
pushBlock(trailingMediaLines, "media");
}
}
if (inCodeBlock) {
lines.push("```");
}
return lines;
}
export function formatArticleMarkdown(article: unknown): string {
const candidate = coerceArticleEntity(article);
if (!candidate) {
return `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``;
}
const lines: string[] = [];
const usedUrls = new Set<string>();
const mediaById = buildMediaById(candidate);
const title = typeof candidate.title === "string" ? candidate.title.trim() : "";
if (title) {
lines.push(`# ${title}`);
}
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info);
if (coverUrl) {
if (lines.length > 0) lines.push("");
lines.push(`![](${coverUrl})`);
usedUrls.add(coverUrl);
}
const blocks = candidate.content_state?.blocks;
const entityMap = candidate.content_state?.entityMap;
if (Array.isArray(blocks) && blocks.length > 0) {
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls);
if (rendered.length > 0) {
if (lines.length > 0) lines.push("");
lines.push(...rendered);
}
} else if (typeof candidate.plain_text === "string") {
if (lines.length > 0) lines.push("");
lines.push(candidate.plain_text.trim());
} else if (typeof candidate.preview_text === "string") {
if (lines.length > 0) lines.push("");
lines.push(candidate.preview_text.trim());
}
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl);
if (mediaUrls.length > 0) {
lines.push("", "## Media", "");
for (const url of mediaUrls) {
lines.push(`![](${url})`);
}
}
return lines.join("\n").trimEnd();
}
@@ -0,0 +1,41 @@
import os from "node:os";
import path from "node:path";
import process from "node:process";
const APP_DATA_DIR = "baoyu-skills";
const X_TO_MARKDOWN_DATA_DIR = "x-to-markdown";
const COOKIE_FILE_NAME = "cookies.json";
const PROFILE_DIR_NAME = "chrome-profile";
const CONSENT_FILE_NAME = "consent.json";
export function resolveUserDataRoot(): string {
if (process.platform === "win32") {
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
}
if (process.platform === "darwin") {
return path.join(os.homedir(), "Library", "Application Support");
}
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share");
}
export function resolveXToMarkdownDataDir(): string {
const override = process.env.X_DATA_DIR?.trim();
if (override) return path.resolve(override);
return path.join(resolveUserDataRoot(), APP_DATA_DIR, X_TO_MARKDOWN_DATA_DIR);
}
export function resolveXToMarkdownCookiePath(): string {
const override = process.env.X_COOKIE_PATH?.trim();
if (override) return path.resolve(override);
return path.join(resolveXToMarkdownDataDir(), COOKIE_FILE_NAME);
}
export function resolveXToMarkdownChromeProfileDir(): string {
const override = process.env.X_CHROME_PROFILE_DIR?.trim();
if (override) return path.resolve(override);
return path.join(resolveXToMarkdownDataDir(), PROFILE_DIR_NAME);
}
export function resolveXToMarkdownConsentPath(): string {
return path.join(resolveXToMarkdownDataDir(), CONSENT_FILE_NAME);
}
@@ -0,0 +1,295 @@
type ThreadLike = {
requestedId?: string;
rootId?: string;
tweets?: unknown[];
totalTweets?: number;
user?: any;
};
type TweetPhoto = {
src: string;
alt?: string;
};
type TweetVideo = {
url: string;
poster?: string;
alt?: string;
type?: string;
};
export type ThreadTweetsMarkdownOptions = {
username?: string;
headingLevel?: number;
startIndex?: number;
includeTweetUrls?: boolean;
};
export type ThreadMarkdownOptions = ThreadTweetsMarkdownOptions & {
includeHeader?: boolean;
title?: string;
sourceUrl?: string;
};
function coerceThread(value: unknown): ThreadLike | null {
if (!value || typeof value !== "object") return null;
const candidate = value as ThreadLike;
if (!Array.isArray(candidate.tweets)) return null;
return candidate;
}
function escapeMarkdownAlt(text: string): string {
return text.replace(/[\[\]]/g, "\\$&");
}
function normalizeAlt(text?: string | null): string {
const trimmed = text?.trim();
if (!trimmed) return "";
return trimmed.replace(/\s+/g, " ");
}
function parseTweetText(tweet: any): string {
const noteText = tweet?.note_tweet?.note_tweet_results?.result?.text;
const legacyText = tweet?.legacy?.full_text ?? tweet?.legacy?.text ?? "";
return (noteText ?? legacyText ?? "").trim();
}
function parsePhotos(tweet: any): TweetPhoto[] {
const media = tweet?.legacy?.extended_entities?.media ?? [];
return media
.reduce((acc: TweetPhoto[], item: any) => {
if (item?.type !== "photo") {
return acc;
}
const src = item.media_url_https ?? item.media_url;
if (!src) {
return acc;
}
const alt = normalizeAlt(item.ext_alt_text);
acc.push({ src, alt });
return acc;
}, [])
.filter((photo) => Boolean(photo.src));
}
function parseVideos(tweet: any): TweetVideo[] {
const media = tweet?.legacy?.extended_entities?.media ?? [];
return media
.reduce((acc: TweetVideo[], item: any) => {
if (!item?.type || !["animated_gif", "video"].includes(item.type)) {
return acc;
}
const variants = item?.video_info?.variants ?? [];
const sources = variants
.map((variant: any) => ({
contentType: variant?.content_type,
url: variant?.url,
bitrate: variant?.bitrate ?? 0,
}))
.filter((variant: any) => Boolean(variant.url));
const videoSources = sources.filter((variant: any) =>
String(variant.contentType ?? "").includes("video")
);
const sorted = (videoSources.length > 0 ? videoSources : sources).sort(
(a: any, b: any) => (b.bitrate ?? 0) - (a.bitrate ?? 0)
);
const best = sorted[0];
if (!best?.url) {
return acc;
}
const alt = normalizeAlt(item.ext_alt_text);
acc.push({
url: best.url,
poster: item.media_url_https ?? item.media_url ?? undefined,
alt,
type: item.type,
});
return acc;
}, [])
.filter((video) => Boolean(video.url));
}
function unwrapTweetResult(result: any): any {
if (!result) return null;
if (result.__typename === "TweetWithVisibilityResults" && result.tweet) {
return result.tweet;
}
return result;
}
function resolveTweetId(tweet: any): string | undefined {
return tweet?.legacy?.id_str ?? tweet?.rest_id;
}
function buildTweetUrl(username: string | undefined, tweetId: string | undefined): string | null {
if (!tweetId) return null;
if (username) {
return `https://x.com/${username}/status/${tweetId}`;
}
return `https://x.com/i/web/status/${tweetId}`;
}
function formatTweetMarkdown(
tweet: any,
index: number,
options: ThreadTweetsMarkdownOptions
): string[] {
const headingLevel = options.headingLevel ?? 2;
const includeTweetUrls = options.includeTweetUrls ?? true;
const headingPrefix = "#".repeat(Math.min(Math.max(headingLevel, 1), 6));
const tweetId = resolveTweetId(tweet);
const tweetUrl = includeTweetUrls ? buildTweetUrl(options.username, tweetId) : null;
const lines: string[] = [];
lines.push(`${headingPrefix} ${index}`);
if (tweetUrl) {
lines.push(tweetUrl);
}
lines.push("");
const text = parseTweetText(tweet);
const photos = parsePhotos(tweet);
const videos = parseVideos(tweet);
const quoted = unwrapTweetResult(tweet?.quoted_status_result?.result);
const bodyLines: string[] = [];
if (text) {
bodyLines.push(...text.split(/\r?\n/));
}
const quotedLines = formatQuotedTweetMarkdown(quoted);
if (quotedLines.length > 0) {
if (bodyLines.length > 0) bodyLines.push("");
bodyLines.push(...quotedLines);
}
const photoLines = photos.map((photo) => {
const alt = photo.alt ? escapeMarkdownAlt(photo.alt) : "";
return `![${alt}](${photo.src})`;
});
if (photoLines.length > 0) {
if (bodyLines.length > 0) bodyLines.push("");
bodyLines.push(...photoLines);
}
const videoLines: string[] = [];
for (const video of videos) {
if (video.poster) {
const alt = video.alt ? escapeMarkdownAlt(video.alt) : "video";
videoLines.push(`![${alt}](${video.poster})`);
}
videoLines.push(`[${video.type ?? "video"}](${video.url})`);
}
if (videoLines.length > 0) {
if (bodyLines.length > 0) bodyLines.push("");
bodyLines.push(...videoLines);
}
if (bodyLines.length === 0) {
bodyLines.push("_No text or media._");
}
lines.push(...bodyLines);
return lines;
}
function formatQuotedTweetMarkdown(quoted: any): string[] {
if (!quoted) return [];
const quotedUser = quoted?.core?.user_results?.result?.legacy;
const quotedUsername = quotedUser?.screen_name;
const quotedName = quotedUser?.name;
const quotedAuthor =
quotedUsername && quotedName
? `${quotedName} (@${quotedUsername})`
: quotedUsername
? `@${quotedUsername}`
: quotedName ?? "Unknown";
const quotedId = resolveTweetId(quoted);
const quotedUrl =
buildTweetUrl(quotedUsername, quotedId) ??
(quotedId ? `https://x.com/i/web/status/${quotedId}` : "unavailable");
const quotedText = parseTweetText(quoted);
const lines: string[] = [];
lines.push(`Author: ${quotedAuthor}`);
lines.push(`URL: ${quotedUrl}`);
if (quotedText) {
lines.push("", ...quotedText.split(/\r?\n/));
} else {
lines.push("", "(no content)");
}
return lines.map((line) => `> ${line}`.trimEnd());
}
export function formatThreadTweetsMarkdown(
tweets: unknown[],
options: ThreadTweetsMarkdownOptions = {}
): string {
const lines: string[] = [];
const startIndex = options.startIndex ?? 1;
if (!Array.isArray(tweets) || tweets.length === 0) {
return "";
}
tweets.forEach((tweet, index) => {
if (lines.length > 0) {
lines.push("");
}
lines.push(...formatTweetMarkdown(tweet, startIndex + index, options));
});
return lines.join("\n").trimEnd();
}
export function formatThreadMarkdown(
thread: unknown,
options: ThreadMarkdownOptions = {}
): string {
const candidate = coerceThread(thread);
if (!candidate) {
return `\`\`\`json\n${JSON.stringify(thread, null, 2)}\n\`\`\``;
}
const tweets = candidate.tweets ?? [];
const firstTweet = tweets[0] as any;
const user = candidate.user ?? firstTweet?.core?.user_results?.result?.legacy;
const username = user?.screen_name;
const name = user?.name;
const includeHeader = options.includeHeader ?? true;
const lines: string[] = [];
if (includeHeader) {
if (options.title) {
lines.push(`# ${options.title}`);
} else if (username) {
lines.push(`# Thread by @${username}${name ? ` (${name})` : ""}`);
} else {
lines.push("# Thread");
}
const sourceUrl = options.sourceUrl ?? buildTweetUrl(username, candidate.rootId ?? candidate.requestedId);
if (sourceUrl) {
lines.push(`Source: ${sourceUrl}`);
}
if (typeof candidate.totalTweets === "number") {
lines.push(`Tweets: ${candidate.totalTweets}`);
}
}
const tweetMarkdown = formatThreadTweetsMarkdown(tweets, {
...options,
username,
});
if (tweetMarkdown) {
if (lines.length > 0) {
lines.push("");
}
lines.push(tweetMarkdown);
}
return lines.join("\n").trimEnd();
}
@@ -0,0 +1,311 @@
import { fetchTweetDetail } from "./graphql.js";
type TweetEntry = {
tweet: any;
user?: any;
};
type ParsedEntries = {
entries: TweetEntry[];
moreCursor?: string;
topCursor?: string;
bottomCursor?: string;
};
type ThreadResult = {
requestedId: string;
rootId: string;
tweets: any[];
totalTweets: number;
user?: any;
responses?: unknown[];
};
function unwrapTweetResult(result: any): any {
if (!result) return null;
if (result.__typename === "TweetWithVisibilityResults" && result.tweet) {
return result.tweet;
}
return result;
}
function extractTweetEntry(itemContent: any): TweetEntry | null {
const result = itemContent?.tweet_results?.result;
if (!result) return null;
const resolved = unwrapTweetResult(result?.tweet ?? result);
if (!resolved) return null;
const user = resolved?.core?.user_results?.result?.legacy;
return { tweet: resolved, user };
}
function parseInstruction(instruction?: any): ParsedEntries {
const { entries: entities, moduleItems } = instruction || {};
const entries: TweetEntry[] = [];
let moreCursor: string | undefined;
let topCursor: string | undefined;
let bottomCursor: string | undefined;
const parseItems = (items: any[]) => {
items?.forEach((item) => {
const itemContent = item?.item?.itemContent ?? item?.itemContent;
if (!itemContent) {
return;
}
if (
itemContent.cursorType &&
["ShowMore", "ShowMoreThreads"].includes(itemContent.cursorType) &&
itemContent.itemType === "TimelineTimelineCursor"
) {
moreCursor = itemContent.value;
return;
}
const entry = extractTweetEntry(itemContent);
if (entry) {
entries.push(entry);
}
});
};
if (moduleItems) {
parseItems(moduleItems);
}
for (const entity of entities ?? []) {
if (entity?.content?.clientEventInfo?.component === "you_might_also_like") {
continue;
}
const { itemContent, items, cursorType, entryType, value } = entity?.content ?? {};
if (cursorType === "Bottom" && entryType === "TimelineTimelineCursor") {
bottomCursor = value;
}
if (
itemContent?.cursorType === "Bottom" &&
itemContent?.itemType === "TimelineTimelineCursor"
) {
bottomCursor = bottomCursor ?? itemContent?.value;
}
if (cursorType === "Top" && entryType === "TimelineTimelineCursor") {
topCursor = topCursor ?? value;
}
if (itemContent) {
const entry = extractTweetEntry(itemContent);
if (entry) {
entries.push(entry);
}
if (
itemContent.cursorType &&
["ShowMore", "ShowMoreThreads"].includes(itemContent.cursorType) &&
itemContent.itemType === "TimelineTimelineCursor"
) {
moreCursor = moreCursor ?? itemContent.value;
}
if (itemContent.cursorType === "Top" && itemContent.itemType === "TimelineTimelineCursor") {
topCursor = topCursor ?? itemContent.value;
}
}
if (items) {
parseItems(items);
}
}
return { entries, moreCursor, topCursor, bottomCursor };
}
function parseTweetsAndToken(response: any): ParsedEntries {
const instruction =
response?.data?.threaded_conversation_with_injections_v2?.instructions?.find(
(ins: any) => ins?.type === "TimelineAddEntries" || ins?.type === "TimelineAddToModule"
) ??
response?.data?.threaded_conversation_with_injections?.instructions?.find(
(ins: any) => ins?.type === "TimelineAddEntries" || ins?.type === "TimelineAddToModule"
);
return parseInstruction(instruction);
}
function toTimestamp(value: string | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
export async function fetchTweetThread(
tweetId: string,
cookieMap: Record<string, string>,
includeResponses = false
): Promise<ThreadResult | null> {
const responses: unknown[] = [];
const res = await fetchTweetDetail(tweetId, cookieMap);
if (includeResponses) {
responses.push(res);
}
let { entries, moreCursor, topCursor, bottomCursor } = parseTweetsAndToken(res);
if (!entries.length) {
const errorMessage = res?.errors?.[0]?.message;
if (errorMessage) {
throw new Error(errorMessage);
}
return null;
}
let allEntries = entries.slice();
const root = allEntries.find((entry) => entry.tweet?.legacy?.id_str === tweetId);
if (!root) {
throw new Error("Can not fetch the root tweet");
}
let rootEntry = root.tweet.legacy;
const isSameThread = (entry: TweetEntry) => {
const tweet = entry.tweet?.legacy;
if (!tweet) return false;
return (
tweet.user_id_str === rootEntry.user_id_str &&
tweet.conversation_id_str === rootEntry.conversation_id_str &&
(tweet.id_str === rootEntry.id_str ||
tweet.in_reply_to_user_id_str === rootEntry.user_id_str ||
tweet.in_reply_to_status_id_str === rootEntry.conversation_id_str ||
!tweet.in_reply_to_user_id_str)
);
};
const inThread = (items: TweetEntry[]) => items.some(isSameThread);
let hasThread = inThread(entries);
let maxRequestCount = 1000;
let topHasThread = true;
while (topCursor && topHasThread && maxRequestCount > 0) {
const newRes = await fetchTweetDetail(tweetId, cookieMap, topCursor);
if (includeResponses) {
responses.push(newRes);
}
const parsed = parseTweetsAndToken(newRes);
topHasThread = inThread(parsed.entries);
topCursor = parsed.topCursor;
allEntries = parsed.entries.concat(allEntries);
maxRequestCount--;
}
async function checkMoreTweets(focalId: string) {
while (moreCursor && hasThread && maxRequestCount > 0) {
const newRes = await fetchTweetDetail(focalId, cookieMap, moreCursor);
if (includeResponses) {
responses.push(newRes);
}
const parsed = parseTweetsAndToken(newRes);
moreCursor = parsed.moreCursor;
bottomCursor = bottomCursor ?? parsed.bottomCursor;
hasThread = inThread(parsed.entries);
allEntries = allEntries.concat(parsed.entries);
maxRequestCount--;
}
if (bottomCursor) {
const newRes = await fetchTweetDetail(focalId, cookieMap, bottomCursor);
if (includeResponses) {
responses.push(newRes);
}
const parsed = parseTweetsAndToken(newRes);
allEntries = allEntries.concat(parsed.entries);
bottomCursor = undefined;
}
}
await checkMoreTweets(tweetId);
const allThreadEntries = allEntries.filter(
(entry) => entry.tweet?.legacy?.id_str === tweetId || isSameThread(entry)
);
const lastEntity = allThreadEntries[allThreadEntries.length - 1];
if (lastEntity?.tweet?.legacy?.id_str) {
const lastRes = await fetchTweetDetail(lastEntity.tweet.legacy.id_str, cookieMap);
if (includeResponses) {
responses.push(lastRes);
}
const parsed = parseTweetsAndToken(lastRes);
hasThread = inThread(parsed.entries);
allEntries = allEntries.concat(parsed.entries);
moreCursor = parsed.moreCursor;
bottomCursor = parsed.bottomCursor;
maxRequestCount--;
await checkMoreTweets(lastEntity.tweet.legacy.id_str);
}
const distinctEntries: TweetEntry[] = [];
const entriesMap = allEntries.reduce((acc, entry) => {
const id = entry.tweet?.legacy?.id_str ?? entry.tweet?.rest_id;
if (id && !acc.has(id)) {
distinctEntries.push(entry);
acc.set(id, entry);
}
return acc;
}, new Map<string, TweetEntry>());
allEntries = distinctEntries;
while (rootEntry.in_reply_to_status_id_str) {
const parent = entriesMap.get(rootEntry.in_reply_to_status_id_str)?.tweet?.legacy;
if (
parent &&
parent.user_id_str === rootEntry.user_id_str &&
parent.conversation_id_str === rootEntry.conversation_id_str &&
parent.id_str !== rootEntry.id_str
) {
rootEntry = parent;
} else {
break;
}
}
allEntries = allEntries.sort((a, b) => {
const aTime = toTimestamp(a.tweet?.legacy?.created_at);
const bTime = toTimestamp(b.tweet?.legacy?.created_at);
return aTime - bTime;
});
const rootIndex = allEntries.findIndex(
(entry) => entry.tweet?.legacy?.id_str === rootEntry.id_str
);
if (rootIndex > 0) {
allEntries = allEntries.slice(rootIndex);
}
const threadEntries = allEntries.filter(
(entry) => entry.tweet?.legacy?.id_str === tweetId || isSameThread(entry)
);
if (!threadEntries.length) {
return null;
}
const tweets = threadEntries.map((entry) => entry.tweet);
const user = threadEntries[0].user ?? threadEntries[0].tweet?.core?.user_results?.result?.legacy;
const result: ThreadResult = {
requestedId: tweetId,
rootId: rootEntry.id_str ?? tweetId,
tweets,
totalTweets: tweets.length,
user,
};
if (includeResponses) {
result.responses = responses;
}
return result;
}
@@ -0,0 +1,96 @@
import { fetchXArticle } from "./graphql.js";
import type { ArticleEntity } from "./types.js";
function coerceArticleEntity(value: unknown): ArticleEntity | null {
if (!value || typeof value !== "object") return null;
const candidate = value as ArticleEntity;
if (
typeof candidate.title === "string" ||
typeof candidate.plain_text === "string" ||
typeof candidate.preview_text === "string" ||
candidate.content_state
) {
return candidate;
}
return null;
}
function hasArticleContent(article: ArticleEntity): boolean {
const blocks = article.content_state?.blocks;
if (Array.isArray(blocks) && blocks.length > 0) {
return true;
}
if (typeof article.plain_text === "string" && article.plain_text.trim()) {
return true;
}
if (typeof article.preview_text === "string" && article.preview_text.trim()) {
return true;
}
return false;
}
function parseArticleIdFromUrl(raw: string | undefined): string | null {
if (!raw) return null;
try {
const parsed = new URL(raw);
const match = parsed.pathname.match(/\/(?:i\/)?article\/(\d+)/);
if (match?.[1]) return match[1];
} catch {
return null;
}
return null;
}
function extractArticleIdFromUrls(urls: any[] | undefined): string | null {
if (!Array.isArray(urls)) return null;
for (const url of urls) {
const candidate =
url?.expanded_url ?? url?.url ?? (url?.display_url ? `https://${url.display_url}` : undefined);
const id = parseArticleIdFromUrl(candidate);
if (id) return id;
}
return null;
}
export function extractArticleEntityFromTweet(tweet: any): unknown | null {
return (
tweet?.article?.article_results?.result ??
tweet?.article?.result ??
tweet?.legacy?.article?.article_results?.result ??
tweet?.legacy?.article?.result ??
tweet?.article_results?.result ??
null
);
}
export function extractArticleIdFromTweet(tweet: any): string | null {
const embedded = extractArticleEntityFromTweet(tweet);
const embeddedArticle = embedded as { rest_id?: string } | null;
if (embeddedArticle?.rest_id) {
return embeddedArticle.rest_id;
}
const noteUrls = tweet?.note_tweet?.note_tweet_results?.result?.entity_set?.urls;
const legacyUrls = tweet?.legacy?.entities?.urls;
return extractArticleIdFromUrls(noteUrls) ?? extractArticleIdFromUrls(legacyUrls);
}
export async function resolveArticleEntityFromTweet(
tweet: any,
cookieMap: Record<string, string>
): Promise<unknown | null> {
if (!tweet) return null;
const embedded = extractArticleEntityFromTweet(tweet);
const embeddedArticle = coerceArticleEntity(embedded);
if (embeddedArticle && hasArticleContent(embeddedArticle)) {
return embedded;
}
const articleId = extractArticleIdFromTweet(tweet);
if (!articleId) {
return embedded ?? null;
}
const fetched = await fetchXArticle(articleId, cookieMap, false);
return fetched ?? embedded ?? null;
}
@@ -0,0 +1,190 @@
#!/usr/bin/env npx tsx
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { hasRequiredXCookies, loadXCookies } from "./cookies.js";
import { fetchTweetThread } from "./thread.js";
import { formatArticleMarkdown } from "./markdown.js";
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
type TweetToMarkdownOptions = {
log?: (message: string) => void;
};
function parseArgs(): { url?: string } {
const args = process.argv.slice(2);
let url: string | undefined;
for (const arg of args) {
if (!arg.startsWith("-") && !url) {
url = arg;
}
}
return { url };
}
function normalizeInputUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
try {
return new URL(trimmed).toString();
} catch {
return trimmed;
}
}
function parseTweetId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed);
const match = parsed.pathname.match(/\/status(?:es)?\/(\d+)/);
if (match?.[1]) return match[1];
} catch {
return null;
}
return null;
}
function buildTweetUrl(username: string | undefined, tweetId: string | undefined): string | null {
if (!tweetId) return null;
if (username) {
return `https://x.com/${username}/status/${tweetId}`;
}
return `https://x.com/i/web/status/${tweetId}`;
}
function formatMetaMarkdown(meta: Record<string, string | number | null | undefined>): string {
const lines = ["---"];
for (const [key, value] of Object.entries(meta)) {
if (value === undefined || value === null || value === "") continue;
if (typeof value === "number") {
lines.push(`${key}: ${value}`);
} else {
lines.push(`${key}: ${JSON.stringify(value)}`);
}
}
lines.push("---");
return lines.join("\n");
}
function extractTweetText(tweet: any): string {
const noteText = tweet?.note_tweet?.note_tweet_results?.result?.text;
const legacyText = tweet?.legacy?.full_text ?? tweet?.legacy?.text ?? "";
return (noteText ?? legacyText ?? "").trim();
}
function isOnlyUrl(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return true;
return /^https?:\/\/\S+$/.test(trimmed);
}
export async function tweetToMarkdown(
inputUrl: string,
options: TweetToMarkdownOptions = {}
): Promise<string> {
const normalizedUrl = normalizeInputUrl(inputUrl);
const tweetId = parseTweetId(normalizedUrl);
if (!tweetId) {
throw new Error("Invalid tweet url. Example: https://x.com/<user>/status/<tweet_id>");
}
const log = options.log ?? (() => {});
log("[tweet-to-markdown] Loading cookies...");
const cookieMap = await loadXCookies(log);
if (!hasRequiredXCookies(cookieMap)) {
throw new Error("Missing auth cookies. Provide X_AUTH_TOKEN and X_CT0 or log in via Chrome.");
}
log(`[tweet-to-markdown] Fetching thread for ${tweetId}...`);
const thread = await fetchTweetThread(tweetId, cookieMap);
if (!thread) {
throw new Error("Failed to fetch thread.");
}
const tweets = thread.tweets ?? [];
if (tweets.length === 0) {
throw new Error("No tweets found in thread.");
}
const firstTweet = tweets[0] as any;
const user = thread.user ?? firstTweet?.core?.user_results?.result?.legacy;
const username = user?.screen_name;
const name = user?.name;
const author =
username && name ? `${name} (@${username})` : username ? `@${username}` : name ?? null;
const authorUrl = username ? `https://x.com/${username}` : undefined;
const requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim();
const rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl;
const meta = formatMetaMarkdown({
url: rootUrl,
requested_url: requestedUrl,
author,
author_name: name ?? null,
author_username: username ?? null,
author_url: authorUrl ?? null,
tweet_count: thread.totalTweets ?? tweets.length,
});
const parts: string[] = [meta];
const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap);
let remainingTweets = tweets;
if (articleEntity) {
const articleMarkdown = formatArticleMarkdown(articleEntity).trimEnd();
if (articleMarkdown) {
parts.push(articleMarkdown);
const firstTweetText = extractTweetText(firstTweet);
if (isOnlyUrl(firstTweetText)) {
remainingTweets = tweets.slice(1);
}
}
}
if (remainingTweets.length > 0) {
const hasArticle = parts.length > 1;
if (hasArticle) {
parts.push("## Thread");
}
const tweetMarkdown = formatThreadTweetsMarkdown(remainingTweets, {
username,
headingLevel: hasArticle ? 3 : 2,
startIndex: 1,
includeTweetUrls: true,
});
if (tweetMarkdown) {
parts.push(tweetMarkdown);
}
}
return parts.join("\n\n").trimEnd();
}
async function main() {
const { url } = parseArgs();
if (!url) {
console.error("Usage:");
console.error(" npx -y bun skills/baoyu-danger-x-to-markdown/scripts/tweet-to-markdown.ts <tweet url>");
process.exit(1);
}
const markdown = await tweetToMarkdown(url, { log: console.log });
console.log(markdown);
}
const isCliExecution =
process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCliExecution) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
@@ -0,0 +1,79 @@
export type CookieLike = {
name?: string;
value?: string;
domain?: string;
path?: string;
url?: string;
};
export type ArticleQueryInfo = {
queryId: string;
featureSwitches: string[];
fieldToggles: string[];
html: string;
};
export type ArticleEntityRange = {
key?: number;
offset?: number;
length?: number;
};
export type ArticleBlock = {
type?: string;
text?: string;
entityRanges?: ArticleEntityRange[];
};
export type ArticleEntityMapMediaItem = {
mediaId?: string;
media_id?: string;
localMediaId?: string;
};
export type ArticleEntityMapEntry = {
key?: string;
value?: {
type?: string;
mutability?: string;
data?: {
caption?: string;
mediaItems?: ArticleEntityMapMediaItem[];
url?: string;
};
};
};
export type ArticleContentState = {
blocks?: ArticleBlock[];
entityMap?: Record<string, ArticleEntityMapEntry>;
};
export type ArticleMediaInfo = {
__typename?: string;
original_img_url?: string;
preview_image?: {
original_img_url?: string;
};
variants?: Array<{
content_type?: string;
url?: string;
bit_rate?: number;
}>;
};
export type ArticleMediaEntity = {
media_id?: string;
media_info?: ArticleMediaInfo;
};
export type ArticleEntity = {
title?: string;
plain_text?: string;
preview_text?: string;
content_state?: ArticleContentState;
cover_media?: {
media_info?: ArticleMediaInfo;
};
media_entities?: ArticleMediaEntity[];
};
@@ -1,5 +1,5 @@
---
name: post-to-wechat
name: baoyu-post-to-wechat
description: Post content to WeChat Official Account (微信公众号). Supports both article posting (文章) and image-text posting (图文).
---
@@ -7,25 +7,45 @@ description: Post content to WeChat Official Account (微信公众号). Supports
Post content to WeChat Official Account using Chrome CDP automation.
## 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/wechat-browser.ts` | Image-text posts (图文) |
| `scripts/wechat-article.ts` | Full article posting (文章) |
| `scripts/md-to-wechat.ts` | Markdown → WeChat HTML conversion |
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
## Quick Usage
### Image-Text (图文) - Multiple images with title/content
```bash
# From markdown file and image directory
npx -y bun ./scripts/wechat-browser.ts --markdown article.md --images ./images/
npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --markdown article.md --images ./images/
# With explicit parameters
npx -y bun ./scripts/wechat-browser.ts --title "标题" --content "内容" --image img1.png --image img2.png --submit
npx -y bun ${SKILL_DIR}/scripts/wechat-browser.ts --title "标题" --content "内容" --image img1.png --image img2.png --submit
```
### Article (文章) - Full markdown with formatting
```bash
# Post markdown article
npx -y bun ./scripts/wechat-article.ts --markdown article.md --theme grace
npx -y bun ${SKILL_DIR}/scripts/wechat-article.ts --markdown article.md --theme grace
```
> **Note**: `${SKILL_DIR}` represents this skill's installation directory. Agent replaces with actual path at runtime.
## References
- **Image-Text Posting**: See `references/image-text-posting.md` for detailed image-text posting guide
@@ -52,3 +72,13 @@ npx -y bun ./scripts/wechat-article.ts --markdown article.md --theme grace
- **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.

Some files were not shown because too many files have changed in this diff Show More