mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33bc0a6255 | |||
| c15a44b439 | |||
| 6e533f938f | |||
| c60eb85629 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.51.0"
|
||||
"version": "1.51.1"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.51.1 - 2026-03-06
|
||||
|
||||
### Refactor
|
||||
- Unify Chrome CDP profile path — all skills now share `baoyu-skills/chrome-profile` instead of per-skill directories
|
||||
- Fix `baoyu-post-to-weibo` incorrectly reusing `x-browser-profile` path
|
||||
|
||||
### Fixes
|
||||
- Remove `curl | bash` remote code execution pattern from all install instructions
|
||||
- Enforce HTTPS-only for remote image downloads in `md-to-html` scripts
|
||||
- Add redirect limit (max 5) to prevent infinite redirect loops
|
||||
- Add Security Guidelines section to CLAUDE.md
|
||||
|
||||
## 1.51.0 - 2026-03-06
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.51.1 - 2026-03-06
|
||||
|
||||
### 重构
|
||||
- 统一 Chrome CDP profile 路径——所有 skill 共享 `baoyu-skills/chrome-profile`,不再各自独立目录
|
||||
- 修复 `baoyu-post-to-weibo` 错误复用 `x-browser-profile` 路径的问题
|
||||
|
||||
### 修复
|
||||
- 移除所有安装说明中的 `curl | bash` 远程代码执行模式
|
||||
- `md-to-html` 脚本强制仅允许 HTTPS 下载远程图片
|
||||
- 添加重定向次数限制(最多 5 次),防止无限重定向
|
||||
- 在 CLAUDE.md 中新增安全准则章节
|
||||
|
||||
## 1.51.0 - 2026-03-06
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -56,7 +56,7 @@ if command -v bun &>/dev/null; then
|
||||
elif command -v npx &>/dev/null; then
|
||||
BUN_X="npx -y bun"
|
||||
else
|
||||
echo "Error: Neither bun nor npx found. Install bun: curl -fsSL https://bun.sh/install | bash"
|
||||
echo "Error: Neither bun nor npx found. Install bun: brew install oven-sh/bun/bun (macOS) or npm install -g bun"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
@@ -65,7 +65,7 @@ fi
|
||||
|----------|-----------|-------------------|-------|
|
||||
| 1 | `bun` installed | `bun` | Fastest, native execution |
|
||||
| 2 | `npx` available | `npx -y bun` | Downloads bun on first run via npm |
|
||||
| 3 | Neither found | Error + install guide | Suggest: `curl -fsSL https://bun.sh/install \| bash` |
|
||||
| 3 | Neither found | Error + install guide | `brew install oven-sh/bun/bun` (macOS) or `npm install -g bun` |
|
||||
|
||||
### Script Execution
|
||||
|
||||
@@ -91,6 +91,74 @@ ${BUN_X} skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md
|
||||
- **Chrome**: Required for `baoyu-danger-gemini-web` auth and `baoyu-post-to-x` automation
|
||||
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
||||
|
||||
## Chrome Profile (Unified)
|
||||
|
||||
All skills that use Chrome CDP share a **single** profile directory. Do NOT create per-skill profiles.
|
||||
|
||||
| Platform | Default Path |
|
||||
|----------|-------------|
|
||||
| macOS | `~/Library/Application Support/baoyu-skills/chrome-profile` |
|
||||
| Linux | `$XDG_DATA_HOME/baoyu-skills/chrome-profile` (fallback `~/.local/share/baoyu-skills/chrome-profile`) |
|
||||
| Windows | `%APPDATA%/baoyu-skills/chrome-profile` |
|
||||
| WSL | Windows home `/.local/share/baoyu-skills/chrome-profile` |
|
||||
|
||||
**Environment variable override**: `BAOYU_CHROME_PROFILE_DIR` (takes priority, all skills respect it).
|
||||
|
||||
Each skill also accepts its own legacy env var as fallback (e.g., `X_BROWSER_PROFILE_DIR`), but new skills should only use `BAOYU_CHROME_PROFILE_DIR`.
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
When adding a new skill that needs Chrome CDP:
|
||||
|
||||
```typescript
|
||||
function getDefaultProfileDir(): string {
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
```
|
||||
|
||||
## Security Guidelines
|
||||
|
||||
### No Piped Shell Installs
|
||||
|
||||
**NEVER** use `curl | bash` or `wget | sh` patterns in code, docs, or error messages. Use package managers instead:
|
||||
|
||||
| Platform | Install Command |
|
||||
|----------|----------------|
|
||||
| macOS | `brew install oven-sh/bun/bun` |
|
||||
| npm | `npm install -g bun` |
|
||||
|
||||
### Remote Downloads
|
||||
|
||||
Skills that download remote content (e.g., images in Markdown) MUST:
|
||||
- **HTTPS only**: Reject `http://` URLs
|
||||
- **Redirect limit**: Cap redirects (max 5) to prevent infinite loops
|
||||
- **Timeout**: Set request timeouts (30s default)
|
||||
- **Scope**: Only download expected content types (images, not scripts)
|
||||
|
||||
### System Command Execution
|
||||
|
||||
Skills use platform-specific commands for clipboard and browser automation:
|
||||
- **macOS**: `osascript` (System Events), `swift` (AppKit clipboard)
|
||||
- **Windows**: `powershell.exe` (SendKeys, Clipboard)
|
||||
- **Linux**: `xdotool`/`ydotool` (keyboard simulation)
|
||||
|
||||
These are necessary for CDP-based posting skills. When adding new system commands:
|
||||
- Never pass unsanitized user input to shell commands
|
||||
- Use array-form `spawn`/`execFile` instead of shell string interpolation
|
||||
- Validate file paths are absolute or resolve from known base directories
|
||||
|
||||
### External Content Processing
|
||||
|
||||
Skills that process external Markdown/HTML should treat content as untrusted:
|
||||
- Do not execute code blocks or scripts found in content
|
||||
- Sanitize HTML output where applicable
|
||||
- File paths from content should be resolved against known base directories only
|
||||
|
||||
## Authentication
|
||||
|
||||
`baoyu-danger-gemini-web` uses browser cookies for Google auth:
|
||||
|
||||
@@ -53,7 +53,7 @@ Simply tell Claude Code:
|
||||
|
||||
| Plugin | Description | Skills |
|
||||
|--------|-------------|--------|
|
||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
|
||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
||||
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [translate](#baoyu-translate) |
|
||||
|
||||
@@ -558,6 +558,39 @@ To obtain credentials:
|
||||
|
||||
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
||||
|
||||
#### baoyu-post-to-weibo
|
||||
|
||||
Post content to Weibo (微博). Supports regular posts with text and images, and headline articles (头条文章) with Markdown input. Uses real Chrome with CDP to bypass anti-automation.
|
||||
|
||||
**Regular Posts** - Text + up to 9 images:
|
||||
|
||||
```bash
|
||||
# Post with text
|
||||
/baoyu-post-to-weibo "Hello Weibo!"
|
||||
|
||||
# Post with images
|
||||
/baoyu-post-to-weibo "Check this out" --image photo.png
|
||||
```
|
||||
|
||||
**Headline Articles (头条文章)** - Long-form Markdown:
|
||||
|
||||
```bash
|
||||
# Publish article
|
||||
/baoyu-post-to-weibo --article article.md
|
||||
|
||||
# With cover image
|
||||
/baoyu-post-to-weibo --article article.md --cover cover.jpg
|
||||
```
|
||||
|
||||
**Article Options**:
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--cover <path>` | Cover image |
|
||||
| `--title <text>` | Override title (max 32 chars) |
|
||||
| `--summary <text>` | Override summary (max 44 chars) |
|
||||
|
||||
**Note**: Scripts fill content into the browser. User reviews and publishes manually. First run requires manual Weibo login (session persists).
|
||||
|
||||
### AI Generation Skills
|
||||
|
||||
AI-powered generation backends.
|
||||
|
||||
+34
-1
@@ -53,7 +53,7 @@ npx skills add jimliu/baoyu-skills
|
||||
|
||||
| 插件 | 说明 | 包含技能 |
|
||||
|------|------|----------|
|
||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat) |
|
||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
||||
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
||||
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [translate](#baoyu-translate) |
|
||||
|
||||
@@ -558,6 +558,39 @@ WECHAT_APP_SECRET=你的AppSecret
|
||||
|
||||
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
|
||||
#### baoyu-post-to-weibo
|
||||
|
||||
发布内容到微博。支持带图文本发布和头条文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。
|
||||
|
||||
**普通微博** - 文字 + 最多 9 张图片:
|
||||
|
||||
```bash
|
||||
# 发布文字
|
||||
/baoyu-post-to-weibo "Hello Weibo!"
|
||||
|
||||
# 发布带图片
|
||||
/baoyu-post-to-weibo "看看这个" --image photo.png
|
||||
```
|
||||
|
||||
**头条文章** - 长篇 Markdown 文章:
|
||||
|
||||
```bash
|
||||
# 发布文章
|
||||
/baoyu-post-to-weibo --article article.md
|
||||
|
||||
# 带封面图
|
||||
/baoyu-post-to-weibo --article article.md --cover cover.jpg
|
||||
```
|
||||
|
||||
**文章选项**:
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `--cover <path>` | 封面图 |
|
||||
| `--title <text>` | 覆盖标题(最多 32 字) |
|
||||
| `--summary <text>` | 覆盖摘要(最多 44 字) |
|
||||
|
||||
**说明**:脚本会将内容填入浏览器,用户需手动检查并发布。首次运行需手动登录微博(登录状态会保存)。
|
||||
|
||||
### AI 生成技能 (AI Generation Skills)
|
||||
|
||||
AI 驱动的生成后端。
|
||||
|
||||
@@ -42,11 +42,11 @@ function getWslWindowsHome(): string | null {
|
||||
}
|
||||
|
||||
export function resolveGeminiWebChromeProfileDir(): string {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim();
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, '.local', 'share', APP_DATA_DIR, GEMINI_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
|
||||
if (wslHome) return path.join(wslHome, '.local', 'share', APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveUserDataRoot(), APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
}
|
||||
|
||||
export function resolveGeminiWebSessionsDir(): string {
|
||||
|
||||
@@ -43,11 +43,11 @@ function getWslWindowsHome(): string | null {
|
||||
}
|
||||
|
||||
export function resolveXToMarkdownChromeProfileDir(): string {
|
||||
const override = process.env.X_CHROME_PROFILE_DIR?.trim();
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.X_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, ".local", "share", APP_DATA_DIR, X_TO_MARKDOWN_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveXToMarkdownDataDir(), PROFILE_DIR_NAME);
|
||||
if (wslHome) return path.join(wslHome, ".local", "share", APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveUserDataRoot(), APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
}
|
||||
|
||||
export function resolveXToMarkdownConsentPath(): string {
|
||||
|
||||
@@ -102,8 +102,8 @@ Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystrok
|
||||
| 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` |
|
||||
| Profile dir | Shared profile at `baoyu-skills/chrome-profile` (see CLAUDE.md Chrome Profile section) |
|
||||
| Bun runtime | `brew install oven-sh/bun/bun` (macOS) or `npm install -g bun` |
|
||||
| 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 |
|
||||
|
||||
@@ -61,8 +61,12 @@ export function findChromeExecutable(): string | undefined {
|
||||
}
|
||||
|
||||
export function getDefaultProfileDir(): string {
|
||||
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'wechat-browser-profile');
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
|
||||
@@ -181,7 +181,7 @@ async function checkBun(): Promise<void> {
|
||||
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');
|
||||
log('Bun runtime', false, 'Cannot run bun. Install: brew install oven-sh/bun/bun (macOS) or npm install -g bun');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,11 +183,14 @@ function getWslWindowsHome(): string | null {
|
||||
}
|
||||
|
||||
function getDefaultProfileDir(): string {
|
||||
const override = process.env.WECHAT_BROWSER_PROFILE_DIR?.trim();
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.WECHAT_BROWSER_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const home = getWslWindowsHome() ?? os.homedir();
|
||||
const base = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
||||
return path.join(base, 'wechat-browser-profile');
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, '.local', 'share', 'baoyu-skills', 'chrome-profile');
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
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';
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
localPath: string;
|
||||
@@ -28,35 +31,55 @@ interface ParsedMarkdown {
|
||||
type FrontmatterFields = Record<string, unknown>;
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: FrontmatterFields; body: string } {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) return { frontmatter: {}, body: content };
|
||||
|
||||
const fields: FrontmatterFields = {};
|
||||
for (const line of match[1]!.split('\n')) {
|
||||
const kv = line.match(/^(\w[\w_]*)\s*:\s*(.+)$/);
|
||||
if (kv) {
|
||||
let val = kv[2]!.trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
fields[kv[1]!] = val;
|
||||
}
|
||||
try {
|
||||
const parsed = frontMatter<FrontmatterFields>(content);
|
||||
return {
|
||||
frontmatter: parsed.attributes ?? {},
|
||||
body: parsed.body,
|
||||
};
|
||||
} catch {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
|
||||
return { frontmatter: fields, body: match[2]! };
|
||||
}
|
||||
|
||||
function pickFirstString(fm: FrontmatterFields, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const v = fm[key];
|
||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
||||
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 extractTitleFromBody(body: string): string {
|
||||
const match = body.match(/^#\s+(.+)$/m);
|
||||
return match ? match[1]!.trim() : '';
|
||||
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 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 extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
@@ -66,33 +89,53 @@ function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
return firstParagraph.slice(0, maxLen - 1) + '\u2026';
|
||||
}
|
||||
|
||||
function downloadFile(url: string, destPath: string): Promise<void> {
|
||||
function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
if (!url.startsWith('https://')) {
|
||||
reject(new Error(`Refusing non-HTTPS download: ${url}`));
|
||||
return;
|
||||
}
|
||||
if (maxRedirects <= 0) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
|
||||
const request = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
|
||||
downloadFile(redirectUrl, destPath, maxRedirects - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
reject(new Error(`Failed to download: ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
file.on('finish', () => { file.close(); resolve(); });
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (err) => { file.close(); fs.unlink(destPath, () => {}); reject(err); });
|
||||
request.setTimeout(30000, () => { request.destroy(); reject(new Error('Download timeout')); });
|
||||
request.on('error', (err) => {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
|
||||
request.setTimeout(30000, () => {
|
||||
request.destroy();
|
||||
reject(new Error('Download timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,115 +167,137 @@ function resolveLocalWithFallback(resolved: string): string {
|
||||
}
|
||||
|
||||
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {
|
||||
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||
if (imagePath.startsWith('http://')) {
|
||||
console.error(`[md-to-html] Skipping non-HTTPS image: ${imagePath}`);
|
||||
return '';
|
||||
}
|
||||
if (imagePath.startsWith('https://')) {
|
||||
const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);
|
||||
const ext = getImageExtension(imagePath);
|
||||
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
|
||||
|
||||
if (!fs.existsSync(localPath)) {
|
||||
console.error(`[md-to-html] Downloading: ${imagePath}`);
|
||||
await downloadFile(imagePath, localPath);
|
||||
}
|
||||
return localPath;
|
||||
}
|
||||
|
||||
const resolved = path.isAbsolute(imagePath) ? imagePath : path.resolve(baseDir, imagePath);
|
||||
return resolveLocalWithFallback(resolved);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function markdownToHtml(body: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
||||
const lines = body.split('\n');
|
||||
const htmlParts: string[] = [];
|
||||
let blockCount = 0;
|
||||
let inCodeBlock = false;
|
||||
let codeLines: string[] = [];
|
||||
let codeLang = '';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('```')) {
|
||||
if (!inCodeBlock) {
|
||||
inCodeBlock = true;
|
||||
codeLang = line.slice(3).trim();
|
||||
codeLines = [];
|
||||
} else {
|
||||
inCodeBlock = false;
|
||||
htmlParts.push(`<pre><code class="language-${escapeHtml(codeLang)}">${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
||||
blockCount++;
|
||||
const EMPTY_PARAGRAPH = '<p></p>';
|
||||
|
||||
function convertMarkdownToHtml(markdown: string, imageCallback: (src: string, alt: string) => string): { html: string; totalBlocks: number } {
|
||||
const blockTokens = Lexer.lex(markdown, { gfm: true, breaks: true });
|
||||
|
||||
const renderer: RendererObject = {
|
||||
heading({ depth, tokens }: Tokens.Heading): string {
|
||||
if (depth === 1) {
|
||||
return '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return `<h2>${this.parser.parseInline(tokens)}</h2>`;
|
||||
},
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeLines.push(line);
|
||||
continue;
|
||||
}
|
||||
paragraph({ tokens }: Tokens.Paragraph): string {
|
||||
const text = this.parser.parseInline(tokens).trim();
|
||||
if (!text) return '';
|
||||
return `<p>${text}</p>`;
|
||||
},
|
||||
|
||||
// H1 (skip, used as title)
|
||||
if (line.match(/^#\s+/)) continue;
|
||||
blockquote({ tokens }: Tokens.Blockquote): string {
|
||||
return `<blockquote>${this.parser.parse(tokens)}</blockquote>`;
|
||||
},
|
||||
|
||||
// H2-H6
|
||||
const headingMatch = line.match(/^(#{2,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1]!.length;
|
||||
htmlParts.push(`<h${level}>${processInline(headingMatch[2]!)}</h${level}>`);
|
||||
blockCount++;
|
||||
continue;
|
||||
}
|
||||
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
|
||||
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
|
||||
if (imgMatch) {
|
||||
htmlParts.push(imageCallback(imgMatch[2]!, imgMatch[1]!));
|
||||
blockCount++;
|
||||
continue;
|
||||
}
|
||||
image({ href, text }: Tokens.Image): string {
|
||||
if (!href) return '';
|
||||
return imageCallback(href, text ?? '');
|
||||
},
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
htmlParts.push(`<blockquote><p>${processInline(line.slice(2))}</p></blockquote>`);
|
||||
blockCount++;
|
||||
continue;
|
||||
}
|
||||
link({ href, title, tokens, text }: Tokens.Link): string {
|
||||
const label = tokens?.length ? this.parser.parseInline(tokens) : escapeHtml(text || href || '');
|
||||
if (!href) return label;
|
||||
|
||||
// Unordered list
|
||||
if (line.match(/^[-*]\s+/)) {
|
||||
htmlParts.push(`<li>${processInline(line.replace(/^[-*]\s+/, ''))}</li>`);
|
||||
blockCount++;
|
||||
continue;
|
||||
}
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(href)}"${titleAttr} rel="noopener noreferrer nofollow">${label}</a>`;
|
||||
},
|
||||
};
|
||||
|
||||
// Ordered list
|
||||
if (line.match(/^\d+\.\s+/)) {
|
||||
htmlParts.push(`<li>${processInline(line.replace(/^\d+\.\s+/, ''))}</li>`);
|
||||
blockCount++;
|
||||
continue;
|
||||
}
|
||||
const parser = new Marked({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
parser.use({ renderer });
|
||||
|
||||
// Horizontal rule
|
||||
if (line.match(/^[-*]{3,}$/)) {
|
||||
htmlParts.push('<hr>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// Paragraph
|
||||
htmlParts.push(`<p>${processInline(line)}</p>`);
|
||||
blockCount++;
|
||||
const rendered = parser.parse(markdown);
|
||||
if (typeof rendered !== 'string') {
|
||||
throw new Error('Unexpected async markdown parse result');
|
||||
}
|
||||
|
||||
return { html: htmlParts.join('\n'), totalBlocks: blockCount };
|
||||
}
|
||||
const totalBlocks = blockTokens.filter((token) => {
|
||||
if (token.type === 'space') return false;
|
||||
if (token.type === 'heading' && token.depth === 1) return false;
|
||||
return true;
|
||||
}).length;
|
||||
|
||||
function processInline(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
const blocks = rendered
|
||||
.replace(/(<\/(?:p|h[1-6]|blockquote|ol|ul|hr|pre)>)/gi, '$1\n')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim());
|
||||
|
||||
const spaced: string[] = [];
|
||||
const nestTags = ['ol', 'ul', 'blockquote'];
|
||||
let depth = 0;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]!;
|
||||
const opens = (block.match(new RegExp(`<(${nestTags.join('|')})[\\s>]`, 'gi')) || []).length;
|
||||
const closes = (block.match(new RegExp(`</(${nestTags.join('|')})>`, 'gi')) || []).length;
|
||||
|
||||
spaced.push(block);
|
||||
depth += opens - closes;
|
||||
|
||||
if (depth <= 0 && i < blocks.length - 1) {
|
||||
const lastIsEmpty = spaced.length > 0 && spaced[spaced.length - 1] === EMPTY_PARAGRAPH;
|
||||
if (!lastIsEmpty) {
|
||||
spaced.push(EMPTY_PARAGRAPH);
|
||||
}
|
||||
}
|
||||
if (depth < 0) depth = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
html: spaced.join('\n'),
|
||||
totalBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseMarkdown(
|
||||
@@ -247,40 +312,60 @@ export async function parseMarkdown(
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
let title = options?.title?.trim() || pickFirstString(frontmatter, ['title']) || '';
|
||||
if (!title) title = extractTitleFromBody(body);
|
||||
if (!title) title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
let title = stripWrappingQuotes(options?.title ?? '') || pickFirstString(frontmatter, ['title']) || '';
|
||||
if (!title) {
|
||||
title = extractTitleFromMarkdown(body);
|
||||
}
|
||||
if (!title) {
|
||||
title = path.basename(markdownPath, path.extname(markdownPath));
|
||||
}
|
||||
|
||||
let summary = pickFirstString(frontmatter, ['summary', 'description', 'excerpt']) || '';
|
||||
if (!summary) summary = extractSummaryFromBody(body, 44);
|
||||
const shortSummary = extractSummaryFromBody(body, 44);
|
||||
|
||||
let coverImagePath = options?.coverImage?.trim() || pickFirstString(frontmatter, [
|
||||
let coverImagePath = stripWrappingQuotes(options?.coverImage ?? '') || pickFirstString(frontmatter, [
|
||||
'featureImage', 'cover_image', 'coverImage', 'cover', 'image',
|
||||
]) || null;
|
||||
|
||||
const images: Array<{ src: string; alt: string }> = [];
|
||||
const images: Array<{ src: string; alt: string; blockIndex: number }> = [];
|
||||
let imageCounter = 0;
|
||||
|
||||
const { html, totalBlocks } = markdownToHtml(body, (src, alt) => {
|
||||
const { html, totalBlocks } = convertMarkdownToHtml(body, (src, alt) => {
|
||||
const placeholder = `WBIMGPH_${++imageCounter}`;
|
||||
images.push({ src, alt });
|
||||
images.push({ src, alt, blockIndex: -1 });
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
const htmlLines = html.split('\n');
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const placeholder = `WBIMGPH_${i + 1}`;
|
||||
for (let lineIndex = 0; lineIndex < htmlLines.length; lineIndex++) {
|
||||
const regex = new RegExp(`\\b${placeholder}\\b`);
|
||||
if (regex.test(htmlLines[lineIndex]!)) {
|
||||
images[i]!.blockIndex = lineIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const contentImages: ImageInfo[] = [];
|
||||
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i]!;
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||
|
||||
contentImages.push({
|
||||
placeholder: `WBIMGPH_${i + 1}`,
|
||||
localPath,
|
||||
originalPath: img.src,
|
||||
alt: img.alt,
|
||||
blockIndex: i,
|
||||
blockIndex: img.blockIndex,
|
||||
});
|
||||
}
|
||||
|
||||
const finalHtml = html.replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
let resolvedCoverImage: string | null = null;
|
||||
if (coverImagePath) {
|
||||
resolvedCoverImage = await resolveImagePath(coverImagePath, baseDir, tempDir);
|
||||
@@ -292,7 +377,7 @@ export async function parseMarkdown(
|
||||
shortSummary,
|
||||
coverImage: resolvedCoverImage,
|
||||
contentImages,
|
||||
html: html.replace(/\n{3,}/g, '\n\n').trim(),
|
||||
html: finalHtml,
|
||||
totalBlocks,
|
||||
};
|
||||
}
|
||||
@@ -309,6 +394,8 @@ Options:
|
||||
--title <title> Override title
|
||||
--cover <image> Override cover image
|
||||
--output <json|html> Output format (default: json)
|
||||
--html-only Output only the HTML content
|
||||
--save-html <path> Save HTML to file
|
||||
--help Show this help
|
||||
`);
|
||||
process.exit(0);
|
||||
@@ -318,6 +405,8 @@ Options:
|
||||
let title: string | undefined;
|
||||
let coverImage: string | undefined;
|
||||
let outputFormat: 'json' | 'html' = 'json';
|
||||
let htmlOnly = false;
|
||||
let saveHtmlPath: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
@@ -327,6 +416,10 @@ Options:
|
||||
coverImage = args[++i];
|
||||
} else if (arg === '--output' && args[i + 1]) {
|
||||
outputFormat = args[++i] as 'json' | 'html';
|
||||
} else if (arg === '--html-only') {
|
||||
htmlOnly = true;
|
||||
} else if (arg === '--save-html' && args[i + 1]) {
|
||||
saveHtmlPath = args[++i];
|
||||
} else if (!arg.startsWith('-')) {
|
||||
markdownPath = arg;
|
||||
}
|
||||
@@ -339,7 +432,14 @@ Options:
|
||||
|
||||
const result = await parseMarkdown(markdownPath, { title, coverImage });
|
||||
|
||||
if (outputFormat === 'html') {
|
||||
if (saveHtmlPath) {
|
||||
await writeFile(saveHtmlPath, result.html, 'utf-8');
|
||||
console.error(`[md-to-html] HTML saved to: ${saveHtmlPath}`);
|
||||
}
|
||||
|
||||
if (htmlOnly) {
|
||||
console.log(result.html);
|
||||
} else if (outputFormat === 'html') {
|
||||
console.log(result.html);
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
@@ -54,10 +54,12 @@ export function findExistingChromeDebugPort(profileDir: string): number | null {
|
||||
}
|
||||
|
||||
export function getDefaultProfileDir(): string {
|
||||
const override = process.env.WEIBO_BROWSER_PROFILE_DIR?.trim();
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.WEIBO_BROWSER_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'x-browser-profile');
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
|
||||
@@ -84,8 +84,8 @@ Checks: Chrome, profile isolation, Bun, Accessibility, clipboard, paste keystrok
|
||||
| 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` |
|
||||
| Profile dir | Shared profile at `baoyu-skills/chrome-profile` (see CLAUDE.md Chrome Profile section) |
|
||||
| Bun runtime | `brew install oven-sh/bun/bun` (macOS) or `npm install -g bun` |
|
||||
| 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 |
|
||||
|
||||
@@ -182,7 +182,7 @@ async function checkBun(): Promise<void> {
|
||||
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');
|
||||
log('Bun runtime', false, 'Cannot run bun. Install: brew install oven-sh/bun/bun (macOS) or npm install -g bun');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
@@ -106,18 +105,25 @@ function extractTitleFromMarkdown(markdown: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function downloadFile(url: string, destPath: string): Promise<void> {
|
||||
function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
if (!url.startsWith('https://')) {
|
||||
reject(new Error(`Refusing non-HTTPS download: ${url}`));
|
||||
return;
|
||||
}
|
||||
if (maxRedirects <= 0) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const file = fs.createWriteStream(destPath);
|
||||
|
||||
const request = protocol.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
|
||||
const request = https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
file.close();
|
||||
fs.unlinkSync(destPath);
|
||||
downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
|
||||
downloadFile(redirectUrl, destPath, maxRedirects - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -155,7 +161,11 @@ function getImageExtension(urlOrPath: string): string {
|
||||
}
|
||||
|
||||
async function resolveImagePath(imagePath: string, baseDir: string, tempDir: string): Promise<string> {
|
||||
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||
if (imagePath.startsWith('http://')) {
|
||||
console.error(`[md-to-html] Skipping non-HTTPS image: ${imagePath}`);
|
||||
return '';
|
||||
}
|
||||
if (imagePath.startsWith('https://')) {
|
||||
const hash = createHash('md5').update(imagePath).digest('hex').slice(0, 8);
|
||||
const ext = getImageExtension(imagePath);
|
||||
const localPath = path.join(tempDir, `remote_${hash}.${ext}`);
|
||||
|
||||
@@ -81,11 +81,14 @@ function getWslWindowsHome(): string | null {
|
||||
}
|
||||
|
||||
export function getDefaultProfileDir(): string {
|
||||
const override = process.env.X_BROWSER_PROFILE_DIR?.trim();
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.X_BROWSER_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const home = getWslWindowsHome() ?? os.homedir();
|
||||
const base = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
||||
return path.join(base, 'x-browser-profile');
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, '.local', 'share', 'baoyu-skills', 'chrome-profile');
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
|
||||
@@ -23,7 +23,7 @@ export function resolveUrlToMarkdownDataDir(): string {
|
||||
}
|
||||
|
||||
export function resolveUrlToMarkdownChromeProfileDir(): string {
|
||||
const override = process.env.URL_CHROME_PROFILE_DIR?.trim();
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.URL_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
return path.join(resolveUserDataRoot(), APP_DATA_DIR, URL_TO_MARKDOWN_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveUserDataRoot(), APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user