Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8a5a68a74 |
@@ -6,15 +6,16 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.4.2"
|
"version": "0.8.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "content-skills",
|
"name": "content-skills",
|
||||||
"description": "Content generation and publishing skills",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"./skills/baoyu-gemini-web",
|
||||||
"./skills/baoyu-xhs-images",
|
"./skills/baoyu-xhs-images",
|
||||||
"./skills/baoyu-post-to-x",
|
"./skills/baoyu-post-to-x",
|
||||||
"./skills/baoyu-post-to-wechat",
|
"./skills/baoyu-post-to-wechat",
|
||||||
@@ -23,25 +24,6 @@
|
|||||||
"./skills/baoyu-slide-deck",
|
"./skills/baoyu-slide-deck",
|
||||||
"./skills/baoyu-comic"
|
"./skills/baoyu-comic"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ai-generation-skills",
|
|
||||||
"description": "AI-powered generation backends",
|
|
||||||
"source": "./",
|
|
||||||
"strict": false,
|
|
||||||
"skills": [
|
|
||||||
"./skills/baoyu-danger-gemini-web"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "utility-skills",
|
|
||||||
"description": "Utility tools for content processing",
|
|
||||||
"source": "./",
|
|
||||||
"strict": false,
|
|
||||||
"skills": [
|
|
||||||
"./skills/baoyu-danger-x-to-markdown",
|
|
||||||
"./skills/baoyu-compress-image"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,8 +121,6 @@ git add README.md README.zh.md CHANGELOG.md CHANGELOG.zh.md .claude-plugin/marke
|
|||||||
git commit -m "chore: release v{NEW_VERSION}"
|
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
|
### Step 7: Create Version Tag
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -140,7 +140,3 @@ vite.config.ts.timestamp-*
|
|||||||
tests-data/
|
tests-data/
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Skill extensions (user customization)
|
|
||||||
.baoyu-skills/
|
|
||||||
x-to-markdown/
|
|
||||||
|
|||||||
@@ -2,106 +2,10 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
## 1.4.2 - 2026-01-19
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- `baoyu-danger-gemini-web`: adds supported browsers list (Chrome, Chromium, Edge) and proxy configuration guide.
|
|
||||||
|
|
||||||
## 1.4.1 - 2026-01-18
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- `baoyu-post-to-x`: supports multi-language UI selectors for X Articles (contributed by [@ianchenx](https://github.com/ianchenx)).
|
|
||||||
|
|
||||||
## 1.4.0 - 2026-01-18
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- `baoyu-cover-image`: expands style library from 8 to 19 styles with 12 new additions—`blueprint`, `bold-editorial`, `chalkboard`, `dark-atmospheric`, `editorial-infographic`, `fantasy-animation`, `intuition-machine`, `notion`, `pixel-art`, `sketch-notes`, `vector-illustration`, `vintage`, `watercolor`.
|
|
||||||
- `baoyu-slide-deck`: adds `chalkboard` style—black chalkboard background with colorful chalk drawings for education and tutorials.
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
- `baoyu-cover-image`: removes `tech` style (use `blueprint` or `editorial-infographic` for technical content).
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- `README.md`, `README.zh.md`: updates style preview screenshots for cover-image and slide-deck.
|
|
||||||
|
|
||||||
## 1.3.0 - 2026-01-18
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- `baoyu-comic`: adds `wuxia` style—Hong Kong martial arts comic style with ink brush strokes, dynamic combat poses, and qi energy effects. Best for wuxia/xianxia and Chinese historical fiction.
|
|
||||||
- `baoyu-comic`: adds style and layout preview screenshots for all 8 styles and 6 layouts in README.
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
- `baoyu-comic`: removes `tech` style (replaced by `ohmsha` for technical content).
|
|
||||||
|
|
||||||
## 1.2.0 - 2026-01-18
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Session-independent output directories: each generation session creates a new directory (`<skill-suffix>/<topic-slug>/`), even for the same source file. Conflicts resolved by appending timestamp.
|
|
||||||
- Multi-source file support: source files now saved as `source-{slug}.{ext}`, supporting multiple inputs (text, images, files from conversation).
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- `CLAUDE.md`: updates Output Path Convention with new session-independent directory structure and multi-source file naming.
|
|
||||||
- Multiple skills: updates file management sections to reflect new directory and source file conventions.
|
|
||||||
- `baoyu-slide-deck`, `baoyu-article-illustrator`, `baoyu-cover-image`, `baoyu-xhs-images`, `baoyu-comic`
|
|
||||||
|
|
||||||
## 1.1.0 - 2026-01-18
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- `baoyu-compress-image`: new utility skill for cross-platform image compression. Converts to WebP by default with PNG-to-PNG support. Uses system tools (sips, cwebp, ImageMagick) with Sharp fallback.
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
- Marketplace structure: reorganizes plugins into three categories—`content-skills`, `ai-generation-skills`, and `utility-skills`—for better organization.
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- `CLAUDE.md`, `README.md`, `README.zh.md`: updates skill architecture documentation to reflect the new three-category structure.
|
|
||||||
|
|
||||||
## 1.0.1 - 2026-01-18
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
- Code structure improvements for better readability and maintainability.
|
|
||||||
- `baoyu-slide-deck`: unified style reference file formats.
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- Screenshots: converted from PNG to WebP format for smaller file sizes; added screenshots for new styles.
|
|
||||||
|
|
||||||
## 1.0.0 - 2026-01-18
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- `baoyu-danger-x-to-markdown`: new skill to convert X/Twitter posts and threads to Markdown format.
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
- `baoyu-gemini-web` renamed to `baoyu-danger-gemini-web` to indicate potential risks of using reverse-engineered APIs.
|
|
||||||
|
|
||||||
## 0.11.0 - 2026-01-18
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- `baoyu-danger-gemini-web`: adds disclaimer consent check flow—requires user acceptance before first use, with persistent consent storage per platform.
|
|
||||||
|
|
||||||
## 0.10.0 - 2026-01-18
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- `baoyu-slide-deck`: expands style library from 10 to 15 styles with 8 new additions—`dark-atmospheric`, `editorial-infographic`, `fantasy-animation`, `intuition-machine`, `pixel-art`, `scientific`, `vintage`, `watercolor`.
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
- `baoyu-slide-deck`: removes 3 styles (`playful`, `storytelling`, `warm`); changes default style from `notion` to `blueprint`.
|
|
||||||
|
|
||||||
## 0.9.0 - 2026-01-17
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Extension support: all skills now support customization via `EXTEND.md` files. Check `.baoyu-skills/<skill-name>/EXTEND.md` (project) or `~/.baoyu-skills/<skill-name>/EXTEND.md` (user) for custom styles and configurations.
|
|
||||||
|
|
||||||
### Other
|
|
||||||
- `.gitignore`: adds `.baoyu-skills/` directory for user extension files.
|
|
||||||
|
|
||||||
## 0.8.2 - 2026-01-17
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
- `baoyu-danger-gemini-web`: reorganizes script architecture—moves modular files into `gemini-webapi/` subdirectory and updates SKILL.md with `${SKILL_DIR}` path references.
|
|
||||||
|
|
||||||
## 0.8.1 - 2026-01-17
|
## 0.8.1 - 2026-01-17
|
||||||
|
|
||||||
### Refactor
|
### 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).
|
- `baoyu-gemini-web`: refactors script architecture—consolidates 10 separate files into a structured `gemini-webapi/` module (TypeScript port of gemini_webapi Python library).
|
||||||
|
|
||||||
## 0.8.0 - 2026-01-17
|
## 0.8.0 - 2026-01-17
|
||||||
|
|
||||||
@@ -131,7 +35,7 @@ English | [中文](./CHANGELOG.zh.md)
|
|||||||
|
|
||||||
- `baoyu-slide-deck`: adds `scripts/merge-to-pdf.ts` to export generated slides into a single PDF; docs updated with pptx/pdf outputs.
|
- `baoyu-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).
|
- `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.
|
- Docs conventions: adds a “Script Directory” template to `CLAUDE.md`; aligns `baoyu-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` docs to use `${SKILL_DIR}` in commands so agents can run scripts from any install location.
|
||||||
|
|
||||||
## 0.6.0 - 2026-01-17
|
## 0.6.0 - 2026-01-17
|
||||||
|
|
||||||
@@ -146,8 +50,8 @@ English | [中文](./CHANGELOG.zh.md)
|
|||||||
|
|
||||||
## 0.5.2 - 2026-01-16
|
## 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-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.
|
- `baoyu-gemini-web`: adds `--reference/--ref` for reference images (vision input), plus stronger timeout handling and cookie refresh recovery.
|
||||||
- Docs: `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic` document session usage (reuse one `sessionId` per set) to improve visual consistency.
|
- 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
|
## 0.5.1 - 2026-01-16
|
||||||
@@ -163,7 +67,7 @@ English | [中文](./CHANGELOG.zh.md)
|
|||||||
|
|
||||||
## 0.4.2 - 2026-01-15
|
## 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`).
|
- `baoyu-gemini-web`: updates description to clarify it as the image-generation backend for other skills (e.g. `cover-image`, `xhs-images`, `article-illustrator`).
|
||||||
|
|
||||||
## 0.4.1 - 2026-01-15
|
## 0.4.1 - 2026-01-15
|
||||||
|
|
||||||
|
|||||||
@@ -2,106 +2,10 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
## 1.4.2 - 2026-01-19
|
|
||||||
|
|
||||||
### 文档
|
|
||||||
- `baoyu-danger-gemini-web`:添加支持的浏览器列表(Chrome、Chromium、Edge)和代理配置指南。
|
|
||||||
|
|
||||||
## 1.4.1 - 2026-01-18
|
|
||||||
|
|
||||||
### 修复
|
|
||||||
- `baoyu-post-to-x`:支持 X Articles 多语言 UI 选择器(感谢 [@ianchenx](https://github.com/ianchenx) 贡献)。
|
|
||||||
|
|
||||||
## 1.4.0 - 2026-01-18
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
- `baoyu-cover-image`:风格库从 8 个扩展至 19 个,新增 12 种风格——`blueprint`(蓝图)、`bold-editorial`(大胆编辑)、`chalkboard`(黑板)、`dark-atmospheric`(暗黑氛围)、`editorial-infographic`(杂志信息图)、`fantasy-animation`(奇幻动画)、`intuition-machine`(技术简报)、`notion`(Notion 风格)、`pixel-art`(像素艺术)、`sketch-notes`(手绘笔记)、`vector-illustration`(矢量插画)、`vintage`(复古文献)、`watercolor`(水彩)。
|
|
||||||
- `baoyu-slide-deck`:新增 `chalkboard`(黑板)风格——黑色黑板背景配彩色粉笔绘画,适合教育和教程内容。
|
|
||||||
|
|
||||||
### 破坏性变更
|
|
||||||
- `baoyu-cover-image`:移除 `tech` 风格(技术内容改用 `blueprint` 或 `editorial-infographic` 风格)。
|
|
||||||
|
|
||||||
### 文档
|
|
||||||
- `README.md`、`README.zh.md`:更新 cover-image 和 slide-deck 风格预览截图。
|
|
||||||
|
|
||||||
## 1.3.0 - 2026-01-18
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
- `baoyu-comic`:新增 `wuxia` 武侠风格——港漫武侠风格,水墨笔触、动态打斗、气功特效。适用于武侠、仙侠、中国历史小说。
|
|
||||||
- `baoyu-comic`:README 新增风格和布局预览截图(8 种风格 + 6 种布局)。
|
|
||||||
|
|
||||||
### 重构
|
|
||||||
- `baoyu-comic`:移除 `tech` 风格(技术内容改用 `ohmsha` 风格)。
|
|
||||||
|
|
||||||
## 1.2.0 - 2026-01-18
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
- Session 独立输出目录:每次生成创建独立目录(`<skill-suffix>/<topic-slug>/`),即使是同一源文件也会新建目录。目录冲突时追加时间戳。
|
|
||||||
- 多源文件支持:源文件现以 `source-{slug}.{ext}` 命名,支持多个输入(文本、图片、会话中的文件)。
|
|
||||||
|
|
||||||
### 文档
|
|
||||||
- `CLAUDE.md`:更新 Output Path Convention,采用新的 session 独立目录结构和多源文件命名规范。
|
|
||||||
- 多个技能:更新文件管理部分,反映新的目录和源文件规范。
|
|
||||||
- `baoyu-slide-deck`、`baoyu-article-illustrator`、`baoyu-cover-image`、`baoyu-xhs-images`、`baoyu-comic`
|
|
||||||
|
|
||||||
## 1.1.0 - 2026-01-18
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
- `baoyu-compress-image`:新增跨平台图片压缩技能。默认转换为 WebP 格式,支持 PNG 转 PNG。自动选择系统工具(sips、cwebp、ImageMagick),Sharp 作为兜底方案。
|
|
||||||
|
|
||||||
### 重构
|
|
||||||
- Marketplace 结构重组:将插件分为三大类——`content-skills`(内容技能)、`ai-generation-skills`(AI 生成技能)和 `utility-skills`(工具技能),便于管理和发现。
|
|
||||||
|
|
||||||
### 文档
|
|
||||||
- `CLAUDE.md`、`README.md`、`README.zh.md`:更新技能架构文档,反映新的三类分组结构。
|
|
||||||
|
|
||||||
## 1.0.1 - 2026-01-18
|
|
||||||
|
|
||||||
### 重构
|
|
||||||
- 代码结构优化,提升可读性和可维护性。
|
|
||||||
- `baoyu-slide-deck`:统一风格参考文件格式。
|
|
||||||
|
|
||||||
### 其他
|
|
||||||
- 截图:从 PNG 转换为 WebP 格式,减小文件体积;新增新风格的截图。
|
|
||||||
|
|
||||||
## 1.0.0 - 2026-01-18
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
- `baoyu-danger-x-to-markdown`:新增技能,将 X/Twitter 帖子和线程转换为 Markdown 格式。
|
|
||||||
|
|
||||||
### 破坏性变更
|
|
||||||
- `baoyu-gemini-web` 重命名为 `baoyu-danger-gemini-web`,以提示使用逆向工程 API 的潜在风险。
|
|
||||||
|
|
||||||
## 0.11.0 - 2026-01-18
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
- `baoyu-danger-gemini-web`:新增 Disclaimer 同意检查流程——首次使用前需用户确认接受,同意状态按平台持久化存储。
|
|
||||||
|
|
||||||
## 0.10.0 - 2026-01-18
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
- `baoyu-slide-deck`:风格库从 10 个扩展至 15 个,新增 8 种风格——`dark-atmospheric`(暗黑氛围)、`editorial-infographic`(杂志信息图)、`fantasy-animation`(奇幻动画)、`intuition-machine`(技术简报)、`pixel-art`(像素艺术)、`scientific`(科学图解)、`vintage`(复古文献)、`watercolor`(水彩手绘)。
|
|
||||||
|
|
||||||
### 破坏性变更
|
|
||||||
- `baoyu-slide-deck`:移除 3 种风格(`playful`、`storytelling`、`warm`);默认风格从 `notion` 改为 `blueprint`。
|
|
||||||
|
|
||||||
## 0.9.0 - 2026-01-17
|
|
||||||
|
|
||||||
### 新功能
|
|
||||||
- 扩展支持:所有技能现支持通过 `EXTEND.md` 文件自定义。检查 `.baoyu-skills/<skill-name>/EXTEND.md`(项目级)或 `~/.baoyu-skills/<skill-name>/EXTEND.md`(用户级)配置自定义样式与设置。
|
|
||||||
|
|
||||||
### 其他
|
|
||||||
- `.gitignore`:添加 `.baoyu-skills/` 目录忽略,存放用户扩展文件。
|
|
||||||
|
|
||||||
## 0.8.2 - 2026-01-17
|
|
||||||
|
|
||||||
### 重构
|
|
||||||
- `baoyu-danger-gemini-web`:重组脚本架构——将模块文件移至 `gemini-webapi/` 子目录,并更新 SKILL.md 使用 `${SKILL_DIR}` 路径引用。
|
|
||||||
|
|
||||||
## 0.8.1 - 2026-01-17
|
## 0.8.1 - 2026-01-17
|
||||||
|
|
||||||
### 重构
|
### 重构
|
||||||
- `baoyu-danger-gemini-web`:重构脚本架构——将 10 个分散的脚本文件整合为结构化的 `gemini-webapi/` 模块(gemini_webapi Python 库的 TypeScript 移植版)。
|
- `baoyu-gemini-web`:重构脚本架构——将 10 个分散的脚本文件整合为结构化的 `gemini-webapi/` 模块(gemini_webapi Python 库的 TypeScript 移植版)。
|
||||||
|
|
||||||
## 0.8.0 - 2026-01-17
|
## 0.8.0 - 2026-01-17
|
||||||
|
|
||||||
@@ -131,7 +35,7 @@
|
|||||||
|
|
||||||
- `baoyu-slide-deck`:新增 `scripts/merge-to-pdf.ts`,可将生成的 slide 图片一键合并为 PDF;文档补充导出步骤与产物命名(pptx/pdf)。
|
- `baoyu-slide-deck`:新增 `scripts/merge-to-pdf.ts`,可将生成的 slide 图片一键合并为 PDF;文档补充导出步骤与产物命名(pptx/pdf)。
|
||||||
- `baoyu-comic`:新增 `scripts/merge-to-pdf.ts`,将封面/分页图片合并为 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 在任意安装目录运行。
|
- 文档规范:在 `CLAUDE.md` 中补充“Script Directory”模板;`baoyu-gemini-web` / `baoyu-slide-deck` / `baoyu-comic` 文档统一用 `${SKILL_DIR}` 引用脚本路径,方便 agent 在任意安装目录运行。
|
||||||
|
|
||||||
## 0.6.0 - 2026-01-17
|
## 0.6.0 - 2026-01-17
|
||||||
|
|
||||||
@@ -146,8 +50,8 @@
|
|||||||
|
|
||||||
## 0.5.2 - 2026-01-16
|
## 0.5.2 - 2026-01-16
|
||||||
|
|
||||||
- `baoyu-danger-gemini-web`:新增 `--sessionId`(本地持久化会话,支持 `--list-sessions`),用于多轮对话/多图生成保持上下文一致。
|
- `baoyu-gemini-web`:新增 `--sessionId`(本地持久化会话,支持 `--list-sessions`),用于多轮对话/多图生成保持上下文一致。
|
||||||
- `baoyu-danger-gemini-web`:新增 `--reference/--ref` 传入参考图片(vision 输入),并增强超时与 cookie 失效自动恢复逻辑。
|
- `baoyu-gemini-web`:新增 `--reference/--ref` 传入参考图片(vision 输入),并增强超时与 cookie 失效自动恢复逻辑。
|
||||||
- `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic`:文档补充 session 约定(整套图使用同一 `sessionId`,增强风格一致性)。
|
- `baoyu-xhs-images` / `baoyu-slide-deck` / `baoyu-comic`:文档补充 session 约定(整套图使用同一 `sessionId`,增强风格一致性)。
|
||||||
|
|
||||||
## 0.5.1 - 2026-01-16
|
## 0.5.1 - 2026-01-16
|
||||||
@@ -163,7 +67,7 @@
|
|||||||
|
|
||||||
## 0.4.2 - 2026-01-15
|
## 0.4.2 - 2026-01-15
|
||||||
|
|
||||||
- `baoyu-danger-gemini-web`:描述信息更新,明确其作为 `cover-image` / `xhs-images` / `article-illustrator` 等技能的图片生成后端。
|
- `baoyu-gemini-web`:描述信息更新,明确其作为 `cover-image` / `xhs-images` / `article-illustrator` 等技能的图片生成后端。
|
||||||
|
|
||||||
## 0.4.1 - 2026-01-15
|
## 0.4.1 - 2026-01-15
|
||||||
|
|
||||||
|
|||||||
@@ -8,34 +8,17 @@ Claude Code marketplace plugin providing AI-powered content generation skills. S
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Skills are organized into three plugin categories in `marketplace.json`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
skills/
|
skills/
|
||||||
├── [content-skills] # Content generation and publishing
|
├── baoyu-gemini-web/ # Core: Gemini API wrapper (text + image gen)
|
||||||
│ ├── baoyu-xhs-images/ # Xiaohongshu infographic series (1-10 images)
|
├── baoyu-xhs-images/ # Xiaohongshu infographic series (1-10 images)
|
||||||
│ ├── baoyu-cover-image/ # Article cover images (2.35:1 aspect)
|
├── baoyu-cover-image/ # Article cover images (2.35:1 aspect)
|
||||||
│ ├── baoyu-slide-deck/ # Presentation slides with outlines
|
├── baoyu-slide-deck/ # Presentation slides with outlines
|
||||||
│ ├── baoyu-article-illustrator/ # Smart illustration placement
|
├── baoyu-article-illustrator/ # Smart illustration placement
|
||||||
│ ├── baoyu-comic/ # Knowledge comics (Logicomix/Ohmsha style)
|
├── baoyu-post-to-x/ # X/Twitter posting automation
|
||||||
│ ├── baoyu-post-to-x/ # X/Twitter posting automation
|
└── baoyu-post-to-wechat/ # WeChat Official Account posting
|
||||||
│ └── baoyu-post-to-wechat/ # WeChat Official Account posting
|
|
||||||
│
|
|
||||||
├── [ai-generation-skills] # AI-powered generation backends
|
|
||||||
│ └── baoyu-danger-gemini-web/ # Gemini API wrapper (text + image gen)
|
|
||||||
│
|
|
||||||
└── [utility-skills] # Utility tools for content processing
|
|
||||||
├── baoyu-danger-x-to-markdown/ # X/Twitter content to markdown
|
|
||||||
└── baoyu-compress-image/ # Image compression
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Plugin Categories**:
|
|
||||||
| Category | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `content-skills` | Skills that generate or publish content (images, slides, comics, posts) |
|
|
||||||
| `ai-generation-skills` | Backend skills providing AI generation capabilities |
|
|
||||||
| `utility-skills` | Helper tools for content processing (conversion, compression) |
|
|
||||||
|
|
||||||
Each skill contains:
|
Each skill contains:
|
||||||
- `SKILL.md` - YAML front matter (name, description) + documentation
|
- `SKILL.md` - YAML front matter (name, description) + documentation
|
||||||
- `scripts/` - TypeScript implementations
|
- `scripts/` - TypeScript implementations
|
||||||
@@ -52,24 +35,24 @@ npx -y bun skills/<skill>/scripts/main.ts [options]
|
|||||||
Examples:
|
Examples:
|
||||||
```bash
|
```bash
|
||||||
# Text generation
|
# Text generation
|
||||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "Hello"
|
npx -y bun skills/baoyu-gemini-web/scripts/main.ts "Hello"
|
||||||
|
|
||||||
# Image generation
|
# Image generation
|
||||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
|
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
|
||||||
|
|
||||||
# From prompt files
|
# From prompt files
|
||||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
|
npx -y bun skills/baoyu-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Dependencies
|
## Key Dependencies
|
||||||
|
|
||||||
- **Bun**: TypeScript runtime (via `npx -y bun`)
|
- **Bun**: TypeScript runtime (via `npx -y bun`)
|
||||||
- **Chrome**: Required for `baoyu-danger-gemini-web` auth and `baoyu-post-to-x` automation
|
- **Chrome**: Required for `baoyu-gemini-web` auth and `baoyu-post-to-x` automation
|
||||||
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
`baoyu-danger-gemini-web` uses browser cookies for Google auth:
|
`baoyu-gemini-web` uses browser cookies for Google auth:
|
||||||
- First run opens Chrome for login
|
- First run opens Chrome for login
|
||||||
- Cookies cached in data directory
|
- Cookies cached in data directory
|
||||||
- Force refresh: `--login` flag
|
- Force refresh: `--login` flag
|
||||||
@@ -87,28 +70,9 @@ npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.m
|
|||||||
- SKILL.md `name` field: `baoyu-<name>`
|
- SKILL.md `name` field: `baoyu-<name>`
|
||||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
|
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
|
||||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||||
4. **Choose the appropriate category** and register in `marketplace.json`:
|
4. Register in `marketplace.json` plugins[0].skills array as `./skills/baoyu-<name>`
|
||||||
- `content-skills`: For content generation/publishing (images, slides, posts)
|
|
||||||
- `ai-generation-skills`: For AI backend capabilities
|
|
||||||
- `utility-skills`: For helper tools (conversion, compression)
|
|
||||||
- If none fit, create a new category with descriptive name
|
|
||||||
5. **Add Script Directory section** to SKILL.md (see template below)
|
5. **Add Script Directory section** to SKILL.md (see template below)
|
||||||
|
|
||||||
### Choosing a Category
|
|
||||||
|
|
||||||
| If your skill... | Use category |
|
|
||||||
|------------------|--------------|
|
|
||||||
| Generates visual content (images, slides, comics) | `content-skills` |
|
|
||||||
| Publishes to platforms (X, WeChat, etc.) | `content-skills` |
|
|
||||||
| Provides AI generation backend | `ai-generation-skills` |
|
|
||||||
| Converts or processes content | `utility-skills` |
|
|
||||||
| Compresses or optimizes files | `utility-skills` |
|
|
||||||
|
|
||||||
**Creating a new category**: If the skill doesn't fit existing categories, add a new plugin object to `marketplace.json` with:
|
|
||||||
- `name`: Descriptive kebab-case name (e.g., `analytics-skills`)
|
|
||||||
- `description`: Brief description of the category
|
|
||||||
- `skills`: Array with the skill path
|
|
||||||
|
|
||||||
### Script Directory Template
|
### Script Directory Template
|
||||||
|
|
||||||
Every SKILL.md with scripts MUST include this section after Usage:
|
Every SKILL.md with scripts MUST include this section after Usage:
|
||||||
@@ -157,7 +121,7 @@ Use this template when implementing image generation in skills:
|
|||||||
### Step N: Generate Images
|
### Step N: Generate Images
|
||||||
|
|
||||||
**Skill Selection**:
|
**Skill Selection**:
|
||||||
1. Check available image generation skills (e.g., `baoyu-danger-gemini-web`)
|
1. Check available image generation skills (e.g., `baoyu-gemini-web`)
|
||||||
2. Read selected skill's SKILL.md for parameter reference
|
2. Read selected skill's SKILL.md for parameter reference
|
||||||
3. If multiple skills available, ask user to choose
|
3. If multiple skills available, ask user to choose
|
||||||
|
|
||||||
@@ -173,33 +137,27 @@ Use this template when implementing image generation in skills:
|
|||||||
|
|
||||||
### Output Path Convention
|
### Output Path Convention
|
||||||
|
|
||||||
Each session creates an independent directory. Even the same source file generates a new directory per session.
|
Generated images from the same skill and source file MUST be grouped together:
|
||||||
|
|
||||||
**Output Directory**:
|
**With source file** (e.g., `/path/to/project/content/my-article.md`):
|
||||||
```
|
```
|
||||||
<skill-suffix>/<topic-slug>/
|
/path/to/project/content/my-article/<skill-suffix>/
|
||||||
```
|
```
|
||||||
- `<skill-suffix>`: Skill name suffix (e.g., `xhs-images`, `cover-image`, `slide-deck`, `comic`)
|
- Remove file extension from source filename
|
||||||
- `<topic-slug>`: Generated from content topic (2-4 words, kebab-case)
|
- Use skill name suffix (e.g., `xhs-images`, `cover-image`, `slide-deck`)
|
||||||
- Example: `xhs-images/ai-future-trends/`
|
- Example: source `/tests-data/anthropic-economic-index.md` + skill `baoyu-xhs-images` → `/tests-data/anthropic-economic-index/xhs-images/`
|
||||||
|
|
||||||
**Slug Generation**:
|
**Without source file**:
|
||||||
1. Extract main topic from content (2-4 words, kebab-case)
|
```
|
||||||
2. Example: "Introduction to Machine Learning" → `intro-machine-learning`
|
./<skill-suffix>/<source-slug>/
|
||||||
|
```
|
||||||
|
- Place under current project directory
|
||||||
|
- Use descriptive slug for the content
|
||||||
|
|
||||||
**Conflict Resolution**:
|
**Directory Backup**:
|
||||||
If `<skill-suffix>/<topic-slug>/` already exists:
|
- If output directory already exists, rename existing directory with timestamp
|
||||||
- Append timestamp: `<topic-slug>-YYYYMMDD-HHMMSS`
|
- Format: `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||||
- Example: `ai-future` exists → `ai-future-20260118-143052`
|
- Example: `xhs-images` → `xhs-images-backup-20260117-143052`
|
||||||
|
|
||||||
**Source Files**:
|
|
||||||
- Copy all sources to `<skill-suffix>/<topic-slug>/` with naming: `source-{slug}.{ext}`
|
|
||||||
- Multiple sources supported: text, images, files from conversation
|
|
||||||
- Examples:
|
|
||||||
- `source-article.md` (main text content)
|
|
||||||
- `source-reference.png` (image from conversation)
|
|
||||||
- `source-data.csv` (additional file)
|
|
||||||
- Original source files remain unchanged
|
|
||||||
|
|
||||||
### Image Naming Convention
|
### Image Naming Convention
|
||||||
|
|
||||||
@@ -230,21 +188,3 @@ Image filenames MUST include meaningful slugs for readability:
|
|||||||
- Pass parameters exactly as documented in the skill
|
- Pass parameters exactly as documented in the skill
|
||||||
- Handle failures gracefully with retry logic
|
- Handle failures gracefully with retry logic
|
||||||
- Provide clear progress feedback to user
|
- Provide clear progress feedback to user
|
||||||
|
|
||||||
## Extension Support
|
|
||||||
|
|
||||||
Every SKILL.md MUST include an Extension Support section at the end:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Extension Support
|
|
||||||
|
|
||||||
Custom styles and configurations via EXTEND.md.
|
|
||||||
|
|
||||||
**Check paths** (priority order):
|
|
||||||
1. `.baoyu-skills/<skill-name>/EXTEND.md` (project)
|
|
||||||
2. `~/.baoyu-skills/<skill-name>/EXTEND.md` (user)
|
|
||||||
|
|
||||||
If found, load before Step 1. Extension content overrides defaults.
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `<skill-name>` with the actual skill name (e.g., `baoyu-cover-image`).
|
|
||||||
|
|||||||
@@ -31,16 +31,13 @@ Run the following command in Claude Code:
|
|||||||
|
|
||||||
1. Select **Browse and install plugins**
|
1. Select **Browse and install plugins**
|
||||||
2. Select **baoyu-skills**
|
2. Select **baoyu-skills**
|
||||||
3. Select the plugin(s) you want to install
|
3. Select **content-skills**
|
||||||
4. Select **Install now**
|
4. Select **Install now**
|
||||||
|
|
||||||
**Option 2: Direct Install**
|
**Option 2: Direct Install**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install specific plugin
|
|
||||||
/plugin install content-skills@baoyu-skills
|
/plugin install content-skills@baoyu-skills
|
||||||
/plugin install ai-generation-skills@baoyu-skills
|
|
||||||
/plugin install utility-skills@baoyu-skills
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option 3: Ask the Agent**
|
**Option 3: Ask the Agent**
|
||||||
@@ -49,14 +46,6 @@ Simply tell Claude Code:
|
|||||||
|
|
||||||
> Please install Skills from github.com/JimLiu/baoyu-skills
|
> Please install Skills from github.com/JimLiu/baoyu-skills
|
||||||
|
|
||||||
### Available Plugins
|
|
||||||
|
|
||||||
| Plugin | Description | Skills |
|
|
||||||
|--------|-------------|--------|
|
|
||||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
|
|
||||||
| **ai-generation-skills** | AI-powered generation backends | [danger-gemini-web](#baoyu-danger-gemini-web) |
|
|
||||||
| **utility-skills** | Utility tools for content processing | [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
|
|
||||||
|
|
||||||
## Update Skills
|
## Update Skills
|
||||||
|
|
||||||
To update skills to the latest version:
|
To update skills to the latest version:
|
||||||
@@ -72,13 +61,25 @@ You can also **Enable auto-update** to get the latest versions automatically.
|
|||||||
|
|
||||||
## Available Skills
|
## Available Skills
|
||||||
|
|
||||||
Skills are organized into three categories:
|
### baoyu-gemini-web
|
||||||
|
|
||||||
### Content Skills
|
Interacts with Gemini Web to generate text and images.
|
||||||
|
|
||||||
Content generation and publishing skills.
|
**Text Generation:**
|
||||||
|
|
||||||
#### baoyu-xhs-images
|
```bash
|
||||||
|
/baoyu-gemini-web "Hello, Gemini"
|
||||||
|
/baoyu-gemini-web --prompt "Explain quantum computing"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Image Generation:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/baoyu-gemini-web --prompt "A cute cat" --image cat.png
|
||||||
|
/baoyu-gemini-web --promptfiles system.md content.md --image out.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### baoyu-xhs-images
|
||||||
|
|
||||||
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics with **Style × Layout** two-dimensional system.
|
Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics with **Style × Layout** two-dimensional system.
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-1
|
|||||||
| `comparison` | 2 sides | Before/after, pros/cons |
|
| `comparison` | 2 sides | Before/after, pros/cons |
|
||||||
| `flow` | 3-6 steps | Processes, timelines |
|
| `flow` | 3-6 steps | Processes, timelines |
|
||||||
|
|
||||||
#### baoyu-cover-image
|
### baoyu-cover-image
|
||||||
|
|
||||||
Generate hand-drawn style cover images for articles with multiple style options.
|
Generate hand-drawn style cover images for articles with multiple style options.
|
||||||
|
|
||||||
@@ -127,28 +128,9 @@ Generate hand-drawn style cover images for articles with multiple style options.
|
|||||||
/baoyu-cover-image path/to/article.md --no-title
|
/baoyu-cover-image path/to/article.md --no-title
|
||||||
```
|
```
|
||||||
|
|
||||||
Available styles: `elegant` (default), `blueprint`, `bold`, `bold-editorial`, `chalkboard`, `dark-atmospheric`, `editorial-infographic`, `fantasy-animation`, `intuition-machine`, `minimal`, `nature`, `notion`, `pixel-art`, `playful`, `retro`, `sketch-notes`, `vector-illustration`, `vintage`, `warm`, `watercolor`
|
Available styles: `elegant` (default), `tech`, `warm`, `bold`, `minimal`, `playful`, `nature`, `retro`
|
||||||
|
|
||||||
**Style Previews**:
|
### baoyu-slide-deck
|
||||||
|
|
||||||
| | | |
|
|
||||||
|:---:|:---:|:---:|
|
|
||||||
|  |  |  |
|
|
||||||
| elegant | blueprint | bold |
|
|
||||||
|  |  |  |
|
|
||||||
| bold-editorial | chalkboard | dark-atmospheric |
|
|
||||||
|  |  |  |
|
|
||||||
| editorial-infographic | fantasy-animation | intuition-machine |
|
|
||||||
|  |  |  |
|
|
||||||
| minimal | nature | notion |
|
|
||||||
|  |  |  |
|
|
||||||
| pixel-art | playful | retro |
|
|
||||||
|  |  |  |
|
|
||||||
| sketch-notes | vector-illustration | vintage |
|
|
||||||
|  |  | |
|
|
||||||
| warm | watercolor | |
|
|
||||||
|
|
||||||
#### baoyu-slide-deck
|
|
||||||
|
|
||||||
Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images.
|
Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images.
|
||||||
|
|
||||||
@@ -171,42 +153,20 @@ Generate professional slide deck images from content. Creates comprehensive outl
|
|||||||
|
|
||||||
| Style | Description | Best For |
|
| Style | Description | Best For |
|
||||||
|-------|-------------|----------|
|
|-------|-------------|----------|
|
||||||
| `blueprint` (default) | Technical schematics, grid texture, engineering precision | Architecture, system design |
|
| `notion` (default) | SaaS dashboard aesthetic with clean data focus, card-based layouts | Product demos, SaaS, productivity tools, B2B |
|
||||||
| `notion` | SaaS dashboard aesthetic, card-based layouts, clean data focus | Product demos, SaaS, B2B |
|
| `sketch-notes` | Hand-drawn feel with soft brush strokes, warm off-white background | Educational, tutorials, knowledge sharing |
|
||||||
| `bold-editorial` | High-impact magazine style, bold typography, dark backgrounds | Product launches, keynotes |
|
| `blueprint` | Technical schematics with grid texture, engineering precision | Architecture, system design, data analysis |
|
||||||
| `corporate` | Navy/gold palette, structured layouts, professional icons | Investor decks, proposals |
|
| `bold-editorial` | High-impact magazine style, bold typography, dark backgrounds | Product launches, marketing, keynotes |
|
||||||
| `dark-atmospheric` | Cinematic dark mode, glowing accents, atmospheric depth | Entertainment, gaming, creative |
|
| `vector-illustration` | Flat vector with black outlines, retro soft colors, toy model aesthetic | Creative proposals, children's content, explainers |
|
||||||
| `editorial-infographic` | Magazine-style explainers, flat illustrations | Tech explainers, research |
|
| `minimal` | Ultra-clean with maximum whitespace, single accent color, zen-like | Executive briefings, keynotes, premium brands |
|
||||||
| `fantasy-animation` | Whimsical Ghibli/Disney style, hand-drawn animation | Educational, storytelling |
|
| `storytelling` | Cinematic full-bleed visuals, emotional photography | Case studies, narratives, customer journeys |
|
||||||
| `intuition-machine` | Technical briefing, bilingual labels, aged paper texture | Technical docs, bilingual |
|
| `warm` | Soft gradients, rounded shapes, wellness palette | Lifestyle, wellness, personal development |
|
||||||
| `minimal` | Ultra-clean, maximum whitespace, single accent color | Executive briefings, premium |
|
| `corporate` | Navy/gold palette, structured layouts, professional iconography | Investor decks, client proposals, quarterly reports |
|
||||||
| `pixel-art` | Retro 8-bit aesthetic, chunky pixels, nostalgic gaming | Gaming, developer talks |
|
| `playful` | Vibrant coral/teal/yellow, rounded shapes, dynamic layouts | Workshops, training, creative pitches |
|
||||||
| `scientific` | Academic diagrams, biological pathways, precise labeling | Biology, chemistry, medical |
|
|
||||||
| `sketch-notes` | Hand-drawn feel, soft brush strokes, warm background | Educational, tutorials |
|
|
||||||
| `vector-illustration` | Flat vector, black outlines, retro soft colors | Creative proposals, explainers |
|
|
||||||
| `vintage` | Aged-paper aesthetic, historical document styling | Historical, heritage, biography |
|
|
||||||
| `watercolor` | Soft hand-painted textures, natural warmth | Lifestyle, wellness, travel |
|
|
||||||
|
|
||||||
**Style Previews**:
|
|
||||||
|
|
||||||
| | | |
|
|
||||||
|:---:|:---:|:---:|
|
|
||||||
|  |  |  |
|
|
||||||
| blueprint | chalkboard | bold-editorial |
|
|
||||||
|  |  |  |
|
|
||||||
| corporate | dark-atmospheric | editorial-infographic |
|
|
||||||
|  |  |  |
|
|
||||||
| fantasy-animation | intuition-machine | minimal |
|
|
||||||
|  |  |  |
|
|
||||||
| notion | pixel-art | scientific |
|
|
||||||
|  |  |  |
|
|
||||||
| sketch-notes | vector-illustration | vintage |
|
|
||||||
|  | | |
|
|
||||||
| watercolor | | |
|
|
||||||
|
|
||||||
After generation, slides are automatically merged into a `.pptx` file for easy sharing.
|
After generation, slides are automatically merged into a `.pptx` file for easy sharing.
|
||||||
|
|
||||||
#### baoyu-comic
|
### baoyu-comic
|
||||||
|
|
||||||
Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation.
|
Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation.
|
||||||
|
|
||||||
@@ -235,7 +195,7 @@ Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohms
|
|||||||
**Options**:
|
**Options**:
|
||||||
| Option | Values |
|
| Option | Values |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `--style` | `classic` (default), `dramatic`, `warm`, `sepia`, `vibrant`, `ohmsha`, `realistic`, `wuxia`, or custom description |
|
| `--style` | `classic` (default), `dramatic`, `warm`, `tech`, `sepia`, `vibrant`, `ohmsha`, `realistic`, or custom description |
|
||||||
| `--layout` | `standard` (default), `cinematic`, `dense`, `splash`, `mixed`, `webtoon` |
|
| `--layout` | `standard` (default), `cinematic`, `dense`, `splash`, `mixed`, `webtoon` |
|
||||||
| `--aspect` | `3:4` (default, portrait), `4:3` (landscape), `16:9` (widescreen) |
|
| `--aspect` | `3:4` (default, portrait), `4:3` (landscape), `16:9` (widescreen) |
|
||||||
| `--lang` | `auto` (default), `zh`, `en`, `ja`, etc. |
|
| `--lang` | `auto` (default), `zh`, `en`, `ja`, etc. |
|
||||||
@@ -247,22 +207,11 @@ Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohms
|
|||||||
| `classic` (default) | Traditional Ligne Claire with clean uniform outlines, flat colors, detailed backgrounds | Biographies, balanced narratives, educational content |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `realistic` | Full-color realistic manga with digital painting, smooth gradients, accurate proportions | Wine, food, business, lifestyle, professional topics |
|
||||||
| `wuxia` | Hong Kong martial arts style with ink brush strokes, dynamic combat, qi effects | Martial arts, wuxia/xianxia, Chinese historical fiction |
|
|
||||||
|
|
||||||
**Style Previews**:
|
|
||||||
|
|
||||||
| | | |
|
|
||||||
|:---:|:---:|:---:|
|
|
||||||
|  |  |  |
|
|
||||||
| classic | dramatic | warm |
|
|
||||||
|  |  |  |
|
|
||||||
| sepia | vibrant | ohmsha |
|
|
||||||
|  |  | |
|
|
||||||
| realistic | wuxia | |
|
|
||||||
|
|
||||||
**Layouts** (panel arrangement):
|
**Layouts** (panel arrangement):
|
||||||
| Layout | Panels/Page | Best for |
|
| Layout | Panels/Page | Best for |
|
||||||
@@ -274,39 +223,7 @@ Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohms
|
|||||||
| `mixed` | 3-7 varies | Complex narratives, emotional arcs |
|
| `mixed` | 3-7 varies | Complex narratives, emotional arcs |
|
||||||
| `webtoon` | 3-5 vertical | Ohmsha tutorials, mobile reading |
|
| `webtoon` | 3-5 vertical | Ohmsha tutorials, mobile reading |
|
||||||
|
|
||||||
**Layout Previews**:
|
### baoyu-post-to-wechat
|
||||||
|
|
||||||
| | | |
|
|
||||||
|:---:|:---:|:---:|
|
|
||||||
|  |  |  |
|
|
||||||
| standard | cinematic | dense |
|
|
||||||
|  |  |  |
|
|
||||||
| splash | mixed | webtoon |
|
|
||||||
|
|
||||||
#### baoyu-article-illustrator
|
|
||||||
|
|
||||||
Smart article illustration skill. Analyzes article content and generates illustrations at positions requiring visual aids.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/baoyu-article-illustrator path/to/article.md
|
|
||||||
```
|
|
||||||
|
|
||||||
#### baoyu-post-to-x
|
|
||||||
|
|
||||||
Post content and articles to X (Twitter). Supports regular posts with images and X Articles (long-form Markdown). Uses real Chrome with CDP to bypass anti-automation.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Post with text
|
|
||||||
/baoyu-post-to-x "Hello from Claude Code!"
|
|
||||||
|
|
||||||
# Post with images
|
|
||||||
/baoyu-post-to-x "Check this out" --image photo.png
|
|
||||||
|
|
||||||
# Post X Article
|
|
||||||
/baoyu-post-to-x --article path/to/article.md
|
|
||||||
```
|
|
||||||
|
|
||||||
#### baoyu-post-to-wechat
|
|
||||||
|
|
||||||
Post content to WeChat Official Account (微信公众号). Two modes available:
|
Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||||
|
|
||||||
@@ -328,122 +245,18 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
|
|||||||
|
|
||||||
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
Prerequisites: Google Chrome installed. First run requires QR code login (session preserved).
|
||||||
|
|
||||||
### AI Generation Skills
|
|
||||||
|
|
||||||
AI-powered generation backends.
|
|
||||||
|
|
||||||
#### baoyu-danger-gemini-web
|
|
||||||
|
|
||||||
Interacts with Gemini Web to generate text and images.
|
|
||||||
|
|
||||||
**Text Generation:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/baoyu-danger-gemini-web "Hello, Gemini"
|
|
||||||
/baoyu-danger-gemini-web --prompt "Explain quantum computing"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Image Generation:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/baoyu-danger-gemini-web --prompt "A cute cat" --image cat.png
|
|
||||||
/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png
|
|
||||||
```
|
|
||||||
|
|
||||||
### Utility Skills
|
|
||||||
|
|
||||||
Utility tools for content processing.
|
|
||||||
|
|
||||||
#### baoyu-danger-x-to-markdown
|
|
||||||
|
|
||||||
Converts X (Twitter) content to markdown format. Supports tweet threads and X Articles.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Convert tweet to markdown
|
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456
|
|
||||||
|
|
||||||
# Save to specific file
|
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md
|
|
||||||
|
|
||||||
# JSON output
|
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
|
||||||
```
|
|
||||||
|
|
||||||
**Supported URLs:**
|
|
||||||
- `https://x.com/<user>/status/<id>`
|
|
||||||
- `https://twitter.com/<user>/status/<id>`
|
|
||||||
- `https://x.com/i/article/<id>`
|
|
||||||
|
|
||||||
**Authentication:** Uses environment variables (`X_AUTH_TOKEN`, `X_CT0`) or Chrome login for cookie-based auth.
|
|
||||||
|
|
||||||
#### baoyu-compress-image
|
|
||||||
|
|
||||||
Compress images to reduce file size while maintaining quality.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/baoyu-compress-image path/to/image.png
|
|
||||||
/baoyu-compress-image path/to/images/ --quality 80
|
|
||||||
```
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
All skills support customization via `EXTEND.md` files. Create an extension file to override default styles, add custom configurations, or define your own presets.
|
|
||||||
|
|
||||||
**Extension paths** (checked in priority order):
|
|
||||||
1. `.baoyu-skills/<skill-name>/EXTEND.md` - Project-level (for team/project-specific settings)
|
|
||||||
2. `~/.baoyu-skills/<skill-name>/EXTEND.md` - User-level (for personal preferences)
|
|
||||||
|
|
||||||
**Example**: To customize `baoyu-cover-image` with your brand colors:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .baoyu-skills/baoyu-cover-image
|
|
||||||
```
|
|
||||||
|
|
||||||
Then create `.baoyu-skills/baoyu-cover-image/EXTEND.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Custom Styles
|
|
||||||
|
|
||||||
### brand
|
|
||||||
- Primary color: #1a73e8
|
|
||||||
- Secondary color: #34a853
|
|
||||||
- Font style: Modern sans-serif
|
|
||||||
- Always include company logo watermark
|
|
||||||
```
|
|
||||||
|
|
||||||
The extension content will be loaded before skill execution and override defaults.
|
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
### baoyu-danger-gemini-web
|
### baoyu-gemini-web
|
||||||
|
|
||||||
This skill uses the Gemini Web API (reverse-engineered).
|
This skill uses the Gemini Web API (reverse-engineered).
|
||||||
|
|
||||||
**Warning:** This project uses unofficial API access via browser cookies. Use at your own risk.
|
**Warning:** This project uses unofficial API access via browser cookies. Use at your own risk.
|
||||||
|
|
||||||
- First run opens a browser to authenticate with Google
|
- First run opens Chrome to authenticate with Google
|
||||||
- Cookies are cached for subsequent runs
|
- Cookies are cached for subsequent runs
|
||||||
- No guarantees on API stability or availability
|
- No guarantees on API stability or availability
|
||||||
|
|
||||||
**Supported browsers** (auto-detected): Google Chrome, Chrome Canary/Beta, Chromium, Microsoft Edge
|
|
||||||
|
|
||||||
**Proxy configuration**: If you need a proxy to access Google services (e.g., in China), set environment variables inline:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 /baoyu-danger-gemini-web "Hello"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -31,16 +31,13 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
|
|
||||||
1. 选择 **Browse and install plugins**
|
1. 选择 **Browse and install plugins**
|
||||||
2. 选择 **baoyu-skills**
|
2. 选择 **baoyu-skills**
|
||||||
3. 选择要安装的插件
|
3. 选择 **content-skills**
|
||||||
4. 选择 **Install now**
|
4. 选择 **Install now**
|
||||||
|
|
||||||
**方式二:直接安装**
|
**方式二:直接安装**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装指定插件
|
|
||||||
/plugin install content-skills@baoyu-skills
|
/plugin install content-skills@baoyu-skills
|
||||||
/plugin install ai-generation-skills@baoyu-skills
|
|
||||||
/plugin install utility-skills@baoyu-skills
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**方式三:告诉 Agent**
|
**方式三:告诉 Agent**
|
||||||
@@ -49,14 +46,6 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
|
|
||||||
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
|
> 请帮我安装 github.com/JimLiu/baoyu-skills 中的 Skills
|
||||||
|
|
||||||
### 可用插件
|
|
||||||
|
|
||||||
| 插件 | 说明 | 包含技能 |
|
|
||||||
|------|------|----------|
|
|
||||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
|
|
||||||
| **ai-generation-skills** | AI 生成后端 | [danger-gemini-web](#baoyu-danger-gemini-web) |
|
|
||||||
| **utility-skills** | 内容处理工具 | [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image) |
|
|
||||||
|
|
||||||
## 更新技能
|
## 更新技能
|
||||||
|
|
||||||
更新技能到最新版本:
|
更新技能到最新版本:
|
||||||
@@ -72,13 +61,25 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
|
|
||||||
## 可用技能
|
## 可用技能
|
||||||
|
|
||||||
技能分为三大类:
|
### baoyu-gemini-web
|
||||||
|
|
||||||
### 内容技能 (Content Skills)
|
与 Gemini Web 交互,生成文本和图片。
|
||||||
|
|
||||||
内容生成和发布技能。
|
**文本生成:**
|
||||||
|
|
||||||
#### baoyu-xhs-images
|
```bash
|
||||||
|
/baoyu-gemini-web "你好,Gemini"
|
||||||
|
/baoyu-gemini-web --prompt "解释量子计算"
|
||||||
|
```
|
||||||
|
|
||||||
|
**图片生成:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/baoyu-gemini-web --prompt "一只可爱的猫" --image cat.png
|
||||||
|
/baoyu-gemini-web --promptfiles system.md content.md --image out.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### baoyu-xhs-images
|
||||||
|
|
||||||
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图,支持 **风格 × 布局** 二维系统。
|
小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图,支持 **风格 × 布局** 二维系统。
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
| `comparison` | 双栏 | 对比、优劣 |
|
| `comparison` | 双栏 | 对比、优劣 |
|
||||||
| `flow` | 3-6 步 | 流程、时间线 |
|
| `flow` | 3-6 步 | 流程、时间线 |
|
||||||
|
|
||||||
#### baoyu-cover-image
|
### baoyu-cover-image
|
||||||
|
|
||||||
为文章生成手绘风格封面图,支持多种风格选项。
|
为文章生成手绘风格封面图,支持多种风格选项。
|
||||||
|
|
||||||
@@ -127,28 +128,9 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
/baoyu-cover-image path/to/article.md --no-title
|
/baoyu-cover-image path/to/article.md --no-title
|
||||||
```
|
```
|
||||||
|
|
||||||
可用风格:`elegant`(默认)、`blueprint`、`bold`、`bold-editorial`、`chalkboard`、`dark-atmospheric`、`editorial-infographic`、`fantasy-animation`、`intuition-machine`、`minimal`、`nature`、`notion`、`pixel-art`、`playful`、`retro`、`sketch-notes`、`vector-illustration`、`vintage`、`warm`、`watercolor`
|
可用风格:`elegant`(默认)、`tech`、`warm`、`bold`、`minimal`、`playful`、`nature`、`retro`
|
||||||
|
|
||||||
**风格预览**:
|
### baoyu-slide-deck
|
||||||
|
|
||||||
| | | |
|
|
||||||
|:---:|:---:|:---:|
|
|
||||||
|  |  |  |
|
|
||||||
| elegant | blueprint | bold |
|
|
||||||
|  |  |  |
|
|
||||||
| bold-editorial | chalkboard | dark-atmospheric |
|
|
||||||
|  |  |  |
|
|
||||||
| editorial-infographic | fantasy-animation | intuition-machine |
|
|
||||||
|  |  |  |
|
|
||||||
| minimal | nature | notion |
|
|
||||||
|  |  |  |
|
|
||||||
| pixel-art | playful | retro |
|
|
||||||
|  |  |  |
|
|
||||||
| sketch-notes | vector-illustration | vintage |
|
|
||||||
|  |  | |
|
|
||||||
| warm | watercolor | |
|
|
||||||
|
|
||||||
#### baoyu-slide-deck
|
|
||||||
|
|
||||||
从内容生成专业的幻灯片图片。先创建包含样式说明的完整大纲,然后逐页生成幻灯片图片。
|
从内容生成专业的幻灯片图片。先创建包含样式说明的完整大纲,然后逐页生成幻灯片图片。
|
||||||
|
|
||||||
@@ -171,42 +153,20 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
|
|
||||||
| 风格 | 描述 | 适用场景 |
|
| 风格 | 描述 | 适用场景 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| `blueprint`(默认) | 技术蓝图风格,网格纹理,工程精度 | 架构设计、系统设计 |
|
| `notion`(默认) | SaaS 仪表盘美学,卡片式布局,数据展示清晰 | 产品演示、SaaS、生产力工具、B2B |
|
||||||
| `notion` | SaaS 仪表盘美学,卡片式布局,数据清晰 | 产品演示、SaaS、B2B |
|
|
||||||
| `bold-editorial` | 杂志社论风格,粗体排版,深色背景 | 产品发布、主题演讲 |
|
|
||||||
| `corporate` | 海军蓝/金色配色,结构化布局,专业图标 | 投资者演示、客户提案 |
|
|
||||||
| `dark-atmospheric` | 电影级暗色调,发光效果,氛围感 | 娱乐、游戏、创意 |
|
|
||||||
| `editorial-infographic` | 杂志风格信息图,扁平插画 | 科技解说、研究报告 |
|
|
||||||
| `fantasy-animation` | 吉卜力/迪士尼风格,手绘动画 | 教育、故事讲述 |
|
|
||||||
| `intuition-machine` | 技术简报,双语标签,做旧纸张纹理 | 技术文档、双语内容 |
|
|
||||||
| `minimal` | 极简风格,大量留白,单一强调色 | 高管简报、高端品牌 |
|
|
||||||
| `pixel-art` | 复古 8-bit 像素风,怀旧游戏感 | 游戏、开发者分享 |
|
|
||||||
| `scientific` | 学术图表,生物通路,精确标注 | 生物、化学、医学 |
|
|
||||||
| `sketch-notes` | 手绘风格,柔和笔触,暖白色背景 | 教育、教程、知识分享 |
|
| `sketch-notes` | 手绘风格,柔和笔触,暖白色背景 | 教育、教程、知识分享 |
|
||||||
| `vector-illustration` | 扁平矢量风格,黑色轮廓线,复古柔和配色 | 创意提案、说明性内容 |
|
| `blueprint` | 技术蓝图风格,网格纹理,工程精度 | 架构设计、系统设计、数据分析 |
|
||||||
| `vintage` | 做旧纸张美学,历史文档风格 | 历史、传记、人文 |
|
| `bold-editorial` | 杂志社论风格,粗体排版,深色背景,高冲击力 | 产品发布、营销、主题演讲 |
|
||||||
| `watercolor` | 柔和手绘水彩纹理,自然温暖 | 生活方式、健康、旅行 |
|
| `vector-illustration` | 扁平矢量风格,黑色轮廓线,复古柔和配色,玩具模型感 | 创意提案、儿童内容、说明性内容 |
|
||||||
|
| `minimal` | 极简风格,大量留白,单一强调色,禅意美学 | 高管简报、主题演讲、高端品牌 |
|
||||||
**风格预览**:
|
| `storytelling` | 电影风格,全幅视觉,情感化摄影 | 案例研究、叙事、客户旅程 |
|
||||||
|
| `warm` | 柔和渐变,圆润形状,健康生活配色 | 生活方式、健康养生、个人成长 |
|
||||||
| | | |
|
| `corporate` | 海军蓝/金色配色,结构化布局,专业图标 | 投资者演示、客户提案、季度报告 |
|
||||||
|:---:|:---:|:---:|
|
| `playful` | 活力珊瑚/青色/黄色,圆润形状,动感布局 | 工作坊、培训、创意提案 |
|
||||||
|  |  |  |
|
|
||||||
| blueprint | chalkboard | bold-editorial |
|
|
||||||
|  |  |  |
|
|
||||||
| corporate | dark-atmospheric | editorial-infographic |
|
|
||||||
|  |  |  |
|
|
||||||
| fantasy-animation | intuition-machine | minimal |
|
|
||||||
|  |  |  |
|
|
||||||
| notion | pixel-art | scientific |
|
|
||||||
|  |  |  |
|
|
||||||
| sketch-notes | vector-illustration | vintage |
|
|
||||||
|  | | |
|
|
||||||
| watercolor | | |
|
|
||||||
|
|
||||||
生成完成后,所有幻灯片会自动合并为 `.pptx` 文件,方便分享。
|
生成完成后,所有幻灯片会自动合并为 `.pptx` 文件,方便分享。
|
||||||
|
|
||||||
#### baoyu-comic
|
### baoyu-comic
|
||||||
|
|
||||||
知识漫画创作器,支持多种风格(Logicomix/清线风格、欧姆社漫画教程风格)。创作带有详细分镜布局的原创教育漫画,逐页生成图片。
|
知识漫画创作器,支持多种风格(Logicomix/清线风格、欧姆社漫画教程风格)。创作带有详细分镜布局的原创教育漫画,逐页生成图片。
|
||||||
|
|
||||||
@@ -235,7 +195,7 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
**选项**:
|
**选项**:
|
||||||
| 选项 | 取值 |
|
| 选项 | 取值 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `--style` | `classic`(默认)、`dramatic`、`warm`、`sepia`、`vibrant`、`ohmsha`、`realistic`、`wuxia`,或自然语言描述 |
|
| `--style` | `classic`(默认)、`dramatic`、`warm`、`tech`、`sepia`、`vibrant`、`ohmsha`、`realistic`,或自然语言描述 |
|
||||||
| `--layout` | `standard`(默认)、`cinematic`、`dense`、`splash`、`mixed`、`webtoon` |
|
| `--layout` | `standard`(默认)、`cinematic`、`dense`、`splash`、`mixed`、`webtoon` |
|
||||||
| `--aspect` | `3:4`(默认,竖版)、`4:3`(横版)、`16:9`(宽屏) |
|
| `--aspect` | `3:4`(默认,竖版)、`4:3`(横版)、`16:9`(宽屏) |
|
||||||
| `--lang` | `auto`(默认)、`zh`、`en`、`ja` 等 |
|
| `--lang` | `auto`(默认)、`zh`、`en`、`ja` 等 |
|
||||||
@@ -247,22 +207,11 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
| `classic`(默认) | 传统清线风格,统一线条、平涂色彩、精细背景 | 传记、平衡叙事、教育内容 |
|
| `classic`(默认) | 传统清线风格,统一线条、平涂色彩、精细背景 | 传记、平衡叙事、教育内容 |
|
||||||
| `dramatic` | 高对比度,重阴影、紧张表情、棱角分明的构图 | 重大发现、冲突、高潮场景 |
|
| `dramatic` | 高对比度,重阴影、紧张表情、棱角分明的构图 | 重大发现、冲突、高潮场景 |
|
||||||
| `warm` | 柔和边缘、金色调、温馨室内、怀旧感 | 个人故事、童年场景、师生情 |
|
| `warm` | 柔和边缘、金色调、温馨室内、怀旧感 | 个人故事、童年场景、师生情 |
|
||||||
|
| `tech` | 精确几何线条、电路纹理、深色背景配霓虹色 | 计算机史、AI 故事、现代科技 |
|
||||||
| `sepia` | 复古插画风格、做旧纸张效果、时代准确细节 | 1950 年前故事、古典科学、历史人物 |
|
| `sepia` | 复古插画风格、做旧纸张效果、时代准确细节 | 1950 年前故事、古典科学、历史人物 |
|
||||||
| `vibrant` | 富有活力的线条、明亮色彩、动感姿态 | 科学解说、"顿悟"时刻、青少年读者 |
|
| `vibrant` | 富有活力的线条、明亮色彩、动感姿态 | 科学解说、"顿悟"时刻、青少年读者 |
|
||||||
| `ohmsha` | 欧姆社漫画风格,视觉比喻、道具、学生/导师互动 | 技术教程、复杂概念(机器学习、物理) |
|
| `ohmsha` | 欧姆社漫画风格,视觉比喻、道具、学生/导师互动 | 技术教程、复杂概念(机器学习、物理) |
|
||||||
| `realistic` | 全彩写实日漫风格,数字绘画、平滑渐变、准确人体比例 | 红酒、美食、商业、生活方式、专业话题 |
|
| `realistic` | 全彩写实日漫风格,数字绘画、平滑渐变、准确人体比例 | 红酒、美食、商业、生活方式、专业话题 |
|
||||||
| `wuxia` | 港漫武侠风格,水墨笔触、动态打斗、气功特效 | 武侠、仙侠、中国历史小说 |
|
|
||||||
|
|
||||||
**风格预览**:
|
|
||||||
|
|
||||||
| | | |
|
|
||||||
|:---:|:---:|:---:|
|
|
||||||
|  |  |  |
|
|
||||||
| classic | dramatic | warm |
|
|
||||||
|  |  |  |
|
|
||||||
| sepia | vibrant | ohmsha |
|
|
||||||
|  |  | |
|
|
||||||
| realistic | wuxia | |
|
|
||||||
|
|
||||||
**布局**(分镜排列):
|
**布局**(分镜排列):
|
||||||
| 布局 | 每页分镜数 | 适用场景 |
|
| 布局 | 每页分镜数 | 适用场景 |
|
||||||
@@ -274,39 +223,7 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
| `mixed` | 3-7 不等 | 复杂叙事、情感弧线 |
|
| `mixed` | 3-7 不等 | 复杂叙事、情感弧线 |
|
||||||
| `webtoon` | 3-5 竖向 | 欧姆社教程、手机阅读 |
|
| `webtoon` | 3-5 竖向 | 欧姆社教程、手机阅读 |
|
||||||
|
|
||||||
**布局预览**:
|
### baoyu-post-to-wechat
|
||||||
|
|
||||||
| | | |
|
|
||||||
|:---:|:---:|:---:|
|
|
||||||
|  |  |  |
|
|
||||||
| standard | cinematic | dense |
|
|
||||||
|  |  |  |
|
|
||||||
| splash | mixed | webtoon |
|
|
||||||
|
|
||||||
#### baoyu-article-illustrator
|
|
||||||
|
|
||||||
智能文章插图技能。分析文章内容,在需要视觉辅助的位置生成插图。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/baoyu-article-illustrator path/to/article.md
|
|
||||||
```
|
|
||||||
|
|
||||||
#### baoyu-post-to-x
|
|
||||||
|
|
||||||
发布内容和文章到 X (Twitter)。支持带图片的普通帖子和 X 文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 发布文字
|
|
||||||
/baoyu-post-to-x "Hello from Claude Code!"
|
|
||||||
|
|
||||||
# 发布带图片
|
|
||||||
/baoyu-post-to-x "看看这个" --image photo.png
|
|
||||||
|
|
||||||
# 发布 X 文章
|
|
||||||
/baoyu-post-to-x --article path/to/article.md
|
|
||||||
```
|
|
||||||
|
|
||||||
#### baoyu-post-to-wechat
|
|
||||||
|
|
||||||
发布内容到微信公众号,支持两种模式:
|
发布内容到微信公众号,支持两种模式:
|
||||||
|
|
||||||
@@ -328,122 +245,18 @@ npx add-skill jimliu/baoyu-skills
|
|||||||
|
|
||||||
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||||
|
|
||||||
### AI 生成技能 (AI Generation Skills)
|
|
||||||
|
|
||||||
AI 驱动的生成后端。
|
|
||||||
|
|
||||||
#### baoyu-danger-gemini-web
|
|
||||||
|
|
||||||
与 Gemini Web 交互,生成文本和图片。
|
|
||||||
|
|
||||||
**文本生成:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/baoyu-danger-gemini-web "你好,Gemini"
|
|
||||||
/baoyu-danger-gemini-web --prompt "解释量子计算"
|
|
||||||
```
|
|
||||||
|
|
||||||
**图片生成:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/baoyu-danger-gemini-web --prompt "一只可爱的猫" --image cat.png
|
|
||||||
/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png
|
|
||||||
```
|
|
||||||
|
|
||||||
### 工具技能 (Utility Skills)
|
|
||||||
|
|
||||||
内容处理工具。
|
|
||||||
|
|
||||||
#### baoyu-danger-x-to-markdown
|
|
||||||
|
|
||||||
将 X (Twitter) 内容转换为 markdown 格式。支持推文串和 X 文章。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 将推文转换为 markdown
|
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456
|
|
||||||
|
|
||||||
# 保存到指定文件
|
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md
|
|
||||||
|
|
||||||
# JSON 输出
|
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
|
||||||
```
|
|
||||||
|
|
||||||
**支持的 URL:**
|
|
||||||
- `https://x.com/<user>/status/<id>`
|
|
||||||
- `https://twitter.com/<user>/status/<id>`
|
|
||||||
- `https://x.com/i/article/<id>`
|
|
||||||
|
|
||||||
**身份验证:** 使用环境变量(`X_AUTH_TOKEN`、`X_CT0`)或 Chrome 登录进行 cookie 认证。
|
|
||||||
|
|
||||||
#### baoyu-compress-image
|
|
||||||
|
|
||||||
压缩图片以减小文件大小,同时保持质量。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/baoyu-compress-image path/to/image.png
|
|
||||||
/baoyu-compress-image path/to/images/ --quality 80
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自定义扩展
|
|
||||||
|
|
||||||
所有技能支持通过 `EXTEND.md` 文件自定义。创建扩展文件可覆盖默认样式、添加自定义配置或定义个人预设。
|
|
||||||
|
|
||||||
**扩展路径**(按优先级检查):
|
|
||||||
1. `.baoyu-skills/<skill-name>/EXTEND.md` - 项目级(团队/项目特定设置)
|
|
||||||
2. `~/.baoyu-skills/<skill-name>/EXTEND.md` - 用户级(个人偏好设置)
|
|
||||||
|
|
||||||
**示例**:为 `baoyu-cover-image` 自定义品牌配色:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .baoyu-skills/baoyu-cover-image
|
|
||||||
```
|
|
||||||
|
|
||||||
然后创建 `.baoyu-skills/baoyu-cover-image/EXTEND.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 自定义风格
|
|
||||||
|
|
||||||
### brand
|
|
||||||
- 主色:#1a73e8
|
|
||||||
- 辅色:#34a853
|
|
||||||
- 字体风格:现代无衬线
|
|
||||||
- 始终包含公司 logo 水印
|
|
||||||
```
|
|
||||||
|
|
||||||
扩展内容会在技能执行前加载,并覆盖默认设置。
|
|
||||||
|
|
||||||
## 免责声明
|
## 免责声明
|
||||||
|
|
||||||
### baoyu-danger-gemini-web
|
### baoyu-gemini-web
|
||||||
|
|
||||||
此技能使用 Gemini Web API(逆向工程)。
|
此技能使用 Gemini Web API(逆向工程)。
|
||||||
|
|
||||||
**警告:** 本项目通过浏览器 cookies 使用非官方 API。使用风险自负。
|
**警告:** 本项目通过浏览器 cookies 使用非官方 API。使用风险自负。
|
||||||
|
|
||||||
- 首次运行会打开浏览器进行 Google 身份验证
|
- 首次运行会打开 Chrome 进行 Google 身份验证
|
||||||
- Cookies 会被缓存供后续使用
|
- Cookies 会被缓存供后续使用
|
||||||
- 不保证 API 的稳定性或可用性
|
- 不保证 API 的稳定性或可用性
|
||||||
|
|
||||||
**支持的浏览器**(自动检测):Google Chrome、Chrome Canary/Beta、Chromium、Microsoft Edge
|
|
||||||
|
|
||||||
**代理配置**:如果需要通过代理访问 Google 服务(如中国大陆用户),请在命令前设置环境变量:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 /baoyu-danger-gemini-web "你好"
|
|
||||||
```
|
|
||||||
|
|
||||||
### baoyu-danger-x-to-markdown
|
|
||||||
|
|
||||||
此技能使用逆向工程的 X (Twitter) API。
|
|
||||||
|
|
||||||
**警告:** 这不是官方 API。使用风险自负。
|
|
||||||
|
|
||||||
- 如果 X 更改其 API,可能会无预警失效
|
|
||||||
- 如检测到 API 使用,账号可能受限
|
|
||||||
- 首次使用需确认免责声明
|
|
||||||
- 通过环境变量或 Chrome 登录进行身份验证
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 98 KiB |
@@ -102,42 +102,43 @@ When no `--style` is specified, analyze content to select the best style:
|
|||||||
|
|
||||||
## File Management
|
## File Management
|
||||||
|
|
||||||
### Output Directory
|
### With Article Path
|
||||||
|
|
||||||
Each session creates an independent directory named by content slug:
|
Save illustrations to `[source-name-no-ext]/illustrations/` subdirectory in the same folder as the article:
|
||||||
|
|
||||||
```
|
```
|
||||||
illustrations/{topic-slug}/
|
path/to/
|
||||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
├── article.md
|
||||||
├── outline.md
|
└── article/
|
||||||
├── outline-{style}.md # Style variant outlines
|
└── illustrations/
|
||||||
├── prompts/
|
├── outline.md
|
||||||
│ ├── illustration-concept-a.md
|
├── prompts/
|
||||||
│ ├── illustration-concept-b.md
|
│ ├── illustration-concept-a.md
|
||||||
│ └── ...
|
│ ├── illustration-concept-b.md
|
||||||
├── illustration-concept-a.png
|
│ └── ...
|
||||||
├── illustration-concept-b.png
|
├── illustration-concept-a.png
|
||||||
└── ...
|
├── illustration-concept-b.png
|
||||||
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
**Slug Generation**:
|
Example: `/posts/ai-future.md` → `/posts/ai-future/illustrations/`
|
||||||
1. Extract main topic from content (2-4 words, kebab-case)
|
|
||||||
2. Example: "The Future of AI" → `future-of-ai`
|
|
||||||
|
|
||||||
### Conflict Resolution
|
### Without Article Path (Pasted Content)
|
||||||
|
|
||||||
If `illustrations/{topic-slug}/` already exists:
|
Save to `./illustrations/[topic-slug]/`:
|
||||||
- Append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
|
||||||
- Example: `ai-future` exists → `ai-future-20260118-143052`
|
|
||||||
|
|
||||||
### Source Files
|
```
|
||||||
|
illustrations/
|
||||||
|
└── ai-future/
|
||||||
|
├── source.md
|
||||||
|
├── outline.md
|
||||||
|
├── prompts/
|
||||||
|
└── *.png
|
||||||
|
```
|
||||||
|
|
||||||
Copy all sources with naming `source-{slug}.{ext}`:
|
### Directory Backup
|
||||||
- `source-article.md` (main text content)
|
|
||||||
- `source-photo.jpg` (image from conversation)
|
|
||||||
- `source-reference.pdf` (additional file)
|
|
||||||
|
|
||||||
Multiple sources supported: text, images, files from conversation.
|
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -448,13 +449,3 @@ Typography: Clean hand-drawn lettering, simple sans-serif labels
|
|||||||
- Sensitive figures should use cartoon alternatives
|
- Sensitive figures should use cartoon alternatives
|
||||||
- Prompts written in user's confirmed language preference
|
- Prompts written in user's confirmed language preference
|
||||||
- Illustration text (labels, captions) should match article language
|
- Illustration text (labels, captions) should match article language
|
||||||
|
|
||||||
## Extension Support
|
|
||||||
|
|
||||||
Custom styles and configurations via EXTEND.md.
|
|
||||||
|
|
||||||
**Check paths** (priority order):
|
|
||||||
1. `.baoyu-skills/baoyu-article-illustrator/EXTEND.md` (project)
|
|
||||||
2. `~/.baoyu-skills/baoyu-article-illustrator/EXTEND.md` (user)
|
|
||||||
|
|
||||||
If found, load before Step 1. Extension content overrides defaults.
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Create original knowledge comics with multiple visual styles.
|
|||||||
|
|
||||||
| Option | Values |
|
| Option | Values |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `--style` | classic (default), dramatic, warm, sepia, vibrant, ohmsha, realistic, wuxia, or custom description |
|
| `--style` | classic (default), dramatic, warm, tech, sepia, vibrant, ohmsha, realistic, or custom description |
|
||||||
| `--layout` | standard (default), cinematic, dense, splash, mixed, webtoon |
|
| `--layout` | standard (default), cinematic, dense, splash, mixed, webtoon |
|
||||||
| `--aspect` | 3:4 (default, portrait), 4:3 (landscape), 16:9 (widescreen) |
|
| `--aspect` | 3:4 (default, portrait), 4:3 (landscape), 16:9 (widescreen) |
|
||||||
| `--lang` | auto (default), zh, en, ja, etc. |
|
| `--lang` | auto (default), zh, en, ja, etc. |
|
||||||
@@ -32,12 +32,11 @@ Style × Layout × Aspect can be freely combined. Custom styles can be described
|
|||||||
| Content Signals | Style | Layout |
|
| Content Signals | Style | Layout |
|
||||||
|-----------------|-------|--------|
|
|-----------------|-------|--------|
|
||||||
| Tutorial, how-to, beginner | ohmsha | webtoon |
|
| Tutorial, how-to, beginner | ohmsha | webtoon |
|
||||||
| Computing, AI, programming | ohmsha | dense |
|
| Computing, AI, programming | tech | dense |
|
||||||
| Pre-1950, classical, ancient | sepia | cinematic |
|
| Pre-1950, classical, ancient | sepia | cinematic |
|
||||||
| Personal story, mentor | warm | standard |
|
| Personal story, mentor | warm | standard |
|
||||||
| Conflict, breakthrough | dramatic | splash |
|
| Conflict, breakthrough | dramatic | splash |
|
||||||
| Wine, food, business, lifestyle, professional | realistic | cinematic |
|
| Wine, food, business, lifestyle, professional | realistic | cinematic |
|
||||||
| Martial arts, wuxia, xianxia, Chinese historical | wuxia | splash |
|
|
||||||
| Biography, balanced | classic | mixed |
|
| Biography, balanced | classic | mixed |
|
||||||
|
|
||||||
## Script Directory
|
## Script Directory
|
||||||
@@ -56,11 +55,9 @@ Style × Layout × Aspect can be freely combined. Custom styles can be described
|
|||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
Each session creates an independent directory named by content slug:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
comic/{topic-slug}/
|
[target]/
|
||||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
├── source.md # Source content (if pasted, not file)
|
||||||
├── analysis.md # Deep analysis results (YAML+MD)
|
├── analysis.md # Deep analysis results (YAML+MD)
|
||||||
├── storyboard-chronological.md # Variant A (preserved)
|
├── storyboard-chronological.md # Variant A (preserved)
|
||||||
├── storyboard-thematic.md # Variant B (preserved)
|
├── storyboard-thematic.md # Variant B (preserved)
|
||||||
@@ -86,19 +83,13 @@ comic/{topic-slug}/
|
|||||||
└── {topic-slug}.pdf
|
└── {topic-slug}.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
**Slug Generation**:
|
**Target directory**:
|
||||||
1. Extract main topic from content (2-4 words, kebab-case)
|
- With source path: `[source-dir]/[source-name-no-ext]/comic/`
|
||||||
2. Example: "Alan Turing Biography" → `alan-turing-bio`
|
- Example: `/posts/turing-story.md` → `/posts/turing-story/comic/`
|
||||||
|
- Without source: `./comic/[topic-slug]/`
|
||||||
|
|
||||||
**Conflict Resolution**:
|
**Directory backup**:
|
||||||
If `comic/{topic-slug}/` already exists:
|
- If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||||
- Append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
|
||||||
- Example: `turing-story` exists → `turing-story-20260118-143052`
|
|
||||||
|
|
||||||
**Source Files**:
|
|
||||||
Copy all sources with naming `source-{slug}.{ext}`:
|
|
||||||
- `source-biography.md`, `source-portrait.jpg`, `source-timeline.png`, etc.
|
|
||||||
- Multiple sources supported: text, images, files from conversation
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -173,7 +164,7 @@ What readers will gain:
|
|||||||
## Content Signals
|
## Content Signals
|
||||||
|
|
||||||
- "biography" → classic + mixed
|
- "biography" → classic + mixed
|
||||||
- "computing history" → ohmsha + dense
|
- "computing history" → tech + dense
|
||||||
- "personal tragedy" → dramatic + splash
|
- "personal tragedy" → dramatic + splash
|
||||||
|
|
||||||
## Recommended Approaches
|
## Recommended Approaches
|
||||||
@@ -190,7 +181,7 @@ Create three distinct variants, each combining a narrative approach with a recom
|
|||||||
| Variant | Narrative Approach | Recommended Style | Layout |
|
| Variant | Narrative Approach | Recommended Style | Layout |
|
||||||
|---------|-------------------|-------------------|--------|
|
|---------|-------------------|-------------------|--------|
|
||||||
| A | Chronological | sepia | cinematic |
|
| A | Chronological | sepia | cinematic |
|
||||||
| B | Thematic | ohmsha | dense |
|
| B | Thematic | tech | dense |
|
||||||
| C | Character-focused | warm | standard |
|
| C | Character-focused | warm | standard |
|
||||||
|
|
||||||
**For each variant**:
|
**For each variant**:
|
||||||
@@ -238,13 +229,13 @@ Create three distinct variants, each combining a narrative approach with a recom
|
|||||||
```
|
```
|
||||||
Question 1 (Storyboard): Which storyboard variant?
|
Question 1 (Storyboard): Which storyboard variant?
|
||||||
- A: Chronological + sepia (Recommended)
|
- A: Chronological + sepia (Recommended)
|
||||||
- B: Thematic + ohmsha
|
- B: Thematic + tech
|
||||||
- C: Character-focused + warm
|
- C: Character-focused + warm
|
||||||
- Custom
|
- Custom
|
||||||
|
|
||||||
Question 2 (Style): Which visual style?
|
Question 2 (Style): Which visual style?
|
||||||
- sepia (Recommended from variant)
|
- sepia (Recommended from variant)
|
||||||
- classic / dramatic / warm / sepia / vibrant / ohmsha / realistic / wuxia
|
- classic / dramatic / warm / tech / vibrant / ohmsha / realistic
|
||||||
- Custom description
|
- Custom description
|
||||||
|
|
||||||
Question 3 (Language) - only if mismatch:
|
Question 3 (Language) - only if mismatch:
|
||||||
@@ -397,13 +388,3 @@ Detailed templates and guidelines in `references/` directory:
|
|||||||
- `ohmsha-guide.md` - Ohmsha manga style specifics
|
- `ohmsha-guide.md` - Ohmsha manga style specifics
|
||||||
- `styles/` - Detailed style definitions
|
- `styles/` - Detailed style definitions
|
||||||
- `layouts/` - Detailed layout definitions
|
- `layouts/` - Detailed layout definitions
|
||||||
|
|
||||||
## Extension Support
|
|
||||||
|
|
||||||
Custom styles and configurations via EXTEND.md.
|
|
||||||
|
|
||||||
**Check paths** (priority order):
|
|
||||||
1. `.baoyu-skills/baoyu-comic/EXTEND.md` (project)
|
|
||||||
2. `~/.baoyu-skills/baoyu-comic/EXTEND.md` (user)
|
|
||||||
|
|
||||||
If found, load before Step 1. Extension content overrides defaults.
|
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ Technical explanations, complex narratives, timelines
|
|||||||
|
|
||||||
## Best Style Pairings
|
## Best Style Pairings
|
||||||
|
|
||||||
ohmsha, vibrant
|
tech, ohmsha, vibrant
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ Ohmsha-style tutorials, mobile reading, step-by-step guides
|
|||||||
|
|
||||||
## Best Style Pairings
|
## Best Style Pairings
|
||||||
|
|
||||||
ohmsha, vibrant
|
ohmsha, tech, vibrant
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# wuxia
|
|
||||||
|
|
||||||
Hong Kong martial arts comic style (港漫武侠风格)
|
|
||||||
|
|
||||||
## Style Guidelines
|
|
||||||
|
|
||||||
### Line Work
|
|
||||||
- 2-3px dynamic brush strokes with varying weight
|
|
||||||
- Ink wash effects, traditional Chinese brush feel
|
|
||||||
- Speed lines and impact effects for action scenes
|
|
||||||
- Bold, confident strokes with sharp edges
|
|
||||||
|
|
||||||
### Character Design
|
|
||||||
- Realistic human proportions (7.5-8 head heights)
|
|
||||||
- Defined musculature, dynamic warrior poses
|
|
||||||
- Flowing hair and clothing in motion
|
|
||||||
- Intense, determined facial expressions
|
|
||||||
- Traditional Chinese martial arts attire (robes, armor)
|
|
||||||
|
|
||||||
### Background Treatment
|
|
||||||
- Dramatic landscapes: mountains, waterfalls, temples
|
|
||||||
- Ink wash atmospheric effects
|
|
||||||
- Flying debris, dust clouds during combat
|
|
||||||
- Energy/qi effects with flowing lines
|
|
||||||
- High contrast silhouettes
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Deep black ink, crimson red (#8B0000), gold (#D4AF37)
|
|
||||||
- Skin: Natural tan with dramatic shadows
|
|
||||||
- Background: Misty grays, earth tones, ink wash gradients
|
|
||||||
- Effects: Glowing qi energy (blue, gold, white)
|
|
||||||
|
|
||||||
## Special Effects
|
|
||||||
|
|
||||||
- **Combat impact**: Radiating lines, shockwaves, debris
|
|
||||||
- **Qi/energy**: Flowing aura, glowing effects around characters
|
|
||||||
- **Movement**: Extreme speed lines, motion blur
|
|
||||||
- **Atmosphere**: Ink wash clouds, mist, flying leaves/petals
|
|
||||||
|
|
||||||
## Quality Markers
|
|
||||||
|
|
||||||
A good wuxia comic page exhibits:
|
|
||||||
- ✓ Dynamic action poses with sense of motion
|
|
||||||
- ✓ Ink brush aesthetic in line work
|
|
||||||
- ✓ High contrast dramatic lighting
|
|
||||||
- ✓ Atmospheric backgrounds with Chinese elements
|
|
||||||
- ✓ Energy/qi effects that feel powerful
|
|
||||||
- ✓ Flowing fabric and hair movement
|
|
||||||
- ✓ Impactful combat moments
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Martial arts stories, Chinese historical fiction, action-heavy narratives, wuxia/xianxia adaptations
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
---
|
|
||||||
name: baoyu-compress-image
|
|
||||||
description: Cross-platform image compression skill. Converts images to WebP by default with PNG-to-PNG support. Uses system tools (sips, cwebp, ImageMagick) with Sharp fallback.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Image Compressor
|
|
||||||
|
|
||||||
Cross-platform image compression with WebP default output, PNG-to-PNG support, preferring system tools with Sharp fallback.
|
|
||||||
|
|
||||||
## Script Directory
|
|
||||||
|
|
||||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
|
||||||
|
|
||||||
**Agent Execution Instructions**:
|
|
||||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
|
||||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
|
||||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
|
||||||
|
|
||||||
**Script Reference**:
|
|
||||||
| Script | Purpose |
|
|
||||||
|--------|---------|
|
|
||||||
| `scripts/main.ts` | CLI entry point for image compression |
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Compress to WebP (default)
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png
|
|
||||||
|
|
||||||
# Keep original format (PNG → PNG)
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --format png
|
|
||||||
|
|
||||||
# Custom quality
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -q 75
|
|
||||||
|
|
||||||
# Process directory
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
### Single File Compression
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Basic (converts to WebP, replaces original)
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png
|
|
||||||
|
|
||||||
# Custom output path
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -o compressed.webp
|
|
||||||
|
|
||||||
# Keep original file
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --keep
|
|
||||||
|
|
||||||
# Custom quality (0-100, default: 80)
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -q 75
|
|
||||||
|
|
||||||
# Keep original format
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -f png
|
|
||||||
```
|
|
||||||
|
|
||||||
### Directory Processing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Process all images in directory
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/
|
|
||||||
|
|
||||||
# Recursive processing
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r
|
|
||||||
|
|
||||||
# With custom quality
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r -q 75
|
|
||||||
```
|
|
||||||
|
|
||||||
### Output Formats
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Plain text (default)
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png
|
|
||||||
|
|
||||||
# JSON output
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
| Option | Short | Description | Default |
|
|
||||||
|--------|-------|-------------|---------|
|
|
||||||
| `<input>` | | Input file or directory | Required |
|
|
||||||
| `--output <path>` | `-o` | Output path | Same path, new extension |
|
|
||||||
| `--format <fmt>` | `-f` | webp, png, jpeg | webp |
|
|
||||||
| `--quality <n>` | `-q` | Quality 0-100 | 80 |
|
|
||||||
| `--keep` | `-k` | Keep original file | false |
|
|
||||||
| `--recursive` | `-r` | Process directories recursively | false |
|
|
||||||
| `--json` | | JSON output | false |
|
|
||||||
| `--help` | `-h` | Show help | |
|
|
||||||
|
|
||||||
## Compressor Selection
|
|
||||||
|
|
||||||
Priority order (auto-detected):
|
|
||||||
|
|
||||||
1. **sips** (macOS built-in, WebP support since macOS 11)
|
|
||||||
2. **cwebp** (Google's official WebP tool)
|
|
||||||
3. **ImageMagick** (`convert` command)
|
|
||||||
4. **Sharp** (npm package, auto-installed by Bun)
|
|
||||||
|
|
||||||
The skill automatically selects the best available compressor.
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
### Text Mode (default)
|
|
||||||
|
|
||||||
```
|
|
||||||
image.png → image.webp (245KB → 89KB, 64% reduction)
|
|
||||||
```
|
|
||||||
|
|
||||||
### JSON Mode
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"input": "image.png",
|
|
||||||
"output": "image.webp",
|
|
||||||
"inputSize": 250880,
|
|
||||||
"outputSize": 91136,
|
|
||||||
"ratio": 0.36,
|
|
||||||
"compressor": "sips"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Directory JSON Mode
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"files": [...],
|
|
||||||
"summary": {
|
|
||||||
"totalFiles": 10,
|
|
||||||
"totalInputSize": 2508800,
|
|
||||||
"totalOutputSize": 911360,
|
|
||||||
"ratio": 0.36,
|
|
||||||
"compressor": "sips"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Compress single image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts photo.png
|
|
||||||
# photo.png → photo.webp (1.2MB → 340KB, 72% reduction)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compress with custom quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts photo.png -q 60
|
|
||||||
# photo.png → photo.webp (1.2MB → 280KB, 77% reduction)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keep original format
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts screenshot.png -f png --keep
|
|
||||||
# screenshot.png → screenshot-compressed.png (500KB → 380KB, 24% reduction)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Process entire directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./screenshots/ -r
|
|
||||||
# Processed 15 files: 12.5MB → 4.2MB (66% reduction)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get JSON for scripting
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --json | jq '.ratio'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extension Support
|
|
||||||
|
|
||||||
Custom configurations via EXTEND.md.
|
|
||||||
|
|
||||||
**Check paths** (priority order):
|
|
||||||
1. `.baoyu-skills/baoyu-compress-image/EXTEND.md` (project)
|
|
||||||
2. `~/.baoyu-skills/baoyu-compress-image/EXTEND.md` (user)
|
|
||||||
|
|
||||||
If found, load before workflow. Extension content overrides defaults.
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
import { existsSync, statSync, readdirSync, unlinkSync, renameSync } from "fs";
|
|
||||||
import { basename, dirname, extname, join, resolve } from "path";
|
|
||||||
import { spawn } from "child_process";
|
|
||||||
|
|
||||||
type Compressor = "sips" | "cwebp" | "imagemagick" | "sharp";
|
|
||||||
type Format = "webp" | "png" | "jpeg";
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
input: string;
|
|
||||||
output?: string;
|
|
||||||
format: Format;
|
|
||||||
quality: number;
|
|
||||||
keep: boolean;
|
|
||||||
recursive: boolean;
|
|
||||||
json: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Result {
|
|
||||||
input: string;
|
|
||||||
output: string;
|
|
||||||
inputSize: number;
|
|
||||||
outputSize: number;
|
|
||||||
ratio: number;
|
|
||||||
compressor: Compressor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUPPORTED_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff"];
|
|
||||||
|
|
||||||
async function commandExists(cmd: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const proc = spawn("which", [cmd], { stdio: "pipe" });
|
|
||||||
return new Promise((res) => {
|
|
||||||
proc.on("close", (code) => res(code === 0));
|
|
||||||
proc.on("error", () => res(false));
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectCompressor(format: Format): Promise<Compressor> {
|
|
||||||
if (format === "webp") {
|
|
||||||
if (await commandExists("cwebp")) return "cwebp";
|
|
||||||
if (await commandExists("convert")) return "imagemagick";
|
|
||||||
return "sharp";
|
|
||||||
}
|
|
||||||
if (process.platform === "darwin") return "sips";
|
|
||||||
if (await commandExists("convert")) return "imagemagick";
|
|
||||||
return "sharp";
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCmd(cmd: string, args: string[]): Promise<{ code: number; stderr: string }> {
|
|
||||||
return new Promise((res) => {
|
|
||||||
const proc = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
||||||
let stderr = "";
|
|
||||||
proc.stderr?.on("data", (d) => (stderr += d.toString()));
|
|
||||||
proc.on("close", (code) => res({ code: code ?? 1, stderr }));
|
|
||||||
proc.on("error", (e) => res({ code: 1, stderr: e.message }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressWithSips(input: string, output: string, format: Format, quality: number): Promise<void> {
|
|
||||||
const fmt = format === "jpeg" ? "jpeg" : format;
|
|
||||||
const args = ["-s", "format", fmt, "-s", "formatOptions", String(quality), input, "--out", output];
|
|
||||||
const { code, stderr } = await runCmd("sips", args);
|
|
||||||
if (code !== 0) throw new Error(`sips failed: ${stderr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressWithCwebp(input: string, output: string, quality: number): Promise<void> {
|
|
||||||
const args = ["-q", String(quality), input, "-o", output];
|
|
||||||
const { code, stderr } = await runCmd("cwebp", args);
|
|
||||||
if (code !== 0) throw new Error(`cwebp failed: ${stderr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressWithImagemagick(input: string, output: string, quality: number): Promise<void> {
|
|
||||||
const args = [input, "-quality", String(quality), output];
|
|
||||||
const { code, stderr } = await runCmd("convert", args);
|
|
||||||
if (code !== 0) throw new Error(`convert failed: ${stderr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressWithSharp(input: string, output: string, format: Format, quality: number): Promise<void> {
|
|
||||||
const sharp = (await import("sharp")).default;
|
|
||||||
let pipeline = sharp(input);
|
|
||||||
if (format === "webp") pipeline = pipeline.webp({ quality });
|
|
||||||
else if (format === "png") pipeline = pipeline.png({ quality });
|
|
||||||
else if (format === "jpeg") pipeline = pipeline.jpeg({ quality });
|
|
||||||
await pipeline.toFile(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compress(
|
|
||||||
compressor: Compressor,
|
|
||||||
input: string,
|
|
||||||
output: string,
|
|
||||||
format: Format,
|
|
||||||
quality: number
|
|
||||||
): Promise<void> {
|
|
||||||
switch (compressor) {
|
|
||||||
case "sips":
|
|
||||||
await compressWithSips(input, output, format, quality);
|
|
||||||
break;
|
|
||||||
case "cwebp":
|
|
||||||
if (format !== "webp") {
|
|
||||||
await compressWithSharp(input, output, format, quality);
|
|
||||||
} else {
|
|
||||||
await compressWithCwebp(input, output, quality);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "imagemagick":
|
|
||||||
await compressWithImagemagick(input, output, quality);
|
|
||||||
break;
|
|
||||||
case "sharp":
|
|
||||||
await compressWithSharp(input, output, format, quality);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOutputPath(input: string, format: Format, keep: boolean, customOutput?: string): string {
|
|
||||||
if (customOutput) return resolve(customOutput);
|
|
||||||
const dir = dirname(input);
|
|
||||||
const base = basename(input, extname(input));
|
|
||||||
const ext = format === "jpeg" ? ".jpg" : `.${format}`;
|
|
||||||
if (keep && extname(input).toLowerCase() === ext) {
|
|
||||||
return join(dir, `${base}-compressed${ext}`);
|
|
||||||
}
|
|
||||||
return join(dir, `${base}${ext}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes}B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processFile(
|
|
||||||
compressor: Compressor,
|
|
||||||
input: string,
|
|
||||||
opts: Options
|
|
||||||
): Promise<Result> {
|
|
||||||
const absInput = resolve(input);
|
|
||||||
const inputSize = statSync(absInput).size;
|
|
||||||
const output = getOutputPath(absInput, opts.format, opts.keep, opts.output);
|
|
||||||
const tempOutput = output + ".tmp";
|
|
||||||
|
|
||||||
await compress(compressor, absInput, tempOutput, opts.format, opts.quality);
|
|
||||||
|
|
||||||
const outputSize = statSync(tempOutput).size;
|
|
||||||
|
|
||||||
if (!opts.keep && absInput !== output) {
|
|
||||||
unlinkSync(absInput);
|
|
||||||
}
|
|
||||||
renameSync(tempOutput, output);
|
|
||||||
|
|
||||||
return {
|
|
||||||
input: absInput,
|
|
||||||
output,
|
|
||||||
inputSize,
|
|
||||||
outputSize,
|
|
||||||
ratio: outputSize / inputSize,
|
|
||||||
compressor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectFiles(dir: string, recursive: boolean): string[] {
|
|
||||||
const files: string[] = [];
|
|
||||||
const entries = readdirSync(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
const full = join(dir, entry.name);
|
|
||||||
if (entry.isDirectory() && recursive) {
|
|
||||||
files.push(...collectFiles(full, recursive));
|
|
||||||
} else if (entry.isFile() && SUPPORTED_EXTS.includes(extname(entry.name).toLowerCase())) {
|
|
||||||
files.push(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function printHelp() {
|
|
||||||
console.log(`Usage: bun main.ts <input> [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-o, --output <path> Output path
|
|
||||||
-f, --format <fmt> Output format: webp, png, jpeg (default: webp)
|
|
||||||
-q, --quality <n> Quality 0-100 (default: 80)
|
|
||||||
-k, --keep Keep original file
|
|
||||||
-r, --recursive Process directories recursively
|
|
||||||
--json JSON output
|
|
||||||
-h, --help Show help`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArgs(args: string[]): Options | null {
|
|
||||||
const opts: Options = {
|
|
||||||
input: "",
|
|
||||||
format: "webp",
|
|
||||||
quality: 80,
|
|
||||||
keep: false,
|
|
||||||
recursive: false,
|
|
||||||
json: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
const arg = args[i];
|
|
||||||
if (arg === "-h" || arg === "--help") {
|
|
||||||
printHelp();
|
|
||||||
process.exit(0);
|
|
||||||
} else if (arg === "-o" || arg === "--output") {
|
|
||||||
opts.output = args[++i];
|
|
||||||
} else if (arg === "-f" || arg === "--format") {
|
|
||||||
const fmt = args[++i]?.toLowerCase();
|
|
||||||
if (fmt === "webp" || fmt === "png" || fmt === "jpeg" || fmt === "jpg") {
|
|
||||||
opts.format = fmt === "jpg" ? "jpeg" : (fmt as Format);
|
|
||||||
} else {
|
|
||||||
console.error(`Invalid format: ${fmt}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else if (arg === "-q" || arg === "--quality") {
|
|
||||||
const q = parseInt(args[++i], 10);
|
|
||||||
if (isNaN(q) || q < 0 || q > 100) {
|
|
||||||
console.error(`Invalid quality: ${args[i]}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
opts.quality = q;
|
|
||||||
} else if (arg === "-k" || arg === "--keep") {
|
|
||||||
opts.keep = true;
|
|
||||||
} else if (arg === "-r" || arg === "--recursive") {
|
|
||||||
opts.recursive = true;
|
|
||||||
} else if (arg === "--json") {
|
|
||||||
opts.json = true;
|
|
||||||
} else if (!arg.startsWith("-") && !opts.input) {
|
|
||||||
opts.input = arg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!opts.input) {
|
|
||||||
console.error("Error: Input file or directory required");
|
|
||||||
printHelp();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return opts;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const opts = parseArgs(args);
|
|
||||||
if (!opts) process.exit(1);
|
|
||||||
|
|
||||||
const input = resolve(opts.input);
|
|
||||||
if (!existsSync(input)) {
|
|
||||||
console.error(`Error: ${input} not found`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const compressor = await detectCompressor(opts.format);
|
|
||||||
const isDir = statSync(input).isDirectory();
|
|
||||||
|
|
||||||
if (isDir) {
|
|
||||||
const files = collectFiles(input, opts.recursive);
|
|
||||||
if (files.length === 0) {
|
|
||||||
console.error("No supported images found");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: Result[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const r = await processFile(compressor, file, { ...opts, output: undefined });
|
|
||||||
results.push(r);
|
|
||||||
if (!opts.json) {
|
|
||||||
const reduction = Math.round((1 - r.ratio) * 100);
|
|
||||||
console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (!opts.json) console.error(`Error processing ${file}: ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.json) {
|
|
||||||
const totalInput = results.reduce((s, r) => s + r.inputSize, 0);
|
|
||||||
const totalOutput = results.reduce((s, r) => s + r.outputSize, 0);
|
|
||||||
console.log(
|
|
||||||
JSON.stringify({
|
|
||||||
files: results,
|
|
||||||
summary: {
|
|
||||||
totalFiles: results.length,
|
|
||||||
totalInputSize: totalInput,
|
|
||||||
totalOutputSize: totalOutput,
|
|
||||||
ratio: totalInput > 0 ? totalOutput / totalInput : 0,
|
|
||||||
compressor,
|
|
||||||
},
|
|
||||||
}, null, 2)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const totalInput = results.reduce((s, r) => s + r.inputSize, 0);
|
|
||||||
const totalOutput = results.reduce((s, r) => s + r.outputSize, 0);
|
|
||||||
const reduction = Math.round((1 - totalOutput / totalInput) * 100);
|
|
||||||
console.log(`\nProcessed ${results.length} files: ${formatSize(totalInput)} → ${formatSize(totalOutput)} (${reduction}% reduction)`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const r = await processFile(compressor, input, opts);
|
|
||||||
if (opts.json) {
|
|
||||||
console.log(JSON.stringify(r, null, 2));
|
|
||||||
} else {
|
|
||||||
const reduction = Math.round((1 - r.ratio) * 100);
|
|
||||||
console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error: ${(e as Error).message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -14,9 +14,9 @@ Generate hand-drawn style cover images for articles with multiple style options.
|
|||||||
/baoyu-cover-image path/to/article.md
|
/baoyu-cover-image path/to/article.md
|
||||||
|
|
||||||
# Specify a style
|
# Specify a style
|
||||||
/baoyu-cover-image path/to/article.md --style blueprint
|
/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 warm
|
||||||
/baoyu-cover-image path/to/article.md --style dark-atmospheric
|
/baoyu-cover-image path/to/article.md --style bold
|
||||||
|
|
||||||
# Without title text
|
# Without title text
|
||||||
/baoyu-cover-image path/to/article.md --no-title
|
/baoyu-cover-image path/to/article.md --no-title
|
||||||
@@ -47,25 +47,13 @@ Generate hand-drawn style cover images for articles with multiple style options.
|
|||||||
| Style | Description |
|
| Style | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `elegant` (Default) | Refined, sophisticated, understated |
|
| `elegant` (Default) | Refined, sophisticated, understated |
|
||||||
| `blueprint` | Technical schematics, engineering precision |
|
| `tech` | Modern, clean, futuristic |
|
||||||
| `bold` | High contrast, attention-grabbing, energetic |
|
|
||||||
| `bold-editorial` | Magazine cover impact, dramatic typography |
|
|
||||||
| `chalkboard` | Black chalkboard, colorful chalk drawings |
|
|
||||||
| `dark-atmospheric` | Cinematic dark mode, glowing accents |
|
|
||||||
| `editorial-infographic` | Magazine explainer, visual storytelling |
|
|
||||||
| `fantasy-animation` | Ghibli/Disney inspired, whimsical charm |
|
|
||||||
| `intuition-machine` | Technical briefing, bilingual labels |
|
|
||||||
| `minimal` | Ultra-clean, zen-like, focused |
|
|
||||||
| `nature` | Organic, calm, earthy |
|
|
||||||
| `notion` | Clean SaaS dashboard, productivity styling |
|
|
||||||
| `pixel-art` | Retro 8-bit, nostalgic gaming aesthetic |
|
|
||||||
| `playful` | Fun, creative, whimsical |
|
|
||||||
| `retro` | Halftone dots, vintage badges, classic |
|
|
||||||
| `sketch-notes` | Hand-drawn, educational, warm |
|
|
||||||
| `vector-illustration` | Flat vector, black outlines, retro colors |
|
|
||||||
| `vintage` | Aged paper, historical, expedition style |
|
|
||||||
| `warm` | Friendly, approachable, human-centered |
|
| `warm` | Friendly, approachable, human-centered |
|
||||||
| `watercolor` | Soft hand-painted, natural warmth |
|
| `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`
|
Detailed style definitions: `references/styles/<style>.md`
|
||||||
|
|
||||||
@@ -75,58 +63,49 @@ When no `--style` is specified, the system analyzes content to select the best s
|
|||||||
|
|
||||||
| Content Signals | Selected Style |
|
| Content Signals | Selected Style |
|
||||||
|----------------|----------------|
|
|----------------|----------------|
|
||||||
| Architecture, system design, engineering | `blueprint` |
|
| AI, coding, tech, digital, algorithm | `tech` |
|
||||||
| Product launch, keynote, marketing, brand | `bold-editorial` |
|
|
||||||
| Education, classroom, tutorial, teaching | `chalkboard` |
|
|
||||||
| Entertainment, creative, premium, cinematic | `dark-atmospheric` |
|
|
||||||
| Technology explainer, science, research | `editorial-infographic` |
|
|
||||||
| Storytelling, children, fantasy, magical | `fantasy-animation` |
|
|
||||||
| Technical docs, academic, bilingual | `intuition-machine` |
|
|
||||||
| Personal story, emotion, growth, life | `warm` |
|
| Personal story, emotion, growth, life | `warm` |
|
||||||
| Controversial, urgent, must-read, warning | `bold` |
|
| Controversial, urgent, must-read, warning | `bold` |
|
||||||
| Simple, zen, focus, essential | `minimal` |
|
| Simple, zen, focus, essential | `minimal` |
|
||||||
| Fun, easy, beginner, casual | `playful` |
|
| Fun, easy, beginner, casual, tutorial | `playful` |
|
||||||
| Nature, eco, wellness, health, organic | `nature` |
|
| Nature, eco, wellness, health, organic | `nature` |
|
||||||
| Pop culture, 80s/90s nostalgia, badges | `retro` |
|
| History, classic, vintage, old, traditional | `retro` |
|
||||||
| Product, SaaS, dashboard, productivity | `notion` |
|
|
||||||
| Gaming, retro tech, developer, 8-bit | `pixel-art` |
|
|
||||||
| Educational, tutorial, knowledge sharing | `sketch-notes` |
|
|
||||||
| Creative proposals, brand, toy-like | `vector-illustration` |
|
|
||||||
| History, exploration, heritage, biography | `vintage` |
|
|
||||||
| Lifestyle, travel, food, personal | `watercolor` |
|
|
||||||
| Business, professional, strategy, analysis | `elegant` |
|
| Business, professional, strategy, analysis | `elegant` |
|
||||||
|
|
||||||
## File Management
|
## File Management
|
||||||
|
|
||||||
### Output Directory
|
### With Article Path
|
||||||
|
|
||||||
Each session creates an independent directory named by content slug:
|
Save to `[source-name-no-ext]/cover-image/` subdirectory in the same folder as the article:
|
||||||
|
|
||||||
```
|
```
|
||||||
cover-image/{topic-slug}/
|
path/to/
|
||||||
├── source-{slug}.{ext} # Source files (text, images, etc.)
|
├── article.md
|
||||||
├── prompts/
|
└── article/
|
||||||
│ └── cover.md
|
└── cover-image/
|
||||||
└── cover.png
|
├── prompts/
|
||||||
|
│ └── cover.md
|
||||||
|
└── cover.png
|
||||||
```
|
```
|
||||||
|
|
||||||
**Slug Generation**:
|
Example: `/posts/ai-future.md` → `/posts/ai-future/cover-image/`
|
||||||
1. Extract main topic from content (2-4 words, kebab-case)
|
|
||||||
2. Example: "The Future of AI" → `future-of-ai`
|
|
||||||
|
|
||||||
### Conflict Resolution
|
### Without Article Path (Pasted Content)
|
||||||
|
|
||||||
If `cover-image/{topic-slug}/` already exists:
|
Save to `./cover-image/[topic-slug]/`:
|
||||||
- Append timestamp: `{topic-slug}-YYYYMMDD-HHMMSS`
|
|
||||||
- Example: `ai-future` exists → `ai-future-20260118-143052`
|
|
||||||
|
|
||||||
### Source Files
|
```
|
||||||
|
cover-image/
|
||||||
|
└── ai-future/
|
||||||
|
├── source.md # Saved pasted content
|
||||||
|
├── prompts/
|
||||||
|
│ └── cover.md
|
||||||
|
└── cover.png
|
||||||
|
```
|
||||||
|
|
||||||
Copy all sources with naming `source-{slug}.{ext}`:
|
### Directory Backup
|
||||||
- `source-article.md` (main text content)
|
|
||||||
- `source-logo.png` (image from conversation)
|
|
||||||
|
|
||||||
Multiple sources supported: text, images, files from conversation.
|
If target directory exists, rename existing to `<dirname>-backup-YYYYMMDD-HHMMSS`
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -273,13 +252,3 @@ Preview the image to verify it matches your expectations.
|
|||||||
- Image generation typically takes 10-30 seconds
|
- Image generation typically takes 10-30 seconds
|
||||||
- Title text uses user's confirmed language preference
|
- 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
|
- 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.
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# blueprint
|
|
||||||
|
|
||||||
Precise technical blueprint style with engineering aesthetic
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Engineering Blue (#2563EB), Navy Blue (#1E3A5F)
|
|
||||||
- Background: Blueprint Off-White (#FAF8F5), subtle grid overlay
|
|
||||||
- Accents: Amber (#F59E0B), Light Blue (#BFDBFE)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Precise lines with consistent stroke weights
|
|
||||||
- Technical schematics and clean vector graphics
|
|
||||||
- Dimension lines and measurement indicators
|
|
||||||
- Grid alignment for all elements
|
|
||||||
- Geometric precision for all shapes
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Clean sans-serif hand lettering, technical and authoritative
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Technical architecture, system design, data analysis, engineering documentation
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# bold-editorial
|
|
||||||
|
|
||||||
High-impact magazine editorial with bold visual expression
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Pure White (#FFFFFF), Electric Blue (#3B82F6), Bright Orange (#FB923C)
|
|
||||||
- Background: Deep Black (#0A0A0A), Deep Blue (#0F172A)
|
|
||||||
- Accents: Magenta (#EC4899), Neon Green (#22C55E), Violet (#8B5CF6)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Strong typography as visual element itself
|
|
||||||
- Geometric shapes and bold color blocks
|
|
||||||
- Full-bleed solid color backgrounds
|
|
||||||
- Dynamic diagonal lines and angles
|
|
||||||
- Dramatic lighting effects on text
|
|
||||||
- Minimal decoration, maximum impact
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Bold condensed typeface, oversized headlines, all-caps, tight letter-spacing
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Product launches, marketing, keynote speeches, brand showcases, investor pitches
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# chalkboard
|
|
||||||
|
|
||||||
Black chalkboard background with colorful chalk drawing style
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Chalk White (#F5F5F5), Chalk Yellow (#FFE566), Chalk Pink (#FF9999)
|
|
||||||
- Background: Chalkboard Black (#1A1A1A), Dark Green-Black (#1C2B1C)
|
|
||||||
- Accents: Chalk Blue (#66B3FF), Chalk Green (#90EE90), Chalk Orange (#FFB366)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Chalkboard texture with subtle dust and smudges
|
|
||||||
- Hand-drawn chalk illustrations with sketchy lines
|
|
||||||
- Chalk dust effects around text and drawings
|
|
||||||
- Imperfect lines with visible texture
|
|
||||||
- Doodles, arrows, underlines, stars
|
|
||||||
- Eraser marks and chalk residue
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Hand-drawn chalk lettering, slightly uneven, with chalk texture
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Educational content, tutorials, classroom themes, back-to-school, teaching materials
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# dark-atmospheric
|
|
||||||
|
|
||||||
Dark moody aesthetic with glowing accent elements
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Electric Purple (#8B5CF6), Cyan Blue (#06B6D4), Magenta Pink (#EC4899)
|
|
||||||
- Background: Deep Purple-Black (#0D0D1A), Rich Navy (#1A1A2E)
|
|
||||||
- Accents: Amber (#F59E0B), Pure White (#FFFFFF)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Glowing accent elements and borders
|
|
||||||
- Subtle gradient backgrounds
|
|
||||||
- Atmospheric fog or particle effects
|
|
||||||
- Neon-style highlights on key elements
|
|
||||||
- Silhouettes with backlit edges
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Elegant serif or refined sans-serif in light/white with subtle glow
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Entertainment, creative industries, premium brands, cinematic storytelling
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# editorial-infographic
|
|
||||||
|
|
||||||
Modern magazine-style editorial with clear visual storytelling
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Near Black (#1A1A1A), Editorial Blue (#2563EB)
|
|
||||||
- Background: Pure White (#FFFFFF), Light Gray (#F8F9FA)
|
|
||||||
- Accents: Coral (#F97316), Emerald (#10B981), Amber (#F59E0B)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Clean flat illustrations (not photos)
|
|
||||||
- Structured multi-section layouts
|
|
||||||
- Callout boxes for key insights
|
|
||||||
- Icon-based data visualization
|
|
||||||
- Visual metaphors for abstract concepts
|
|
||||||
- Pull quotes and highlight boxes
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Bold display serif or modern sans-serif, editorial sophistication
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Technology explainers, science communication, research summaries, thought leadership
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# fantasy-animation
|
|
||||||
|
|
||||||
Whimsical hand-drawn animation style inspired by Ghibli and Disney
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Golden Yellow (#F4D03F), Rose Pink (#E8A0BF), Deep Forest (#2D5A3D)
|
|
||||||
- Background: Soft Sky Blue (#E8F4FC), Warm Cream (#FFF8E7)
|
|
||||||
- Accents: Sage Green (#87A96B), Sky Blue (#7EC8E3), Coral (#F08080)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Central illustrated character (friendly, expressive)
|
|
||||||
- Magical floating objects (books, orbs, sparkles)
|
|
||||||
- Storybook-style environment backgrounds
|
|
||||||
- Soft shadows and gentle highlights
|
|
||||||
- Decorative elements: stars, sparkles, flowers, leaves
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Whimsical serif or decorative hand-lettered style, organic feel
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Educational content, storytelling, creative workshops, fantasy/gaming content
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# intuition-machine
|
|
||||||
|
|
||||||
Technical briefing style with aged paper and bilingual labels
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Dark Maroon (#5D3A3A), Teal (#2F7373)
|
|
||||||
- Background: Aged Cream (#F5F0E6), subtle paper texture with light creases
|
|
||||||
- Accents: Warm Brown (#8B7355), Maroon (#722F37), Deep Charcoal (#2D2D2D)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Isometric 3D technical illustrations or flat 2D diagrams
|
|
||||||
- Bilingual callout labels (English + Chinese)
|
|
||||||
- Faded thematic background patterns
|
|
||||||
- Clean black outlines on all elements
|
|
||||||
- Split or triptych layouts
|
|
||||||
- Key quote box at bottom
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Bold display font in dark maroon, ALL CAPS for titles, bilingual labels
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Technical explanations, academic presentations, bilingual audiences, knowledge docs
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# notion
|
|
||||||
|
|
||||||
Clean SaaS dashboard aesthetic with productivity tool styling
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Near Black (#1F1F1F), Notion Blue (#2383E2)
|
|
||||||
- Background: Light Gray (#F7F7F5), Pure White (#FFFFFF)
|
|
||||||
- Accents: Success Green (#0F7B6C), Alert Red (#E03E3E), Warning Yellow (#DFAB01)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Card-based layouts with subtle borders
|
|
||||||
- Clean data visualizations
|
|
||||||
- Tag and label chips
|
|
||||||
- Icon-based navigation hints
|
|
||||||
- Progress bars and metric displays
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- System UI or Inter style, clean and functional
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Product demos, SaaS presentations, productivity content, B2B topics
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# pixel-art
|
|
||||||
|
|
||||||
Retro 8-bit pixel art aesthetic with nostalgic gaming style
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Dark Navy (#1A1A2E), Pixel Green (#00FF00)
|
|
||||||
- Background: Light Blue (#87CEEB), Soft Lavender (#E6E6FA)
|
|
||||||
- Accents: Pixel Red (#FF0000), Pixel Yellow (#FFFF00), Pixel Cyan (#00FFFF)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- All elements with visible pixel structure
|
|
||||||
- Simple iconography: stars, hearts, arrows
|
|
||||||
- Text bubbles with pixel borders
|
|
||||||
- Progress bars with chunky segments
|
|
||||||
- Dithering patterns for gradients
|
|
||||||
- Limited 16-32 color palette
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Pixelated bitmap font style, chunky blocky letterforms
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Gaming content, tech tutorials, developer talks, retro-themed topics
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# sketch-notes
|
|
||||||
|
|
||||||
Soft hand-drawn illustration style with fresh minimalist aesthetic
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Deep Charcoal (#2C3E50), Soft Orange (#F4A261)
|
|
||||||
- Background: Warm Off-White (#FAF8F0), subtle paper grain
|
|
||||||
- Accents: Mustard Yellow (#E9C46A), Sage Green (#87A96B), Light Blue (#7EC8E3)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Connection lines with hand-drawn wavy feel
|
|
||||||
- Conceptual abstract icons illustrating ideas
|
|
||||||
- Color fills with hand-painted casual feel
|
|
||||||
- Doodle-style decorative elements
|
|
||||||
- Arrows and pointers with sketchy style
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Bold hand-written marker font for headlines, casual handwriting for body
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Educational content, knowledge sharing, technical explanations, tutorials
|
|
||||||
@@ -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
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# vector-illustration
|
|
||||||
|
|
||||||
Flat vector illustration with black outlines and retro soft colors
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Coral Red (#E07A5F), Mint Green (#81B29A), Mustard Yellow (#F2CC8F)
|
|
||||||
- Background: Cream Off-White (#F5F0E6), subtle paper texture
|
|
||||||
- Accents: Burnt Orange (#D4764A), Rock Blue (#577590), Deep Charcoal (#2D2D2D) outlines
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- All objects have closed black outlines (coloring book style)
|
|
||||||
- Rounded line endings, avoid sharp corners
|
|
||||||
- Simplified geometric shapes
|
|
||||||
- 2.5D perspective with layering
|
|
||||||
- Decorative elements: sunbursts, pill clouds, dots, stars
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Large bold retro serif for titles, clean geometric sans-serif for body
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Educational content, creative proposals, brand showcases, explainer content
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# vintage
|
|
||||||
|
|
||||||
Vintage aged-paper aesthetic with historical document styling
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Dark Brown (#3D2914), Forest Green (#2D5A3D), Navy Blue (#1E3A5F)
|
|
||||||
- Background: Aged Parchment (#F5E6D3), Sepia Cream (#FFF8DC)
|
|
||||||
- Accents: Burgundy (#722F37), Gold (#C9A227), Sepia Black (#3D3D3D)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Antique maps with route lines and landmarks
|
|
||||||
- Compass roses and nautical elements
|
|
||||||
- Specimen drawings (flora, fauna)
|
|
||||||
- Handwritten-style annotations
|
|
||||||
- Rope, leather, brass decorative motifs
|
|
||||||
- Aged texture with subtle creases
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Classic serif with historical character, elegant flourishes
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Historical content, travel, heritage storytelling, biography, scientific discovery
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# watercolor
|
|
||||||
|
|
||||||
Soft watercolor illustration style with hand-painted textures
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
- Primary: Soft Coral (#F4A261), Dusty Rose (#E8A0A0)
|
|
||||||
- Background: Warm Off-White (#FAF8F0), Soft Cream (#FFF9E6)
|
|
||||||
- Accents: Sage Green (#87A96B), Sky Blue (#7EC8E3), Soft Lavender (#C5B4E3)
|
|
||||||
|
|
||||||
## Visual Elements
|
|
||||||
|
|
||||||
- Watercolor washes as backgrounds
|
|
||||||
- Visible brush strokes and textures
|
|
||||||
- Natural elements: leaves, flowers, organic shapes
|
|
||||||
- Color bleeds and soft edges
|
|
||||||
- Hand-drawn arrows and connections
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
- Elegant handwritten or brush script, organic letterforms
|
|
||||||
|
|
||||||
## Best For
|
|
||||||
|
|
||||||
Lifestyle content, wellness, travel guides, personal stories, creative topics
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
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",
|
|
||||||
];
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
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("; ");
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
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(``);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackUrl = typeof value.data?.url === "string" ? value.data.url : undefined;
|
|
||||||
if (fallbackUrl && !usedUrls.has(fallbackUrl)) {
|
|
||||||
usedUrls.add(fallbackUrl);
|
|
||||||
lines.push(``);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(``);
|
|
||||||
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(``);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n").trimEnd();
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
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 ``;
|
|
||||||
});
|
|
||||||
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(``);
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
#!/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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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: baoyu-danger-gemini-web
|
name: baoyu-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.
|
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.
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,86 +26,6 @@ Supports:
|
|||||||
| `scripts/main.ts` | CLI entry point for text/image generation |
|
| `scripts/main.ts` | CLI entry point for text/image generation |
|
||||||
| `scripts/gemini-webapi/*` | TypeScript port of `gemini_webapi` (GeminiClient, types, utils) |
|
| `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
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -198,15 +118,7 @@ CLI note: `scripts/main.ts` supports text generation, image generation, referenc
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
First run opens a browser to authenticate with Google. Cookies are cached for subsequent runs.
|
First run opens Chrome to authenticate with Google. Cookies are cached for subsequent runs.
|
||||||
|
|
||||||
**Supported browsers** (auto-detected in order):
|
|
||||||
- Google Chrome
|
|
||||||
- Google Chrome Canary / Beta
|
|
||||||
- Chromium
|
|
||||||
- Microsoft Edge
|
|
||||||
|
|
||||||
Override with `GEMINI_WEB_CHROME_PATH` environment variable if needed.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Force cookie refresh
|
# Force cookie refresh
|
||||||
@@ -222,23 +134,6 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --login
|
|||||||
| `GEMINI_WEB_CHROME_PROFILE_DIR` | Chrome profile directory |
|
| `GEMINI_WEB_CHROME_PROFILE_DIR` | Chrome profile directory |
|
||||||
| `GEMINI_WEB_CHROME_PATH` | Chrome executable path |
|
| `GEMINI_WEB_CHROME_PATH` | Chrome executable path |
|
||||||
|
|
||||||
## Proxy Configuration
|
|
||||||
|
|
||||||
If you need a proxy to access Google services (e.g., in China), set `HTTP_PROXY` and `HTTPS_PROXY` environment variables before running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Example with local proxy
|
|
||||||
HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello"
|
|
||||||
|
|
||||||
# Image generation with proxy
|
|
||||||
HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png
|
|
||||||
|
|
||||||
# Cookie refresh with proxy
|
|
||||||
HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 npx -y bun ${SKILL_DIR}/scripts/main.ts --login
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Environment variables must be set inline with the command. Shell profile settings (e.g., `.bashrc`) may not be inherited by subprocesses.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Generate text response
|
### Generate text response
|
||||||
@@ -280,13 +175,3 @@ Session files are stored in `~/Library/Application Support/baoyu-skills/gemini-w
|
|||||||
- `metadata`: Gemini chat metadata for continuation
|
- `metadata`: Gemini chat metadata for continuation
|
||||||
- `messages`: Array of `{role, content, timestamp, error?}`
|
- `messages`: Array of `{role, content, timestamp, error?}`
|
||||||
- `createdAt`, `updatedAt`: Timestamps
|
- `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.
|
|
||||||