mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d56f1dae8 | |||
| dd08a2aa89 | |||
| d9b47debb3 | |||
| 6cc8627ca8 | |||
| 54103dfd7d | |||
| 6012b9cc88 | |||
| 260b71cdd7 | |||
| 9ff468a6a7 | |||
| c83d114a73 | |||
| e964100dd9 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.31.1"
|
||||
"version": "1.33.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 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
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix PowerShell clipboard copy failing on Windows due to `param()`/`-Path` not working with `-Command`.
|
||||
- `baoyu-post-to-x`: fix PowerShell clipboard copy on Windows (same issue); fix `getScriptDir()` returning invalid path on Windows (`/C:/...` prefix).
|
||||
|
||||
## 1.31.1 - 2026-02-10
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 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
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复 Windows 上 PowerShell 剪贴板复制失败的问题(`param()`/`-Path` 与 `-Command` 参数不兼容)。
|
||||
- `baoyu-post-to-x`:修复 Windows 上 PowerShell 剪贴板复制(同上);修复 `getScriptDir()` 在 Windows 上返回无效路径(`/C:/...` 前缀)。
|
||||
|
||||
## 1.31.1 - 2026-02-10
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -681,6 +681,9 @@ Converts X (Twitter) content to markdown format. Supports tweet threads and X Ar
|
||||
|
||||
# JSON output
|
||||
/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:**
|
||||
@@ -713,7 +716,7 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
||||
|
||||
**Workflow**:
|
||||
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
|
||||
4. Apply formatting: headings, bold, lists, code blocks, quotes
|
||||
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 |
|
||||
| `slug` | Infer from file path or generate from title |
|
||||
| `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**:
|
||||
| Element | Format |
|
||||
|
||||
+5
-2
@@ -681,6 +681,9 @@ AI 驱动的生成后端。
|
||||
|
||||
# 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:**
|
||||
@@ -713,7 +716,7 @@ AI 驱动的生成后端。
|
||||
|
||||
**工作流程**:
|
||||
1. 读取源文件并分析内容结构
|
||||
2. 检查/创建 YAML frontmatter(title、slug、summary、featureImage)
|
||||
2. 检查/创建 YAML frontmatter(title、slug、summary、coverImage)
|
||||
3. 处理标题:使用现有标题、提取 H1 或生成候选标题
|
||||
4. 应用格式:层级标题、加粗、列表、代码块、引用
|
||||
5. 保存为 `{文件名}-formatted.md`
|
||||
@@ -725,7 +728,7 @@ AI 驱动的生成后端。
|
||||
| `title` | 使用现有、提取 H1 或生成候选 |
|
||||
| `slug` | 从文件路径推断或根据标题生成 |
|
||||
| `summary` | 生成吸引人的摘要(100-150 字) |
|
||||
| `featureImage` | 检查同目录下 `imgs/cover.png` |
|
||||
| `coverImage` | 检查同目录下 `imgs/cover.png` |
|
||||
|
||||
**格式化规则**:
|
||||
| 元素 | 格式 |
|
||||
|
||||
@@ -92,16 +92,52 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md" && echo "user
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ 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
|
||||
|
||||
```bash
|
||||
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> --download-media
|
||||
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 |
|
||||
| `-o <path>` | Output path |
|
||||
| `--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 |
|
||||
|
||||
## Supported URLs
|
||||
@@ -124,9 +161,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||
|
||||
```markdown
|
||||
---
|
||||
url: https://x.com/user/status/123
|
||||
url: "https://x.com/user/status/123"
|
||||
author: "Name (@user)"
|
||||
tweet_count: 3
|
||||
tweetCount: 3
|
||||
coverImage: "https://pbs.twimg.com/media/example.jpg"
|
||||
---
|
||||
|
||||
Content...
|
||||
@@ -134,6 +172,32 @@ Content...
|
||||
|
||||
**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
|
||||
|
||||
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 { formatArticleMarkdown } from "./markdown.js";
|
||||
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
||||
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
||||
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
||||
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
||||
@@ -15,6 +16,7 @@ type CliArgs = {
|
||||
output: string | null;
|
||||
json: boolean;
|
||||
login: boolean;
|
||||
downloadMedia: boolean;
|
||||
help: boolean;
|
||||
};
|
||||
|
||||
@@ -38,6 +40,7 @@ Usage:
|
||||
Options:
|
||||
--output <path>, -o Output path (file or dir). Default: ./x-to-markdown/<slug>/
|
||||
--json Output as JSON
|
||||
--download-media Download images/videos to local ./imgs and ./videos next to markdown
|
||||
--login Refresh cookies only, then exit
|
||||
--help, -h Show help
|
||||
|
||||
@@ -45,6 +48,7 @@ Examples:
|
||||
${cmd} https://x.com/username/status/1234567890
|
||||
${cmd} https://x.com/i/article/1234567890 -o ./article.md
|
||||
${cmd} https://x.com/username/status/1234567890 -o ./out/
|
||||
${cmd} https://x.com/username/status/1234567890 --download-media
|
||||
${cmd} https://x.com/username/status/1234567890 --json | jq -r '.markdownPath'
|
||||
${cmd} --login
|
||||
`);
|
||||
@@ -57,6 +61,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
output: null,
|
||||
json: false,
|
||||
login: false,
|
||||
downloadMedia: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
@@ -80,6 +85,11 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--download-media") {
|
||||
out.downloadMedia = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a === "--url") {
|
||||
const v = argv[++i];
|
||||
if (!v) throw new Error("Missing value for --url");
|
||||
@@ -345,16 +355,17 @@ async function convertArticleToMarkdown(
|
||||
|
||||
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
||||
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 meta = formatMetaMarkdown({
|
||||
url: `https://x.com/i/article/${articleId}`,
|
||||
requested_url: inputUrl,
|
||||
requestedUrl: inputUrl,
|
||||
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> {
|
||||
@@ -385,11 +396,24 @@ async function main(): Promise<void> {
|
||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
||||
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
||||
|
||||
const markdown =
|
||||
let markdown =
|
||||
kind === "article" && articleId
|
||||
? await convertArticleToMarkdown(normalizedUrl, articleId, 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");
|
||||
log(`[x-to-markdown] Saved: ${markdownPath}`);
|
||||
|
||||
@@ -398,11 +422,16 @@ async function main(): Promise<void> {
|
||||
JSON.stringify(
|
||||
{
|
||||
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
||||
requested_url: normalizedUrl,
|
||||
requestedUrl: normalizedUrl,
|
||||
type: kind,
|
||||
slug,
|
||||
outputDir,
|
||||
markdownPath,
|
||||
downloadMedia: args.downloadMedia,
|
||||
downloadedImages: mediaResult?.downloadedImages ?? 0,
|
||||
downloadedVideos: mediaResult?.downloadedVideos ?? 0,
|
||||
imageDir: mediaResult?.imageDir ?? null,
|
||||
videoDir: mediaResult?.videoDir ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -257,10 +257,15 @@ function renderContentBlocks(
|
||||
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);
|
||||
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[] = [];
|
||||
@@ -271,10 +276,8 @@ export function formatArticleMarkdown(article: unknown): string {
|
||||
lines.push(`# ${title}`);
|
||||
}
|
||||
|
||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info);
|
||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
|
||||
if (coverUrl) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(``);
|
||||
usedUrls.add(coverUrl);
|
||||
}
|
||||
|
||||
@@ -294,7 +297,7 @@ export function formatArticleMarkdown(article: unknown): string {
|
||||
lines.push(candidate.preview_text.trim());
|
||||
}
|
||||
|
||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl);
|
||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
|
||||
if (mediaUrls.length > 0) {
|
||||
lines.push("", "## Media", "");
|
||||
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 rootUrl = buildTweetUrl(username, thread.rootId ?? tweetId) ?? requestedUrl;
|
||||
|
||||
const meta = formatMetaMarkdown({
|
||||
url: rootUrl,
|
||||
requested_url: requestedUrl,
|
||||
author,
|
||||
author_name: name ?? null,
|
||||
author_username: username ?? null,
|
||||
author_url: authorUrl ?? null,
|
||||
tweet_count: thread.totalTweets ?? tweets.length,
|
||||
});
|
||||
|
||||
const parts: string[] = [meta];
|
||||
|
||||
const articleEntity = await resolveArticleEntityFromTweet(firstTweet, cookieMap);
|
||||
let coverImage: string | null = null;
|
||||
let remainingTweets = tweets;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (articleEntity) {
|
||||
const articleMarkdown = formatArticleMarkdown(articleEntity).trimEnd();
|
||||
const articleResult = formatArticleMarkdown(articleEntity);
|
||||
coverImage = articleResult.coverUrl;
|
||||
const articleMarkdown = articleResult.markdown.trimEnd();
|
||||
if (articleMarkdown) {
|
||||
parts.push(articleMarkdown);
|
||||
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) {
|
||||
const hasArticle = parts.length > 1;
|
||||
if (hasArticle) {
|
||||
|
||||
@@ -127,7 +127,7 @@ Check for YAML frontmatter (`---` block). Create if missing.
|
||||
| `title` | See Step 4 |
|
||||
| `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) |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ description: Posts content to WeChat Official Account (微信公众号) via API
|
||||
| `scripts/wechat-browser.ts` | Image-text posts (图文) |
|
||||
| `scripts/wechat-article.ts` | Article posting via browser (文章) |
|
||||
| `scripts/wechat-api.ts` | Article posting via API (文章) |
|
||||
| `scripts/check-permissions.ts` | Verify environment & permissions |
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
@@ -76,6 +77,29 @@ chrome_profile_path: /path/to/chrome/profile
|
||||
3. EXTEND.md
|
||||
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 (图文)
|
||||
|
||||
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`):
|
||||
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`.
|
||||
4. Else fallback to first inline content image.
|
||||
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);
|
||||
});
|
||||
@@ -228,25 +228,25 @@ async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function copyImageWindows(imagePath: string): Promise<void> {
|
||||
const escaped = imagePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
'$img = [System.Drawing.Image]::FromFile($Path)',
|
||||
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
|
||||
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
||||
'$img.Dispose()',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
|
||||
const escaped = htmlFilePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$html = Get-Content -Raw -LiteralPath $Path',
|
||||
`$html = Get-Content -Raw -LiteralPath '${escaped}'`,
|
||||
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
|
||||
|
||||
@@ -365,7 +365,7 @@ Frontmatter Fields (markdown):
|
||||
title Article title
|
||||
author Author name
|
||||
digest/summary Article summary
|
||||
featureImage/coverImage/cover/image Cover image path
|
||||
coverImage/featureImage/cover/image Cover image path
|
||||
|
||||
Comments:
|
||||
Comments are enabled by default, open to all users.
|
||||
@@ -555,8 +555,8 @@ async function main(): Promise<void> {
|
||||
|
||||
let thumbMediaId = "";
|
||||
const rawCoverPath = args.cover ||
|
||||
frontmatter.featureImage ||
|
||||
frontmatter.coverImage ||
|
||||
frontmatter.featureImage ||
|
||||
frontmatter.cover ||
|
||||
frontmatter.image;
|
||||
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
||||
@@ -578,7 +578,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/copy-to-clipboard.ts` | Copy content to clipboard |
|
||||
| `scripts/paste-from-clipboard.ts` | Send real paste keystroke |
|
||||
| `scripts/check-paste-permissions.ts` | Verify environment & permissions |
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
@@ -63,6 +64,28 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-x/EXTEND.md" && echo "user"
|
||||
- `bun` runtime
|
||||
- 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
|
||||
|
||||
- **Regular Posts**: See `references/regular-posts.md` for manual workflow, troubleshooting, and technical details
|
||||
@@ -151,6 +174,18 @@ npx -y bun ${SKILL_DIR}/scripts/x-article.ts article.md --submit #
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
- First run: manual login required (session persists)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -228,25 +228,25 @@ async function copyHtmlLinux(htmlFilePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function copyImageWindows(imagePath: string): Promise<void> {
|
||||
const escaped = imagePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'Add-Type -AssemblyName System.Drawing',
|
||||
'$img = [System.Drawing.Image]::FromFile($Path)',
|
||||
`$img = [System.Drawing.Image]::FromFile('${escaped}')`,
|
||||
'[System.Windows.Forms.Clipboard]::SetImage($img)',
|
||||
'$img.Dispose()',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', imagePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyHtmlWindows(htmlFilePath: string): Promise<void> {
|
||||
const escaped = htmlFilePath.replace(/'/g, "''");
|
||||
const ps = [
|
||||
'param([string]$Path)',
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$html = Get-Content -Raw -LiteralPath $Path',
|
||||
`$html = Get-Content -Raw -LiteralPath '${escaped}'`,
|
||||
'[System.Windows.Forms.Clipboard]::SetText($html, [System.Windows.Forms.TextDataFormat]::Html)',
|
||||
].join('; ');
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps, '-Path', htmlFilePath]);
|
||||
await runCommand('powershell.exe', ['-NoProfile', '-Sta', '-Command', ps]);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePathInput: string): Promise<void> {
|
||||
|
||||
@@ -602,6 +602,12 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
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
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
@@ -619,9 +625,31 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
console.warn(`[x-article] Failed to paste image after retries`);
|
||||
}
|
||||
|
||||
// Wait for image to upload
|
||||
console.log(`[x-article] Waiting for upload...`);
|
||||
await sleep(5000);
|
||||
// Verify image appeared in editor
|
||||
console.log(`[x-article] Verifying image upload...`);
|
||||
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.');
|
||||
|
||||
@@ -117,6 +117,12 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
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
|
||||
await sleep(500);
|
||||
|
||||
@@ -150,8 +156,27 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
}, { sessionId });
|
||||
}
|
||||
|
||||
console.log('[x-browser] Waiting for image upload...');
|
||||
await sleep(4000);
|
||||
console.log('[x-browser] Verifying image upload...');
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type PlatformCandidates = {
|
||||
darwin?: string[];
|
||||
@@ -216,7 +217,7 @@ export class CdpConnection {
|
||||
}
|
||||
|
||||
export function getScriptDir(): string {
|
||||
return path.dirname(new URL(import.meta.url).pathname);
|
||||
return path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function runBunScript(scriptPath: string, args: string[]): boolean {
|
||||
|
||||
Reference in New Issue
Block a user