Compare commits

...

14 Commits

Author SHA1 Message Date
Jim Liu 宝玉 dad8f3a800 chore: release v1.79.2 2026-03-23 22:39:14 -05:00
Jim Liu 宝玉 35298d7c9d fix(baoyu-post-to-weibo): add no-theme rule for article markdown-to-HTML conversion 2026-03-23 22:38:30 -05:00
Jim Liu 宝玉 f22374ab62 fix(baoyu-cover-image): simplify reference image handling based on model capability 2026-03-23 22:38:27 -05:00
Jim Liu 宝玉 d4e80b1bc3 Fix Node-compatible parser tests (#107)
* Fix Node-compatible parser tests

* Add parser test dependencies to root test env
2026-03-23 15:30:42 -05:00
Jim Liu 宝玉 a5761dc71a chore: release v1.79.1 2026-03-23 12:02:44 -05:00
Jim Liu 宝玉 a5189dff37 fix(baoyu-xhs-images): remove opacity from watermark prompt and fix CJK spacing 2026-03-23 12:01:03 -05:00
Jim Liu 宝玉 39fe872bf3 fix(baoyu-comic): fix Doraemon naming spacing and remove opacity from watermark prompt 2026-03-23 12:01:00 -05:00
Jim Liu 宝玉 52813504f8 fix(baoyu-article-illustrator): remove opacity parameter from watermark prompt 2026-03-23 12:00:53 -05:00
Jim Liu 宝玉 a4d4108cd1 docs(project): update documentation to reflect single-plugin architecture 2026-03-23 12:00:38 -05:00
Yizhou Qian 钱亦舟 d7e763f1f5 fix: consolidate to single plugin to prevent duplicate skill registration (#106)
Merge the three plugins (content-skills, ai-generation-skills,
utility-skills) into one plugin entry. Since all three shared the same
source ("./"), Claude Code cached every skill three times. A single
plugin with one source keeps the flat skills/ layout while ensuring
each skill is registered exactly once.
2026-03-23 09:46:50 -05:00
Jim Liu 宝玉 097c09c59b chore: release v1.79.0 2026-03-22 15:42:33 -05:00
Jim Liu 宝玉 e4cd8bfefc feat(baoyu-post-to-wechat): improve credential loading with multi-source resolution and diagnostics 2026-03-22 15:42:08 -05:00
Jim Liu 宝玉 3dc5f2e06f chore: release v1.78.0 2026-03-22 15:19:29 -05:00
Jim Liu 宝玉 e5d6c8ec68 feat(baoyu-url-to-markdown): add URL-specific parser layer for X/Twitter and archive.ph
- New parsers/ module with pluggable rule system for site-specific HTML extraction
- X status parser: extract tweet text, media, quotes, author from data-testid elements
- X article parser: extract long-form article content with inline media
- archive.ph parser: restore original URL and prefer #CONTENT container
- Improved slug generation with stop words and content-aware slugs
- Output path uses subdirectory structure (domain/slug/slug.md)
- Fix: preserve anchor elements containing media in legacy converter
- Fix: smarter title deduplication in markdown document builder
2026-03-22 15:18:46 -05:00
31 changed files with 1453 additions and 144 deletions
+14 -30
View File
@@ -6,48 +6,32 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.77.0"
"version": "1.79.2"
},
"plugins": [
{
"name": "content-skills",
"description": "Content generation and publishing skills",
"name": "baoyu-skills",
"description": "Content generation, AI backends, and utility tools for daily work efficiency",
"source": "./",
"strict": true,
"skills": [
"./skills/baoyu-xhs-images",
"./skills/baoyu-post-to-x",
"./skills/baoyu-post-to-wechat",
"./skills/baoyu-post-to-weibo",
"./skills/baoyu-article-illustrator",
"./skills/baoyu-cover-image",
"./skills/baoyu-slide-deck",
"./skills/baoyu-comic",
"./skills/baoyu-infographic"
]
},
{
"name": "ai-generation-skills",
"description": "AI-powered generation backends",
"source": "./",
"strict": true,
"skills": [
"./skills/baoyu-danger-gemini-web",
"./skills/baoyu-image-gen"
]
},
{
"name": "utility-skills",
"description": "Utility tools for content processing",
"source": "./",
"strict": true,
"skills": [
"./skills/baoyu-danger-x-to-markdown",
"./skills/baoyu-compress-image",
"./skills/baoyu-url-to-markdown",
"./skills/baoyu-cover-image",
"./skills/baoyu-danger-gemini-web",
"./skills/baoyu-danger-x-to-markdown",
"./skills/baoyu-format-markdown",
"./skills/baoyu-image-gen",
"./skills/baoyu-infographic",
"./skills/baoyu-markdown-to-html",
"./skills/baoyu-post-to-weibo",
"./skills/baoyu-post-to-wechat",
"./skills/baoyu-post-to-x",
"./skills/baoyu-slide-deck",
"./skills/baoyu-translate",
"./skills/baoyu-url-to-markdown",
"./skills/baoyu-xhs-images",
"./skills/baoyu-youtube-transcript"
]
}
+35
View File
@@ -2,6 +2,41 @@
English | [中文](./CHANGELOG.zh.md)
## 1.79.2 - 2026-03-23
### Fixes
- `baoyu-cover-image`: simplify reference image handling — use `--ref` when model supports it, only create description files for models without reference image support
- `baoyu-post-to-weibo`: add no-theme rule for article markdown-to-HTML conversion
### Tests
- Fix Node-compatible parser tests and add parser test dependencies
## 1.79.1 - 2026-03-23
### Fixes
- Consolidate to single plugin to prevent duplicate skill registration (by @TyrealQ)
- `baoyu-article-illustrator`: remove opacity parameter from watermark prompt
- `baoyu-comic`: fix Doraemon naming spacing and remove opacity from watermark prompt
- `baoyu-xhs-images`: remove opacity from watermark prompt and fix CJK spacing
### Documentation
- Update project documentation to reflect single-plugin architecture
## 1.79.0 - 2026-03-22
### Features
- `baoyu-post-to-wechat`: improve credential loading with multi-source resolution, priority ordering, and diagnostics for skipped incomplete sources
## 1.78.0 - 2026-03-22
### Features
- `baoyu-url-to-markdown`: add URL-specific parser layer for X/Twitter and archive.ph sites
- `baoyu-url-to-markdown`: improved slug generation with stop words removal and subdirectory output structure
### Fixes
- `baoyu-url-to-markdown`: preserve anchor elements containing media in legacy converter
- `baoyu-url-to-markdown`: smarter title deduplication to avoid redundant headings
## 1.77.0 - 2026-03-22
### Features
+35
View File
@@ -2,6 +2,41 @@
[English](./CHANGELOG.md) | 中文
## 1.79.2 - 2026-03-23
### 修复
- `baoyu-cover-image`:简化参考图片处理流程 — 模型支持 `--ref` 时直接传递,仅在模型不支持参考图时创建描述文件
- `baoyu-post-to-weibo`:文章 Markdown 转 HTML 时不传递 --theme 参数
### 测试
- 修复 Node 兼容的解析器测试,添加解析器测试依赖
## 1.79.1 - 2026-03-23
### 修复
- 合并为单一插件,防止 skill 重复注册 (by @TyrealQ)
- `baoyu-article-illustrator`:移除水印提示词中的不透明度参数
- `baoyu-comic`:修正哆啦 A 梦命名间距,移除水印不透明度参数
- `baoyu-xhs-images`:移除水印不透明度参数,修正中英文间距
### 文档
- 更新项目文档以反映单一插件架构
## 1.79.0 - 2026-03-22
### 新功能
- `baoyu-post-to-wechat`:改进凭据加载机制,支持多来源优先级解析,并提供不完整凭据来源的诊断信息
## 1.78.0 - 2026-03-22
### 新功能
- `baoyu-url-to-markdown`:新增 URL 专用解析层,支持 X/Twitter 和 archive.ph 站点的定制化 HTML 提取
- `baoyu-url-to-markdown`:改进 slug 生成算法,去除停用词并采用子目录输出结构
### 修复
- `baoyu-url-to-markdown`:旧版转换器保留包含媒体元素的锚标签
- `baoyu-url-to-markdown`:更智能的标题去重,避免重复添加标题
## 1.77.0 - 2026-03-22
### 新功能
+7 -7
View File
@@ -1,16 +1,16 @@
# CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.77.0**.
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.79.2**.
## Architecture
Skills organized into three categories in `.claude-plugin/marketplace.json` (defines plugin metadata, version, and skill paths):
Skills are exposed through the single `baoyu-skills` plugin in `.claude-plugin/marketplace.json` (which defines plugin metadata, version, and skill paths). The repo docs still group them into three logical areas:
| Category | Description |
|----------|-------------|
| `content-skills` | Generate or publish content (images, slides, comics, posts) |
| `ai-generation-skills` | AI generation backends |
| `utility-skills` | Content processing (conversion, compression, translation) |
| Group | Description |
|-------|-------------|
| Content Skills | Generate or publish content (images, slides, comics, posts) |
| AI Generation Skills | AI generation backends |
| Utility Skills | Content processing (conversion, compression, translation) |
Each skill contains `SKILL.md` (YAML front matter + docs), optional `scripts/`, `references/`, `prompts/`.
+9 -11
View File
@@ -52,16 +52,14 @@ Run the following command in Claude Code:
1. Select **Browse and install plugins**
2. Select **baoyu-skills**
3. Select the plugin(s) you want to install
3. Select the **baoyu-skills** plugin
4. Select **Install now**
**Option 2: Direct Install**
```bash
# Install specific plugin
/plugin install content-skills@baoyu-skills
/plugin install ai-generation-skills@baoyu-skills
/plugin install utility-skills@baoyu-skills
# Install the marketplace's single plugin
/plugin install baoyu-skills@baoyu-skills
```
**Option 3: Ask the Agent**
@@ -70,13 +68,13 @@ Simply tell Claude Code:
> Please install Skills from github.com/JimLiu/baoyu-skills
### Available Plugins
### Available Plugin
| 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), [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 | [youtube-transcript](#baoyu-youtube-transcript), [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), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
The marketplace now exposes a single plugin so each skill is registered exactly once.
| Plugin | Description | Includes |
|--------|-------------|----------|
| **baoyu-skills** | Content generation, AI backends, and utility tools for daily work efficiency | All skills in this repository, organized below as Content Skills, AI Generation Skills, and Utility Skills |
## Update Skills
+7 -9
View File
@@ -52,16 +52,14 @@ clawhub install baoyu-markdown-to-html
1. 选择 **Browse and install plugins**
2. 选择 **baoyu-skills**
3. 选择要安装的插件
3. 选择 **baoyu-skills** 插件
4. 选择 **Install now**
**方式二:直接安装**
```bash
# 安装指定插件
/plugin install content-skills@baoyu-skills
/plugin install ai-generation-skills@baoyu-skills
/plugin install utility-skills@baoyu-skills
# 安装 marketplace 中唯一的插件
/plugin install baoyu-skills@baoyu-skills
```
**方式三:告诉 Agent**
@@ -72,11 +70,11 @@ clawhub install baoyu-markdown-to-html
### 可用插件
| 插件 | 说明 | 包含技能 |
现在 marketplace 只暴露一个插件,这样每个 skill 只会注册一次。
| 插件 | 说明 | 包含内容 |
|------|------|----------|
| **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** | 内容处理工具 | [youtube-transcript](#baoyu-youtube-transcript), [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), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
| **baoyu-skills** | 提供内容生成、AI 后端和日常效率工具技能 | 仓库中的全部 skills,仍按下方的内容技能、AI 生成技能、工具技能三个分类展示 |
## 更新技能
+11 -9
View File
@@ -34,20 +34,22 @@ metadata:
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
2. Add TypeScript in `skills/baoyu-<name>/scripts/` (if applicable)
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
4. Register in `marketplace.json` under appropriate category
4. Register the skill in `.claude-plugin/marketplace.json` under the `baoyu-skills` plugin entry
5. Add Script Directory section to SKILL.md if skill has scripts
6. Add openclaw metadata to frontmatter
## Category Selection
## Skill Grouping
| If your skill... | Use category |
|------------------|--------------|
| Generates visual content (images, slides, comics) | `content-skills` |
| Publishes to platforms (X, WeChat, Weibo) | `content-skills` |
| Provides AI generation backend | `ai-generation-skills` |
| Converts or processes content | `utility-skills` |
All skills are registered under the single `baoyu-skills` plugin. Use these logical groups when deciding where the skill should appear in the docs:
New category: add plugin object to `marketplace.json` with `name`, `description`, `skills[]`.
| If your skill... | Use group |
|------------------|-----------|
| Generates visual content (images, slides, comics) | Content Skills |
| Publishes to platforms (X, WeChat, Weibo) | Content Skills |
| Provides AI generation backend | AI Generation Skills |
| Converts or processes content | Utility Skills |
If you add a new logical group, update the docs that present grouped skills, but keep the skill registered under the single `baoyu-skills` plugin entry.
## Writing Descriptions
+105 -1
View File
@@ -9,7 +9,11 @@
"packages/*"
],
"devDependencies": {
"tsx": "^4.20.5"
"@mozilla/readability": "^0.6.0",
"linkedom": "^0.18.12",
"tsx": "^4.20.5",
"turndown": "^7.2.2",
"turndown-plugin-gfm": "^1.0.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -454,6 +458,23 @@
"node": ">=18"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@mozilla/readability": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
"integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -615,6 +636,13 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -896,6 +924,13 @@
"node": ">=12.0.0"
}
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
@@ -984,6 +1019,51 @@
"node": ">=18.17"
}
},
"node_modules/linkedom": {
"version": "0.18.12",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz",
"integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"css-select": "^5.1.0",
"cssom": "^0.5.0",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.0.0",
"uhyphen": "^0.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"canvas": ">= 2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/linkedom/node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -1768,6 +1848,30 @@
"fsevents": "~2.3.3"
}
},
"node_modules/turndown": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/turndown-plugin-gfm": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
"dev": true,
"license": "MIT"
},
"node_modules/uhyphen": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"dev": true,
"license": "ISC"
},
"node_modules/undici": {
"version": "6.24.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz",
+4
View File
@@ -10,6 +10,10 @@
"test:coverage": "node --import tsx --experimental-test-coverage --test"
},
"devDependencies": {
"@mozilla/readability": "^0.6.0",
"linkedom": "^0.18.12",
"turndown": "^7.2.2",
"turndown-plugin-gfm": "^1.0.2",
"tsx": "^4.20.5"
}
}
@@ -280,5 +280,5 @@ TEXTURE: Halftone transitions between sides
If watermark enabled in preferences, append:
```
Include a subtle watermark "[content]" positioned at [position] with approximately [opacity*100]% visibility.
Include a subtle watermark "[content]" positioned at [position].
```
+4 -5
View File
@@ -278,7 +278,7 @@ Create storyboard and character definitions using the confirmed style from Step
| Role | Character | Visual Description |
|------|-----------|-------------------|
| Student | 大雄 (Nobita) | Japanese boy, 10yo, round glasses, black hair parted in middle, yellow shirt, navy shorts |
| Mentor | 哆啦A梦 (Doraemon) | Round blue robot cat, big white eyes, red nose, whiskers, white belly with 4D pocket, golden bell, no ears |
| Mentor | 哆啦 A 梦 (Doraemon) | Round blue robot cat, big white eyes, red nose, whiskers, white belly with 4D pocket, golden bell, no ears |
| Challenge | 胖虎 (Gian) | Stocky boy, rough features, small eyes, orange shirt |
| Support | 静香 (Shizuka) | Cute girl, black short hair, pink dress, gentle expression |
@@ -359,8 +359,7 @@ Art: [art style] | Tone: [tone] | Layout: [layout type]
**Watermark Application** (if enabled in preferences):
Add to each prompt:
```
Include a subtle watermark "[content]" positioned at [position]
with approximately [opacity*100]% visibility. The watermark should
Include a subtle watermark "[content]" positioned at [position]. The watermark should
be legible but not distracting from the comic panels and storytelling.
Ensure watermark does not overlap speech bubbles or key action.
```
@@ -452,8 +451,8 @@ When skill does NOT support reference images, create combined prompt files:
## Character Reference (maintain consistency)
[Copy relevant sections from characters/characters.md here]
- 大雄: Japanese boy, round glasses, yellow shirt, navy shorts...
- 哆啦A梦: Round blue robot cat, white belly, red nose, golden bell...
- 大雄Japanese boy, round glasses, yellow shirt, navy shorts...
- 哆啦 A 梦:Round blue robot cat, white belly, red nose, golden bell...
## Page Content
[Original page prompt here]
+4 -5
View File
@@ -162,15 +162,14 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
5. **Detect language**: Compare source, user input, EXTEND.md preference
6. **Determine output directory**: Per File Structure rules
**⚠️ People in Reference Images — MUST follow all 3 rules:**
**⚠️ People in Reference Images:**
If reference images contain **people** who should appear in the cover:
1. **`usage: direct`** — MUST set in refs description file. NEVER use `style` or `palette` when people need to appear
2. **Per-character description** — MUST describe each person's distinctive features (hair, glasses, skin tone, clothing) in `refs/ref-NN-{slug}.md`. Vague descriptions like "a man" will fail
3. **`--ref` flag** — MUST pass reference image via `--ref` in Step 4 so the model sees actual faces
- **Model supports `--ref`** (default): Copy image to `refs/`, pass via `--ref` at generation. No description file needed — the model sees the face directly.
- **Model does NOT support `--ref`** (Jimeng, Seedream 3.0): Create `refs/ref-NN-{slug}.md` with per-character description (hair, glasses, skin tone, clothing). Embed as MUST/REQUIRED instructions in prompt text.
See [reference-images.md § Character Analysis](references/workflow/reference-images.md) for description format.
See [reference-images.md](references/workflow/reference-images.md) for full decision table.
### Step 2: Confirm Options ⚠️
@@ -16,17 +16,24 @@ Guide for processing user-provided reference images in cover generation.
**If user provides file path**:
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
2. Create description: `refs/ref-NN-{slug}.md`
3. Verify files exist before proceeding
2. **Only** create description file `refs/ref-NN-{slug}.md` when model does NOT support `--ref` (see below)
3. Verify image file exists before proceeding
**Description File Format**:
**When to create description file**:
| Situation | Action |
|-----------|--------|
| Model supports `--ref` (Google, OpenAI, OpenRouter, Replicate, Seedream 4.0+) | Copy image only. **No description file needed.** Pass via `--ref` at generation. |
| Model does NOT support `--ref` (Jimeng, Seedream 3.0) | Copy image + create description file. Embed description in prompt text. |
**Description File Format** (only when needed):
```yaml
---
ref_id: NN
filename: ref-NN-{slug}.{ext}
usage: direct | style | palette
---
[User's description or auto-generated description]
[Character or style description to embed in prompt]
```
| Usage | When to Use |
@@ -694,6 +694,10 @@ async function main(): Promise<void> {
}
const creds = loadCredentials(resolved);
for (const skippedSource of creds.skippedSources) {
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
}
console.error(`[wechat-api] Credentials source: ${creds.source}`);
console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import test, { type TestContext } from "node:test";
import { loadCredentials } from "./wechat-extend-config.ts";
function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd();
process.chdir(cwd);
t.after(() => {
process.chdir(previous);
});
}
function useHome(t: TestContext, home: string): void {
const previous = process.env.HOME;
process.env.HOME = home;
t.after(() => {
if (previous === undefined) {
delete process.env.HOME;
return;
}
process.env.HOME = previous;
});
}
function useWechatEnv(
t: TestContext,
values: Partial<Record<"WECHAT_APP_ID" | "WECHAT_APP_SECRET", string | undefined>>,
): void {
const previous = {
WECHAT_APP_ID: process.env.WECHAT_APP_ID,
WECHAT_APP_SECRET: process.env.WECHAT_APP_SECRET,
};
if (values.WECHAT_APP_ID === undefined) {
delete process.env.WECHAT_APP_ID;
} else {
process.env.WECHAT_APP_ID = values.WECHAT_APP_ID;
}
if (values.WECHAT_APP_SECRET === undefined) {
delete process.env.WECHAT_APP_SECRET;
} else {
process.env.WECHAT_APP_SECRET = values.WECHAT_APP_SECRET;
}
t.after(() => {
if (previous.WECHAT_APP_ID === undefined) {
delete process.env.WECHAT_APP_ID;
} else {
process.env.WECHAT_APP_ID = previous.WECHAT_APP_ID;
}
if (previous.WECHAT_APP_SECRET === undefined) {
delete process.env.WECHAT_APP_SECRET;
} else {
process.env.WECHAT_APP_SECRET = previous.WECHAT_APP_SECRET;
}
});
}
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function writeEnvFile(root: string, content: string): Promise<void> {
const envPath = path.join(root, ".baoyu-skills", ".env");
await fs.mkdir(path.dirname(envPath), { recursive: true });
await fs.writeFile(envPath, content);
}
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
const homeRoot = await makeTempDir("wechat-creds-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useWechatEnv(t, {
WECHAT_APP_ID: undefined,
WECHAT_APP_SECRET: "stale-secret-from-process-env",
});
await writeEnvFile(cwdRoot, "WECHAT_APP_ID=cwd-app-id\nWECHAT_APP_SECRET=cwd-app-secret\n");
await writeEnvFile(homeRoot, "WECHAT_APP_ID=home-app-id\nWECHAT_APP_SECRET=home-app-secret\n");
const credentials = loadCredentials();
assert.equal(credentials.appId, "cwd-app-id");
assert.equal(credentials.appSecret, "cwd-app-secret");
assert.equal(credentials.source, "<cwd>/.baoyu-skills/.env");
assert.deepEqual(credentials.skippedSources, [
"process.env missing WECHAT_APP_ID",
]);
});
test("loadCredentials prefers a complete process.env pair over lower-priority files", async (t) => {
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
const homeRoot = await makeTempDir("wechat-creds-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useWechatEnv(t, {
WECHAT_APP_ID: "env-app-id",
WECHAT_APP_SECRET: "env-app-secret",
});
await writeEnvFile(cwdRoot, "WECHAT_APP_ID=cwd-app-id\nWECHAT_APP_SECRET=cwd-app-secret\n");
await writeEnvFile(homeRoot, "WECHAT_APP_ID=home-app-id\nWECHAT_APP_SECRET=home-app-secret\n");
const credentials = loadCredentials();
assert.equal(credentials.appId, "env-app-id");
assert.equal(credentials.appSecret, "env-app-secret");
assert.equal(credentials.source, "process.env");
assert.deepEqual(credentials.skippedSources, []);
});
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
const homeRoot = await makeTempDir("wechat-creds-home-");
useCwd(t, cwdRoot);
useHome(t, homeRoot);
useWechatEnv(t, {
WECHAT_APP_ID: "env-app-id",
WECHAT_APP_SECRET: undefined,
});
await writeEnvFile(cwdRoot, "WECHAT_APP_SECRET=cwd-app-secret\n");
assert.throws(
() => loadCredentials(),
/Incomplete credential sources skipped:\n- process\.env missing WECHAT_APP_SECRET\n- <cwd>\/\.baoyu-skills\/\.env missing WECHAT_APP_ID/,
);
});
@@ -196,48 +196,116 @@ function aliasToEnvKey(alias: string): string {
return alias.toUpperCase().replace(/-/g, "_");
}
export function loadCredentials(account?: ResolvedAccount): { appId: string; appSecret: string } {
if (account?.app_id && account?.app_secret) {
return { appId: account.app_id, appSecret: account.app_secret };
interface CredentialSource {
name: string;
appIdKey: string;
appSecretKey: string;
appId?: string;
appSecret?: string;
}
export interface LoadedCredentials {
appId: string;
appSecret: string;
source: string;
skippedSources: string[];
}
function normalizeCredentialValue(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function describeMissingKeys(source: CredentialSource): string {
const missingKeys: string[] = [];
if (!source.appId) missingKeys.push(source.appIdKey);
if (!source.appSecret) missingKeys.push(source.appSecretKey);
return `${source.name} missing ${missingKeys.join(" and ")}`;
}
function buildCredentialSource(
name: string,
values: Record<string, string | undefined>,
appIdKey: string,
appSecretKey: string,
): CredentialSource {
return {
name,
appIdKey,
appSecretKey,
appId: normalizeCredentialValue(values[appIdKey]),
appSecret: normalizeCredentialValue(values[appSecretKey]),
};
}
function resolveCredentialSource(
sources: CredentialSource[],
account?: ResolvedAccount,
): LoadedCredentials {
const skippedSources: string[] = [];
for (const source of sources) {
if (source.appId && source.appSecret) {
return {
appId: source.appId,
appSecret: source.appSecret,
source: source.name,
skippedSources,
};
}
if (source.appId || source.appSecret) {
skippedSources.push(describeMissingKeys(source));
}
}
const hint = account?.alias ? ` (account: ${account.alias})` : "";
const partialHint = skippedSources.length > 0
? `\nIncomplete credential sources skipped:\n- ${skippedSources.join("\n- ")}`
: "";
throw new Error(
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file." +
partialHint
);
}
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
const cwdEnv = loadEnvFile(cwdEnvPath);
const homeEnv = loadEnvFile(homeEnvPath);
const sources: CredentialSource[] = [];
if (account?.app_id || account?.app_secret) {
sources.push({
name: account.alias ? `EXTEND.md account "${account.alias}"` : "EXTEND.md account config",
appIdKey: "app_id",
appSecretKey: "app_secret",
appId: normalizeCredentialValue(account.app_id),
appSecret: normalizeCredentialValue(account.app_secret),
});
}
const prefix = account?.alias ? `WECHAT_${aliasToEnvKey(account.alias)}_` : "";
let appId = "";
let appSecret = "";
if (prefix) {
appId = process.env[`${prefix}APP_ID`]
|| cwdEnv[`${prefix}APP_ID`]
|| homeEnv[`${prefix}APP_ID`]
|| "";
appSecret = process.env[`${prefix}APP_SECRET`]
|| cwdEnv[`${prefix}APP_SECRET`]
|| homeEnv[`${prefix}APP_SECRET`]
|| "";
}
if (!appId) {
appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID || "";
}
if (!appSecret) {
appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET || "";
}
if (!appId || !appSecret) {
const hint = account?.alias ? ` (account: ${account.alias})` : "";
throw new Error(
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file."
const prefixedKeyLabel = `${prefix}APP_ID/${prefix}APP_SECRET`;
sources.push(
buildCredentialSource(`process.env (${prefixedKeyLabel})`, process.env, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
buildCredentialSource(`<cwd>/.baoyu-skills/.env (${prefixedKeyLabel})`, cwdEnv, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
buildCredentialSource(`~/.baoyu-skills/.env (${prefixedKeyLabel})`, homeEnv, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
);
}
return { appId, appSecret };
sources.push(
buildCredentialSource("process.env", process.env, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
buildCredentialSource("<cwd>/.baoyu-skills/.env", cwdEnv, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
buildCredentialSource("~/.baoyu-skills/.env", homeEnv, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
);
return resolveCredentialSource(sources, account);
}
export function listAccounts(config: WechatExtendConfig): string[] {
+2
View File
@@ -123,6 +123,8 @@ ${BUN_X} {baseDir}/scripts/weibo-article.ts article.md --cover ./cover.jpg
- Title: 32 characters max (truncated with warning if longer)
- Summary/导语: 44 characters max (auto-regenerated from content if longer)
**Markdown-to-HTML**: Do NOT pass any `--theme` parameter when converting markdown to HTML. Use the default theme (no theme argument).
**Article Workflow**:
1. Opens `https://card.weibo.com/article/v3/editor`
2. Clicks "写文章" button, waits for editor to become editable
+14 -6
View File
@@ -1,7 +1,7 @@
---
name: baoyu-url-to-markdown
description: Fetch any URL and convert to markdown using Chrome CDP. Saves the rendered HTML snapshot alongside the markdown, uses an upgraded Defuddle pipeline with better web-component handling and YouTube transcript extraction, and automatically falls back to the pre-Defuddle HTML-to-Markdown pipeline when needed. If local browser capture fails entirely, it can fall back to the hosted defuddle.md API. Supports two modes - auto-capture on page load, or wait for user signal (for pages requiring login). Use when user wants to save a webpage as markdown.
version: 1.58.1
version: 1.59.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-url-to-markdown
@@ -30,6 +30,9 @@ Fetches any URL via Chrome CDP, saves the rendered HTML snapshot, and converts i
|--------|---------|
| `scripts/main.ts` | CLI entry point for URL fetching |
| `scripts/html-to-markdown.ts` | Markdown conversion entry point and converter selection |
| `scripts/parsers/index.ts` | Unified parser entry: dispatches URL-specific rules before generic converters |
| `scripts/parsers/types.ts` | Unified parser interface shared by all rule files |
| `scripts/parsers/rules/*.ts` | One file per URL rule, for example X status and X article |
| `scripts/defuddle-converter.ts` | Defuddle-based conversion |
| `scripts/legacy-converter.ts` | Pre-Defuddle legacy extraction and markdown conversion |
| `scripts/markdown-conversion-shared.ts` | Shared metadata parsing and markdown document helpers |
@@ -115,10 +118,13 @@ Full reference: [references/config/first-time-setup.md](references/config/first-
## Features
- Chrome CDP for full JavaScript rendering
- URL-specific parser layer for sites that need custom HTML rules before generic extraction
- Two capture modes: auto or wait-for-user
- Save rendered HTML as a sibling `-captured.html` file
- Clean markdown output with metadata
- Upgraded Defuddle-first markdown conversion with automatic fallback to the pre-Defuddle extractor from git history
- X/Twitter pages can use HTML-specific parsing for Tweets and Articles, which improves title/body/media extraction on `x.com` / `twitter.com`
- `archive.ph` / related archive mirrors can restore the original URL from `input[name=q]` and prefer `#CONTENT` before falling back to the page body
- Materializes shadow DOM content before conversion so web-component pages survive serialization better
- YouTube pages can include transcript/caption text in the markdown when YouTube exposes a caption track
- If local browser capture fails completely, can fall back to `defuddle.md/<url>` and still save markdown
@@ -201,14 +207,16 @@ When `--download-media` is enabled:
Conversion order:
1. Try Defuddle first
2. For rich pages such as YouTube, prefer Defuddle's extractor-specific output (including transcripts when available) instead of replacing it with the legacy pipeline
3. If Defuddle throws, cannot load, returns obviously incomplete markdown, or captures lower-quality content than the legacy pipeline, automatically fall back to the pre-Defuddle extractor
4. If the entire local browser capture flow fails before markdown can be produced, try the hosted `https://defuddle.md/<url>` API and save its markdown output directly
5. The legacy fallback path uses the older Readability/selector/Next.js-data based HTML-to-Markdown implementation recovered from git history
1. Try the URL-specific parser layer first when a site rule matches
2. If no specialized parser matches, try Defuddle
3. For rich pages such as YouTube, prefer Defuddle's extractor-specific output (including transcripts when available) instead of replacing it with the legacy pipeline
4. If Defuddle throws, cannot load, returns obviously incomplete markdown, or captures lower-quality content than the legacy pipeline, automatically fall back to the pre-Defuddle extractor
5. If the entire local browser capture flow fails before markdown can be produced, try the hosted `https://defuddle.md/<url>` API and save its markdown output directly
6. The legacy fallback path uses the older Readability/selector/Next.js-data based HTML-to-Markdown implementation recovered from git history
CLI output will show:
- `Converter: parser:...` when a URL-specific parser succeeded
- `Converter: defuddle` when Defuddle succeeds
- `Converter: legacy:...` plus `Fallback used: ...` when fallback was needed
- `Converter: defuddle-api` when local browser capture failed and the hosted API was used instead
@@ -12,6 +12,7 @@ import {
scoreMarkdownQuality,
shouldCompareWithLegacy,
} from "./legacy-converter.js";
import { tryUrlRuleParsers } from "./parsers/index.js";
export type { ConversionResult, PageMetadata };
export { createMarkdownDocument, formatMetadataYaml };
@@ -105,6 +106,11 @@ export async function extractContent(html: string, url: string): Promise<Convers
const capturedAt = new Date().toISOString();
const baseMetadata = extractMetadataFromHtml(html, url, capturedAt);
const specializedResult = tryUrlRuleParsers(html, url, baseMetadata);
if (specializedResult) {
return specializedResult;
}
const defuddleResult = await tryDefuddleConversion(html, url, baseMetadata);
if (defuddleResult.ok) {
if (shouldPreferDefuddle(defuddleResult.result)) {
@@ -521,14 +521,18 @@ turndown.addRule("collapseFigure", {
turndown.addRule("dropInvisibleAnchors", {
filter(node) {
return node.nodeName === "A" && !(node as Element).textContent?.trim();
return (
node.nodeName === "A" &&
!(node as Element).textContent?.trim() &&
!(node as Element).querySelector("img, video, picture, source")
);
},
replacement() {
return "";
},
});
function convertHtmlToMarkdown(html: string): string {
export function convertHtmlFragmentToMarkdown(html: string): string {
if (!html || !html.trim()) return "";
try {
@@ -609,7 +613,7 @@ export function shouldCompareWithLegacy(markdown: string): boolean {
export function convertWithLegacyExtractor(html: string, baseMetadata: PageMetadata): ConversionResult {
const extracted = extractFromHtml(html);
let markdown = extracted?.html ? convertHtmlToMarkdown(extracted.html) : "";
let markdown = extracted?.html ? convertHtmlFragmentToMarkdown(extracted.html) : "";
if (!markdown.trim()) {
markdown = extracted?.textContent?.trim() || fallbackPlainText(html);
}
+70 -16
View File
@@ -52,15 +52,71 @@ function parseArgs(argv: string[]): Args {
return args;
}
function generateSlug(title: string, url: string): string {
const text = title || new URL(url).pathname.replace(/\//g, "-");
return text
const SLUG_STOP_WORDS = new Set([
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did", "will", "would", "shall",
"should", "may", "might", "must", "can", "could", "to", "of", "in",
"for", "on", "with", "at", "by", "from", "as", "into", "through",
"during", "before", "after", "above", "below", "between", "out",
"off", "over", "under", "again", "further", "then", "once", "here",
"there", "when", "where", "why", "how", "all", "both", "each",
"few", "more", "most", "other", "some", "such", "no", "nor", "not",
"only", "own", "same", "so", "than", "too", "very", "just", "but",
"and", "or", "if", "this", "that", "these", "those", "it", "its",
"http", "https", "www", "com", "org", "net", "post", "article",
]);
function extractSlugFromContent(content: string): string | null {
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, "").slice(0, 1000);
const words = body
.replace(/[^\w\s-]/g, " ")
.split(/\s+/)
.filter((w) => /^[a-zA-Z]/.test(w) && w.length >= 2 && !SLUG_STOP_WORDS.has(w.toLowerCase()))
.map((w) => w.toLowerCase());
const unique: string[] = [];
const seen = new Set<string>();
for (const w of words) {
if (!seen.has(w)) {
seen.add(w);
unique.push(w);
if (unique.length >= 6) break;
}
}
return unique.length >= 2 ? unique.join("-").slice(0, 50) : null;
}
function generateSlug(title: string, url: string, content?: string): string {
const asciiWords = title
.replace(/[^\w\s]/g, " ")
.split(/\s+/)
.filter((w) => /[a-zA-Z]/.test(w) && w.length >= 2 && !SLUG_STOP_WORDS.has(w.toLowerCase()))
.map((w) => w.toLowerCase());
if (asciiWords.length >= 2) {
return asciiWords.slice(0, 6).join("-").slice(0, 50);
}
if (content) {
const contentSlug = extractSlugFromContent(content);
if (contentSlug) return contentSlug;
}
const GENERIC_PATH_SEGMENTS = new Set(["status", "article", "post", "posts", "p", "blog", "news", "articles"]);
const parsed = new URL(url);
const pathSlug = parsed.pathname
.split("/")
.filter((s) => s.length > 0 && !/^\d{10,}$/.test(s) && !GENERIC_PATH_SEGMENTS.has(s.toLowerCase()))
.join("-")
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50) || "page";
.slice(0, 40);
const prefix = asciiWords.slice(0, 2).join("-");
const combined = prefix ? `${prefix}-${pathSlug}` : pathSlug;
return combined.slice(0, 50) || "page";
}
function formatTimestamp(): string {
@@ -124,18 +180,18 @@ async function fetchDefuddleApiMarkdown(targetUrl: string): Promise<{ markdown:
};
}
async function generateOutputPath(url: string, title: string, outputDir?: string): Promise<string> {
async function generateOutputPath(url: string, title: string, outputDir?: string, content?: string): Promise<string> {
const domain = new URL(url).hostname.replace(/^www\./, "");
const slug = generateSlug(title, url);
const slug = generateSlug(title, url, content);
const dataDir = outputDir ? path.resolve(outputDir) : resolveUrlToMarkdownDataDir();
const basePath = path.join(dataDir, domain, `${slug}.md`);
const basePath = path.join(dataDir, domain, slug, `${slug}.md`);
if (!(await fileExists(basePath))) {
return basePath;
}
const timestampSlug = `${slug}-${formatTimestamp()}`;
return path.join(dataDir, domain, `${timestampSlug}.md`);
return path.join(dataDir, domain, timestampSlug, `${timestampSlug}.md`);
}
async function waitForUserSignal(): Promise<void> {
@@ -249,13 +305,12 @@ async function main(): Promise<void> {
try {
const result = await captureUrl(args);
outputPath = args.output || await generateOutputPath(args.url, result.metadata.title, args.outputDir);
document = createMarkdownDocument(result);
outputPath = args.output || await generateOutputPath(args.url, result.metadata.title, args.outputDir, document);
const outputDir = path.dirname(outputPath);
htmlSnapshotPath = deriveHtmlSnapshotPath(outputPath);
await mkdir(outputDir, { recursive: true });
await writeFile(htmlSnapshotPath, result.rawHtml, "utf-8");
document = createMarkdownDocument(result);
conversionMethod = result.conversionMethod;
fallbackReason = result.fallbackReason;
} catch (error) {
@@ -265,10 +320,9 @@ async function main(): Promise<void> {
try {
const remoteResult = await fetchDefuddleApiMarkdown(args.url);
outputPath = args.output || await generateOutputPath(args.url, remoteResult.title, args.outputDir);
await mkdir(path.dirname(outputPath), { recursive: true });
document = remoteResult.markdown;
outputPath = args.output || await generateOutputPath(args.url, remoteResult.title, args.outputDir, document);
await mkdir(path.dirname(outputPath), { recursive: true });
conversionMethod = "defuddle-api";
fallbackReason = `Local browser capture failed: ${primaryError}`;
} catch (remoteError) {
@@ -300,6 +300,24 @@ export function createMarkdownDocument(result: ConversionResult): string {
const escapedTitle = result.metadata.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const titleRegex = new RegExp(`^#\\s+${escapedTitle}\\s*(\\n|$)`, "i");
const hasTitle = titleRegex.test(result.markdown.trimStart());
const title = result.metadata.title && !hasTitle ? `\n\n# ${result.metadata.title}\n\n` : "\n\n";
const firstMeaningfulLine = result.markdown
.replace(/\r\n/g, "\n")
.split("\n")
.map((line) => line.trim())
.find((line) => line && !/^!?\[[^\]]*\]\([^)]+\)$/.test(line))
?.replace(/^>\s*/, "")
?.replace(/^#+\s+/, "")
?.trim();
const comparableTitle = result.metadata.title.toLowerCase().replace(/(?:\.{3}|…)\s*$/, "");
const comparableFirstLine = firstMeaningfulLine?.toLowerCase() ?? "";
const titleRepeatsContent =
comparableTitle !== "" &&
comparableFirstLine !== "" &&
(comparableFirstLine === comparableTitle ||
comparableFirstLine.startsWith(comparableTitle) ||
comparableTitle.startsWith(comparableFirstLine));
const title = result.metadata.title && !hasTitle && !titleRepeatsContent
? `\n\n# ${result.metadata.title}\n\n`
: "\n\n";
return yaml + title + result.markdown;
}
@@ -0,0 +1,206 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createMarkdownDocument,
extractMetadataFromHtml,
} from "../markdown-conversion-shared.js";
import { tryUrlRuleParsers } from "./index.js";
const CAPTURED_AT = "2026-03-22T06:00:00.000Z";
const ARTICLE_HTML = `<!doctype html>
<html lang="zh-CN">
<body>
<div data-testid="twitterArticleReadView">
<a href="/dotey/article/2035141635713941927/media/1">
<div data-testid="tweetPhoto">
<img src="https://pbs.twimg.com/media/article-cover.jpg" alt="Image">
</div>
</a>
<div data-testid="twitter-article-title">Karpathy"写代码"</div>
<div data-testid="User-Name">
<a href="/dotey"> Verified account</a>
<a href="/dotey">@dotey</a>
<time datetime="2026-03-20T23:49:11.000Z">Mar 20</time>
</div>
<div data-testid="twitterArticleRichTextView">
<p>Andrej Karpathy 2024 12 </p>
<a href="/dotey/article/2035141635713941927/media/2">
<div>
<div>
<div data-testid="tweetPhoto">
<img src="https://pbs.twimg.com/media/article-inline.jpg" alt="Image">
</div>
</div>
</div>
</a>
<h2></h2>
<ul>
<li> GPU Token </li>
</ul>
<blockquote>
<p></p>
</blockquote>
</div>
</div>
</body>
</html>`;
const STATUS_HTML = `<!doctype html>
<html lang="en">
<body>
<article data-testid="tweet">
<div data-testid="User-Name">
<a href="/dotey"> Verified account</a>
<a href="/dotey">@dotey</a>
<time datetime="2026-03-22T05:33:00.000Z">Mar 22</time>
</div>
<div data-testid="tweetText">
<span> Codex </span>
</div>
<div data-testid="tweetPhoto">
<img src="https://pbs.twimg.com/media/tweet-main.jpg" alt="Image">
</div>
<div data-testid="User-Name">
<a href="/mattshumer_">Matt Shumer Verified account</a>
<a href="/mattshumer_">@mattshumer_</a>
<time datetime="2026-03-17T00:00:00.000Z">Mar 17</time>
</div>
<div data-testid="tweetText">
<span>Add this to your Codex custom instructions for a way better experience.</span>
</div>
</article>
</body>
</html>`;
const ARCHIVE_HTML = `<!doctype html>
<html>
<head>
<title>archive.ph</title>
</head>
<body>
<form>
<input
type="text"
name="q"
value="https://www.newscientist.com/article/2520204-major-leap-towards-reanimation-after-death-as-mammals-brain-preserved/"
>
</form>
<div id="HEADER">
Archive shell text that should be ignored when CONTENT exists.
</div>
<div id="CONTENT">
<h1>Major leap towards reanimation after death as mammal brain preserved</h1>
<p>
Researchers say the preserved structure and activity markers suggest a significant step
forward in keeping delicate brain tissue viable after clinical death.
</p>
<p>
The archive wrapper should not take precedence over the actual article body when the
CONTENT container is available for parsing.
</p>
<img src="https://cdn.example.com/brain.jpg" alt="Brain tissue">
</div>
</body>
</html>`;
const ARCHIVE_FALLBACK_HTML = `<!doctype html>
<html>
<head>
<title>archive.ph</title>
</head>
<body>
<input type="text" name="q" value="https://example.com/fallback-story">
<main>
<h1>Fallback body parsing still works</h1>
<p>
When CONTENT is absent, the parser should fall back to the body content instead of
returning null or keeping the archive wrapper as the final URL.
</p>
<p>
This ensures archived pages with slightly different layouts still produce usable markdown.
</p>
</main>
</body>
</html>`;
function parse(html: string, url: string) {
const baseMetadata = extractMetadataFromHtml(html, url, CAPTURED_AT);
return tryUrlRuleParsers(html, url, baseMetadata);
}
test("parses archive.ph pages from CONTENT and restores the original URL", () => {
const result = parse(ARCHIVE_HTML, "https://archive.ph/SMcX5");
assert.ok(result);
assert.equal(result.conversionMethod, "parser:archive-ph");
assert.equal(
result.metadata.url,
"https://www.newscientist.com/article/2520204-major-leap-towards-reanimation-after-death-as-mammals-brain-preserved/"
);
assert.equal(
result.metadata.title,
"Major leap towards reanimation after death as mammal brain preserved"
);
assert.equal(result.metadata.coverImage, "https://cdn.example.com/brain.jpg");
assert.ok(result.markdown.includes("Researchers say the preserved structure"));
assert.ok(result.markdown.includes("![Brain tissue](https://cdn.example.com/brain.jpg)"));
assert.ok(!result.markdown.includes("Archive shell text that should be ignored"));
});
test("falls back to body when archive.ph CONTENT is missing", () => {
const result = parse(ARCHIVE_FALLBACK_HTML, "https://archive.ph/fallback");
assert.ok(result);
assert.equal(result.conversionMethod, "parser:archive-ph");
assert.equal(result.metadata.url, "https://example.com/fallback-story");
assert.equal(result.metadata.title, "Fallback body parsing still works");
assert.ok(result.markdown.includes("When CONTENT is absent"));
});
test("parses X article pages from HTML", () => {
const result = parse(
ARTICLE_HTML,
"https://x.com/dotey/article/2035141635713941927"
);
assert.ok(result);
assert.equal(result.conversionMethod, "parser:x-article");
assert.equal(result.metadata.title, "Karpathy\"写代码\"已经不是对的动词了");
assert.equal(result.metadata.author, "宝玉 (@dotey)");
assert.equal(result.metadata.coverImage, "https://pbs.twimg.com/media/article-cover.jpg");
assert.equal(result.metadata.published, "2026-03-20T23:49:11.000Z");
assert.equal(result.metadata.language, "zh");
assert.ok(result.markdown.includes("## 要点速览"));
assert.ok(
result.markdown.includes(
"[![](https://pbs.twimg.com/media/article-inline.jpg)](/dotey/article/2035141635713941927/media/2)"
)
);
assert.ok(result.markdown.includes("写代码已经不是对的动词了。"));
const document = createMarkdownDocument(result);
assert.ok(document.includes("# Karpathy\"写代码\"已经不是对的动词了"));
});
test("parses X status pages from HTML without duplicating the title heading", () => {
const result = parse(
STATUS_HTML,
"https://x.com/dotey/status/2035590649081196710"
);
assert.ok(result);
assert.equal(result.conversionMethod, "parser:x-status");
assert.equal(result.metadata.author, "宝玉 (@dotey)");
assert.equal(result.metadata.coverImage, "https://pbs.twimg.com/media/tweet-main.jpg");
assert.equal(result.metadata.language, "zh");
assert.ok(result.markdown.includes("转译:把下面这段加到你的 Codex 自定义指令里"));
assert.ok(result.markdown.includes("> Quote from Matt Shumer (@mattshumer_)"));
assert.ok(result.markdown.includes("!["));
const document = createMarkdownDocument(result);
assert.ok(
!document.includes("\n\n# 转译:把下面这段加到你的 Codex 自定义指令里,体验会好太多:\n\n")
);
});
@@ -0,0 +1,47 @@
import {
isMarkdownUsable,
normalizeMarkdown,
parseDocument,
type ConversionResult,
type PageMetadata,
} from "../markdown-conversion-shared.js";
import { URL_RULE_PARSERS } from "./rules/index.js";
import type { UrlRuleParserContext } from "./types.js";
export type { UrlRuleParser, UrlRuleParserContext } from "./types.js";
export function tryUrlRuleParsers(
html: string,
url: string,
baseMetadata: PageMetadata
): ConversionResult | null {
const document = parseDocument(html);
const context: UrlRuleParserContext = {
html,
url,
document,
baseMetadata,
};
for (const parser of URL_RULE_PARSERS) {
if (!parser.supports(context)) continue;
try {
const result = parser.parse(context);
if (!result) continue;
const markdown = normalizeMarkdown(result.markdown);
if (!isMarkdownUsable(markdown, html)) continue;
return {
...result,
markdown,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[url-to-markdown] parser ${parser.id} failed: ${message}`);
}
}
return null;
}
@@ -0,0 +1,97 @@
import { convertHtmlFragmentToMarkdown } from "../../legacy-converter.js";
import {
normalizeMarkdown,
pickString,
type ConversionResult,
} from "../../markdown-conversion-shared.js";
import type { UrlRuleParser, UrlRuleParserContext } from "../types.js";
const ARCHIVE_HOSTS = new Set([
"archive.ph",
"archive.is",
"archive.today",
"archive.md",
"archive.vn",
"archive.li",
"archive.fo",
]);
function isArchiveHost(url: string): boolean {
try {
return ARCHIVE_HOSTS.has(new URL(url).hostname.toLowerCase());
} catch {
return false;
}
}
function readOriginalUrl(document: Document): string | undefined {
const value = document.querySelector("input[name='q']")?.getAttribute("value")?.trim();
if (!value) return undefined;
try {
return new URL(value).href;
} catch {
return undefined;
}
}
function summarize(text: string, maxLength: number): string | undefined {
const normalized = text.replace(/\s+/g, " ").trim();
if (!normalized) return undefined;
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}`;
}
function pickContentRoot(document: Document): Element | null {
return (
document.querySelector("#CONTENT") ??
document.querySelector("#content") ??
document.body
);
}
function pickContentTitle(root: Element, fallbackTitle: string): string {
const contentTitle = pickString(
root.querySelector("h1")?.textContent,
root.querySelector("[itemprop='headline']")?.textContent,
root.querySelector("article h2")?.textContent
);
if (contentTitle) return contentTitle;
if (fallbackTitle && !/^archive\./i.test(fallbackTitle.trim())) return fallbackTitle;
return "";
}
function parseArchivePage(context: UrlRuleParserContext): ConversionResult | null {
const root = pickContentRoot(context.document);
if (!root) return null;
const markdown = normalizeMarkdown(convertHtmlFragmentToMarkdown(root.innerHTML));
if (!markdown) return null;
const originalUrl = readOriginalUrl(context.document) ?? context.baseMetadata.url;
const bodyText = root.textContent?.replace(/\s+/g, " ").trim() ?? "";
const published = root.querySelector("time[datetime]")?.getAttribute("datetime") ?? undefined;
const coverImage = root.querySelector("img[src]")?.getAttribute("src") ?? undefined;
return {
metadata: {
...context.baseMetadata,
url: originalUrl,
title: pickContentTitle(root, context.baseMetadata.title),
description: summarize(bodyText, 220) ?? context.baseMetadata.description,
published: pickString(published, context.baseMetadata.published) ?? undefined,
coverImage: pickString(coverImage, context.baseMetadata.coverImage) ?? undefined,
},
markdown,
rawHtml: context.html,
conversionMethod: "parser:archive-ph",
};
}
export const archivePhRuleParser: UrlRuleParser = {
id: "archive-ph",
supports(context) {
return isArchiveHost(context.url);
},
parse: parseArchivePage,
};
@@ -0,0 +1,10 @@
import { archivePhRuleParser } from "./archive-ph.js";
import { xArticleRuleParser } from "./x-article.js";
import { xStatusRuleParser } from "./x-status.js";
import type { UrlRuleParser } from "../types.js";
export const URL_RULE_PARSERS: UrlRuleParser[] = [
archivePhRuleParser,
xArticleRuleParser,
xStatusRuleParser,
];
@@ -0,0 +1,137 @@
import {
normalizeMarkdown,
pickString,
type ConversionResult,
} from "../../markdown-conversion-shared.js";
import type { UrlRuleParser, UrlRuleParserContext } from "../types.js";
import {
cleanText,
collectMediaMarkdown,
convertXRichTextElementToMarkdown,
extractPublishedForCurrentUrl,
inferLanguage,
isXArticlePath,
isXHost,
normalizeXMarkdown,
parseUrl,
pickFirstValidLinkText,
sanitizeCoverImage,
summarizeText,
} from "./x-shared.js";
function collectArticleMarkdown(root: Element): { markdown: string; mediaUrls: string[] } {
const parts: string[] = [];
const seenMedia = new Set<string>();
const mediaUrls: string[] = [];
function pushPart(value: string): void {
const normalized = normalizeMarkdown(value);
if (!normalized) return;
parts.push(normalized);
}
function walk(node: Element): void {
const testId = node.getAttribute("data-testid");
if (testId === "twitterArticleRichTextView" || testId === "longformRichTextComponent") {
const bodyMedia = collectMediaMarkdown(node, seenMedia);
mediaUrls.push(...bodyMedia.urls.filter((url) => !mediaUrls.includes(url)));
pushPart(convertXRichTextElementToMarkdown(node));
return;
}
if (testId === "tweetPhoto") {
const media = collectMediaMarkdown(node, seenMedia);
mediaUrls.push(...media.urls.filter((url) => !mediaUrls.includes(url)));
for (const line of media.lines) pushPart(line);
return;
}
if (
testId === "twitter-article-title" ||
testId === "User-Name" ||
testId === "Tweet-User-Avatar" ||
testId === "reply" ||
testId === "retweet" ||
testId === "like" ||
testId === "bookmark" ||
testId === "caret" ||
testId === "app-text-transition-container"
) {
return;
}
if (node.tagName === "TIME" || node.tagName === "BUTTON") {
return;
}
for (const child of Array.from(node.children)) {
walk(child);
}
}
for (const child of Array.from(root.children)) {
walk(child);
}
return {
markdown: normalizeXMarkdown(parts.join("\n\n")),
mediaUrls,
};
}
function parseXArticle(context: UrlRuleParserContext): ConversionResult | null {
const articleRoot = context.document.querySelector("[data-testid='twitterArticleReadView']") as Element | null;
if (!articleRoot) return null;
const title = cleanText(
context.document.querySelector("[data-testid='twitter-article-title']")?.textContent
);
const identity = pickFirstValidLinkText(
context.document.querySelector("[data-testid='User-Name']")
);
const published = extractPublishedForCurrentUrl(articleRoot, context.url);
const { markdown, mediaUrls } = collectArticleMarkdown(articleRoot);
if (!markdown) return null;
const bodyText = cleanText(
context.document.querySelector("[data-testid='twitterArticleRichTextView']")?.textContent ??
context.document.querySelector("[data-testid='longformRichTextComponent']")?.textContent
);
return {
metadata: {
...context.baseMetadata,
title: pickString(title, context.baseMetadata.title) ?? "",
description: summarizeText(bodyText, 220) ?? context.baseMetadata.description,
author: pickString(identity.author, context.baseMetadata.author) ?? undefined,
published: pickString(published, context.baseMetadata.published) ?? undefined,
coverImage: sanitizeCoverImage(mediaUrls[0], context.baseMetadata.coverImage),
language: inferLanguage(bodyText, context.baseMetadata.language),
},
markdown,
rawHtml: context.html,
conversionMethod: "parser:x-article",
};
}
export const xArticleRuleParser: UrlRuleParser = {
id: "x-article",
supports(context) {
const parsed = parseUrl(context.url);
if (!parsed || !isXHost(parsed.hostname)) {
return false;
}
return (
isXArticlePath(parsed.pathname) ||
Boolean(
context.document.querySelector("[data-testid='twitterArticleReadView']") ||
context.document.querySelector("[data-testid='twitterArticleRichTextView']")
)
);
},
parse(context) {
return parseXArticle(context);
},
};
@@ -0,0 +1,249 @@
import { convertHtmlFragmentToMarkdown } from "../../legacy-converter.js";
import { normalizeMarkdown } from "../../markdown-conversion-shared.js";
export const DEFAULT_X_OG_IMAGE = "https://abs.twimg.com/rweb/ssr/default/v2/og/image.png";
export type MediaResult = {
lines: string[];
urls: string[];
};
export function isXHost(hostname: string): boolean {
const normalized = hostname.toLowerCase();
return (
normalized === "x.com" ||
normalized === "twitter.com" ||
normalized.endsWith(".x.com") ||
normalized.endsWith(".twitter.com")
);
}
export function parseUrl(input: string): URL | null {
try {
return new URL(input);
} catch {
return null;
}
}
export function isXStatusPath(pathname: string): boolean {
return /^\/[^/]+\/status(?:es)?\/\d+$/i.test(pathname) || /^\/i\/web\/status\/\d+$/i.test(pathname);
}
export function isXArticlePath(pathname: string): boolean {
return /^\/[^/]+\/article\/\d+$/i.test(pathname) || /^\/(?:i\/)?article\/\d+$/i.test(pathname);
}
export function cleanText(value: string | null | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}
export function cleanUserLabel(value: string | null | undefined): string {
return cleanText(value).replace(/\bVerified account\b/gi, "").replace(/\s{2,}/g, " ").trim();
}
export function escapeMarkdownAlt(text: string): string {
return text.replace(/[\[\]]/g, "\\$&");
}
export function normalizeAlt(text: string | null | undefined): string {
const cleaned = cleanText(text);
if (!cleaned || /^(image|photo)$/i.test(cleaned)) return "";
return escapeMarkdownAlt(cleaned);
}
export function summarizeText(text: string, maxLength: number): string | undefined {
const normalized = cleanText(text);
if (!normalized) return undefined;
return normalized.length > maxLength
? `${normalized.slice(0, maxLength - 3)}...`
: normalized;
}
export function buildTweetTitle(text: string, fallback: string): string {
return summarizeText(text, 80) ?? fallback;
}
export function normalizeXMarkdown(markdown: string): string {
return normalizeMarkdown(markdown.replace(/^(#{1,6})\s*\n+([^\n])/gm, "$1 $2"));
}
export function inferLanguage(text: string, fallback?: string): string | undefined {
const normalized = cleanText(text);
if (!normalized) return fallback;
const han = (normalized.match(/\p{Script=Han}/gu) || []).length;
const hiragana = (normalized.match(/\p{Script=Hiragana}/gu) || []).length;
const katakana = (normalized.match(/\p{Script=Katakana}/gu) || []).length;
const hangul = (normalized.match(/\p{Script=Hangul}/gu) || []).length;
if (hangul >= 8) return "ko";
if (hiragana + katakana >= 8) return "ja";
if (han >= 16) return "zh";
return fallback;
}
export function buildQuoteMarkdown(markdown: string, author?: string): string {
const normalized = normalizeMarkdown(markdown);
if (!normalized) return "";
const lines = normalized.split("\n");
const prefixed = lines.map((line) => (line ? `> ${line}` : ">")).join("\n");
const header = author ? `> Quote from ${author}` : "> Quote";
return `${header}\n${prefixed}`;
}
export function pickFirstValidLinkText(userNameEl: Element | null | undefined): {
name?: string;
username?: string;
author?: string;
} {
if (!userNameEl) return {};
const linkTexts = Array.from(userNameEl.querySelectorAll("a[href]"))
.map((link) => cleanUserLabel(link.textContent))
.filter(Boolean);
let username = linkTexts.find((text) => text.startsWith("@"));
let name = linkTexts.find((text) => !text.startsWith("@") && !/^(promote|more)$/i.test(text));
if (!username || !name) {
const text = cleanUserLabel(userNameEl.textContent);
const fallbackMatch = text.match(/^(.*?)\s*(@[A-Za-z0-9_]+)(?:\s*·.*)?$/);
if (fallbackMatch) {
name = name ?? cleanText(fallbackMatch[1]);
username = username ?? cleanText(fallbackMatch[2]);
}
}
const author = name && username ? `${name} (${username})` : username ?? name;
return { name, username, author };
}
export function extractPublishedForCurrentUrl(root: ParentNode, url: string): string | undefined {
const parsed = parseUrl(url);
if (!parsed) return undefined;
const currentPath = parsed.pathname.toLowerCase();
for (const timeElement of root.querySelectorAll("a[href] time[datetime]")) {
const href = timeElement.closest("a")?.getAttribute("href");
const hrefUrl = href ? parseUrl(href.startsWith("http") ? href : `${parsed.origin}${href}`) : null;
if (hrefUrl?.pathname.toLowerCase() === currentPath) {
return timeElement.getAttribute("datetime") ?? undefined;
}
}
return root.querySelector("time[datetime]")?.getAttribute("datetime") ?? undefined;
}
export function collectMediaMarkdown(root: ParentNode, seen: Set<string>): MediaResult {
const lines: string[] = [];
const urls: string[] = [];
const rootElement = root as Element & {
getAttribute?: (name: string) => string | null;
};
const photoNodes = [
...(typeof rootElement.getAttribute === "function" &&
rootElement.getAttribute("data-testid") === "tweetPhoto"
? [rootElement]
: []),
...Array.from(root.querySelectorAll("[data-testid='tweetPhoto']")),
];
for (const node of photoNodes) {
const img = node.querySelector("img");
const imageUrl = img?.getAttribute("src");
if (imageUrl && !seen.has(imageUrl)) {
seen.add(imageUrl);
urls.push(imageUrl);
lines.push(`![${normalizeAlt(img?.getAttribute("alt"))}](${imageUrl})`);
}
const video = node.querySelector("video");
const posterUrl = video?.getAttribute("poster");
if (posterUrl && !seen.has(posterUrl)) {
seen.add(posterUrl);
urls.push(posterUrl);
lines.push(`![video](${posterUrl})`);
}
const videoUrl = video?.getAttribute("src") ?? video?.querySelector("source")?.getAttribute("src");
if (videoUrl && !seen.has(videoUrl)) {
seen.add(videoUrl);
urls.push(videoUrl);
lines.push(`[video](${videoUrl})`);
}
}
return { lines, urls };
}
export function materializeTweetPhotoNodes(root: Element): void {
for (const photo of Array.from(root.querySelectorAll("[data-testid='tweetPhoto']"))) {
const document = photo.ownerDocument;
const container = document.createElement("span");
const img = photo.querySelector("img");
const imageUrl = img?.getAttribute("src");
if (imageUrl) {
const image = document.createElement("img");
image.setAttribute("src", imageUrl);
const alt = normalizeAlt(img?.getAttribute("alt"));
if (alt) {
image.setAttribute("alt", alt);
}
container.appendChild(image);
}
const video = photo.querySelector("video");
const posterUrl = video?.getAttribute("poster");
if (posterUrl) {
const poster = document.createElement("img");
poster.setAttribute("src", posterUrl);
poster.setAttribute("alt", "video");
container.appendChild(poster);
}
const videoUrl = video?.getAttribute("src") ?? video?.querySelector("source")?.getAttribute("src");
if (videoUrl) {
if (container.childNodes.length > 0) {
container.appendChild(document.createTextNode(" "));
}
const link = document.createElement("a");
link.setAttribute("href", videoUrl);
link.textContent = "video";
container.appendChild(link);
}
if (container.childNodes.length === 0) {
photo.remove();
continue;
}
photo.replaceWith(container);
}
}
function collapseLinkedMediaContainers(root: Element): void {
for (const anchor of Array.from(root.querySelectorAll("a[href]"))) {
const images = Array.from(anchor.querySelectorAll("img"));
if (images.length !== 1) continue;
if (cleanText(anchor.textContent)) continue;
const image = images[0].cloneNode(true);
anchor.replaceChildren(image);
}
}
export function convertXRichTextElementToMarkdown(node: Element): string {
const clone = node.cloneNode(true) as Element;
materializeTweetPhotoNodes(clone);
collapseLinkedMediaContainers(clone);
return normalizeXMarkdown(convertHtmlFragmentToMarkdown(clone.innerHTML));
}
export function sanitizeCoverImage(primary?: string, fallback?: string): string | undefined {
if (primary) return primary;
if (!fallback || fallback === DEFAULT_X_OG_IMAGE) return undefined;
return fallback;
}
@@ -0,0 +1,82 @@
import type { ConversionResult } from "../../markdown-conversion-shared.js";
import type { UrlRuleParser, UrlRuleParserContext } from "../types.js";
import {
buildQuoteMarkdown,
buildTweetTitle,
cleanText,
collectMediaMarkdown,
convertXRichTextElementToMarkdown,
extractPublishedForCurrentUrl,
inferLanguage,
isXHost,
isXStatusPath,
normalizeXMarkdown,
parseUrl,
pickFirstValidLinkText,
sanitizeCoverImage,
summarizeText,
} from "./x-shared.js";
function parseXStatus(context: UrlRuleParserContext): ConversionResult | null {
const article = context.document.querySelector("article[data-testid='tweet'], article") as Element | null;
if (!article) return null;
const tweetTextElements = Array.from(article.querySelectorAll("[data-testid='tweetText']")) as Element[];
if (tweetTextElements.length === 0) return null;
const userNameElements = Array.from(article.querySelectorAll("[data-testid='User-Name']")) as Element[];
const mainTextElement = tweetTextElements[0];
const mainIdentity = pickFirstValidLinkText(userNameElements[0]);
const published = extractPublishedForCurrentUrl(article, context.url);
const mainMarkdown = normalizeXMarkdown(convertXRichTextElementToMarkdown(mainTextElement));
if (!mainMarkdown) return null;
const parts = [mainMarkdown];
const quotedTextElements = tweetTextElements.slice(1);
const quotedUserNameElements = userNameElements.slice(1);
quotedTextElements.forEach((element, index) => {
const quoteMarkdown = normalizeXMarkdown(convertXRichTextElementToMarkdown(element));
if (!quoteMarkdown) return;
const quoteIdentity = pickFirstValidLinkText(quotedUserNameElements[index]);
parts.push(buildQuoteMarkdown(quoteMarkdown, quoteIdentity.author));
});
const media = collectMediaMarkdown(article, new Set<string>());
if (media.lines.length > 0) {
parts.push(media.lines.join("\n\n"));
}
const mainText = cleanText(mainTextElement.textContent);
const markdown = normalizeXMarkdown(parts.join("\n\n"));
return {
metadata: {
...context.baseMetadata,
title: buildTweetTitle(mainText, context.baseMetadata.title),
description: summarizeText(mainText, 220) ?? context.baseMetadata.description,
author: mainIdentity.author ?? context.baseMetadata.author,
published: published ?? context.baseMetadata.published,
coverImage: sanitizeCoverImage(media.urls[0], context.baseMetadata.coverImage),
language: inferLanguage(mainText, context.baseMetadata.language),
},
markdown,
rawHtml: context.html,
conversionMethod: "parser:x-status",
};
}
export const xStatusRuleParser: UrlRuleParser = {
id: "x-status",
supports(context) {
const parsed = parseUrl(context.url);
if (!parsed || !isXHost(parsed.hostname)) {
return false;
}
return isXStatusPath(parsed.pathname) && Boolean(context.document.querySelector("[data-testid='tweetText']"));
},
parse(context): ConversionResult | null {
return parseXStatus(context);
},
};
@@ -0,0 +1,14 @@
import type { ConversionResult, PageMetadata } from "../markdown-conversion-shared.js";
export interface UrlRuleParserContext {
html: string;
url: string;
document: Document;
baseMetadata: PageMetadata;
}
export interface UrlRuleParser {
id: string;
supports(context: UrlRuleParserContext): boolean;
parse(context: UrlRuleParserContext): ConversionResult | null;
}
@@ -151,8 +151,7 @@ From outline entry:
```markdown
## Watermark
Include a subtle watermark "{content}" positioned at {position}
with approximately {opacity*100}% visibility. The watermark should
Include a subtle watermark "{content}" positioned at {position}. The watermark should
be legible but not distracting from the main content.
```
@@ -295,19 +294,19 @@ Create a Xiaohongshu (Little Red Book) style infographic following these guideli
## Content
**Position**: Content (Page 3 of 6)
**Core Message**: ChatGPT使用技巧
**Core Message**: ChatGPT 使用技巧
**Text Content**:
- Title: 「ChatGPT」
- Subtitle: 最强AI助手
- Subtitle: 最强 AI 助手
- Points:
- 写文案:给出框架,秒出初稿
- 改文章:润色、翻译、总结
- 编程:写代码、找bug
- 编程:写代码、找 bug
- 学习:解释概念、出题练习
**Visual Concept**:
ChatGPT logo居中,四周放射状展示功能点
ChatGPT logo 居中,四周放射状展示功能点
深色科技背景,霓虹绿点缀
---