mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 05:51:44 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b2c02a007 | |||
| 98f49eae96 | |||
| 1bdf44df9e | |||
| d59ecf22c1 | |||
| 22fa1a62fc | |||
| 3d48eed33a | |||
| 8f1c4a65dd | |||
| 9b97720f16 | |||
| 145e1d2d04 | |||
| 49403b6fab | |||
| 6d56f1dae8 | |||
| dd08a2aa89 | |||
| d9b47debb3 | |||
| 6cc8627ca8 | |||
| 54103dfd7d | |||
| 6012b9cc88 | |||
| 260b71cdd7 |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.31.2"
|
"version": "1.34.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,43 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.34.1 - 2026-02-20
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-post-to-wechat`: fix upload progress check crashing on second iteration (by @LyInfi)
|
||||||
|
|
||||||
|
## 1.34.0 - 2026-02-17
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-xhs-images`: add reference image chain for visual consistency across multi-image series (by @jeffrey94)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-article-illustrator`: enforce prompt file creation as blocking step before image generation, add structured prompt quality requirements (ZONES / LABELS / COLORS / STYLE / ASPECT) and verification checklist.
|
||||||
|
|
||||||
|
## 1.33.1 - 2026-02-14
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-post-to-x`: replace hand-rolled markdown parser with marked ecosystem for X Articles HTML conversion.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `baoyu-post-to-x`: remove `--submit` flag from all scripts; clarify that scripts only fill content into browser for manual review and publish.
|
||||||
|
|
||||||
|
## 1.33.0 - 2026-02-13
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-post-to-x`: add pre-flight environment check script (`check-paste-permissions.ts`); add troubleshooting section for Chrome debug port conflicts; replace fixed sleep with image upload verification polling up to 15s.
|
||||||
|
- `baoyu-post-to-wechat`: add pre-flight environment check script (`check-permissions.ts`) covering Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials.
|
||||||
|
|
||||||
|
## 1.32.0 - 2026-02-12
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-danger-x-to-markdown`: add `--download-media` flag to download images/videos locally and rewrite markdown links to relative paths; add media localization module; add first-time setup with EXTEND.md preferences; add `coverImage` to frontmatter output.
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-danger-x-to-markdown`: use camelCase for frontmatter keys (`tweetCount`, `coverImage`, `requestedUrl`, etc.).
|
||||||
|
- `baoyu-format-markdown`: rename `featureImage` to `coverImage` as primary frontmatter key (with `featureImage` as accepted alias).
|
||||||
|
- `baoyu-post-to-wechat`: prioritize `coverImage` over `featureImage` in cover image frontmatter lookup order.
|
||||||
|
|
||||||
## 1.31.2 - 2026-02-10
|
## 1.31.2 - 2026-02-10
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -2,6 +2,43 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.34.1 - 2026-02-20
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-post-to-wechat`:修复上传进度检查在第二次迭代时崩溃的问题 (by @LyInfi)
|
||||||
|
|
||||||
|
## 1.34.0 - 2026-02-17
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-xhs-images`:新增参考图片链功能,确保多图系列的视觉一致性 (by @jeffrey94)
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-article-illustrator`:将提示词文件创建设为生成图片前的阻断步骤,新增结构化提示词质量要求(ZONES / LABELS / COLORS / STYLE / ASPECT)和验证清单。
|
||||||
|
|
||||||
|
## 1.33.1 - 2026-02-14
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-post-to-x`:将手写 markdown 解析器替换为 marked 生态系统,用于 X Articles HTML 转换。
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `baoyu-post-to-x`:移除所有脚本的 `--submit` 参数;明确脚本仅将内容填充到浏览器,由用户手动审核和发布。
|
||||||
|
|
||||||
|
## 1.33.0 - 2026-02-13
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-post-to-x`:新增环境预检脚本(`check-paste-permissions.ts`);新增 Chrome 调试端口冲突的故障排查说明;将固定等待替换为图片上传轮询验证(最长 15 秒)。
|
||||||
|
- `baoyu-post-to-wechat`:新增环境预检脚本(`check-permissions.ts`),检查 Chrome、配置文件隔离、Bun、辅助功能、剪贴板、粘贴按键和 API 凭据。
|
||||||
|
|
||||||
|
## 1.32.0 - 2026-02-12
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-danger-x-to-markdown`:新增 `--download-media` 参数,支持将图片/视频下载到本地并将 markdown 链接改写为相对路径;新增媒体本地化模块;新增首次使用 EXTEND.md 偏好设置;在 frontmatter 中输出 `coverImage`。
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-danger-x-to-markdown`:frontmatter 字段改为 camelCase(`tweetCount`、`coverImage`、`requestedUrl` 等)。
|
||||||
|
- `baoyu-format-markdown`:将主 frontmatter 字段从 `featureImage` 更名为 `coverImage`(兼容 `featureImage`)。
|
||||||
|
- `baoyu-post-to-wechat`:封面图片 frontmatter 查找顺序中优先使用 `coverImage`。
|
||||||
|
|
||||||
## 1.31.2 - 2026-02-10
|
## 1.31.2 - 2026-02-10
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -681,6 +681,9 @@ Converts X (Twitter) content to markdown format. Supports tweet threads and X Ar
|
|||||||
|
|
||||||
# JSON output
|
# JSON output
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
||||||
|
|
||||||
|
# Download media (images/videos) to local files
|
||||||
|
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --download-media
|
||||||
```
|
```
|
||||||
|
|
||||||
**Supported URLs:**
|
**Supported URLs:**
|
||||||
@@ -713,7 +716,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
|||||||
|
|
||||||
**Workflow**:
|
**Workflow**:
|
||||||
1. Read source file and analyze content structure
|
1. Read source file and analyze content structure
|
||||||
2. Check/create YAML frontmatter (title, slug, summary, featureImage)
|
2. Check/create YAML frontmatter (title, slug, summary, coverImage)
|
||||||
3. Handle title: use existing, extract from H1, or generate candidates
|
3. Handle title: use existing, extract from H1, or generate candidates
|
||||||
4. Apply formatting: headings, bold, lists, code blocks, quotes
|
4. Apply formatting: headings, bold, lists, code blocks, quotes
|
||||||
5. Save to `{filename}-formatted.md`
|
5. Save to `{filename}-formatted.md`
|
||||||
@@ -725,7 +728,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
|||||||
| `title` | Use existing, extract H1, or generate candidates |
|
| `title` | Use existing, extract H1, or generate candidates |
|
||||||
| `slug` | Infer from file path or generate from title |
|
| `slug` | Infer from file path or generate from title |
|
||||||
| `summary` | Generate engaging summary (100-150 chars) |
|
| `summary` | Generate engaging summary (100-150 chars) |
|
||||||
| `featureImage` | Check for `imgs/cover.png` in same directory |
|
| `coverImage` | Check for `imgs/cover.png` in same directory |
|
||||||
|
|
||||||
**Formatting Rules**:
|
**Formatting Rules**:
|
||||||
| Element | Format |
|
| Element | Format |
|
||||||
|
|||||||
+5
-2
@@ -681,6 +681,9 @@ AI 驱动的生成后端。
|
|||||||
|
|
||||||
# JSON 输出
|
# JSON 输出
|
||||||
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json
|
||||||
|
|
||||||
|
# 下载媒体文件(图片/视频)到本地
|
||||||
|
/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --download-media
|
||||||
```
|
```
|
||||||
|
|
||||||
**支持的 URL:**
|
**支持的 URL:**
|
||||||
@@ -713,7 +716,7 @@ AI 驱动的生成后端。
|
|||||||
|
|
||||||
**工作流程**:
|
**工作流程**:
|
||||||
1. 读取源文件并分析内容结构
|
1. 读取源文件并分析内容结构
|
||||||
2. 检查/创建 YAML frontmatter(title、slug、summary、featureImage)
|
2. 检查/创建 YAML frontmatter(title、slug、summary、coverImage)
|
||||||
3. 处理标题:使用现有标题、提取 H1 或生成候选标题
|
3. 处理标题:使用现有标题、提取 H1 或生成候选标题
|
||||||
4. 应用格式:层级标题、加粗、列表、代码块、引用
|
4. 应用格式:层级标题、加粗、列表、代码块、引用
|
||||||
5. 保存为 `{文件名}-formatted.md`
|
5. 保存为 `{文件名}-formatted.md`
|
||||||
@@ -725,7 +728,7 @@ AI 驱动的生成后端。
|
|||||||
| `title` | 使用现有、提取 H1 或生成候选 |
|
| `title` | 使用现有、提取 H1 或生成候选 |
|
||||||
| `slug` | 从文件路径推断或根据标题生成 |
|
| `slug` | 从文件路径推断或根据标题生成 |
|
||||||
| `summary` | 生成吸引人的摘要(100-150 字) |
|
| `summary` | 生成吸引人的摘要(100-150 字) |
|
||||||
| `featureImage` | 检查同目录下 `imgs/cover.png` |
|
| `coverImage` | 检查同目录下 `imgs/cover.png` |
|
||||||
|
|
||||||
**格式化规则**:
|
**格式化规则**:
|
||||||
| 元素 | 格式 |
|
| 元素 | 格式 |
|
||||||
|
|||||||
@@ -100,11 +100,16 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
|||||||
|
|
||||||
### Step 5: Generate Images
|
### Step 5: Generate Images
|
||||||
|
|
||||||
1. Create prompts per [references/prompt-construction.md](references/prompt-construction.md)
|
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||||
2. Select generation skill from available skills
|
|
||||||
3. Process references (`direct`/`style`/`palette`)
|
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||||
4. Apply watermark if EXTEND.md enabled
|
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||||
5. Generate sequentially, retry once on failure
|
3. Prompts **MUST** use type-specific templates with structured sections (ZONES / LABELS / COLORS / STYLE / ASPECT)
|
||||||
|
4. LABELS **MUST** include article-specific data: actual numbers, terms, metrics, quotes
|
||||||
|
5. **DO NOT** pass ad-hoc inline prompts to `--prompt` without saving prompt files first
|
||||||
|
6. Select generation skill, process references (`direct`/`style`/`palette`)
|
||||||
|
7. Apply watermark if EXTEND.md enabled
|
||||||
|
8. Generate from saved prompt files; retry once on failure
|
||||||
|
|
||||||
Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images)
|
Full procedures: [references/workflow.md](references/workflow.md#step-5-generate-images)
|
||||||
|
|
||||||
|
|||||||
@@ -261,10 +261,41 @@ references: # Only if references provided
|
|||||||
|
|
||||||
## Step 5: Generate Images
|
## Step 5: Generate Images
|
||||||
|
|
||||||
### 5.1 Create Prompts
|
### 5.1 Create Prompts ⛔ BLOCKING
|
||||||
|
|
||||||
Follow [prompt-construction.md](prompt-construction.md). Save to `prompts/illustration-{slug}.md`.
|
**Every illustration MUST have a saved prompt file before generation begins. DO NOT skip this step.**
|
||||||
- **Backup rule**: If prompt file exists, rename to `prompts/illustration-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
|
||||||
|
For each illustration in the outline:
|
||||||
|
|
||||||
|
1. **Create prompt file**: `prompts/NN-{type}-{slug}.md`
|
||||||
|
2. **Include YAML frontmatter**:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
illustration_id: 01
|
||||||
|
type: infographic
|
||||||
|
style: custom-flat-vector
|
||||||
|
---
|
||||||
|
```
|
||||||
|
3. **Follow type-specific template** from [prompt-construction.md](prompt-construction.md)
|
||||||
|
4. **Prompt quality requirements** (all REQUIRED):
|
||||||
|
- `Layout`: Describe overall composition (grid / radial / hierarchical / left-right / top-down)
|
||||||
|
- `ZONES`: Describe each visual area with specific content, not vague descriptions
|
||||||
|
- `LABELS`: Use **actual numbers, terms, metrics, quotes from the article** — NOT generic placeholders
|
||||||
|
- `COLORS`: Specify hex codes with semantic meaning (e.g., `Coral (#E07A5F) for emphasis`)
|
||||||
|
- `STYLE`: Describe line treatment, texture, mood, character rendering
|
||||||
|
- `ASPECT`: Specify ratio (e.g., `16:9`)
|
||||||
|
5. **Apply defaults**: composition requirements, character rendering, text guidelines, watermark
|
||||||
|
6. **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-{slug}-backup-YYYYMMDD-HHMMSS.md`
|
||||||
|
|
||||||
|
**Verification** ⛔: Before proceeding to 5.2, confirm ALL prompt files exist:
|
||||||
|
```
|
||||||
|
Prompt Files:
|
||||||
|
- prompts/01-infographic-overview.md ✓
|
||||||
|
- prompts/02-infographic-distillation.md ✓
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
||||||
|
|
||||||
**CRITICAL - References in Frontmatter**:
|
**CRITICAL - References in Frontmatter**:
|
||||||
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
- Only add `references` field if files ACTUALLY EXIST in `references/` directory
|
||||||
|
|||||||
@@ -92,16 +92,52 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user
|
|||||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||||
│ Found │ Read, parse, apply settings │
|
│ Found │ Read, parse, apply settings │
|
||||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||||
│ Not found │ Use defaults │
|
│ Not found │ **MUST** run first-time setup (see below) — do NOT silently create defaults │
|
||||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
**EXTEND.md Supports**: Default output directory | Output format preferences
|
**EXTEND.md Supports**: Download media by default | Default output directory
|
||||||
|
|
||||||
|
### First-Time Setup (BLOCKING)
|
||||||
|
|
||||||
|
**CRITICAL**: When EXTEND.md is not found, you **MUST use `AskUserQuestion`** to ask the user for their preferences before creating EXTEND.md. **NEVER** create EXTEND.md with defaults without asking. This is a **BLOCKING** operation — do NOT proceed with any conversion until setup is complete.
|
||||||
|
|
||||||
|
Use `AskUserQuestion` with ALL questions in ONE call:
|
||||||
|
|
||||||
|
**Question 1** — header: "Media", question: "How to handle images and videos in tweets?"
|
||||||
|
- "Ask each time (Recommended)" — After saving markdown, ask whether to download media
|
||||||
|
- "Always download" — Always download media to local imgs/ and videos/ directories
|
||||||
|
- "Never download" — Keep original remote URLs in markdown
|
||||||
|
|
||||||
|
**Question 2** — header: "Output", question: "Default output directory?"
|
||||||
|
- "x-to-markdown (Recommended)" — Save to ./x-to-markdown/{username}/{tweet-id}.md
|
||||||
|
- (User may choose "Other" to type a custom path)
|
||||||
|
|
||||||
|
**Question 3** — header: "Save", question: "Where to save preferences?"
|
||||||
|
- "User (Recommended)" — ~/.baoyu-skills/ (all projects)
|
||||||
|
- "Project" — .baoyu-skills/ (this project only)
|
||||||
|
|
||||||
|
After user answers, create EXTEND.md at the chosen location, confirm "Preferences saved to [path]", then continue.
|
||||||
|
|
||||||
|
Full reference: [references/config/first-time-setup.md](references/config/first-time-setup.md)
|
||||||
|
|
||||||
|
### Supported Keys
|
||||||
|
|
||||||
|
| Key | Default | Values | Description |
|
||||||
|
|-----|---------|--------|-------------|
|
||||||
|
| `download_media` | `ask` | `ask` / `1` / `0` | `ask` = prompt each time, `1` = always download, `0` = never |
|
||||||
|
| `default_output_dir` | empty | path or empty | Default output directory (empty = `./x-to-markdown/`) |
|
||||||
|
|
||||||
|
**Value priority**:
|
||||||
|
1. CLI arguments (`--download-media`, `-o`)
|
||||||
|
2. EXTEND.md
|
||||||
|
3. Skill defaults
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
|
npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md
|
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --download-media
|
||||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,6 +148,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
|||||||
| `<url>` | Tweet or article URL |
|
| `<url>` | Tweet or article URL |
|
||||||
| `-o <path>` | Output path |
|
| `-o <path>` | Output path |
|
||||||
| `--json` | JSON output |
|
| `--json` | JSON output |
|
||||||
|
| `--download-media` | Download image/video assets to local `imgs/` and `videos/`, and rewrite markdown links to local relative paths |
|
||||||
| `--login` | Refresh cookies only |
|
| `--login` | Refresh cookies only |
|
||||||
|
|
||||||
## Supported URLs
|
## Supported URLs
|
||||||
@@ -124,9 +161,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
|||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
url: https://x.com/user/status/123
|
url: "https://x.com/user/status/123"
|
||||||
author: "Name (@user)"
|
author: "Name (@user)"
|
||||||
tweet_count: 3
|
tweetCount: 3
|
||||||
|
coverImage: "https://pbs.twimg.com/media/example.jpg"
|
||||||
---
|
---
|
||||||
|
|
||||||
Content...
|
Content...
|
||||||
@@ -134,6 +172,32 @@ Content...
|
|||||||
|
|
||||||
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
|
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
|
||||||
|
|
||||||
|
When `--download-media` is enabled:
|
||||||
|
- Images are saved to `imgs/` next to the markdown file
|
||||||
|
- Videos are saved to `videos/` next to the markdown file
|
||||||
|
- Markdown media links are rewritten to local relative paths
|
||||||
|
|
||||||
|
## Media Download Workflow
|
||||||
|
|
||||||
|
Based on `download_media` setting in EXTEND.md:
|
||||||
|
|
||||||
|
| Setting | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| `1` (always) | Run script with `--download-media` flag |
|
||||||
|
| `0` (never) | Run script without `--download-media` flag |
|
||||||
|
| `ask` (default) | Follow the ask-each-time flow below |
|
||||||
|
|
||||||
|
### Ask-Each-Time Flow
|
||||||
|
|
||||||
|
1. Run script **without** `--download-media` → markdown saved
|
||||||
|
2. Check saved markdown for remote media URLs (`https://` in image/video links)
|
||||||
|
3. **If no remote media found** → done, no prompt needed
|
||||||
|
4. **If remote media found** → use `AskUserQuestion`:
|
||||||
|
- header: "Media", question: "Download N images/videos to local files?"
|
||||||
|
- "Yes" — Download to local directories
|
||||||
|
- "No" — Keep remote URLs
|
||||||
|
5. If user confirms → run script **again** with `--download-media` (overwrites markdown with localized links)
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
1. **Environment variables** (preferred): `X_AUTH_TOKEN`, `X_CT0`
|
1. **Environment variables** (preferred): `X_AUTH_TOKEN`, `X_CT0`
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
name: first-time-setup
|
||||||
|
description: First-time setup flow for baoyu-danger-x-to-markdown preferences
|
||||||
|
---
|
||||||
|
|
||||||
|
# First-Time Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When no EXTEND.md is found, guide user through preference setup.
|
||||||
|
|
||||||
|
**BLOCKING OPERATION**: This setup MUST complete before ANY other workflow steps. Do NOT:
|
||||||
|
- Start converting tweets or articles
|
||||||
|
- Ask about URLs or output paths
|
||||||
|
- Proceed to any conversion
|
||||||
|
|
||||||
|
ONLY ask the questions in this setup flow, save EXTEND.md, then continue.
|
||||||
|
|
||||||
|
## Setup Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
No EXTEND.md found
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+---------------------+
|
||||||
|
| AskUserQuestion |
|
||||||
|
| (all questions) |
|
||||||
|
+---------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+---------------------+
|
||||||
|
| Create EXTEND.md |
|
||||||
|
+---------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Continue conversion
|
||||||
|
```
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
**Language**: Use user's input language or saved language preference.
|
||||||
|
|
||||||
|
Use AskUserQuestion with ALL questions in ONE call:
|
||||||
|
|
||||||
|
### Question 1: Download Media
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Media"
|
||||||
|
question: "How to handle images and videos in tweets?"
|
||||||
|
options:
|
||||||
|
- label: "Ask each time (Recommended)"
|
||||||
|
description: "After saving markdown, ask whether to download media"
|
||||||
|
- label: "Always download"
|
||||||
|
description: "Always download media to local imgs/ and videos/ directories"
|
||||||
|
- label: "Never download"
|
||||||
|
description: "Keep original remote URLs in markdown"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Question 2: Default Output Directory
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Output"
|
||||||
|
question: "Default output directory?"
|
||||||
|
options:
|
||||||
|
- label: "x-to-markdown (Recommended)"
|
||||||
|
description: "Save to ./x-to-markdown/{username}/{tweet-id}.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: User will likely choose "Other" to type a custom path.
|
||||||
|
|
||||||
|
### Question 3: Save Location
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Save"
|
||||||
|
question: "Where to save preferences?"
|
||||||
|
options:
|
||||||
|
- label: "User (Recommended)"
|
||||||
|
description: "~/.baoyu-skills/ (all projects)"
|
||||||
|
- label: "Project"
|
||||||
|
description: ".baoyu-skills/ (this project only)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Save Locations
|
||||||
|
|
||||||
|
| Choice | Path | Scope |
|
||||||
|
|--------|------|-------|
|
||||||
|
| User | `~/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md` | All projects |
|
||||||
|
| Project | `.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md` | Current project |
|
||||||
|
|
||||||
|
## After Setup
|
||||||
|
|
||||||
|
1. Create directory if needed
|
||||||
|
2. Write EXTEND.md
|
||||||
|
3. Confirm: "Preferences saved to [path]"
|
||||||
|
4. Continue with conversion using saved preferences
|
||||||
|
|
||||||
|
## EXTEND.md Template
|
||||||
|
|
||||||
|
```md
|
||||||
|
download_media: [ask/1/0]
|
||||||
|
default_output_dir: [path or empty]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modifying Preferences Later
|
||||||
|
|
||||||
|
Users can edit EXTEND.md directly or delete it to trigger setup again.
|
||||||
@@ -6,6 +6,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|||||||
|
|
||||||
import { fetchXArticle } from "./graphql.js";
|
import { fetchXArticle } from "./graphql.js";
|
||||||
import { formatArticleMarkdown } from "./markdown.js";
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
|
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
||||||
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
||||||
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
||||||
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
||||||
@@ -15,6 +16,7 @@ type CliArgs = {
|
|||||||
output: string | null;
|
output: string | null;
|
||||||
json: boolean;
|
json: boolean;
|
||||||
login: boolean;
|
login: boolean;
|
||||||
|
downloadMedia: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ Usage:
|
|||||||
Options:
|
Options:
|
||||||
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
|
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
|
||||||
--json Output as JSON
|
--json Output as JSON
|
||||||
|
--download-media Download images/videos to local ./imgs and ./videos next to markdown
|
||||||
--login Refresh cookies only, then exit
|
--login Refresh cookies only, then exit
|
||||||
--help, -h Show help
|
--help, -h Show help
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ Examples:
|
|||||||
${cmd} https://x.com/username/status/1234567890
|
${cmd} https://x.com/username/status/1234567890
|
||||||
${cmd} https://x.com/i/article/1234567890 -o ./article.md
|
${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 -o ./out/
|
||||||
|
${cmd} https://x.com/username/status/1234567890 --download-media
|
||||||
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
|
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
|
||||||
${cmd} --login
|
${cmd} --login
|
||||||
`);
|
`);
|
||||||
@@ -57,6 +61,7 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
output: null,
|
output: null,
|
||||||
json: false,
|
json: false,
|
||||||
login: false,
|
login: false,
|
||||||
|
downloadMedia: false,
|
||||||
help: false,
|
help: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +85,11 @@ function parseArgs(argv: string[]): CliArgs {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (a === "--download-media") {
|
||||||
|
out.downloadMedia = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (a === "--url") {
|
if (a === "--url") {
|
||||||
const v = argv[++i];
|
const v = argv[++i];
|
||||||
if (!v) throw new Error("Missing value for --url");
|
if (!v) throw new Error("Missing value for --url");
|
||||||
@@ -345,16 +355,17 @@ async function convertArticleToMarkdown(
|
|||||||
|
|
||||||
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
||||||
const article = await fetchXArticle(articleId, cookieMap, false);
|
const article = await fetchXArticle(articleId, cookieMap, false);
|
||||||
const body = formatArticleMarkdown(article).trimEnd();
|
const { markdown: body, coverUrl } = formatArticleMarkdown(article);
|
||||||
|
|
||||||
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
||||||
const meta = formatMetaMarkdown({
|
const meta = formatMetaMarkdown({
|
||||||
url: `https://x.com/i/article/${articleId}`,
|
url: `https://x.com/i/article/${articleId}`,
|
||||||
requested_url: inputUrl,
|
requestedUrl: inputUrl,
|
||||||
title: title || null,
|
title: title || null,
|
||||||
|
coverImage: coverUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
return [meta, body].filter(Boolean).join("\n\n").trimEnd();
|
return [meta, body.trimEnd()].filter(Boolean).join("\n\n").trimEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -385,11 +396,24 @@ async function main(): Promise<void> {
|
|||||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
||||||
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
||||||
|
|
||||||
const markdown =
|
let markdown =
|
||||||
kind === "article" && articleId
|
kind === "article" && articleId
|
||||||
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
||||||
: await tweetToMarkdown(normalizedUrl, { log });
|
: await tweetToMarkdown(normalizedUrl, { log });
|
||||||
|
|
||||||
|
let mediaResult: LocalizeMarkdownMediaResult | null = null;
|
||||||
|
|
||||||
|
if (args.downloadMedia) {
|
||||||
|
mediaResult = await localizeMarkdownMedia(markdown, {
|
||||||
|
markdownPath,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
markdown = mediaResult.markdown;
|
||||||
|
log(
|
||||||
|
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await writeFile(markdownPath, markdown, "utf8");
|
await writeFile(markdownPath, markdown, "utf8");
|
||||||
log(`[x-to-markdown] Saved: ${markdownPath}`);
|
log(`[x-to-markdown] Saved: ${markdownPath}`);
|
||||||
|
|
||||||
@@ -398,11 +422,16 @@ async function main(): Promise<void> {
|
|||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
||||||
requested_url: normalizedUrl,
|
requestedUrl: normalizedUrl,
|
||||||
type: kind,
|
type: kind,
|
||||||
slug,
|
slug,
|
||||||
outputDir,
|
outputDir,
|
||||||
markdownPath,
|
markdownPath,
|
||||||
|
downloadMedia: args.downloadMedia,
|
||||||
|
downloadedImages: mediaResult?.downloadedImages ?? 0,
|
||||||
|
downloadedVideos: mediaResult?.downloadedVideos ?? 0,
|
||||||
|
imageDir: mediaResult?.imageDir ?? null,
|
||||||
|
videoDir: mediaResult?.videoDir ?? null,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
|
|||||||
@@ -257,10 +257,15 @@ function renderContentBlocks(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatArticleMarkdown(article: unknown): string {
|
export type FormatArticleResult = {
|
||||||
|
markdown: string;
|
||||||
|
coverUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatArticleMarkdown(article: unknown): FormatArticleResult {
|
||||||
const candidate = coerceArticleEntity(article);
|
const candidate = coerceArticleEntity(article);
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
return `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``;
|
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
@@ -271,10 +276,8 @@ export function formatArticleMarkdown(article: unknown): string {
|
|||||||
lines.push(`# ${title}`);
|
lines.push(`# ${title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info);
|
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
|
||||||
if (coverUrl) {
|
if (coverUrl) {
|
||||||
if (lines.length > 0) lines.push("");
|
|
||||||
lines.push(``);
|
|
||||||
usedUrls.add(coverUrl);
|
usedUrls.add(coverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +297,7 @@ export function formatArticleMarkdown(article: unknown): string {
|
|||||||
lines.push(candidate.preview_text.trim());
|
lines.push(candidate.preview_text.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl);
|
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
|
||||||
if (mediaUrls.length > 0) {
|
if (mediaUrls.length > 0) {
|
||||||
lines.push("", "## Media", "");
|
lines.push("", "## Media", "");
|
||||||
for (const url of mediaUrls) {
|
for (const url of mediaUrls) {
|
||||||
@@ -302,5 +305,5 @@ export function formatArticleMarkdown(article: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n").trimEnd();
|
return { markdown: lines.join("\n").trimEnd(), coverUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
type MediaKind = "image" | "video";
|
||||||
|
type MediaHint = "image" | "unknown";
|
||||||
|
|
||||||
|
type MarkdownLinkCandidate = {
|
||||||
|
url: string;
|
||||||
|
hint: MediaHint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalizeMarkdownMediaOptions = {
|
||||||
|
markdownPath: string;
|
||||||
|
log?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalizeMarkdownMediaResult = {
|
||||||
|
markdown: string;
|
||||||
|
downloadedImages: number;
|
||||||
|
downloadedVideos: number;
|
||||||
|
imageDir: string | null;
|
||||||
|
videoDir: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MARKDOWN_LINK_RE = /(!?\[[^\]\n]*\])\((<)?(https?:\/\/[^)\s>]+)(>)?\)/g;
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"webp",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"avif",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"svg",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "mkv"]);
|
||||||
|
|
||||||
|
const MIME_EXTENSION_MAP: Record<string, string> = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/jpg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/webp": "webp",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/bmp": "bmp",
|
||||||
|
"image/avif": "avif",
|
||||||
|
"image/heic": "heic",
|
||||||
|
"image/heif": "heif",
|
||||||
|
"image/svg+xml": "svg",
|
||||||
|
"video/mp4": "mp4",
|
||||||
|
"video/webm": "webm",
|
||||||
|
"video/quicktime": "mov",
|
||||||
|
"video/x-m4v": "m4v",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOWNLOAD_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";
|
||||||
|
|
||||||
|
function normalizeContentType(raw: string | null): string {
|
||||||
|
return raw?.split(";")[0]?.trim().toLowerCase() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtension(raw: string | undefined | null): string | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const trimmed = raw.replace(/^\./, "").trim().toLowerCase();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (trimmed === "jpeg") return "jpg";
|
||||||
|
if (trimmed === "jpg") return "jpg";
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExtensionFromUrl(rawUrl: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
const extFromPath = normalizeExtension(path.posix.extname(parsed.pathname));
|
||||||
|
if (extFromPath) return extFromPath;
|
||||||
|
const extFromFormat = normalizeExtension(parsed.searchParams.get("format"));
|
||||||
|
if (extFromFormat) return extFromFormat;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKindFromContentType(contentType: string): MediaKind | undefined {
|
||||||
|
if (!contentType) return undefined;
|
||||||
|
if (contentType.startsWith("image/")) return "image";
|
||||||
|
if (contentType.startsWith("video/")) return "video";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKindFromExtension(ext: string | undefined): MediaKind | undefined {
|
||||||
|
if (!ext) return undefined;
|
||||||
|
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
||||||
|
if (VIDEO_EXTENSIONS.has(ext)) return "video";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKindFromHostname(rawUrl: string): MediaKind | undefined {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(rawUrl).hostname.toLowerCase();
|
||||||
|
if (hostname.includes("video.twimg.com")) return "video";
|
||||||
|
if (hostname.includes("pbs.twimg.com")) return "image";
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMediaKind(
|
||||||
|
rawUrl: string,
|
||||||
|
contentType: string,
|
||||||
|
extension: string | undefined,
|
||||||
|
hint: MediaHint
|
||||||
|
): MediaKind | undefined {
|
||||||
|
const kindFromType = resolveKindFromContentType(contentType);
|
||||||
|
if (kindFromType) return kindFromType;
|
||||||
|
|
||||||
|
const kindFromExtension = resolveKindFromExtension(extension);
|
||||||
|
if (kindFromExtension) return kindFromExtension;
|
||||||
|
|
||||||
|
const kindFromHost = resolveKindFromHostname(rawUrl);
|
||||||
|
if (kindFromHost) return kindFromHost;
|
||||||
|
|
||||||
|
if (contentType && contentType !== "application/octet-stream") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hint === "image" ? "image" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOutputExtension(
|
||||||
|
contentType: string,
|
||||||
|
extension: string | undefined,
|
||||||
|
kind: MediaKind
|
||||||
|
): string {
|
||||||
|
const extFromMime = normalizeExtension(MIME_EXTENSION_MAP[contentType]);
|
||||||
|
if (extFromMime) return extFromMime;
|
||||||
|
|
||||||
|
const normalizedExt = normalizeExtension(extension);
|
||||||
|
if (normalizedExt) return normalizedExt;
|
||||||
|
|
||||||
|
return kind === "video" ? "mp4" : "jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecodeURIComponent(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileSegment(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^[-_]+|[-_]+$/g, "")
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFileStem(rawUrl: string, extension: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
const base = path.posix.basename(parsed.pathname);
|
||||||
|
if (!base) return "";
|
||||||
|
const decodedBase = safeDecodeURIComponent(base);
|
||||||
|
const normalizedExt = normalizeExtension(extension);
|
||||||
|
const stripExt = normalizedExt ? new RegExp(`\\.${normalizedExt}$`, "i") : null;
|
||||||
|
const rawStem = stripExt ? decodedBase.replace(stripExt, "") : decodedBase;
|
||||||
|
return sanitizeFileSegment(rawStem);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extension: string): string {
|
||||||
|
const stem = resolveFileStem(sourceUrl, extension);
|
||||||
|
const prefix = kind === "image" ? "img" : "video";
|
||||||
|
const serial = String(index).padStart(3, "0");
|
||||||
|
const suffix = stem ? `-${stem}` : "";
|
||||||
|
return `${prefix}-${serial}${suffix}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
|
||||||
|
|
||||||
|
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||||
|
MARKDOWN_LINK_RE.lastIndex = 0;
|
||||||
|
const candidates: MarkdownLinkCandidate[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
|
||||||
|
const label = match[1] ?? "";
|
||||||
|
const rawUrl = match[3] ?? "";
|
||||||
|
if (!rawUrl || seen.has(rawUrl)) continue;
|
||||||
|
seen.add(rawUrl);
|
||||||
|
candidates.push({
|
||||||
|
url: rawUrl,
|
||||||
|
hint: label.startsWith("![") ? "image" : "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (fmMatch) {
|
||||||
|
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
|
||||||
|
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
|
||||||
|
seen.add(coverMatch[2]);
|
||||||
|
candidates.push({ url: coverMatch[2], hint: "image" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteMarkdownMediaLinks(markdown: string, replacements: Map<string, string>): string {
|
||||||
|
if (replacements.size === 0) return markdown;
|
||||||
|
MARKDOWN_LINK_RE.lastIndex = 0;
|
||||||
|
|
||||||
|
let result = markdown.replace(MARKDOWN_LINK_RE, (full, label, _openAngle, rawUrl) => {
|
||||||
|
const localPath = replacements.get(rawUrl);
|
||||||
|
if (!localPath) return full;
|
||||||
|
return `${label}(${localPath})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
result = result.replace(FRONTMATTER_COVER_RE, (full, prefix, rawUrl, suffix) => {
|
||||||
|
const localPath = replacements.get(rawUrl);
|
||||||
|
if (!localPath) return full;
|
||||||
|
return `${prefix}${localPath}${suffix}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function localizeMarkdownMedia(
|
||||||
|
markdown: string,
|
||||||
|
options: LocalizeMarkdownMediaOptions
|
||||||
|
): Promise<LocalizeMarkdownMediaResult> {
|
||||||
|
const log = options.log ?? (() => {});
|
||||||
|
const markdownDir = path.dirname(options.markdownPath);
|
||||||
|
const candidates = collectMarkdownLinkCandidates(markdown);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
downloadedImages: 0,
|
||||||
|
downloadedVideos: 0,
|
||||||
|
imageDir: null,
|
||||||
|
videoDir: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacements = new Map<string, string>();
|
||||||
|
let downloadedImages = 0;
|
||||||
|
let downloadedVideos = 0;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(candidate.url, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "follow",
|
||||||
|
headers: {
|
||||||
|
"user-agent": DOWNLOAD_USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
log(`[x-to-markdown] Skip media (${response.status}): ${candidate.url}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceUrl = response.url || candidate.url;
|
||||||
|
const contentType = normalizeContentType(response.headers.get("content-type"));
|
||||||
|
const extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
|
||||||
|
const kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
|
||||||
|
if (!kind) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputExtension = resolveOutputExtension(contentType, extension, kind);
|
||||||
|
const nextIndex = kind === "image" ? downloadedImages + 1 : downloadedVideos + 1;
|
||||||
|
const dirName = kind === "image" ? "imgs" : "videos";
|
||||||
|
const targetDir = path.join(markdownDir, dirName);
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const fileName = buildFileName(kind, nextIndex, sourceUrl, outputExtension);
|
||||||
|
const absolutePath = path.join(targetDir, fileName);
|
||||||
|
const relativePath = path.posix.join(dirName, fileName);
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
await writeFile(absolutePath, bytes);
|
||||||
|
replacements.set(candidate.url, relativePath);
|
||||||
|
|
||||||
|
if (kind === "image") {
|
||||||
|
downloadedImages = nextIndex;
|
||||||
|
} else {
|
||||||
|
downloadedVideos = nextIndex;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
||||||
|
log(`[x-to-markdown] Failed to download media ${candidate.url}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown: rewriteMarkdownMediaLinks(markdown, replacements),
|
||||||
|
downloadedImages,
|
||||||
|
downloadedVideos,
|
||||||
|
imageDir: downloadedImages > 0 ? path.join(markdownDir, "imgs") : null,
|
||||||
|
videoDir: downloadedVideos > 0 ? path.join(markdownDir, "videos") : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -123,22 +123,15 @@ export async function tweetToMarkdown(
|
|||||||
const requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim();
|
const requestedUrl = normalizedUrl || buildTweetUrl(username, tweetId) || inputUrl.trim();
|
||||||
const rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl;
|
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);
|
const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap);
|
||||||
|
let coverImage: string | null = null;
|
||||||
let remainingTweets = tweets;
|
let remainingTweets = tweets;
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (articleEntity) {
|
if (articleEntity) {
|
||||||
const articleMarkdown = formatArticleMarkdown(articleEntity).trimEnd();
|
const articleResult = formatArticleMarkdown(articleEntity);
|
||||||
|
coverImage = articleResult.coverUrl;
|
||||||
|
const articleMarkdown = articleResult.markdown.trimEnd();
|
||||||
if (articleMarkdown) {
|
if (articleMarkdown) {
|
||||||
parts.push(articleMarkdown);
|
parts.push(articleMarkdown);
|
||||||
const firstTweetText = extractTweetText(firstTweet);
|
const firstTweetText = extractTweetText(firstTweet);
|
||||||
@@ -148,6 +141,19 @@ export async function tweetToMarkdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const meta = formatMetaMarkdown({
|
||||||
|
url: rootUrl,
|
||||||
|
requestedUrl: requestedUrl,
|
||||||
|
author,
|
||||||
|
authorName: name ?? null,
|
||||||
|
authorUsername: username ?? null,
|
||||||
|
authorUrl: authorUrl ?? null,
|
||||||
|
tweetCount: thread.totalTweets ?? tweets.length,
|
||||||
|
coverImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
parts.unshift(meta);
|
||||||
|
|
||||||
if (remainingTweets.length > 0) {
|
if (remainingTweets.length > 0) {
|
||||||
const hasArticle = parts.length > 1;
|
const hasArticle = parts.length > 1;
|
||||||
if (hasArticle) {
|
if (hasArticle) {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ Check for YAML frontmatter (`---` block). Create if missing.
|
|||||||
| `title` | See Step 4 |
|
| `title` | See Step 4 |
|
||||||
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
|
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
|
||||||
| `summary` | Generate engaging summary (100-150 characters) |
|
| `summary` | Generate engaging summary (100-150 characters) |
|
||||||
| `featureImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path |
|
| `coverImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path (also accepted: `featureImage`) |
|
||||||
|
|
||||||
### Step 4: Title Handling
|
### Step 4: Title Handling
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ description: Posts content to WeChat Official Account (微信公众号) via API
|
|||||||
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
|
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
|
||||||
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
|
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
|
||||||
| `scripts/wechat-api.ts` | Article posting via API (文章) |
|
| `scripts/wechat-api.ts` | Article posting via API (文章) |
|
||||||
|
| `scripts/check-permissions.ts` | Verify environment & permissions |
|
||||||
|
|
||||||
## Preferences (EXTEND.md)
|
## Preferences (EXTEND.md)
|
||||||
|
|
||||||
@@ -76,6 +77,29 @@ chrome_profile_path: /path/to/chrome/profile
|
|||||||
3. EXTEND.md
|
3. EXTEND.md
|
||||||
4. Skill defaults
|
4. Skill defaults
|
||||||
|
|
||||||
|
## Pre-flight Check (Optional)
|
||||||
|
|
||||||
|
Before first use, suggest running the environment check. User can skip if they prefer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/check-permissions.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, API credentials, Chrome conflicts.
|
||||||
|
|
||||||
|
**If any check fails**, provide fix guidance per item:
|
||||||
|
|
||||||
|
| Check | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| Chrome | Install Chrome or set `WECHAT_BROWSER_CHROME_PATH` env var |
|
||||||
|
| Profile dir | Ensure `~/.local/share/wechat-browser-profile` is writable |
|
||||||
|
| Bun runtime | `curl -fsSL https://bun.sh/install \| bash` |
|
||||||
|
| Accessibility (macOS) | System Settings → Privacy & Security → Accessibility → enable terminal app |
|
||||||
|
| Clipboard copy | Ensure Swift/AppKit available (macOS Xcode CLI tools: `xcode-select --install`) |
|
||||||
|
| Paste keystroke (macOS) | Same as Accessibility fix above |
|
||||||
|
| Paste keystroke (Linux) | Install `xdotool` (X11) or `ydotool` (Wayland) |
|
||||||
|
| API credentials | Follow guided setup in Step 5, or manually set in `.baoyu-skills/.env` |
|
||||||
|
|
||||||
## Image-Text Posting (图文)
|
## Image-Text Posting (图文)
|
||||||
|
|
||||||
For short posts with multiple images (up to 9):
|
For short posts with multiple images (up to 9):
|
||||||
@@ -204,7 +228,7 @@ Check extracted metadata from Step 3 (or HTML meta tags if direct HTML input).
|
|||||||
|
|
||||||
**Cover Image Check** (required for `article_type=news`):
|
**Cover Image Check** (required for `article_type=news`):
|
||||||
1. Use CLI `--cover` if provided.
|
1. Use CLI `--cover` if provided.
|
||||||
2. Else use frontmatter (`featureImage`, `coverImage`, `cover`, `image`).
|
2. Else use frontmatter (`coverImage`, `featureImage`, `cover`, `image`).
|
||||||
3. Else check article directory default path: `imgs/cover.png`.
|
3. Else check article directory default path: `imgs/cover.png`.
|
||||||
4. Else fallback to first inline content image.
|
4. Else fallback to first inline content image.
|
||||||
5. If still missing, stop and request a cover image before publishing.
|
5. If still missing, stop and request a cover image before publishing.
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { findChromeExecutable, getDefaultProfileDir } from './cdp.ts';
|
||||||
|
|
||||||
|
interface CheckResult {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: CheckResult[] = [];
|
||||||
|
|
||||||
|
function log(label: string, ok: boolean, detail: string): void {
|
||||||
|
results.push({ name: label, ok, detail });
|
||||||
|
const icon = ok ? '✅' : '❌';
|
||||||
|
console.log(`${icon} ${label}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function warn(label: string, detail: string): void {
|
||||||
|
results.push({ name: label, ok: true, detail });
|
||||||
|
console.log(`⚠️ ${label}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkChrome(): Promise<void> {
|
||||||
|
const chromePath = findChromeExecutable();
|
||||||
|
if (chromePath) {
|
||||||
|
log('Chrome', true, chromePath);
|
||||||
|
} else {
|
||||||
|
log('Chrome', false, 'Not found. Set WECHAT_BROWSER_CHROME_PATH env var or install Chrome.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkProfileIsolation(): Promise<void> {
|
||||||
|
const profileDir = getDefaultProfileDir();
|
||||||
|
const userChromeDir = process.platform === 'darwin'
|
||||||
|
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
|
||||||
|
: process.platform === 'win32'
|
||||||
|
? path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
|
||||||
|
: path.join(os.homedir(), '.config', 'google-chrome');
|
||||||
|
|
||||||
|
const isIsolated = !profileDir.startsWith(userChromeDir);
|
||||||
|
log('Profile isolation', isIsolated, `Skill profile: ${profileDir}`);
|
||||||
|
|
||||||
|
if (isIsolated) {
|
||||||
|
const exists = fs.existsSync(profileDir);
|
||||||
|
if (exists) {
|
||||||
|
log('Profile dir', true, 'Exists and accessible');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(profileDir, { recursive: true });
|
||||||
|
log('Profile dir', true, 'Created successfully');
|
||||||
|
} catch (e) {
|
||||||
|
log('Profile dir', false, `Cannot create: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAccessibility(): Promise<void> {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
log('Accessibility', true, `Skipped (not macOS, platform: ${process.platform})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = spawnSync('osascript', ['-e', `
|
||||||
|
tell application "System Events"
|
||||||
|
set frontApp to name of first application process whose frontmost is true
|
||||||
|
return frontApp
|
||||||
|
end tell
|
||||||
|
`], { stdio: 'pipe', timeout: 10_000 });
|
||||||
|
|
||||||
|
if (result.status === 0) {
|
||||||
|
const app = result.stdout?.toString().trim();
|
||||||
|
log('Accessibility (System Events)', true, `Frontmost app: ${app}`);
|
||||||
|
} else {
|
||||||
|
const stderr = result.stderr?.toString().trim() || '';
|
||||||
|
if (stderr.includes('not allowed assistive access') || stderr.includes('1002')) {
|
||||||
|
log('Accessibility (System Events)', false,
|
||||||
|
'Denied. Grant access: System Settings → Privacy & Security → Accessibility → enable your terminal app');
|
||||||
|
} else {
|
||||||
|
log('Accessibility (System Events)', false, `Failed: ${stderr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkClipboardCopy(): Promise<void> {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
log('Clipboard copy (image)', true, `Skipped (not macOS)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'wechat-check-'));
|
||||||
|
try {
|
||||||
|
const testPng = path.join(tmpDir, 'test.png');
|
||||||
|
const swiftSrc = `import AppKit
|
||||||
|
import Foundation
|
||||||
|
let size = NSSize(width: 2, height: 2)
|
||||||
|
let image = NSImage(size: size)
|
||||||
|
image.lockFocus()
|
||||||
|
NSColor.red.set()
|
||||||
|
NSBezierPath.fill(NSRect(origin: .zero, size: size))
|
||||||
|
image.unlockFocus()
|
||||||
|
guard let tiff = image.tiffRepresentation,
|
||||||
|
let rep = NSBitmapImageRep(data: tiff),
|
||||||
|
let png = rep.representation(using: .png, properties: [:]) else {
|
||||||
|
FileHandle.standardError.write("Failed to create test PNG\\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
|
||||||
|
`;
|
||||||
|
const genScript = path.join(tmpDir, 'gen.swift');
|
||||||
|
await writeFile(genScript, swiftSrc, 'utf8');
|
||||||
|
const genResult = spawnSync('swift', [genScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||||
|
if (genResult.status !== 0) {
|
||||||
|
log('Clipboard copy (image)', false, `Cannot create test image: ${genResult.stderr?.toString().trim()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipSrc = `import AppKit
|
||||||
|
import Foundation
|
||||||
|
guard let image = NSImage(contentsOfFile: CommandLine.arguments[1]) else {
|
||||||
|
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
pb.clearContents()
|
||||||
|
if !pb.writeObjects([image]) {
|
||||||
|
FileHandle.standardError.write("Failed to write to clipboard\\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const clipScript = path.join(tmpDir, 'clip.swift');
|
||||||
|
await writeFile(clipScript, clipSrc, 'utf8');
|
||||||
|
const clipResult = spawnSync('swift', [clipScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||||
|
if (clipResult.status === 0) {
|
||||||
|
log('Clipboard copy (image)', true, 'Can copy image to clipboard via Swift/AppKit');
|
||||||
|
} else {
|
||||||
|
log('Clipboard copy (image)', false, `Failed: ${clipResult.stderr?.toString().trim()}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPasteKeystroke(): Promise<void> {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const result = spawnSync('osascript', ['-e', `
|
||||||
|
tell application "System Events"
|
||||||
|
set canSend to true
|
||||||
|
return canSend
|
||||||
|
end tell
|
||||||
|
`], { stdio: 'pipe', timeout: 10_000 });
|
||||||
|
|
||||||
|
if (result.status === 0) {
|
||||||
|
log('Paste keystroke (osascript)', true, 'System Events can send keystrokes');
|
||||||
|
} else {
|
||||||
|
const stderr = result.stderr?.toString().trim() || '';
|
||||||
|
log('Paste keystroke (osascript)', false, `Cannot send keystrokes: ${stderr}`);
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'linux') {
|
||||||
|
const xdotool = spawnSync('which', ['xdotool'], { stdio: 'pipe' });
|
||||||
|
const ydotool = spawnSync('which', ['ydotool'], { stdio: 'pipe' });
|
||||||
|
if (xdotool.status === 0) {
|
||||||
|
log('Paste keystroke', true, 'xdotool available (X11)');
|
||||||
|
} else if (ydotool.status === 0) {
|
||||||
|
log('Paste keystroke', true, 'ydotool available (Wayland)');
|
||||||
|
} else {
|
||||||
|
log('Paste keystroke', false, 'No tool found. Install xdotool (X11) or ydotool (Wayland).');
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
log('Paste keystroke', true, 'Windows uses PowerShell SendKeys (built-in)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkBun(): Promise<void> {
|
||||||
|
const result = spawnSync('npx', ['-y', 'bun', '--version'], { stdio: 'pipe', timeout: 30_000 });
|
||||||
|
if (result.status === 0) {
|
||||||
|
log('Bun runtime', true, `v${result.stdout?.toString().trim()}`);
|
||||||
|
} else {
|
||||||
|
log('Bun runtime', false, 'Cannot run bun. Install: curl -fsSL https://bun.sh/install | bash');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkApiCredentials(): Promise<void> {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const projectEnv = path.join(cwd, '.baoyu-skills', '.env');
|
||||||
|
const userEnv = path.join(os.homedir(), '.baoyu-skills', '.env');
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
for (const envPath of [projectEnv, userEnv]) {
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
const content = fs.readFileSync(envPath, 'utf8');
|
||||||
|
if (content.includes('WECHAT_APP_ID')) {
|
||||||
|
log('API credentials', true, `Found in ${envPath}`);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
warn('API credentials', 'Not found. Required for API publishing method. Run the skill to set up via guided flow.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRunningChromeConflict(): Promise<void> {
|
||||||
|
if (process.platform !== 'darwin') return;
|
||||||
|
|
||||||
|
const result = spawnSync('pgrep', ['-f', 'Google Chrome'], { stdio: 'pipe' });
|
||||||
|
const pids = result.stdout?.toString().trim().split('\n').filter(Boolean) || [];
|
||||||
|
|
||||||
|
if (pids.length > 0) {
|
||||||
|
warn('Running Chrome instances', `${pids.length} Chrome process(es) detected. The skill uses --user-data-dir for isolation, so this is safe.`);
|
||||||
|
} else {
|
||||||
|
log('Running Chrome instances', true, 'No existing Chrome processes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('=== baoyu-post-to-wechat: Permission & Environment Check ===\n');
|
||||||
|
|
||||||
|
await checkChrome();
|
||||||
|
await checkProfileIsolation();
|
||||||
|
await checkBun();
|
||||||
|
await checkAccessibility();
|
||||||
|
await checkClipboardCopy();
|
||||||
|
await checkPasteKeystroke();
|
||||||
|
await checkApiCredentials();
|
||||||
|
await checkRunningChromeConflict();
|
||||||
|
|
||||||
|
console.log('\n--- Summary ---');
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
if (failed.length === 0) {
|
||||||
|
console.log('All checks passed. Ready to post to WeChat.');
|
||||||
|
} else {
|
||||||
|
console.log(`${failed.length} issue(s) found:`);
|
||||||
|
for (const f of failed) {
|
||||||
|
console.log(` ❌ ${f.name}: ${f.detail}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((err) => {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -365,7 +365,7 @@ Frontmatter Fields (markdown):
|
|||||||
title Article title
|
title Article title
|
||||||
author Author name
|
author Author name
|
||||||
digest/summary Article summary
|
digest/summary Article summary
|
||||||
featureImage/coverImage/cover/image Cover image path
|
coverImage/featureImage/cover/image Cover image path
|
||||||
|
|
||||||
Comments:
|
Comments:
|
||||||
Comments are enabled by default, open to all users.
|
Comments are enabled by default, open to all users.
|
||||||
@@ -555,8 +555,8 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
let thumbMediaId = "";
|
let thumbMediaId = "";
|
||||||
const rawCoverPath = args.cover ||
|
const rawCoverPath = args.cover ||
|
||||||
frontmatter.featureImage ||
|
|
||||||
frontmatter.coverImage ||
|
frontmatter.coverImage ||
|
||||||
|
frontmatter.featureImage ||
|
||||||
frontmatter.cover ||
|
frontmatter.cover ||
|
||||||
frontmatter.image;
|
frontmatter.image;
|
||||||
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
||||||
@@ -578,7 +578,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.articleType === "news" && !thumbMediaId) {
|
if (args.articleType === "news" && !thumbMediaId) {
|
||||||
console.error("Error: No cover image. Provide via --cover, frontmatter.featureImage, or include an image in content.");
|
console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -570,10 +570,7 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
|||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
const uploadCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
const uploadCheck = await cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||||
expression: `
|
expression: `JSON.stringify({ uploaded: document.querySelectorAll('.weui-desktop-upload__thumb, .pic_item, [class*=upload_thumb]').length })`,
|
||||||
const thumbs = document.querySelectorAll('.weui-desktop-upload__thumb, .pic_item, [class*=upload_thumb]');
|
|
||||||
JSON.stringify({ uploaded: thumbs.length });
|
|
||||||
`,
|
|
||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId });
|
}, { sessionId });
|
||||||
const status = JSON.parse(uploadCheck.result.value);
|
const status = JSON.parse(uploadCheck.result.value);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Posts text, images, videos, and long-form articles to X via real Chrome browser
|
|||||||
| `scripts/md-to-html.ts` | Markdown → HTML conversion |
|
| `scripts/md-to-html.ts` | Markdown → HTML conversion |
|
||||||
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
|
| `scripts/copy-to-clipboard.ts` | Copy content to clipboard |
|
||||||
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
|
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
|
||||||
|
| `scripts/check-paste-permissions.ts` | Verify environment & permissions |
|
||||||
|
|
||||||
## Preferences (EXTEND.md)
|
## Preferences (EXTEND.md)
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
|||||||
│ Not found │ Use defaults │
|
│ Not found │ Use defaults │
|
||||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
**EXTEND.md Supports**: Default Chrome profile | Auto-submit preference
|
**EXTEND.md Supports**: Default Chrome profile
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -63,6 +64,28 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
|||||||
- `bun` runtime
|
- `bun` runtime
|
||||||
- First run: log in to X manually (session saved)
|
- First run: log in to X manually (session saved)
|
||||||
|
|
||||||
|
## Pre-flight Check (Optional)
|
||||||
|
|
||||||
|
Before first use, suggest running the environment check. User can skip if they prefer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/check-paste-permissions.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystroke, Chrome conflicts.
|
||||||
|
|
||||||
|
**If any check fails**, provide fix guidance per item:
|
||||||
|
|
||||||
|
| Check | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| Chrome | Install Chrome or set `X_BROWSER_CHROME_PATH` env var |
|
||||||
|
| Profile dir | Ensure `~/.local/share/x-browser-profile` is writable |
|
||||||
|
| Bun runtime | `curl -fsSL https://bun.sh/install \| bash` |
|
||||||
|
| Accessibility (macOS) | System Settings → Privacy & Security → Accessibility → enable terminal app |
|
||||||
|
| Clipboard copy | Ensure Swift/AppKit available (macOS Xcode CLI tools: `xcode-select --install`) |
|
||||||
|
| Paste keystroke (macOS) | Same as Accessibility fix above |
|
||||||
|
| Paste keystroke (Linux) | Install `xdotool` (X11) or `ydotool` (Wayland) |
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
|
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
|
||||||
@@ -75,8 +98,7 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
|||||||
Text + up to 4 images.
|
Text + up to 4 images.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png # Preview
|
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --submit # Post
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -84,9 +106,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|
|||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `<text>` | Post content (positional) |
|
| `<text>` | Post content (positional) |
|
||||||
| `--image <path>` | Image file (repeatable, max 4) |
|
| `--image <path>` | Image file (repeatable, max 4) |
|
||||||
| `--submit` | Post (default: preview) |
|
|
||||||
| `--profile <dir>` | Custom Chrome profile |
|
| `--profile <dir>` | Custom Chrome profile |
|
||||||
|
|
||||||
|
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Video Posts
|
## Video Posts
|
||||||
@@ -94,8 +117,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-browser.ts "Hello!" --image ./photo.png --subm
|
|||||||
Text + video file.
|
Text + video file.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4 # Preview
|
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Check this out!" --video ./clip.mp4
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4 --submit # Post
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -103,9 +125,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
|
|||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `<text>` | Post content (positional) |
|
| `<text>` | Post content (positional) |
|
||||||
| `--video <path>` | Video file (MP4, MOV, WebM) |
|
| `--video <path>` | Video file (MP4, MOV, WebM) |
|
||||||
| `--submit` | Post (default: preview) |
|
|
||||||
| `--profile <dir>` | Custom Chrome profile |
|
| `--profile <dir>` | Custom Chrome profile |
|
||||||
|
|
||||||
|
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||||
|
|
||||||
**Limits**: Regular 140s max, Premium 60min. Processing: 30-60s.
|
**Limits**: Regular 140s max, Premium 60min. Processing: 30-60s.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -115,8 +138,7 @@ npx -y bun ${SKILL_DIR}/scripts/x-video.ts "Amazing content" --video ./demo.mp4
|
|||||||
Quote an existing tweet with comment.
|
Quote an existing tweet with comment.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!" # Preview
|
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "Great insight!"
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agree!" --submit # Post
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -124,9 +146,10 @@ npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agre
|
|||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| `<tweet-url>` | URL to quote (positional) |
|
| `<tweet-url>` | URL to quote (positional) |
|
||||||
| `<comment>` | Comment text (positional, optional) |
|
| `<comment>` | Comment text (positional, optional) |
|
||||||
| `--submit` | Post (default: preview) |
|
|
||||||
| `--profile <dir>` | Custom Chrome profile |
|
| `--profile <dir>` | Custom Chrome profile |
|
||||||
|
|
||||||
|
**Note**: Script opens browser with content filled in. User reviews and publishes manually.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## X Articles
|
## X Articles
|
||||||
@@ -134,9 +157,8 @@ npx -y bun ${SKILL_DIR}/scripts/x-quote.ts https://x.com/user/status/123 "I agre
|
|||||||
Long-form Markdown articles (requires X Premium).
|
Long-form Markdown articles (requires X Premium).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md # Preview
|
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg # With cover
|
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --cover ./cover.jpg
|
||||||
npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit # Publish
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Parameters**:
|
**Parameters**:
|
||||||
@@ -145,16 +167,29 @@ npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit #
|
|||||||
| `<markdown>` | Markdown file (positional) |
|
| `<markdown>` | Markdown file (positional) |
|
||||||
| `--cover <path>` | Cover image |
|
| `--cover <path>` | Cover image |
|
||||||
| `--title <text>` | Override title |
|
| `--title <text>` | Override title |
|
||||||
| `--submit` | Publish (default: preview) |
|
|
||||||
|
|
||||||
**Frontmatter**: `title`, `cover_image` supported in YAML front matter.
|
**Frontmatter**: `title`, `cover_image` supported in YAML front matter.
|
||||||
|
|
||||||
|
**Note**: Script opens browser with article filled in. User reviews and publishes manually.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Chrome debug port not ready
|
||||||
|
|
||||||
|
If a script fails with `Chrome debug port not ready` or `Unable to connect`, kill existing Chrome CDP instances first, then retry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: This should be done automatically — when encountering this error, kill Chrome CDP instances and retry the command without asking the user.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- First run: manual login required (session persists)
|
- First run: manual login required (session persists)
|
||||||
- Always preview before `--submit`
|
- All scripts only fill content into the browser, user must review and publish manually
|
||||||
- Cross-platform: macOS, Linux, Windows
|
- Cross-platform: macOS, Linux, Windows
|
||||||
|
|
||||||
## Extension Support
|
## Extension Support
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "baoyu-post-to-x-scripts",
|
||||||
|
"dependencies": {
|
||||||
|
"front-matter": "^4.0.2",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"marked": "^15.0.6",
|
||||||
|
"remark-cjk-friendly": "^1.1.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-stringify": "^11.0.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
|
|
||||||
|
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||||
|
|
||||||
|
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||||
|
|
||||||
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
|
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||||
|
|
||||||
|
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||||
|
|
||||||
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
|
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||||
|
|
||||||
|
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||||
|
|
||||||
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
|
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||||
|
|
||||||
|
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||||
|
|
||||||
|
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||||
|
|
||||||
|
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||||
|
|
||||||
|
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||||
|
|
||||||
|
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||||
|
|
||||||
|
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||||
|
|
||||||
|
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||||
|
|
||||||
|
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||||
|
|
||||||
|
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||||
|
|
||||||
|
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||||
|
|
||||||
|
"micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="],
|
||||||
|
|
||||||
|
"micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="],
|
||||||
|
|
||||||
|
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||||
|
|
||||||
|
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||||
|
|
||||||
|
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||||
|
|
||||||
|
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||||
|
|
||||||
|
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||||
|
|
||||||
|
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||||
|
|
||||||
|
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||||
|
|
||||||
|
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||||
|
|
||||||
|
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||||
|
|
||||||
|
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||||
|
|
||||||
|
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||||
|
|
||||||
|
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||||
|
|
||||||
|
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||||
|
|
||||||
|
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||||
|
|
||||||
|
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||||
|
|
||||||
|
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||||
|
|
||||||
|
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||||
|
|
||||||
|
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||||
|
|
||||||
|
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
|
||||||
|
|
||||||
|
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||||
|
|
||||||
|
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||||
|
|
||||||
|
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
|
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||||
|
|
||||||
|
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||||
|
|
||||||
|
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||||
|
|
||||||
|
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||||
|
|
||||||
|
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||||
|
|
||||||
|
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||||
|
|
||||||
|
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||||
|
|
||||||
|
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||||
|
|
||||||
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { findChromeExecutable, CHROME_CANDIDATES_FULL, getDefaultProfileDir } from './x-utils.js';
|
||||||
|
|
||||||
|
interface CheckResult {
|
||||||
|
name: string;
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: CheckResult[] = [];
|
||||||
|
|
||||||
|
function log(label: string, ok: boolean, detail: string): void {
|
||||||
|
results.push({ name: label, ok, detail });
|
||||||
|
const icon = ok ? '✅' : '❌';
|
||||||
|
console.log(`${icon} ${label}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function warn(label: string, detail: string): void {
|
||||||
|
results.push({ name: label, ok: true, detail });
|
||||||
|
console.log(`⚠️ ${label}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkChrome(): Promise<void> {
|
||||||
|
const chromePath = findChromeExecutable(CHROME_CANDIDATES_FULL);
|
||||||
|
if (chromePath) {
|
||||||
|
log('Chrome', true, chromePath);
|
||||||
|
} else {
|
||||||
|
log('Chrome', false, 'Not found. Set X_BROWSER_CHROME_PATH env var or install Chrome.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkProfileIsolation(): Promise<void> {
|
||||||
|
const profileDir = getDefaultProfileDir();
|
||||||
|
const userChromeDir = process.platform === 'darwin'
|
||||||
|
? path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
|
||||||
|
: process.platform === 'win32'
|
||||||
|
? path.join(os.homedir(), 'AppData', 'Local', 'Google', 'Chrome', 'User Data')
|
||||||
|
: path.join(os.homedir(), '.config', 'google-chrome');
|
||||||
|
|
||||||
|
const isIsolated = !profileDir.startsWith(userChromeDir);
|
||||||
|
log('Profile isolation', isIsolated, `Skill profile: ${profileDir}`);
|
||||||
|
|
||||||
|
if (isIsolated) {
|
||||||
|
const exists = fs.existsSync(profileDir);
|
||||||
|
if (exists) {
|
||||||
|
log('Profile dir', true, 'Exists and accessible');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(profileDir, { recursive: true });
|
||||||
|
log('Profile dir', true, 'Created successfully');
|
||||||
|
} catch (e) {
|
||||||
|
log('Profile dir', false, `Cannot create: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAccessibility(): Promise<void> {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
log('Accessibility', true, `Skipped (not macOS, platform: ${process.platform})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = spawnSync('osascript', ['-e', `
|
||||||
|
tell application "System Events"
|
||||||
|
set frontApp to name of first application process whose frontmost is true
|
||||||
|
return frontApp
|
||||||
|
end tell
|
||||||
|
`], { stdio: 'pipe', timeout: 10_000 });
|
||||||
|
|
||||||
|
if (result.status === 0) {
|
||||||
|
const app = result.stdout?.toString().trim();
|
||||||
|
log('Accessibility (System Events)', true, `Frontmost app: ${app}`);
|
||||||
|
} else {
|
||||||
|
const stderr = result.stderr?.toString().trim() || '';
|
||||||
|
if (stderr.includes('not allowed assistive access') || stderr.includes('1002')) {
|
||||||
|
log('Accessibility (System Events)', false,
|
||||||
|
'Denied. Grant access: System Settings → Privacy & Security → Accessibility → enable your terminal app');
|
||||||
|
} else {
|
||||||
|
log('Accessibility (System Events)', false, `Failed: ${stderr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkClipboardCopy(): Promise<void> {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
log('Clipboard copy (image)', true, `Skipped (not macOS)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'x-check-'));
|
||||||
|
try {
|
||||||
|
const testPng = path.join(tmpDir, 'test.png');
|
||||||
|
const swiftSrc = `import AppKit
|
||||||
|
import Foundation
|
||||||
|
let size = NSSize(width: 2, height: 2)
|
||||||
|
let image = NSImage(size: size)
|
||||||
|
image.lockFocus()
|
||||||
|
NSColor.red.set()
|
||||||
|
NSBezierPath.fill(NSRect(origin: .zero, size: size))
|
||||||
|
image.unlockFocus()
|
||||||
|
guard let tiff = image.tiffRepresentation,
|
||||||
|
let rep = NSBitmapImageRep(data: tiff),
|
||||||
|
let png = rep.representation(using: .png, properties: [:]) else {
|
||||||
|
FileHandle.standardError.write("Failed to create test PNG\\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
try png.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
|
||||||
|
`;
|
||||||
|
const genScript = path.join(tmpDir, 'gen.swift');
|
||||||
|
await writeFile(genScript, swiftSrc, 'utf8');
|
||||||
|
const genResult = spawnSync('swift', [genScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||||
|
if (genResult.status !== 0) {
|
||||||
|
log('Clipboard copy (image)', false, `Cannot create test image: ${genResult.stderr?.toString().trim()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipSrc = `import AppKit
|
||||||
|
import Foundation
|
||||||
|
guard let image = NSImage(contentsOfFile: CommandLine.arguments[1]) else {
|
||||||
|
FileHandle.standardError.write("Failed to load image\\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
pb.clearContents()
|
||||||
|
if !pb.writeObjects([image]) {
|
||||||
|
FileHandle.standardError.write("Failed to write to clipboard\\n".data(using: .utf8)!)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const clipScript = path.join(tmpDir, 'clip.swift');
|
||||||
|
await writeFile(clipScript, clipSrc, 'utf8');
|
||||||
|
const clipResult = spawnSync('swift', [clipScript, testPng], { stdio: 'pipe', timeout: 30_000 });
|
||||||
|
if (clipResult.status === 0) {
|
||||||
|
log('Clipboard copy (image)', true, 'Can copy image to clipboard via Swift/AppKit');
|
||||||
|
} else {
|
||||||
|
log('Clipboard copy (image)', false, `Failed: ${clipResult.stderr?.toString().trim()}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPasteKeystroke(): Promise<void> {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const result = spawnSync('osascript', ['-e', `
|
||||||
|
tell application "System Events"
|
||||||
|
-- Dry run: just check we CAN query key sending capability
|
||||||
|
set canSend to true
|
||||||
|
return canSend
|
||||||
|
end tell
|
||||||
|
`], { stdio: 'pipe', timeout: 10_000 });
|
||||||
|
|
||||||
|
if (result.status === 0) {
|
||||||
|
log('Paste keystroke (osascript)', true, 'System Events can send keystrokes');
|
||||||
|
} else {
|
||||||
|
const stderr = result.stderr?.toString().trim() || '';
|
||||||
|
log('Paste keystroke (osascript)', false, `Cannot send keystrokes: ${stderr}`);
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'linux') {
|
||||||
|
const xdotool = spawnSync('which', ['xdotool'], { stdio: 'pipe' });
|
||||||
|
const ydotool = spawnSync('which', ['ydotool'], { stdio: 'pipe' });
|
||||||
|
if (xdotool.status === 0) {
|
||||||
|
log('Paste keystroke', true, 'xdotool available (X11)');
|
||||||
|
} else if (ydotool.status === 0) {
|
||||||
|
log('Paste keystroke', true, 'ydotool available (Wayland)');
|
||||||
|
} else {
|
||||||
|
log('Paste keystroke', false, 'No tool found. Install xdotool (X11) or ydotool (Wayland).');
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
log('Paste keystroke', true, 'Windows uses PowerShell SendKeys (built-in)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkBun(): Promise<void> {
|
||||||
|
const result = spawnSync('npx', ['-y', 'bun', '--version'], { stdio: 'pipe', timeout: 30_000 });
|
||||||
|
if (result.status === 0) {
|
||||||
|
log('Bun runtime', true, `v${result.stdout?.toString().trim()}`);
|
||||||
|
} else {
|
||||||
|
log('Bun runtime', false, 'Cannot run bun. Install: curl -fsSL https://bun.sh/install | bash');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRunningChromeConflict(): Promise<void> {
|
||||||
|
if (process.platform !== 'darwin') return;
|
||||||
|
|
||||||
|
const result = spawnSync('pgrep', ['-f', 'Google Chrome'], { stdio: 'pipe' });
|
||||||
|
const pids = result.stdout?.toString().trim().split('\n').filter(Boolean) || [];
|
||||||
|
|
||||||
|
if (pids.length > 0) {
|
||||||
|
warn('Running Chrome instances', `${pids.length} Chrome process(es) detected. The skill uses --user-data-dir for isolation, so this is safe. Paste keystroke targets Chrome by app name (minor risk if multiple Chrome windows visible).`);
|
||||||
|
} else {
|
||||||
|
log('Running Chrome instances', true, 'No existing Chrome processes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('=== baoyu-post-to-x: Permission & Environment Check ===\n');
|
||||||
|
|
||||||
|
await checkChrome();
|
||||||
|
await checkProfileIsolation();
|
||||||
|
await checkBun();
|
||||||
|
await checkAccessibility();
|
||||||
|
await checkClipboardCopy();
|
||||||
|
await checkPasteKeystroke();
|
||||||
|
await checkRunningChromeConflict();
|
||||||
|
|
||||||
|
console.log('\n--- Summary ---');
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
if (failed.length === 0) {
|
||||||
|
console.log('All checks passed. Ready to post to X.');
|
||||||
|
} else {
|
||||||
|
console.log(`${failed.length} issue(s) found:`);
|
||||||
|
for (const f of failed) {
|
||||||
|
console.log(` ❌ ${f.name}: ${f.detail}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((err) => {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -7,6 +7,14 @@ import path from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import frontMatter from 'front-matter';
|
||||||
|
import hljs from 'highlight.js/lib/common';
|
||||||
|
import { Lexer, Marked, type RendererObject, type Tokens } from 'marked';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import remarkCjkFriendly from 'remark-cjk-friendly';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkStringify from 'remark-stringify';
|
||||||
|
|
||||||
interface ImageInfo {
|
interface ImageInfo {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
@@ -22,25 +30,80 @@ interface ParsedMarkdown {
|
|||||||
totalBlocks: number;
|
totalBlocks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
|
type FrontmatterFields = Record<string, unknown>;
|
||||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
||||||
if (!match) return { frontmatter: {}, body: content };
|
|
||||||
|
|
||||||
const frontmatter: Record<string, string> = {};
|
function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {
|
||||||
const lines = match[1]!.split('\n');
|
try {
|
||||||
for (const line of lines) {
|
const parsed = frontMatter<FrontmatterFields>(content);
|
||||||
const colonIdx = line.indexOf(':');
|
return {
|
||||||
if (colonIdx > 0) {
|
frontmatter: parsed.attributes ?? {},
|
||||||
const key = line.slice(0, colonIdx).trim();
|
body: parsed.body,
|
||||||
let value = line.slice(colonIdx + 1).trim();
|
};
|
||||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
} catch {
|
||||||
value = value.slice(1, -1);
|
return { frontmatter: {}, body: content };
|
||||||
}
|
}
|
||||||
frontmatter[key] = value;
|
}
|
||||||
|
|
||||||
|
function stripWrappingQuotes(value: string): string {
|
||||||
|
if (!value) return value;
|
||||||
|
const doubleQuoted = value.startsWith('"') && value.endsWith('"');
|
||||||
|
const singleQuoted = value.startsWith("'") && value.endsWith("'");
|
||||||
|
const cjkDoubleQuoted = value.startsWith('\u201c') && value.endsWith('\u201d');
|
||||||
|
const cjkSingleQuoted = value.startsWith('\u2018') && value.endsWith('\u2019');
|
||||||
|
if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {
|
||||||
|
return value.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFrontmatterString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return stripWrappingQuotes(value);
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirstString(frontmatter: FrontmatterFields, keys: string[]): string | undefined {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = toFrontmatterString(frontmatter[key]);
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCoverImageNearMarkdown(baseDir: string): string | null {
|
||||||
|
const candidateDirs = [baseDir, path.join(baseDir, 'imgs')];
|
||||||
|
const coverPattern = /^cover\.(png|jpe?g|webp)$/i;
|
||||||
|
|
||||||
|
for (const dir of candidateDirs) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = fs.readdirSync(dir).find((entry) => coverPattern.test(entry));
|
||||||
|
if (match) {
|
||||||
|
return path.join(dir, match);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { frontmatter, body: match[2]! };
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTitleFromMarkdown(markdown: string): string {
|
||||||
|
const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.type === 'heading' && token.depth === 1) {
|
||||||
|
return stripWrappingQuotes(token.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadFile(url: string, destPath: string): Promise<void> {
|
function downloadFile(url: string, destPath: string): Promise<void> {
|
||||||
@@ -116,141 +179,99 @@ function escapeHtml(text: string): string {
|
|||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightCode(code: string, lang: string): string {
|
||||||
|
try {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(code).value;
|
||||||
|
} catch {
|
||||||
|
return escapeHtml(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preprocessCjkMarkdown(markdown: string): string {
|
||||||
|
try {
|
||||||
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkCjkFriendly)
|
||||||
|
.use(remarkStringify);
|
||||||
|
|
||||||
|
const result = String(processor.processSync(markdown));
|
||||||
|
return result.replace(/&#x([0-9A-Fa-f]+);/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)));
|
||||||
|
} catch {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
||||||
const lines = markdown.split('\n');
|
const preprocessedMarkdown = preprocessCjkMarkdown(markdown);
|
||||||
const blocks: string[] = [];
|
const blockTokens = Lexer.lex(preprocessedMarkdown, { gfm: true, breaks: true });
|
||||||
let inCodeBlock = false;
|
|
||||||
let codeBlockContent: string[] = [];
|
|
||||||
let inList = false;
|
|
||||||
let listItems: string[] = [];
|
|
||||||
let listType: 'ul' | 'ol' = 'ul';
|
|
||||||
|
|
||||||
const flushList = () => {
|
const renderer: RendererObject = {
|
||||||
if (listItems.length > 0) {
|
heading({ depth, tokens }: Tokens.Heading): string {
|
||||||
const tag = listType === 'ol' ? 'ol' : 'ul';
|
if (depth === 1) {
|
||||||
blocks.push(`<${tag}>${listItems.map((item) => `<li>${item}</li>`).join('')}</${tag}>`);
|
return '';
|
||||||
listItems = [];
|
|
||||||
inList = false;
|
|
||||||
}
|
}
|
||||||
|
return `<h2>${this.parser.parseInline(tokens)}</h2>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||||
|
const text = this.parser.parseInline(tokens).trim();
|
||||||
|
if (!text) return '';
|
||||||
|
return `<p>${text}</p>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||||
|
return `<blockquote>${this.parser.parse(tokens)}</blockquote>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
code({ text, lang = '' }: Tokens.Code): string {
|
||||||
|
const language = lang.split(/\s+/)[0]!.toLowerCase();
|
||||||
|
const source = text.replace(/\n$/, '');
|
||||||
|
const highlighted = highlightCode(source, language).replace(/\n/g, '<br>');
|
||||||
|
const label = language ? `<strong>[${escapeHtml(language)}]</strong><br>` : '';
|
||||||
|
return `<blockquote>${label}${highlighted}</blockquote>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
image({ href, text }: Tokens.Image): string {
|
||||||
|
if (!href) return '';
|
||||||
|
return imageCallback(href, text ?? '');
|
||||||
|
},
|
||||||
|
|
||||||
|
link({ href, title, tokens, text }: Tokens.Link): string {
|
||||||
|
const label = tokens?.length ? this.parser.parseInline(tokens) : escapeHtml(text || href || '');
|
||||||
|
if (!href) return label;
|
||||||
|
|
||||||
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||||
|
return `<a href="${escapeHtml(href)}"${titleAttr} rel="noopener noreferrer nofollow">${label}</a>`;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const processInline = (text: string): string => {
|
const parser = new Marked({
|
||||||
// Bold
|
gfm: true,
|
||||||
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
breaks: true,
|
||||||
text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
});
|
||||||
|
parser.use({ renderer });
|
||||||
|
|
||||||
// Italic
|
const rendered = parser.parse(preprocessedMarkdown);
|
||||||
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
if (typeof rendered !== 'string') {
|
||||||
text = text.replace(/_(.+?)_/g, '<em>$1</em>');
|
throw new Error('Unexpected async markdown parse result');
|
||||||
|
|
||||||
// Links
|
|
||||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
||||||
|
|
||||||
// Inline code
|
|
||||||
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
||||||
|
|
||||||
return text;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i]!;
|
|
||||||
|
|
||||||
// Code block
|
|
||||||
if (line.startsWith('```')) {
|
|
||||||
if (inCodeBlock) {
|
|
||||||
// X doesn't support <pre><code>, convert to blockquote
|
|
||||||
const codeContent = codeBlockContent.map((l) => escapeHtml(l)).join('<br>');
|
|
||||||
blocks.push(`<blockquote>${codeContent}</blockquote>`);
|
|
||||||
codeBlockContent = [];
|
|
||||||
inCodeBlock = false;
|
|
||||||
} else {
|
|
||||||
flushList();
|
|
||||||
inCodeBlock = true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inCodeBlock) {
|
const totalBlocks = blockTokens.filter((token) => {
|
||||||
codeBlockContent.push(line);
|
if (token.type === 'space') return false;
|
||||||
continue;
|
if (token.type === 'heading' && token.depth === 1) return false;
|
||||||
}
|
return true;
|
||||||
|
}).length;
|
||||||
// Empty line
|
|
||||||
if (line.trim() === '') {
|
|
||||||
flushList();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image
|
|
||||||
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
|
||||||
if (imgMatch) {
|
|
||||||
flushList();
|
|
||||||
const placeholder = imageCallback(imgMatch[2]!, imgMatch[1]!);
|
|
||||||
blocks.push(`<p>${placeholder}</p>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heading (H1 is title, skip it; H2-H6 become H2)
|
|
||||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
||||||
if (headingMatch) {
|
|
||||||
flushList();
|
|
||||||
const level = headingMatch[1]!.length;
|
|
||||||
if (level === 1) continue; // Skip H1, it's the title
|
|
||||||
blocks.push(`<h2>${processInline(headingMatch[2]!)}</h2>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blockquote
|
|
||||||
if (line.startsWith('> ')) {
|
|
||||||
flushList();
|
|
||||||
blocks.push(`<blockquote>${processInline(line.slice(2))}</blockquote>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unordered list
|
|
||||||
const ulMatch = line.match(/^[-*]\s+(.+)$/);
|
|
||||||
if (ulMatch) {
|
|
||||||
if (!inList || listType !== 'ul') {
|
|
||||||
flushList();
|
|
||||||
inList = true;
|
|
||||||
listType = 'ul';
|
|
||||||
}
|
|
||||||
listItems.push(processInline(ulMatch[1]!));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordered list
|
|
||||||
const olMatch = line.match(/^\d+\.\s+(.+)$/);
|
|
||||||
if (olMatch) {
|
|
||||||
if (!inList || listType !== 'ol') {
|
|
||||||
flushList();
|
|
||||||
inList = true;
|
|
||||||
listType = 'ol';
|
|
||||||
}
|
|
||||||
listItems.push(processInline(olMatch[1]!));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal rule
|
|
||||||
if (/^[-*_]{3,}\s*$/.test(line)) {
|
|
||||||
flushList();
|
|
||||||
blocks.push('<hr>');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular paragraph
|
|
||||||
flushList();
|
|
||||||
blocks.push(`<p>${processInline(line)}</p>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
flushList();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: blocks.join('\n'),
|
html: rendered,
|
||||||
totalBlocks: blocks.length,
|
totalBlocks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,59 +287,58 @@ export async function parseMarkdown(
|
|||||||
|
|
||||||
const { frontmatter, body } = parseFrontmatter(content);
|
const { frontmatter, body } = parseFrontmatter(content);
|
||||||
|
|
||||||
// Extract title from frontmatter, option, or first H1
|
let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';
|
||||||
let title = options?.title ?? frontmatter.title ?? '';
|
|
||||||
if (!title) {
|
if (!title) {
|
||||||
const h1Match = body.match(/^#\s+(.+)$/m);
|
title = extractTitleFromMarkdown(body);
|
||||||
if (h1Match) title = h1Match[1]!;
|
}
|
||||||
|
if (!title) {
|
||||||
|
title = path.basename(markdownPath, path.extname(markdownPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract cover image from frontmatter or option
|
let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [
|
||||||
let coverImagePath = options?.coverImage ?? frontmatter.cover_image ?? frontmatter.coverImage ?? frontmatter.cover ?? frontmatter.image ?? frontmatter.featureImage ?? frontmatter.feature_image ?? null;
|
'cover_image',
|
||||||
|
'coverImage',
|
||||||
|
'cover',
|
||||||
|
'image',
|
||||||
|
'featureImage',
|
||||||
|
'feature_image',
|
||||||
|
]) || null;
|
||||||
|
if (!coverImagePath) {
|
||||||
|
coverImagePath = findCoverImageNearMarkdown(baseDir);
|
||||||
|
}
|
||||||
|
|
||||||
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
||||||
let imageCounter = 0;
|
let imageCounter = 0;
|
||||||
|
|
||||||
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
|
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
|
||||||
const placeholder = `XIMGPH_${++imageCounter}`;
|
const placeholder = `XIMGPH_${++imageCounter}`;
|
||||||
const currentBlockIndex = images.length; // Will be set properly after HTML generation
|
images.push({ src, alt, blockIndex: -1 });
|
||||||
|
|
||||||
images.push({ src, alt, blockIndex: -1 }); // blockIndex set later
|
|
||||||
return placeholder;
|
return placeholder;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update block indices by finding placeholders in HTML
|
|
||||||
const htmlLines = html.split('\n');
|
const htmlLines = html.split('\n');
|
||||||
let blockIdx = 0;
|
|
||||||
for (const line of htmlLines) {
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
const placeholder = `XIMGPH_${i + 1}`;
|
const placeholder = `XIMGPH_${i + 1}`;
|
||||||
if (line.includes(placeholder)) {
|
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
|
||||||
images[i]!.blockIndex = blockIdx;
|
const regex = new RegExp(`\\b${placeholder}\\b`);
|
||||||
|
if (regex.test(htmlLines[lineIndex]!)) {
|
||||||
|
images[i]!.blockIndex = lineIndex;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blockIdx++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve image paths (download remote, resolve relative)
|
|
||||||
const contentImages: ImageInfo[] = [];
|
const contentImages: ImageInfo[] = [];
|
||||||
let isFirstImage = true;
|
let firstImageAsCover: string | null = null;
|
||||||
let coverPlaceholder: string | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
const img = images[i]!;
|
const img = images[i]!;
|
||||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||||
|
|
||||||
// First image becomes cover if no cover specified
|
if (i === 0 && !coverImagePath) {
|
||||||
if (isFirstImage && !coverImagePath) {
|
firstImageAsCover = localPath;
|
||||||
coverImagePath = localPath;
|
|
||||||
coverPlaceholder = `XIMGPH_${i + 1}`;
|
|
||||||
isFirstImage = false;
|
|
||||||
// Don't add to contentImages, it's the cover
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isFirstImage = false;
|
|
||||||
contentImages.push({
|
contentImages.push({
|
||||||
placeholder: `XIMGPH_${i + 1}`,
|
placeholder: `XIMGPH_${i + 1}`,
|
||||||
localPath,
|
localPath,
|
||||||
@@ -327,17 +347,13 @@ export async function parseMarkdown(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove cover placeholder from HTML if first image was used as cover
|
const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
let finalHtml = html;
|
|
||||||
if (coverPlaceholder) {
|
|
||||||
// Remove the placeholder and its containing <p> tag
|
|
||||||
finalHtml = finalHtml.replace(new RegExp(`<p>${coverPlaceholder}</p>\\n?`, 'g'), '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve cover image path
|
|
||||||
let resolvedCoverImage: string | null = null;
|
let resolvedCoverImage: string | null = null;
|
||||||
if (coverImagePath) {
|
if (coverImagePath) {
|
||||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
||||||
|
} else if (firstImageAsCover) {
|
||||||
|
resolvedCoverImage = firstImageAsCover;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "baoyu-post-to-x-scripts",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"front-matter": "^4.0.2",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"marked": "^15.0.6",
|
||||||
|
"remark-cjk-friendly": "^1.1.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-stringify": "^11.0.0",
|
||||||
|
"unified": "^11.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -602,6 +602,12 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count existing image blocks before paste
|
||||||
|
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||||
|
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
|
||||||
|
returnByValue: true,
|
||||||
|
}, { sessionId });
|
||||||
|
|
||||||
// Focus editor to ensure cursor is in position
|
// Focus editor to ensure cursor is in position
|
||||||
await cdp.send('Runtime.evaluate', {
|
await cdp.send('Runtime.evaluate', {
|
||||||
expression: `(() => {
|
expression: `(() => {
|
||||||
@@ -619,9 +625,31 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
|||||||
console.warn(`[x-article] Failed to paste image after retries`);
|
console.warn(`[x-article] Failed to paste image after retries`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for image to upload
|
// Verify image appeared in editor
|
||||||
console.log(`[x-article] Waiting for upload...`);
|
console.log(`[x-article] Verifying image upload...`);
|
||||||
await sleep(5000);
|
const expectedImgCount = imgCountBefore.result.value + 1;
|
||||||
|
let imgUploadOk = false;
|
||||||
|
const imgWaitStart = Date.now();
|
||||||
|
while (Date.now() - imgWaitStart < 15_000) {
|
||||||
|
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||||
|
expression: `document.querySelectorAll('section[data-block="true"][contenteditable="false"] img[src^="blob:"]').length`,
|
||||||
|
returnByValue: true,
|
||||||
|
}, { sessionId });
|
||||||
|
if (r.result.value >= expectedImgCount) {
|
||||||
|
imgUploadOk = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imgUploadOk) {
|
||||||
|
console.log(`[x-article] Image upload verified (${expectedImgCount} image block(s))`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[x-article] Image upload not detected after 15s`);
|
||||||
|
if (i === 0) {
|
||||||
|
console.error('[x-article] First image paste failed. Run check-paste-permissions.ts to diagnose.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[x-article] All images processed.');
|
console.log('[x-article] All images processed.');
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count uploaded images before paste
|
||||||
|
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||||
|
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
||||||
|
returnByValue: true,
|
||||||
|
}, { sessionId });
|
||||||
|
|
||||||
// Wait for clipboard to be ready
|
// Wait for clipboard to be ready
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
@@ -150,8 +156,27 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
|||||||
}, { sessionId });
|
}, { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[x-browser] Waiting for image upload...');
|
console.log('[x-browser] Verifying image upload...');
|
||||||
await sleep(4000);
|
const expectedImgCount = imgCountBefore.result.value + 1;
|
||||||
|
let imgUploadOk = false;
|
||||||
|
const imgWaitStart = Date.now();
|
||||||
|
while (Date.now() - imgWaitStart < 15_000) {
|
||||||
|
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
|
||||||
|
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
|
||||||
|
returnByValue: true,
|
||||||
|
}, { sessionId });
|
||||||
|
if (r.result.value >= expectedImgCount) {
|
||||||
|
imgUploadOk = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imgUploadOk) {
|
||||||
|
console.log('[x-browser] Image upload verified');
|
||||||
|
} else {
|
||||||
|
console.warn('[x-browser] Image upload not detected after 15s. Run check-paste-permissions.ts to diagnose.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submit) {
|
if (submit) {
|
||||||
|
|||||||
@@ -368,10 +368,21 @@ Display the selected style's default elements from preset, then ask:
|
|||||||
|
|
||||||
With confirmed outline + style + layout:
|
With confirmed outline + style + layout:
|
||||||
|
|
||||||
|
**Visual Consistency — Reference Image Chain**:
|
||||||
|
To ensure character/style consistency across all images in a series:
|
||||||
|
1. **Generate image 1 (cover) FIRST** — without `--ref`
|
||||||
|
2. **Use image 1 as `--ref` for ALL remaining images** (2, 3, ..., N)
|
||||||
|
- This anchors the character design, color rendering, and illustration style
|
||||||
|
- Command pattern: `--ref <path-to-image-01.png>` added to every subsequent generation
|
||||||
|
|
||||||
|
This is critical for styles that use recurring characters, mascots, or illustration elements. Image 1 becomes the visual anchor for the entire series.
|
||||||
|
|
||||||
**For each image (cover + content + ending)**:
|
**For each image (cover + content + ending)**:
|
||||||
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
1. Save prompt to `prompts/NN-{type}-[slug].md` (in user's preferred language)
|
||||||
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
- **Backup rule**: If prompt file exists, rename to `prompts/NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||||
2. Generate image using confirmed style and layout
|
2. Generate image:
|
||||||
|
- **Image 1**: Generate without `--ref` (this establishes the visual anchor)
|
||||||
|
- **Images 2+**: Generate with `--ref <image-01-path>` for consistency
|
||||||
- **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
- **Backup rule**: If image file exists, rename to `NN-{type}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||||
3. Report progress after each generation
|
3. Report progress after each generation
|
||||||
|
|
||||||
@@ -391,7 +402,7 @@ Reference: `references/config/watermark-guide.md`
|
|||||||
If image generation skill supports `--sessionId`:
|
If image generation skill supports `--sessionId`:
|
||||||
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
|
1. Generate unique session ID: `xhs-{topic-slug}-{timestamp}`
|
||||||
2. Use same session ID for all images
|
2. Use same session ID for all images
|
||||||
3. Ensures visual consistency across generated images
|
3. Combined with reference image chain, ensures maximum visual consistency
|
||||||
|
|
||||||
### Step 6: Completion Report
|
### Step 6: Completion Report
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,21 @@ From outline entry, format:
|
|||||||
If preferences include watermark:
|
If preferences include watermark:
|
||||||
- Add watermark section with content, position, opacity
|
- Add watermark section with content, position, opacity
|
||||||
|
|
||||||
### Step 5: Combine
|
### Step 5: Visual Consistency — Reference Image Chain
|
||||||
|
|
||||||
|
When generating multiple images in a series:
|
||||||
|
|
||||||
|
1. **Image 1 (cover)**: Generate without `--ref` — this establishes the visual anchor
|
||||||
|
2. **Images 2+**: Always pass image 1 as `--ref` to the image generation skill:
|
||||||
|
```bash
|
||||||
|
npx -y bun ${SKILL_DIR}/scripts/main.ts \
|
||||||
|
--promptfiles prompts/02-content-xxx.md \
|
||||||
|
--ref path/to/01-cover-xxx.png \
|
||||||
|
--image 02-content-xxx.png --ar 3:4 --quality 2k
|
||||||
|
```
|
||||||
|
This ensures the AI maintains the same character design, illustration style, and color rendering across the series.
|
||||||
|
|
||||||
|
### Step 6: Combine
|
||||||
|
|
||||||
Assemble all sections into final prompt following base structure.
|
Assemble all sections into final prompt following base structure.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user