@clawhub-wuchubuzai2018-0763012e23
AI图片生成技能,使用ChatGPT最新生图 gpt-image-2-all,当用户需要生成图片、视觉信息图、创建图像、编辑/修改/调整已有图片时使用此技能。基于API易平台(https://api.apiyi.com/)的ChatGPT最新生图gpt-image-2-all模型(gpt-image-2-all)...
---
name: apiyi-gpt-image-2-all-gen
description: AI图片生成技能,使用ChatGPT最新生图 gpt-image-2-all,当用户需要生成图片、视觉信息图、创建图像、编辑/修改/调整已有图片时使用此技能。基于API易平台(https://api.apiyi.com/)的ChatGPT最新生图gpt-image-2-all模型(gpt-image-2-all)的图片生成服务,无需访问外网。该模型以 $0.03/张 按次计费,支持文生图、单图编辑、多图融合、自然语言改图,文字还原度高、中文提示词友好。尺寸通过prompt描述控制(无显式size参数)与NanoBanana2不同的关键点:无size参数,需在prompt开头描述尺寸;统一$0.03/张无分辨率阶梯;使用对话式端点/v1/chat/completions为主推。
---
# 图片生成与编辑(GPT Image 2 All)Skills
基于API易平台的ChatGPT最新生图gpt-image-2-all模型实现图片生成技能,可以通过自然语言帮助用户生成图片,通过API易国内代理服务访问,支持Node.js和Python两种运行环境。gpt-image-2-all是API易平台上线的一款GPT图像生成官逆模型,以 $0.03/张 的极具竞争力的按次计费定价,约60秒到300秒出图,支持文生图/单图编辑/多图融合/自然语言改图,文字还原度高、内容限制少、原生支持中文提示词。
## 使用指引
遵循以下步骤:
### 第1步:分析需求与参数提取
1. **明确意图**:区分用户是需要【文生图】(生成新图片)还是【图生图】(编辑/修改现有图片)或【多图融合】。
2. **提示词(Prompt)分析**:
- **使用用户原始完整输入**:把用户输入的原始完整问题需求描述(原文)直接作为 `-p` 提示词的主体,避免自行改写、总结或二次创作,防止细节丢失。
- **需要补充时先确认**:如果信息不足(例如缺少风格、主体数量、镜头语言、场景细节、文字内容、禁止元素等),先向用户提问确认;用户确认后,再把补充内容**以"追加"的方式**拼接到原始提示词后。
- 样例:
- 用户输入:"帮我生成一张猫的图片,风格要可爱一点。"
- 正例说明:直接使用用户输入作为提示词:`-p "帮我生成一张猫的图片,风格要可爱一点。"`
- 反例说明:擅自改写为"生成一张可爱风格的猫的图片"会丢失用户原始输入的细节和语气。
- 如果需要补充细节(例如颜色、背景等),先提问确认:"你希望猫是什么颜色的?背景有什么要求吗?"用户回答后,再追加到提示词中:`-p "帮我生成一张猫的图片,风格要可爱一点。猫是橘色的,背景是草地。"`
3. **关键参数整理**:
- **Prompt(必需)**:提示词分析后的最终提示词(默认=用户原始完整且一致的输入;仅在用户确认后才追加补充信息)。
- **Filename(可选)**:输出图片文件名/路径(需包含文件随机标识,避免重复)。不传则脚本会自动生成带时间戳的文件名。建议根据内容生成合理文件名(例如 `cat_in_garden.png`),避免使用通用名。
- **Size/Aspect(可选)**:由于该模型无显式size参数,尺寸通过prompt描述控制。建议在prompt开头描述尺寸。
- "手机壁纸" -> 在prompt开头写 `竖版 9:16` 或 `手机海报 9:16`
- "电脑壁纸/视频封面" -> 在prompt开头写 `横版 16:9` 或 `电影画幅 16:9`
- "头像" -> 在prompt开头写 `1:1 方形构图` 或 `1024×1024 方图`
- 默认若用户未明确指定图片比例,保持图片比例为空(由模型自适应)。
- **Response Format(可选)**:响应格式,默认 `url`(R2 CDN加速链接),可选 `b64_json`(base64图片数据)。
- **注意**:该模型不支持 `size`、`n`、`quality`、`aspect_ratio` 参数,传入可能触发参数校验错误。
### 第2步:环境检查与命令执行
1. **检查环境**:确认 `APIYI_API_KEY` 环境变量是否已设置(通常假定已设置,若运行失败再提示用户)。
2. **构建并运行命令**:
- **优先尝试 Node.js 版本**:如果环境有 Node(`node` 命令可用),优先使用 `scripts/generate_image.js`(零依赖,参数与 Python 保持一致)。
- **Node 不可用再用 Python 版本**:使用 `scripts/generate_image.py`。
**文生图命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "{prompt}" -f "{filename}" [-r {response_format}]
```
**图生图命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "{edit_instruction}" -i "{input_path}" -f "{output_filename}" [-r {response_format}]
```
**多图融合命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "融合图1和图2的风格" -i ref1.png ref2.png -f "merged.png" [-r {response_format}]
```
**(可选)Python 版本命令模板(Node 不可用时)**:
```bash
python scripts/generate_image.py -p "{prompt}" -f "{filename}" [-r {response_format}]
python scripts/generate_image.py -p "{edit_instruction}" -i "{input_path}" -f "{output_filename}" [-r {response_format}]
```
## ⏱️ 长时间任务处理策略
### 1. 任务前提示
**执行前必须告知用户**:
- "图片生成已启动,预计需要60秒到300秒"
### 2. 🎨 最佳实践示例
> "图片生成中,预计60秒到300秒完成...\n⏳ 正在生成..."
### 第3步:结果反馈
1. **执行反馈**:等待终端命令执行完毕。
2. **成功**:告知用户图片已生成,并指出保存路径。
3. **失败**:
- 若提示 API Key 缺失,请指导用户设置环境变量。
- 若提示网络错误,建议用户检查网络或稍后重试。
## 命令行使用样例
### 生成新图片
```bash
python scripts/generate_image.py -p "图片描述文本" -f "output.png" [-r url|b64_json]
```
**示例:**
```bash
# 基础生成
python scripts/generate_image.py -p "一只可爱的橘猫在草地上玩耍" -f "cat.png"
# 指定尺寸(在prompt开头描述)
python scripts/generate_image.py -p "横版 16:9 电影画幅,日落山脉风景" -f "sunset.png"
# 竖版高清图片(适合手机壁纸)
python scripts/generate_image.py -p "竖版 9:16 手机海报,城市夜景" -f "city.png"
```
**(可选)Node.js 版本示例:**
```bash
# 基础生成
node scripts/generate_image.js -p "一只可爱的橘猫在草地上玩耍" -f "cat.png"
# 指定尺寸
node scripts/generate_image.js -p "横版 16:9 电影画幅,日落山脉风景" -f "sunset.png"
```
### 编辑已有图片
```bash
python scripts/generate_image.py -p "编辑指令" -f "output.png" -i "path/to/input.png"
```
**示例:**
```bash
# 修改风格
python scripts/generate_image.py -p "将图片转换成水彩画风格" -f "watercolor.png" -i "original.png"
# 添加元素
python scripts/generate_image.py -p "在天空添加彩虹" -f "rainbow.png" -i "landscape.png"
# 替换背景
python scripts/generate_image.py -p "将背景换成海滩" -f "beach-bg.png" -i "portrait.png"
```
**(可选)Node.js 版本示例:**
```bash
# 修改风格
node scripts/generate_image.js -p "将图片转换成水彩画风格" -f "watercolor.png" -i "original.png"
# 多张参考图融合(最多5张)
node scripts/generate_image.js -p "融合图1和图2的风格" -i ref1.png ref2.png -f "merged.png"
```
## 附加资源
- 尺寸与比例控制文档:references/size-guide.md
## 命令行参数说明
> Python 与 Node.js 版本参数保持一致(短参数与长参数等价)。
| 参数 | 必填 | 说明 |
|------|------|------|
| `-p` / `--prompt` | 是 | 图片描述(文生图)或编辑指令(图生图)。保留用户原始完整输入。 |
| `-f` / `--filename` | 否 | 输出图片路径/文件名;不传则自动生成带时间戳的 PNG 文件名,并写入当前目录。 |
| `-r` / `--response-format` | 否 | 响应格式:`url`(默认,R2 CDN链接)或 `b64_json`(base64图片数据)。 |
| `-i` / `--input-image` | 否 | 图生图输入图片路径;可传多张(最多5张)。传入该参数即进入编辑模式。 |
## 图片比例说明
由于gpt-image-2-all模型没有size参数,尺寸通过prompt描述控制。经验证较稳定的写法:
| 需求 | 推荐写法 |
|------|----------|
| 方形 | 1024×1024 方图 / 1:1 方形构图 |
| 横版 | 横版 16:9 / 宽屏 16:9 电影画幅 |
| 竖版 | 竖版 9:16 / 手机海报 9:16 |
| 超宽横幅 | 横幅 21:9 超宽银幕 |
| 经典印刷 | 4:3 标准画幅 / 3:2 经典画幅 |
**技巧**:在prompt开头描述尺寸/构图,模型遵循度更高。可搭配画幅风格词(如 电影画幅、手机海报、方形构图)进一步提升一致性。
## 响应格式说明
### url(默认)
默认返回 R2 CDN 加速链接,有效期约24小时。适用于Web应用直接渲染。对于需要长期保存的图片,请生成后立即转存到自己的对象存储。
### b64_json
返回 base64 编码的图片数据(已含 `data:image/png;base64,` 前缀)。适用于:
- 服务端需要直接处理图片数据
- 需要写入本地文件
- 前端直接渲染
## 注意事项
- API密钥必须设置,可通过环境变量或命令行参数提供
- 图片生成时间:约60秒到300秒
- 编辑图片时,输入图片会自动转换为base64编码
- 确保输出目录有写入权限
- 该模型不支持 `size`、`n`、`quality`、`aspect_ratio` 参数
- 默认响应的 url 字段是 R2 CDN 加速链接,有效期约24小时
### API Key设置与获取
#### 如何获取API Key
如果你还没有API密钥,请前往 **https://api.apiyi.com** 注册账号并申请API Key。
获取步骤:
1. 访问 https://api.apiyi.com
2. 注册/登录你的账号
3. 在控制台中创建API密钥
4. 复制密钥并设置环境变量或在命令行中使用
#### 设置API Key
脚本按以下顺序查找API密钥:
1. `--api-key` 命令行参数(临时使用)
2. `APIYI_API_KEY` 环境变量(推荐)
**设置环境变量(推荐):**
```bash
# Linux/Mac
export APIYI_API_KEY="your-api-key-here"
# Windows CMD
我的电脑高级设置中设置环境变量或者执行set APIYI_API_KEY=your-api-key-here
# Windows PowerShell
在我的电脑中设置环境变量:$env:APIYI_API_KEY="your-api-key-here"
```
**命令行参数方式(临时):**
```bash
python scripts/generate_image.py -p "一只猫" -k "your-api-key-here"
```
## API端点说明
### 主推端点:POST /v1/chat/completions
对话式端点——相比 `/v1/images/generations` 和 `/v1/images/edits`,对话式端点对提示词遵循更好,并且同一端点同时支持文生图与带参考图改图,可以天然做多轮迭代。
- 仅输入文本 messages → 文生图
- messages 中加入 image_url(URL 或 base64 data URL)→ 带参考图改图
- 保留 assistant 历史消息继续追问 → 多轮迭代改图
## 模型信息
- 模型名:gpt-image-2-all
- 出图速度:约 60-300 秒
- 输出分辨率:无显式 size 参数,由模型自适应(建议在 prompt 中描述)
- 默认响应格式:url(R2 CDN 加速链接,默认 1 天有效期)
- 可选响应格式:b64_json
- 中文提示词:✅ 原生支持
- 支持能力:文生图、单图编辑、多图融合、自然语言改图
- 价格:$0.03/张
## 作者介绍
- 爱海贼的无处不在
- 我的微信公众号:无处不在的技术
FILE:scripts/generate_image.py
#!/usr/bin/env python3
"""
基于GPT Image 2 All的图片生成与编辑脚本(Python版)
使用API易国内代理服务
支持功能:
- 文生图:根据提示词生成图片
- 图生图:根据编辑指令修改已有图片
- 多图融合:参考多张图片融合
参数说明:
- -p, --prompt 图片描述或编辑指令文本(必需)
- -f, --filename 输出图片路径(可选,默认自动生成时间戳文件名)
- -r, --response-format 响应格式(可选:url/b64_json,默认url)
- -i, --input-image 输入图片路径(可选,可多张,最多5张)
- -k, --api-key API密钥(可选,覆盖环境变量 APIYI_API_KEY)
使用示例:
【生成新图片】
python generate_image.py -p "一只可爱的橘猫"
python generate_image.py -p "横版 16:9 电影画幅,日落山脉" -f sunset.png
python generate_image.py -p "竖版 9:16 手机海报,城市夜景" -f city.png
【编辑已有图片】
python generate_image.py -p "转换成油画风格" -i original.png
python generate_image.py -p "添加彩虹到天空" -i photo.jpg -f edited.png
python generate_image.py -p "将背景换成海滩" -i portrait.png -f beach-bg.png
【多图融合】
python generate_image.py -p "融合图1和图2的风格" -i ref1.png ref2.png -f merged.png
【环境变量】
export APIYI_API_KEY="your-api-key"
"""
import os
import sys
import re
import json
import base64
import argparse
import datetime
from pathlib import Path
from typing import Optional, List, Dict, Any, Union
try:
import requests
except ImportError:
print("错误: 需要安装 requests 库,请运行: pip install requests")
sys.exit(1)
SUPPORTED_RESPONSE_FORMATS = ['url', 'b64_json']
DEFAULT_RESPONSE_FORMAT = 'url'
DEFAULT_TIMEOUT = 300
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description='基于GPT Image 2 All的图片生成与编辑工具(Python版)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
尺寸说明(通过prompt描述,无显式size参数):
- 方形: 1024×1024 方图 / 1:1 方形构图
- 横版: 横版 16:9 / 宽屏 16:9 电影画幅
- 竖版: 竖版 9:16 / 手机海报 9:16
- 超宽: 横幅 21:9 超宽银幕
- 印刷: 4:3 标准画幅 / 3:2 经典画幅
运行示例:
python scripts/generate_image.py -p "一只可爱的橘猫"
python scripts/generate_image.py -p "横版 16:9 电影画幅,日落山脉" -f sunset.png
python scripts/generate_image.py -p "转换成油画风格" -i original.png
python scripts/generate_image.py -p "融合图1和图2的风格" -i ref1.png ref2.png -f merged.png
'''
)
parser.add_argument('-p', '--prompt', required=True, help='图片描述或编辑指令文本(必需)')
parser.add_argument('-f', '--filename', default=None, help='输出图片路径 (默认: 自动生成时间戳文件名)')
parser.add_argument('-r', '--response-format', default=DEFAULT_RESPONSE_FORMAT,
choices=SUPPORTED_RESPONSE_FORMATS,
help='响应格式 (默认: url)')
parser.add_argument('-i', '--input-image', nargs='+', default=None,
help='输入图片路径(编辑模式,可传多张,最多5张)')
parser.add_argument('-k', '--api-key', default=None, help='API密钥(覆盖环境变量)')
return parser.parse_args()
def get_api_key(args_key: Optional[str]) -> str:
if args_key:
return args_key
api_key = os.environ.get('APIYI_API_KEY')
if not api_key:
print('错误: 未设置 APIYI_API_KEY 环境变量', file=sys.stderr)
print('请前往 https://api.apiyi.com 注册申请API Key', file=sys.stderr)
print('或使用 -k/--api-key 参数临时指定', file=sys.stderr)
sys.exit(1)
return api_key
def encode_image_to_base64(image_path: str) -> str:
try:
with open(image_path, 'rb') as f:
return base64.b64encode(f.read()).decode()
except Exception as e:
print(f'错误: 无法读取图片文件 {image_path} - {e}', file=sys.stderr)
sys.exit(1)
def generate_filename(prompt: str) -> str:
now = datetime.datetime.now()
timestamp = now.strftime('%Y-%m-%d-%H-%M-%S')
keywords = str(prompt).split()[:3]
keyword_str = '-'.join(keywords) if keywords else 'image'
keyword_str = ''.join(c if c.isalnum() or c in '-_.' else '-' for c in keyword_str)
keyword_str = keyword_str.lower()[:30]
return f'{timestamp}-{keyword_str}.png'
def add_timestamp_to_filename(file_path: str, timestamp: str) -> str:
path = Path(file_path)
name = path.stem
ext = path.suffix
new_name = f'{name}-{timestamp}{ext}'
return str(path.parent / new_name)
def extract_image_url(content: str) -> Optional[str]:
if not content:
return None
url_match = re.search(r'(https?://[^\s)]+\.(png|jpg|jpeg|webp))', content, re.IGNORECASE)
if url_match:
return url_match.group(1)
b64_match = re.search(r'(data:image/[^;]+;base64,[A-Za-z0-9+/=]+)', content)
if b64_match:
return b64_match.group(1)
return None
def download_image(url_string: str) -> bytes:
try:
response = requests.get(url_string, timeout=30)
if response.status_code < 200 or response.status_code >= 300:
print(f'错误: 下载图片失败 - HTTP {response.status_code}', file=sys.stderr)
sys.exit(1)
return response.content
except requests.exceptions.Timeout:
print('错误: 下载图片超时', file=sys.stderr)
sys.exit(1)
except requests.exceptions.RequestException as e:
print(f'错误: 下载图片失败 - {e}', file=sys.stderr)
sys.exit(1)
def download_base64_image(url_string: str) -> str:
image_buffer = download_image(url_string)
return base64.b64encode(image_buffer).decode()
def main():
args = parse_args()
timestamp = datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')
if args.response_format not in SUPPORTED_RESPONSE_FORMATS:
print(f"错误: 不支持的响应格式 '{args.response_format}'", file=sys.stderr)
print(f"支持的格式: {', '.join(SUPPORTED_RESPONSE_FORMATS)}", file=sys.stderr)
sys.exit(1)
if not args.filename:
args.filename = generate_filename(args.prompt)
else:
resolved = Path(args.filename).resolve()
if resolved.exists():
adjusted = add_timestamp_to_filename(args.filename, timestamp)
print(f'⚠️ 输出文件已存在,将避免覆盖并改为: {adjusted}')
args.filename = adjusted
api_key = get_api_key(args.api_key)
url = 'https://api.apiyi.com/v1/chat/completions'
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
content = []
mode_str = '生成图片'
if args.input_image and len(args.input_image) > 0:
if len(args.input_image) > 5:
print(f'错误: 输入图片最多支持5张,当前为 {len(args.input_image)} 张', file=sys.stderr)
sys.exit(1)
for img_path in args.input_image:
if not Path(img_path).exists():
print(f'错误: 输入图片不存在: {img_path}', file=sys.stderr)
sys.exit(1)
image_base64 = encode_image_to_base64(img_path)
data_url = f'data:image/png;base64,{image_base64}'
content.append({
'type': 'image_url',
'image_url': {'url': data_url}
})
mode_str = '编辑图片' if len(args.input_image) == 1 else '多图融合'
content = [
{
'type': 'text',
'text': args.prompt,
},
*content,
]
else:
content = args.prompt
payload = {
'model': 'gpt-image-2-all',
'messages': [
{
'role': 'user',
'content': content,
},
],
}
if args.response_format == 'b64_json':
payload['response_format'] = {'type': 'b64_json'}
print('🎨 图片生成已启动!')
print(f'⏱️ 预计时间: 约60秒到300秒')
print(f'正在{mode_str}...')
print(f'提示词: {args.prompt}')
print('image generation in progress...')
start_time = datetime.datetime.now()
try:
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
response.raise_for_status()
data = response.json()
except requests.exceptions.Timeout:
print('错误: 请求超时,请稍后重试', file=sys.stderr)
sys.exit(1)
except requests.exceptions.HTTPError as e:
print(f'错误: 请求失败 - {e}', file=sys.stderr)
try:
error_detail = e.response.json()
print(f'错误详情: {json.dumps(error_detail, indent=2, ensure_ascii=False)}', file=sys.stderr)
except:
print(f'响应内容: {e.response.text}', file=sys.stderr)
sys.exit(1)
except requests.exceptions.RequestException as e:
print(f'错误: 请求失败 - {e}', file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError:
print('错误: 响应不是有效的JSON', file=sys.stderr)
sys.exit(1)
elapsed = (datetime.datetime.now() - start_time).total_seconds()
print(f'⏱️ 生成完成,耗时 {elapsed:.1f}秒')
response_content = None
if data and data.get('choices') and len(data['choices']) > 0:
choice = data['choices'][0]
if choice.get('message'):
response_content = choice['message'].get('content')
if not response_content:
print('错误: 响应中未找到内容', file=sys.stderr)
print(f'完整响应: {json.dumps(data, indent=2, ensure_ascii=False)}', file=sys.stderr)
sys.exit(1)
image_data = None
if args.response_format == 'b64_json':
b64_match = re.search(r'data:image/png;base64,([A-Za-z0-9+/=]+)', response_content)
if b64_match:
image_data = b64_match.group(1)
if not image_data:
image_url = extract_image_url(response_content)
if image_url:
if image_url.startswith('data:'):
image_data = image_url.replace('data:image/png;base64,', '')
else:
print('📥 正在下载图片...')
image_data = download_base64_image(image_url)
if not image_data:
print('错误: 未能从响应中提取图片数据', file=sys.stderr)
print(f'响应内容: {response_content}', file=sys.stderr)
sys.exit(1)
try:
image_bytes = base64.b64decode(image_data)
except Exception as e:
print(f'错误: 图片数据解码失败 - {e}', file=sys.stderr)
sys.exit(1)
output_file = Path(args.filename).resolve()
output_dir = output_file.parent
output_dir.mkdir(parents=True, exist_ok=True)
output_file.write_bytes(image_bytes)
print(f'✓ 图片已成功{mode_str}并保存到: {args.filename}')
print('✅ 生成完成!')
if __name__ == '__main__':
main()
FILE:scripts/generate_image.js
#!/usr/bin/env node
/*
基于GPT Image 2 All的图片生成与编辑脚本(Node.js版)
使用API易国内代理服务
支持功能:
- 文生图:根据提示词生成图片
- 图生图:根据编辑指令修改已有图片
- 多图融合:参考多张图片融合
参数说明:
- -p, --prompt 图片描述或编辑指令文本(必需)
- -f, --filename 输出图片路径(可选,默认自动生成时间戳文件名)
- -r, --response-format 响应格式(可选:url/b64_json,默认url)
- -i, --input-image 输入图片路径(可选,可多张,最多5张)
- -k, --api-key API密钥(可选,覆盖环境变量 APIYI_API_KEY)
使用示例:
【生成新图片】
node generate_image.js -p "一只可爱的橘猫"
node generate_image.js -p "横版 16:9 电影画幅,日落山脉" -f sunset.png
node generate_image.js -p "竖版 9:16 手机海报,城市夜景" -f city.png
【编辑已有图片】
node generate_image.js -p "转换成油画风格" -i original.png
node generate_image.js -p "添加彩虹到天空" -i photo.jpg -f edited.png
node generate_image.js -p "将背景换成海滩" -i portrait.png -f beach-bg.png
【多图融合】
node generate_image.js -p "融合图1和图2的风格" -i ref1.png ref2.png -f merged.png
【环境变量】
export APIYI_API_KEY="your-api-key"
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const SUPPORTED_RESPONSE_FORMATS = ['url', 'b64_json'];
function printHelpAndExit(exitCode = 0) {
const help = `usage: generate_image.js [-h] --prompt PROMPT [--filename FILENAME]
[--response-format url|b64_json]
[--input-image INPUT_IMAGE [INPUT_IMAGE ...]]
[--api-key API_KEY]
基于GPT Image 2 All的图片生成与编辑工具(Node.js版)
options:
-h, --help show this help message and exit
-p, --prompt PROMPT 图片描述或编辑指令文本(必需)
-f, --filename FILE 输出图片路径 (默认: 自动生成时间戳文件名)
-r, --response-format 响应格式 (可选: url, b64_json,默认url)
-i, --input-image 输入图片路径(编辑模式,可传多张,最多5张)
-k, --api-key API密钥(覆盖环境变量)
尺寸说明(通过prompt描述,无显式size参数):
- 方形: 1024×1024 方图 / 1:1 方形构图
- 横版: 横版 16:9 / 宽屏 16:9 电影画幅
- 竖版: 竖版 9:16 / 手机海报 9:16
- 超宽: 横幅 21:9 超宽银幕
- 印刷: 4:3 标准画幅 / 3:2 经典画幅
运行示例:
node scripts/generate_image.js -p "一只可爱的橘猫"
node scripts/generate_image.js -p "横版 16:9 电影画幅,日落山脉" -f sunset.png
node scripts/generate_image.js -p "转换成油画风格" -i original.png
node scripts/generate_image.js -p "融合图1和图2的风格" -i ref1.png ref2.png -f merged.png
`;
process.stdout.write(help);
process.exit(exitCode);
}
function exitWithError(message) {
process.stderr.write(`message\n`);
process.exit(1);
}
function pad2(n) {
return String(n).padStart(2, '0');
}
function formatTimestamp(dateObj) {
const d = dateObj || new Date();
return `d.getFullYear()-pad2(d.getMonth() + 1)-pad2(d.getDate())-pad2(d.getHours())-pad2(d.getMinutes())-pad2(d.getSeconds())`;
}
function addTimestampToFilename(filePath, timestamp) {
const ts = timestamp || formatTimestamp(new Date());
const parsed = path.parse(filePath);
const base = parsed.name ? `parsed.name-ts` : ts;
return path.join(parsed.dir || '.', `baseparsed.ext || ''`);
}
function generateFilename(prompt) {
const now = new Date();
const timestamp = formatTimestamp(now);
const keywords = String(prompt).split(/\s+/).filter(Boolean).slice(0, 3);
const keywordStrRaw = keywords.join('-') || 'image';
const keywordStr = keywordStrRaw
.split('')
.map((c) => (/^[a-zA-Z0-9\-_.]$/.test(c) ? c : '-'))
.join('')
.toLowerCase()
.slice(0, 30);
return `timestamp-keywordStr.png`;
}
function getApiKey(argsKey) {
if (argsKey) return argsKey;
const apiKey = process.env.APIYI_API_KEY;
if (!apiKey) {
exitWithError(
'错误: 未设置 APIYI_API_KEY 环境变量\n' +
'请前往 https://api.apiyi.com 注册申请API Key\n' +
'或使用 -k/--api-key 参数临时指定'
);
}
return apiKey;
}
function encodeImageToBase64(imagePath) {
try {
const bytes = fs.readFileSync(imagePath);
return bytes.toString('base64');
} catch (e) {
exitWithError(`错误: 无法读取图片文件 imagePath - e.message || String(e)`);
}
}
function postJson(urlString, headers, payload, timeoutMs) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const body = Buffer.from(JSON.stringify(payload), 'utf8');
const req = https.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: 'POST',
headers: {
...headers,
'Content-Length': body.length,
},
},
(res) => {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
const statusCode = res.statusCode || 0;
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(`HTTP statusCode`);
err.statusCode = statusCode;
err.responseText = text;
return reject(err);
}
try {
resolve(JSON.parse(text));
} catch (e) {
const err = new Error('响应不是有效的JSON');
err.responseText = text;
return reject(err);
}
});
}
);
req.on('error', reject);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error('timeout'));
});
req.write(body);
req.end();
});
}
function parseArgs(argv) {
const args = {
prompt: null,
filename: null,
responseFormat: null,
inputImages: null,
apiKey: null,
};
const knownFlags = new Set([
'-h',
'--help',
'-p',
'--prompt',
'-f',
'--filename',
'-r',
'--response-format',
'-i',
'--input-image',
'-k',
'--api-key',
]);
function requireValue(i, flag) {
const v = argv[i + 1];
if (!v || (v.startsWith('-') && knownFlags.has(v))) {
exitWithError(`错误: 参数 flag 需要一个值`);
}
return v;
}
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '-h' || a === '--help') {
printHelpAndExit(0);
}
if (a === '-p' || a === '--prompt') {
args.prompt = requireValue(i, a);
i++;
continue;
}
if (a === '-f' || a === '--filename') {
args.filename = requireValue(i, a);
i++;
continue;
}
if (a === '-r' || a === '--response-format') {
args.responseFormat = requireValue(i, a);
i++;
continue;
}
if (a === '-k' || a === '--api-key') {
args.apiKey = requireValue(i, a);
i++;
continue;
}
if (a === '-i' || a === '--input-image') {
const images = [];
let j = i + 1;
while (j < argv.length) {
const v = argv[j];
if (v.startsWith('-') && knownFlags.has(v)) break;
images.push(v);
j++;
}
if (images.length === 0) {
exitWithError(`错误: 参数 a 需要至少一个图片路径`);
}
args.inputImages = images;
i = j - 1;
continue;
}
if (a.startsWith('-')) {
exitWithError(`错误: 未知参数 a,请使用 --help 查看帮助`);
}
}
if (!args.prompt) {
exitWithError('错误: 缺少必需参数 -p/--prompt');
}
return args;
}
function extractImageUrl(content) {
if (!content) return null;
const urlMatch = content.match(/(https?:\/\/[^\s)]+\.(png|jpg|jpeg|webp))/i);
if (urlMatch) return urlMatch[1];
const b64Match = content.match(/(data:image\/[^\s;]+;base64,[A-Za-z0-9+/=]+)/);
if (b64Match) return b64Match[1];
return null;
}
async function downloadImage(urlString) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const req = https.get(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
},
(res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
const err = new Error(`HTTP res.statusCode`);
err.statusCode = res.statusCode;
return reject(err);
}
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => resolve(Buffer.concat(chunks)));
}
);
req.on('error', reject);
req.setTimeout(30000, () => {
req.destroy(new Error('timeout'));
});
});
}
async function downloadBase64Image(urlString) {
const imageBuffer = await downloadImage(urlString);
return imageBuffer.toString('base64');
}
async function main() {
const argv = process.argv.slice(2);
const args = parseArgs(argv);
const runTimestamp = formatTimestamp(new Date());
let checkProgress = null;
const clearProgressTimer = () => {
if (checkProgress) {
clearInterval(checkProgress);
checkProgress = null;
}
};
if (args.responseFormat != null && !SUPPORTED_RESPONSE_FORMATS.includes(args.responseFormat)) {
exitWithError(
`错误: 不支持的响应格式 'args.responseFormat'\n支持的格式: SUPPORTED_RESPONSE_FORMATS.join(', ')`
);
}
if (!args.filename) {
args.filename = generateFilename(args.prompt);
} else {
const resolved = path.resolve(args.filename);
if (fs.existsSync(resolved)) {
const adjusted = addTimestampToFilename(args.filename, runTimestamp);
process.stdout.write(`⚠️ 输出文件已存在,将避免覆盖并改为: adjusted\n`);
args.filename = adjusted;
}
}
const apiKey = getApiKey(args.apiKey);
const url = 'https://api.apiyi.com/v1/chat/completions';
const headers = {
Authorization: `Bearer apiKey`,
'Content-Type': 'application/json',
};
let content = [];
let modeStr = '生成图片';
if (args.inputImages && args.inputImages.length > 0) {
if (args.inputImages.length > 5) {
exitWithError(`错误: 输入图片最多支持5张,当前为 args.inputImages.length 张`);
}
for (let idx = 0; idx < args.inputImages.length; idx++) {
const imgPath = args.inputImages[idx];
if (!fs.existsSync(imgPath)) {
exitWithError(`错误: 输入图片不存在: imgPath`);
}
const imageBase64 = encodeImageToBase64(imgPath);
const dataUrl = `data:image/png;base64,imageBase64`;
content.push({
type: 'image_url',
image_url: { url: dataUrl },
});
}
modeStr = args.inputImages.length === 1 ? '编辑图片' : '多图融合';
content = [
{
type: 'text',
text: args.prompt,
},
...content,
];
} else {
content = args.prompt;
}
const payload = {
model: 'gpt-image-2-all',
messages: [
{
role: 'user',
content: content,
},
],
};
if (args.responseFormat === 'b64_json') {
payload.response_format = { type: 'b64_json' };
}
process.stdout.write('🎨 图片生成已启动!\n');
process.stdout.write(`⏱️ 预计时间: 约60秒到300秒\n`);
process.stdout.write(`正在modeStr...\n`);
process.stdout.write(`提示词: args.prompt\n`);
process.stdout.write('image generation in progress...\n');
const startTime = Date.now();
checkProgress = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
process.stdout.write(`🔄 已进行 elapsed秒...\n`);
}, 5000);
let data;
try {
data = await postJson(url, headers, payload, 300_000);
} catch (e) {
clearProgressTimer();
if (e && e.message === 'timeout') {
exitWithError('错误: 请求超时,请稍后重试');
}
if (e && e.statusCode) {
process.stderr.write(`错误: 请求失败 - HTTP e.statusCode\n`);
if (e.responseText) {
try {
const detail = JSON.parse(e.responseText);
process.stderr.write(`错误详情: JSON.stringify(detail, null, 2)\n`);
} catch {
process.stderr.write(`响应内容: e.responseText\n`);
}
}
process.exit(1);
}
exitWithError(`错误: 请求失败 - e.message || String(e)`);
}
clearProgressTimer();
const responseContent =
data &&
data.choices &&
Array.isArray(data.choices) &&
data.choices[0] &&
data.choices[0].message &&
data.choices[0].message.content;
if (!responseContent) {
process.stderr.write('错误: 响应中未找到内容\n');
process.stderr.write(`完整响应: JSON.stringify(data, null, 2)\n`);
process.exit(1);
}
let imageData = null;
if (args.responseFormat === 'b64_json') {
const b64Match = responseContent.match(/data:image\/png;base64,([A-Za-z0-9+/=]+)/);
if (b64Match) {
imageData = b64Match[1];
}
}
if (!imageData) {
const imageUrl = extractImageUrl(responseContent);
if (imageUrl) {
if (imageUrl.startsWith('data:')) {
imageData = imageUrl.replace(/^data:image\/png;base64,/, '');
} else {
process.stdout.write(`📥 正在下载图片...\n`);
imageData = await downloadBase64Image(imageUrl);
}
}
}
if (!imageData) {
process.stderr.write('错误: 未能从响应中提取图片数据\n');
process.stderr.write(`响应内容: responseContent\n`);
process.exit(1);
}
const imageBytes = Buffer.from(imageData, 'base64');
const outputFile = path.resolve(args.filename);
const outputDir = path.dirname(outputFile);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputFile, imageBytes);
process.stdout.write(`✓ 图片已成功modeStr并保存到: args.filename\n`);
process.stdout.write('✅ 生成完成!\n');
}
main().catch((e) => {
exitWithError(`错误: String(e)`);
});图片生成技能,当用户需要生成图片、视觉信息图、创建图像、编辑/修改/调整已有图片时使用此技能。基于API易平台(https://api.apiyi.com/)的ChatGPT Image 2模型(gpt-image-2)的官方正式版图片生成服务。该模型支持精确的尺寸/画质控制(含4K),按token计费。与gpt...
---
name: apiyi-gpt-image-2-gen
description: 图片生成技能,当用户需要生成图片、视觉信息图、创建图像、编辑/修改/调整已有图片时使用此技能。基于API易平台(https://api.apiyi.com/)的ChatGPT Image 2模型(gpt-image-2)的官方正式版图片生成服务。该模型支持精确的尺寸/画质控制(含4K),按token计费。与gpt-image-2-all(官逆版)不同的关键点:使用/v1/images/generations和/v1/images/edits端点;有显式size参数;有quality参数;按token计费;使用multipart/form-data上传参考图;b64_json为纯base64无前缀。
---
# 图片生成与编辑(GPT Image 2 官方正式版)
基于API易平台的GPT Image 2模型(gpt-image-2)官方正式版实现图片生成技能,可以通过自然语言帮助用户生成图片,通过API易国内代理服务访问,支持Node.js和Python两种运行环境。gpt-image-2是API易平台的官方正式版GPT图像生成模型,支持精确的尺寸/画质控制(含4K),按token计费。
## 使用指引
遵循以下步骤:
### 第1步:分析需求与参数提取
1. **明确意图**:区分用户是需要【文生图】(生成新图片)还是【图生图】(编辑/修改现有图片)或【多图融合】。
2. **提示词(Prompt)分析**:
- **使用用户原始完整输入**:把用户输入的原始完整问题需求描述(原文)直接作为 `-p` 提示词的主体,避免自行改写、总结或二次创作,防止细节丢失。
- **需要补充时先确认**:如果信息不足(例如缺少风格、主体数量、镜头语言、场景细节、文字内容、禁止元素等),先向用户提问确认;用户确认后,再把补充内容**以"追加"的方式**拼接到原始提示词后。
- 样例:
- 用户输入:"帮我生成一张猫的图片,风格要可爱一点。"
- 正例说明:直接使用用户输入作为提示词:`-p "帮我生成一张猫的图片,风格要可爱一点。"`
- 反例说明:擅自改写为"生成一张可爱风格的猫的图片"会丢失用户原始输入的细节和语气。
- 如果需要补充细节(例如颜色、背景等),先提问确认:"你希望猫是什么颜色的?背景有什么要求吗?"用户回答后,再追加到提示词中:`-p "帮我生成一张猫的图片,风格要可爱一点。猫是橘色的,背景是草地。"`
3. **关键参数整理**:
- **Prompt(必需)**:提示词分析后的最终提示词(默认=用户原始完整且一致的输入;仅在用户确认后才追加补充信息)。
- **Filename(可选)**:输出图片文件名/路径(需包含文件随机标识,避免重复)。不传则脚本会自动生成带时间戳的文件名。建议根据内容生成合理文件名(例如 `cat_in_garden.png`),避免使用通用名。
- **Size(可选)**:输出尺寸。
- 预设值:`1024x1024`、`1536x1024`、`1024x1536`、`2048x2048`、`2048x1152`、`3840x2160`、`2160x3840`
- 也可使用自定义尺寸(满足:最大边≤3840、两边16倍数、比例≤3:1、总像素0.65–8.3MP)
- 默认由模型自适应(auto)
- **Quality(可选)**:画质档位。`low`(草图/批量)、`medium`(日常)、`high`(终稿/精细文字)、`auto`(默认)
- **Output Format(可选)**:`png`(默认)、`jpeg`、`webp`
- **Output Compression(可选)**:输出压缩率(0-100),仅jpeg/webp生效
- **注意**:该模型使用官方正式版端点,与官逆版gpt-image-2-all不同。
### 第2步:环境检查与命令执行
1. **检查环境**:确认 `APIYI_API_KEY` 环境变量是否已设置(通常假定已设置,若运行失败再提示用户)���
2. **构建并运行命令**:
- **优先尝试 Node.js 版本**:如果环境有 Node(`node` 命令可用),优先使用 `scripts/generate_image.js`(零依赖,参数与 Python 保持一致)。
- **Node 不可用再用 Python 版本**:使用 `scripts/generate_image.py`。
**文生图命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "{prompt}" -f "{filename}" [-s {size}] [-q {quality}] [-o {output_format}]
```
**图生图命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "{edit_instruction}" -i "{input_path}" -f "{output_filename}" [-s {size}] [-q {quality}]
```
**多图融合命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "融合图1和图2的风格" -i ref1.png ref2.png -f "merged.png" [-s {size}] [-q {quality}]
```
**(可选)Python 版本命令模板(Node 不可用时)**:
```bash
python scripts/generate_image.py -p "{prompt}" -f "{filename}" [-s {size}] [-q {quality}] [-o {output_format}]
python scripts/generate_image.py -p "{edit_instruction}" -i "{input_path}" -f "{output_filename}" [-s {size}] [-q {quality}]
```
## ⏱️ 长时间任务处理策略
### 1. 任务前提示
**执行前必须告知用户**:
- "图片生成已启动,预计需要120-150秒,请耐心等待"
### 2. 🎨 最佳实践示例
> "图片生成中,预计120-150秒完成...\n⏳ 正在生成...\n(high + 2K/4K 复杂场景可能需要更长时间,请耐心等待)"
### 第3步:结果反馈
1. **执行反馈**:等待终端命令执行完毕。
2. **成功**:告知用户图片已生成,并指出保存路径。
3. **失败**:
- 若提示 API Key 缺失,请指导用户设置环境变量。
- 若提示网络错误,建议用户检查网络或稍后重试。
## 命令行使用样例
### 生成新图片
```bash
python scripts/generate_image.py -p "图片描述文本" -f "output.png" [-s {size}] [-q {quality}] [-o {output_format}]
```
**示例:**
```bash
# 基础生成
python scripts/generate_image.py -p "一只可爱的橘猫在草地上玩耍" -f "cat.png"
# 指定尺寸和画质
python scripts/generate_image.py -p "日落山脉风景" -f "sunset.png" -s "2048x1152" -q "high"
# 竖版高清图片(适合手机壁纸)
python scripts/generate_image.py -p "城市夜景" -f "city.png" -s "2160x3840" -q "high"
# 输出为JPEG
python scripts/generate_image.py -p "风景照片" -f "landscape.jpg" -s "3840x2160" -q "high" -o "jpeg"
```
**(可选)Node.js 版本示例:**
```bash
# 基础生成
node scripts/generate_image.js -p "一只可爱的橘猫在草地上玩耍" -f "cat.png"
# 指定尺寸和画质
node scripts/generate_image.js -p "日落山脉风景" -f "sunset.png" -s "2048x1152" -q "high"
```
### 编辑已有图片
```bash
python scripts/generate_image.py -p "编辑指令" -f "output.png" -i "path/to/input.png" [-s {size}] [-q {quality}]
```
**示例:**
```bash
# 修改风格
python scripts/generate_image.py -p "将图片转换成水彩画风格" -f "watercolor.png" -i "original.png"
# 添加元素
python scripts/generate_image.py -p "在天空添加彩虹" -f "rainbow.png" -i "landscape.png" -q "high"
# 替换背景
python scripts/generate_image.py -p "将背景换成海滩" -f "beach-bg.png" -i "portrait.png" -s "2048x2048"
```
**(可选)Node.js 版本示例:**
```bash
# 修改风格
node scripts/generate_image.js -p "将图片转换成水彩画风格" -f "watercolor.png" -i "original.png"
# 多��参考图融合(最多5张)
node scripts/generate_image.js -p "把图1的人物放进图2的场景" -i ref1.png ref2.png -f "merged.png"
```
## 附加资源
- 尺寸与比例控制文档:references/size-guide.md
## 命令行参数说明
> Python 与 Node.js 版本参数保持一致(短参数与长参数等价)。
| 参数 | 必填 | 说明 |
|------|------|------|
| `-p` / `--prompt` | 是 | 图片描述(文生图)或编辑指令(图生图)。保留用户原始完整输入。 |
| `-f` / `--filename` | 否 | 输出图片路径/文件名;不传则自动生成带时间戳的文件名。 |
| `-s` / `--size` | 否 | 输出尺寸:1024x1024 / 1536x1024 / 1024x1536 / 2048x2048 / 2048x1152 / 3840x2160 / 2160x3840 或自定义尺寸。 |
| `-q` / `--quality` | 否 | 画质档位:low / medium / high / auto(默认auto)。 |
| `-o` / `--output-format` | 否 | 输出格式:png(默认)/ jpeg / webp。 |
| `-c` / `--output-compression` | 否 | 输出压缩率(0-100),仅jpeg/webp生效。 |
| `-i` / `--input-image` | 否 | 图生图输入图片路径;可传多张(最多5张)。传入该参数即进入编辑模式。 |
## 尺寸说明
### 预设尺寸
| 尺寸 | 比例 | 适用场景 |
|------|------|----------|
| 1024x1024 | 1:1 | 头像、Instagram帖子 |
| 1536x1024 | 3:2 | 标准横版 |
| 1024x1536 | 2:3 | 标准竖版 |
| 2048x2048 | 1:1 | 高清方图 |
| 2048x1152 | 16:9 | 横版视频封面、桌面壁纸 |
| 3840x2160 | 16:9 | 4K超高清 |
| 2160x3840 | 9:16 | 竖版4K |
### 自定义尺寸
可使用任意合法自定义尺寸,需满足:
- 最大边 ≤ 3840
- 两边都能被16整除
- 比例 ≤ 3:1
- 总像素 0.65–8.3MP
## 画质说明
| 画质 | 说明 | 适用场景 |
|------|------|----------|
| low | 草图/批量生成 | 快速预览、多次迭代 |
| medium | 日常 | 普通使用 |
| high | 终稿/精细文字 | 最终输出、包含文字的图像 |
| auto | 默认 | 由模型决定 |
## 输出格式说明
| 格式 | 说明 | 适用场景 |
|------|------|----------|
| png | 无压缩,透明背景 | 需要透明背景、保留最佳画质 |
| jpeg | 有压缩 | 照片、存储空间敏感 |
| webp | 现代格式 | Web使用、平衡画质与大小 |
**注意**:b64_json字段是纯base64,不含 `data:image/...;base64,` 前缀。客户端需要:
- 写文件:`base64.b64decode(b64_str)` → 写入磁盘
- 浏览器渲染:自行拼前缀 `data:image/png;base64,` + b64
## 注意事项
- API密钥必须设置,可通过环境变量或命令行参数提供
- 图片生成时间:约120-150秒,high + 2K/4K 复杂场景可能需要更长时间
- 编辑图片时,使用multipart/form-data上传参考图
- 确保输出目录有写入权限
- 按token计费(非按张)
### API Key设置与获取
#### 如何获取API Key
如果你还没有API密钥,请前往 **https://api.apiyi.com** 注册账号并申请API Key。
获取步骤:
1. 访问 https://api.apiyi.com
2. 注册/登录你的账号
3. 在控制台中创建API密钥
4. 复制密钥并设置环境变量或在命令行中使用
#### 设置API Key
脚本按以下顺序查找API密钥:
1. `--api-key` 命令行参数(临时使用)
2. `APIYI_API_KEY` 环境变量(推荐)
**设置环境变量(推荐):**
```bash
# Linux/Mac
export APIYI_API_KEY="your-api-key-here"
# Windows CMD
我的电脑高级设置中设置环境变量或者执行set APIYI_API_KEY=your-api-key-here
# Windows PowerShell
在我的电脑中设置环境变量:$env:APIYI_API_KEY="your-api-key-here"
```
**命令行参数方式(临时):**
```bash
python scripts/generate_image.py -p "一只猫" -k "your-api-key-here"
```
## API端点说明
### 文生图端点:POST /v1/images/generations
文生图端点,使用JSON格式请求。
### 图生图端点:POST /v1/images/edits
图生图端点,使用multipart/form-data格式请求。上传参考图(最多5张)+ 指令进行单图改图、多图融合。
参考图顺序有意义,prompt中可用"图1/图2/图3"指代。
## 模型信息
- 模型名:gpt-image-2
- 出图速度:约 120-150秒(4K复杂场景可能需要更长时间)
- 输出分辨率:1024x1024 / 1536x1024 / 1024x1536 / 2048x2048 / 2048x1152 / 3840x2160 / 2160x3840 或自定义
- 默认响应格式:b64_json(纯base64,无前缀)
- 画质档位:low / medium / high / auto
- 输出格式:png / jpeg / webp
- 支持能力:文生图、单图编辑、多图融合
- 计费方式:按token计费
## gpt-image-2(官转)vs gpt-image-2-all(官逆)对比
| 特性 | gpt-image-2 | gpt-image-2-all |
|------|-------------|-----------------|
| 性质 | 官方正式版 | 官方逆向版 |
| 计费 | 按token | 统一$0.03/张 |
| 端点 | /v1/images/generations, /v1/images/edits | /v1/chat/completions |
| 上传参考图 | multipart form-data | base64 data URL |
| 下载图片 | b64_json(纯base64) | url或b64_json(带前缀) |
| 多图融合 | image[]数组最多5张 | chat多个image_url |
| 尺寸控制 | 显式size参数 | prompt描述 |
| 速度 | 约120-150秒 | 约60-300秒 |
## 作者介绍
FILE:scripts/generate_image.py
#!/usr/bin/env python3
"""
基于GPT Image 2官方正式版的图片生成与编辑脚本(Python版)
使用API易国内代理服务
支持功能:
- 文生图:根据提示词生成图片
- 图生图:根据编辑指令修改已有图片
- 多图融合:参考多张图片融合
参数说明:
- -p, --prompt 图片描述或编辑指令文本(必需)
- -f, --filename 输出图片路径(可选,默认自动生成时间戳文件名)
- -s, --size 输出尺寸(可选)
- -q, --quality 画质档位(可选:low/medium/high/auto,默认auto)
- -o, --output-format 输出格式(可选:png/jpeg/webp,默认png)
- -c, --output-compression 输出压缩率(可选:0-100,默认85)
- -i, --input-image 输入图片路径(可选,可多张,最多5张)
- -k, --api-key API密钥(可选,覆盖环境变量 APIYI_API_KEY)
使用示例:
【生成新图片】
python generate_image.py -p "一只可爱的橘猫"
python generate_image.py -p "日落山脉" -s "2048x1152" -q "high" -f sunset.png
python generate_image.py -p "城市夜景" -s "2160x3840" -q "high" -f city.png
【编辑已有图片】
python generate_image.py -p "转换成油画风格" -i original.png
python generate_image.py -p "添加彩虹到天空" -i photo.jpg -f edited.png
python generate_image.py -p "将背景换成海滩" -i portrait.png -f beach-bg.png
【多图融合】
python generate_image.py -p "融合图1和图2的风格" -i ref1.png ref2.png -f merged.png
【环境变量】
export APIYI_API_KEY="your-api-key"
"""
import os
import sys
import re
import json
import base64
import argparse
import datetime
from pathlib import Path
from typing import Optional
try:
import requests
except ImportError:
print("错误: 需要安装 requests 库,请运行: pip install requests")
sys.exit(1)
SUPPORTED_SIZES = ['1024x1024', '1536x1024', '1024x1536', '2048x2048', '2048x1152', '3840x2160', '2160x3840']
SUPPORTED_QUALITIES = ['auto', 'low', 'medium', 'high']
SUPPORTED_OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
DEFAULT_TIMEOUT = 500
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description='基于GPT Image 2官方正式版的图片生成与编辑工具(Python版)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
尺寸说明:
- 预设值: 1024x1024, 1536x1024, 1024x1536, 2048x2048, 2048x1152, 3840x2160, 2160x3840
- 也支持自定义尺寸(最大边≤3840,两边16倍数,比例≤3:1)
运行示例:
python scripts/generate_image.py -p "一只可爱的橘猫"
python scripts/generate_image.py -p "日落山脉" -s "2048x1152" -q "high" -f sunset.png
python scripts/generate_image.py -p "转换成油画风格" -i original.png
python scripts/generate_image.py -p "融合图1和图2的风格" -i ref1.png ref2.png -f merged.png
'''
)
parser.add_argument('-p', '--prompt', required=True, help='图片描述或编辑指令文本(必需)')
parser.add_argument('-f', '--filename', default=None, help='输出图片路径 (默认: 自动生成时间戳文件名)')
parser.add_argument('-s', '--size', default=None, help='输出尺寸 (可选)')
parser.add_argument('-q', '--quality', default='auto', choices=SUPPORTED_QUALITIES, help='画质档位 (默认: auto)')
parser.add_argument('-o', '--output-format', default='png', choices=SUPPORTED_OUTPUT_FORMATS, help='输出格式 (默认: png)')
parser.add_argument('-c', '--output-compression', type=int, default=None, help='输出压缩率 (0-100,仅jpeg/webp生效)')
parser.add_argument('-i', '--input-image', nargs='+', default=None, help='输入图片路径(编辑模式,可传多张,最多5张)')
parser.add_argument('-k', '--api-key', default=None, help='API密钥(覆盖环境变量)')
return parser.parse_args()
def get_api_key(args_key: Optional[str]) -> str:
if args_key:
return args_key
api_key = os.environ.get('APIYI_API_KEY')
if not api_key:
print('错误: 未设置 APIYI_API_KEY 环境变量', file=sys.stderr)
print('请前往 https://api.apiyi.com 注册申请API Key', file=sys.stderr)
print('或使用 -k/--api-key 参数临时指定', file=sys.stderr)
sys.exit(1)
return api_key
def encode_image_to_base64(image_path: str) -> bytes:
try:
with open(image_path, 'rb') as f:
return f.read()
except Exception as e:
print(f'错误: 无法读取图片文件 {image_path} - {e}', file=sys.stderr)
sys.exit(1)
def generate_filename(prompt: str, output_format: str = 'png') -> str:
now = datetime.datetime.now()
timestamp = now.strftime('%Y-%m-%d-%H-%M-%S')
keywords = str(prompt).split()[:3]
keyword_str = '-'.join(keywords) if keywords else 'image'
keyword_str = ''.join(c if c.isalnum() or c in '-_.' else '-' for c in keyword_str)
keyword_str = keyword_str.lower()[:30]
ext = output_format if output_format != 'jpeg' else 'jpg'
return f'{timestamp}-{keyword_str}.{ext}'
def add_timestamp_to_filename(file_path: str, timestamp: str) -> str:
path = Path(file_path)
name = path.stem
ext = path.suffix
new_name = f'{name}-{timestamp}{ext}'
return str(path.parent / new_name)
def main():
args = parse_args()
timestamp = datetime.datetime.now().strftime('%Y-%m-%d-%H-%M-%S')
if args.size and args.size not in SUPPORTED_SIZES:
size_pattern = re.match(r'^(\d+)x(\d+)$', args.size)
if not size_pattern:
print(f"错误: 无效的尺寸格式 '{args.size}'", file=sys.stderr)
print(f"支持的预设尺寸: {', '.join(SUPPORTED_SIZES)} 或自定义尺寸 (如 1920x1080)", file=sys.stderr)
sys.exit(1)
w = int(size_pattern.group(1))
h = int(size_pattern.group(2))
if w > 3840 or h > 3840:
print('错误: 尺寸最大边不能超过3840', file=sys.stderr)
sys.exit(1)
if w % 16 != 0 or h % 16 != 0:
print('错误: 尺寸两边必须能被16整除', file=sys.stderr)
sys.exit(1)
if max(w / h, h / w) > 3:
print('错误: 尺寸比例不能超过3:1', file=sys.stderr)
sys.exit(1)
mp = (w * h) / 1000000
if mp < 0.65 or mp > 8.3:
print(f'错误: 总像素必须在0.65-8.3MP之间 (当前{mp:.2f}MP)', file=sys.stderr)
sys.exit(1)
if args.quality not in SUPPORTED_QUALITIES:
print(f"错误: 不支持的画质 '{args.quality}'", file=sys.stderr)
print(f"支持的画质: {', '.join(SUPPORTED_QUALITIES)}", file=sys.stderr)
sys.exit(1)
if args.output_format not in SUPPORTED_OUTPUT_FORMATS:
print(f"错误: 不支持的输出格式 '{args.output_format}'", file=sys.stderr)
print(f"支持的格式: {', '.join(SUPPORTED_OUTPUT_FORMATS)}", file=sys.stderr)
sys.exit(1)
if args.output_compression is not None:
if args.output_compression < 0 or args.output_compression > 100:
print('错误: 输出压缩率必须在0-100之间', file=sys.stderr)
sys.exit(1)
if not args.filename:
args.filename = generate_filename(args.prompt, args.output_format)
else:
resolved = Path(args.filename).resolve()
if resolved.exists():
adjusted = add_timestamp_to_filename(args.filename, timestamp)
print(f'⚠️ 输出文件已存在,将避免覆盖并改为: {adjusted}')
args.filename = adjusted
api_key = get_api_key(args.api_key)
headers = {
'Authorization': f'Bearer {api_key}',
}
mode_str = '生成图片'
start_time = datetime.datetime.now()
if args.input_image and len(args.input_image) > 0:
if len(args.input_image) > 5:
print(f'错误: 输入图片最多支持5张,当前为 {len(args.input_image)} 张', file=sys.stderr)
sys.exit(1)
for img_path in args.input_image:
if not Path(img_path).exists():
print(f'错误: 输入图片不存在: {img_path}', file=sys.stderr)
sys.exit(1)
mode_str = '编辑图片' if len(args.input_image) == 1 else '多图融合'
url = 'https://api.apiyi.com/v1/images/edits'
files_list = [
('model', (None, 'gpt-image-2')),
('prompt', (None, args.prompt)),
]
if args.size:
files_list.append(('size', (None, args.size)))
if args.quality:
files_list.append(('quality', (None, args.quality)))
if args.output_format:
files_list.append(('output_format', (None, args.output_format)))
if args.output_compression is not None:
files_list.append(('output_compression', (None, str(args.output_compression))))
for img_path in args.input_image:
img_data = encode_image_to_base64(img_path)
file_name = Path(img_path).name
suffix = Path(img_path).suffix[1:].lower()
mime_type = f'image/{suffix}' if suffix in ['png', 'jpg', 'jpeg', 'webp'] else 'image/png'
files_list.append(('image[]', (file_name, img_data, mime_type)))
print('🎨 图片生成已启动!')
print(f'⏱️ 预计时间: 约120-150秒,请耐心等待')
print(f'正在{mode_str}...')
print(f'提示词: {args.prompt}')
if args.size:
print(f'尺寸: {args.size}')
if args.quality:
print(f'画质: {args.quality}')
print('image generation in progress...')
try:
response = requests.post(url, headers=headers, files=files_list, timeout=DEFAULT_TIMEOUT)
response.raise_for_status()
data = response.json()
except requests.exceptions.Timeout:
print('错误: 请求超时,请稍后重试', file=sys.stderr)
sys.exit(1)
except requests.exceptions.HTTPError as e:
print(f'错误: 请求失败 - {e}', file=sys.stderr)
try:
error_detail = e.response.json()
print(f'错误详情: {json.dumps(error_detail, indent=2, ensure_ascii=False)}', file=sys.stderr)
except:
print(f'响应内容: {e.response.text}', file=sys.stderr)
sys.exit(1)
except requests.exceptions.RequestException as e:
print(f'错误: 请求失败 - {e}', file=sys.stderr)
sys.exit(1)
else:
url = 'https://api.apiyi.com/v1/images/generations'
payload = {
'model': 'gpt-image-2',
'prompt': args.prompt,
}
if args.size:
payload['size'] = args.size
if args.quality:
payload['quality'] = args.quality
if args.output_format:
payload['output_format'] = args.output_format
if args.output_compression is not None:
payload['output_compression'] = args.output_compression
print('🎨 图片生成已启动!')
print(f'⏱️ 预计时间: 约120-150秒,请耐心等待')
print(f'正在{mode_str}...')
print(f'提示词: {args.prompt}')
if args.size:
print(f'尺��: {args.size}')
if args.quality:
print(f'画质: {args.quality}')
print('image generation in progress...')
try:
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
response.raise_for_status()
data = response.json()
except requests.exceptions.Timeout:
print('错误: 请求超时,请稍后重试', file=sys.stderr)
sys.exit(1)
except requests.exceptions.HTTPError as e:
print(f'错误: 请求失败 - {e}', file=sys.stderr)
try:
error_detail = e.response.json()
print(f'错误详情: {json.dumps(error_detail, indent=2, ensure_ascii=False)}', file=sys.stderr)
except:
print(f'响应内容: {e.response.text}', file=sys.stderr)
sys.exit(1)
except requests.exceptions.RequestException as e:
print(f'错误: 请求失败 - {e}', file=sys.stderr)
sys.exit(1)
elapsed = (datetime.datetime.now() - start_time).total_seconds()
print(f'⏱️ 生成完成,耗时 {elapsed:.1f}秒')
b64_json = None
if data and data.get('data') and len(data['data']) > 0:
b64_json = data['data'][0].get('b64_json')
if not b64_json:
print('错误: 响应中未找到图片数据', file=sys.stderr)
print(f'完整响应: {json.dumps(data, indent=2, ensure_ascii=False)}', file=sys.stderr)
sys.exit(1)
try:
image_bytes = base64.b64decode(b64_json)
except Exception as e:
print(f'错误: 图片数据解码失败 - {e}', file=sys.stderr)
sys.exit(1)
output_file = Path(args.filename).resolve()
output_dir = output_file.parent
output_dir.mkdir(parents=True, exist_ok=True)
output_file.write_bytes(image_bytes)
print(f'✓ 图片已成功{mode_str}并保存到: {args.filename}')
print('✅ 生成完成!')
if __name__ == '__main__':
main()
FILE:scripts/generate_image.js
#!/usr/bin/env node
/*
基于GPT Image 2官方正式版的图片生成与编辑脚本(Node.js版)
使用API易国内代理服务
支持功能:
- 文生图:根据提示词生成图片
- 图生图:根据编辑指令修改已有图片
- 多图融合:参考多张图片融合
参数说明:
- -p, --prompt 图片描述或编辑指令文本(必需)
- -f, --filename 输出图片路径(可选,默认自动生成时间戳文件名)
- -s, --size 输出尺寸(可选)
- -q, --quality 画质档位(可选:low/medium/high/auto,默认auto)
- -o, --output-format 输出格式(可选:png/jpeg/webp,默认png)
- -c, --output-compression 输出压缩率(可选:0-100,默认85)
- -i, --input-image 输入图片路径(可选,可多张,最多5张)
- -k, --api-key API密钥(可选,覆盖环境变量 APIYI_API_KEY)
使用示例:
【生成新图片】
node generate_image.js -p "一只可爱的橘猫"
node generate_image.js -p "日落山脉" -s "2048x1152" -q "high" -f sunset.png
node generate_image.js -p "城市夜景" -s "2160x3840" -q "high" -f city.png
【编辑已有图片】
node generate_image.js -p "转换成油画风格" -i original.png
node generate_image.js -p "添加彩虹到天空" -i photo.jpg -f edited.png
node generate_image.js -p "将背景换成海滩" -i portrait.png -f beach-bg.png
【多图融合】
node generate_image.js -p "融合图1和图2的风格" -i ref1.png ref2.png -f merged.png
【环境变量】
export APIYI_API_KEY="your-api-key"
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const SUPPORTED_SIZES = [
'1024x1024',
'1536x1024',
'1024x1536',
'2048x2048',
'2048x1152',
'3840x2160',
'2160x3840',
];
const SUPPORTED_QUALITIES = ['auto', 'low', 'medium', 'high'];
const SUPPORTED_OUTPUT_FORMATS = ['png', 'jpeg', 'webp'];
function printHelpAndExit(exitCode = 0) {
const help = `usage: generate_image.js [-h] --prompt PROMPT [--filename FILENAME]
[--size SIZE]
[--quality auto|low|medium|high]
[--output-format png|jpeg|webp]
[--output-compression 0-100]
[--input-image INPUT_IMAGE [INPUT_IMAGE ...]]
[--api-key API_KEY]
基于GPT Image 2官方正式版的图片生成与编辑工具(Node.js版)
options:
-h, --help show this help message and exit
-p, --prompt PROMPT 图片描述或编辑指令文本(必需)
-f, --filename FILE 输出图片路径 (默认: 自动生成时间戳文件名)
-s, --size 输出尺寸 (可选: 1024x1024, 1536x1024, 1024x1536, 2048x2048, 2048x1152, 3840x2160, 2160x3840)
-q, --quality 画质档位 (可选: auto, low, medium, high)
-o, --output-format 输出格式 (可选: png, jpeg, webp)
-c, --output-compression 输出压缩率 (0-100,仅jpeg/webp生效)
-i, --input-image 输入图片路径(编辑模式,可传多张,最多5张)
-k, --api-key API密钥(覆盖环境变量)
尺寸说明:
- 预设值: 1024x1024, 1536x1024, 1024x1536, 2048x2048, 2048x1152, 3840x2160, 2160x3840
- 也支持自定义尺寸(最大边≤3840,两边16倍数,比例≤3:1)
运行示例:
node scripts/generate_image.js -p "一只可爱的橘猫"
node scripts/generate_image.js -p "日落山脉" -s "2048x1152" -q "high" -f sunset.png
node scripts/generate_image.js -p "转换成油画风格" -i original.png
node scripts/generate_image.js -p "融合图1和图2的风格" -i ref1.png ref2.png -f merged.png
`;
process.stdout.write(help);
process.exit(exitCode);
}
function exitWithError(message) {
process.stderr.write(`message\n`);
process.exit(1);
}
function pad2(n) {
return String(n).padStart(2, '0');
}
function formatTimestamp(dateObj) {
const d = dateObj || new Date();
return `d.getFullYear()-pad2(d.getMonth() + 1)-pad2(d.getDate())-pad2(d.getHours())-pad2(d.getMinutes())-pad2(d.getSeconds())`;
}
function addTimestampToFilename(filePath, timestamp) {
const ts = timestamp || formatTimestamp(new Date());
const parsed = path.parse(filePath);
const base = parsed.name ? `parsed.name-ts` : ts;
return path.join(parsed.dir || '.', `baseparsed.ext || ''`);
}
function generateFilename(prompt) {
const now = new Date();
const timestamp = formatTimestamp(now);
const keywords = String(prompt).split(/\s+/).filter(Boolean).slice(0, 3);
const keywordStrRaw = keywords.join('-') || 'image';
const keywordStr = keywordStrRaw
.split('')
.map((c) => (/^[a-zA-Z0-9\-_.]$/.test(c) ? c : '-'))
.join('')
.toLowerCase()
.slice(0, 30);
return `timestamp-keywordStr.png`;
}
function getApiKey(argsKey) {
if (argsKey) return argsKey;
const apiKey = process.env.APIYI_API_KEY;
if (!apiKey) {
exitWithError(
'错误: 未设置 APIYI_API_KEY 环境变量\n' +
'请前往 https://api.apiyi.com 注册申请API Key\n' +
'或使用 -k/--api-key 参数临时指定'
);
}
return apiKey;
}
function encodeImageToBase64(imagePath) {
try {
const bytes = fs.readFileSync(imagePath);
return bytes.toString('base64');
} catch (e) {
exitWithError(`错误: 无法读取图片文件 imagePath - e.message || String(e)`);
}
}
function parseArgs(argv) {
const args = {
prompt: null,
filename: null,
size: null,
quality: null,
outputFormat: null,
outputCompression: null,
inputImages: null,
apiKey: null,
};
const knownFlags = new Set([
'-h', '--help',
'-p', '--prompt',
'-f', '--filename',
'-s', '--size',
'-q', '--quality',
'-o', '--output-format',
'-c', '--output-compression',
'-i', '--input-image',
'-k', '--api-key',
]);
function requireValue(i, flag) {
const v = argv[i + 1];
if (!v || (v.startsWith('-') && knownFlags.has(v))) {
exitWithError(`错误: 参数 flag 需要一个值`);
}
return v;
}
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '-h' || a === '--help') {
printHelpAndExit(0);
}
if (a === '-p' || a === '--prompt') {
args.prompt = requireValue(i, a);
i++;
continue;
}
if (a === '-f' || a === '--filename') {
args.filename = requireValue(i, a);
i++;
continue;
}
if (a === '-s' || a === '--size') {
args.size = requireValue(i, a);
i++;
continue;
}
if (a === '-q' || a === '--quality') {
args.quality = requireValue(i, a);
i++;
continue;
}
if (a === '-o' || a === '--output-format') {
args.outputFormat = requireValue(i, a);
i++;
continue;
}
if (a === '-c' || a === '--output-compression') {
args.outputCompression = requireValue(i, a);
i++;
continue;
}
if (a === '-k' || a === '--api-key') {
args.apiKey = requireValue(i, a);
i++;
continue;
}
if (a === '-i' || a === '--input-image') {
const images = [];
let j = i + 1;
while (j < argv.length) {
const v = argv[j];
if (v.startsWith('-') && knownFlags.has(v)) break;
images.push(v);
j++;
}
if (images.length === 0) {
exitWithError(`错误: 参数 a 需要至少一个图片路径`);
}
args.inputImages = images;
i = j - 1;
continue;
}
if (a.startsWith('-')) {
exitWithError(`错误: 未知参数 a,请使用 --help 查看帮助`);
}
}
if (!args.prompt) {
exitWithError('错误: 缺少必需参数 -p/--prompt');
}
return args;
}
function buildBoundary() {
let s = '';
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 24; i++) {
s += chars[Math.floor(Math.random() * chars.length)];
}
return s;
}
function postMultipart(urlString, headers, boundary, parts) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const bodyParts = [];
for (const part of parts) {
bodyParts.push(Buffer.from(`--boundary\r\n`, 'utf8'));
bodyParts.push(Buffer.from(`part.header\r\n`, 'utf8'));
bodyParts.push(Buffer.from('\r\n', 'utf8'));
if (Buffer.isBuffer(part.body)) {
bodyParts.push(part.body);
} else {
bodyParts.push(Buffer.from(part.body, 'utf8'));
}
bodyParts.push(Buffer.from('\r\n', 'utf8'));
}
bodyParts.push(Buffer.from(`--boundary--\r\n`, 'utf8'));
const body = Buffer.concat(bodyParts);
const req = https.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: 'POST',
headers: {
...headers,
'Content-Type': `multipart/form-data; boundary=boundary`,
'Content-Length': body.length,
},
},
(res) => {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
const statusCode = res.statusCode || 0;
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(`HTTP statusCode`);
err.statusCode = statusCode;
err.responseText = text;
return reject(err);
}
try {
resolve(JSON.parse(text));
} catch (e) {
const err = new Error('响应不是有效的JSON');
err.responseText = text;
return reject(err);
}
});
}
);
req.on('error', reject);
req.setTimeout(500_000, () => {
req.destroy(new Error('timeout'));
});
req.write(body);
req.end();
});
}
function postJson(urlString, headers, payload, timeoutMs) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const body = Buffer.from(JSON.stringify(payload), 'utf8');
const req = https.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
'Content-Length': body.length,
},
},
(res) => {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
const statusCode = res.statusCode || 0;
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(`HTTP statusCode`);
err.statusCode = statusCode;
err.responseText = text;
return reject(err);
}
try {
resolve(JSON.parse(text));
} catch (e) {
const err = new Error('响应不是有效的JSON');
err.responseText = text;
return reject(err);
}
});
}
);
req.on('error', reject);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error('timeout'));
});
req.write(body);
req.end();
});
}
async function main() {
const argv = process.argv.slice(2);
const args = parseArgs(argv);
const runTimestamp = formatTimestamp(new Date());
let checkProgress = null;
const clearProgressTimer = () => {
if (checkProgress) {
clearInterval(checkProgress);
checkProgress = null;
}
};
if (args.size != null && !SUPPORTED_SIZES.includes(args.size)) {
const sizePattern = /^\d+x\d+$/;
if (!sizePattern.test(args.size)) {
exitWithError(
`错误: 不支持的尺寸 'args.size'\n支持的预设尺寸: SUPPORTED_SIZES.join(', ')\n或自定义尺寸(如 1920x1080)`
);
}
const [w, h] = args.size.split('x').map(Number);
if (w > 3840 || h > 3840) {
exitWithError(`错误: 尺寸最大边不能超过3840`);
}
if (w % 16 !== 0 || h % 16 !== 0) {
exitWithError(`错误: 尺寸两边必须能被16整除`);
}
if (Math.max(w / h, h / w) > 3) {
exitWithError(`错误: 尺寸比例不能超过3:1`);
}
const mp = (w * h) / 1000000;
if (mp < 0.65 || mp > 8.3) {
exitWithError(`错误: 总像素必须在0.65-8.3MP之间,当前mp.toFixed(2)MP`);
}
}
if (args.quality != null && !SUPPORTED_QUALITIES.includes(args.quality)) {
exitWithError(
`错误: 不支持的画质 'args.quality'\n支持的画质: SUPPORTED_QUALITIES.join(', ')`
);
}
if (args.outputFormat != null && !SUPPORTED_OUTPUT_FORMATS.includes(args.outputFormat)) {
exitWithError(
`错误: 不支持的输出格式 'args.outputFormat'\n支持的格式: SUPPORTED_OUTPUT_FORMATS.join(', ')`
);
}
if (args.outputCompression != null) {
const comp = parseInt(args.outputCompression);
if (isNaN(comp) || comp < 0 || comp > 100) {
exitWithError(`错误: 输出压缩率必须在0-100之间`);
}
}
if (!args.filename) {
const ext = args.outputFormat === 'jpeg' ? 'jpg' : args.outputFormat === 'webp' ? 'webp' : 'png';
args.filename = generateFilename(args.prompt).replace(/\.png$/, `.ext`);
} else {
const resolved = path.resolve(args.filename);
if (fs.existsSync(resolved)) {
const adjusted = addTimestampToFilename(args.filename, runTimestamp);
process.stdout.write(`⚠️ 输出文件已存在,将避免覆盖并改为: adjusted\n`);
args.filename = adjusted;
}
}
const apiKey = getApiKey(args.apiKey);
const headers = {
Authorization: `Bearer apiKey`,
};
let modeStr = '生成图片';
let data;
if (args.inputImages && args.inputImages.length > 0) {
if (args.inputImages.length > 5) {
exitWithError(`错误: 输入图片最多支持5张,当前为 args.inputImages.length 张`);
}
for (const imgPath of args.inputImages) {
if (!fs.existsSync(imgPath)) {
exitWithError(`错误: 输入图片不存在: imgPath`);
}
}
modeStr = args.inputImages.length === 1 ? '编辑图片' : '多图融合';
const boundary = buildBoundary();
const parts = [];
parts.push({
header: 'Content-Disposition: form-data; name="model"',
body: 'gpt-image-2',
});
parts.push({
header: `Content-Disposition: form-data; name="prompt"`,
body: args.prompt,
});
if (args.size != null) {
parts.push({
header: 'Content-Disposition: form-data; name="size"',
body: args.size,
});
}
if (args.quality != null) {
parts.push({
header: 'Content-Disposition: form-data; name="quality"',
body: args.quality,
});
}
if (args.outputFormat != null) {
parts.push({
header: 'Content-Disposition: form-data; name="output_format"',
body: args.outputFormat,
});
}
if (args.outputCompression != null) {
parts.push({
header: 'Content-Disposition: form-data; name="output_compression"',
body: args.outputCompression,
});
}
for (let i = 0; i < args.inputImages.length; i++) {
const imgPath = args.inputImages[i];
const imageData = fs.readFileSync(imgPath);
const fileName = path.basename(imgPath);
const mimeType = `image/path.extname(imgPath).slice(1)`;
parts.push({
header: `Content-Disposition: form-data; name="image[]"; filename="fileName"\r\nContent-Type: mimeType`,
body: imageData,
});
}
const url = 'https://api.apiyi.com/v1/images/edits';
process.stdout.write('🎨 图片生成已启动!\n');
process.stdout.write(`⏱️ 预计时间: 约120-150秒,请耐心等待\n`);
process.stdout.write(`正在modeStr...\n`);
process.stdout.write(`提示词: args.prompt\n`);
if (args.size) {
process.stdout.write(`尺寸: args.size\n`);
}
if (args.quality) {
process.stdout.write(`画质: args.quality\n`);
}
process.stdout.write('image generation in progress...\n');
const startTime = Date.now();
checkProgress = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
process.stdout.write(`🔄 已进行 elapsed秒...\n`);
}, 5000);
try {
data = await postMultipart(url, headers, boundary, parts);
} catch (e) {
clearProgressTimer();
if (e && e.message === 'timeout') {
exitWithError('错误: 请求超时,请稍后重试');
}
if (e && e.statusCode) {
process.stderr.write(`错误: 请求失败 - HTTP e.statusCode\n`);
if (e.responseText) {
try {
const detail = JSON.parse(e.responseText);
process.stderr.write(`错误详情: JSON.stringify(detail, null, 2)\n`);
} catch {
process.stderr.write(`响应内容: e.responseText\n`);
}
}
process.exit(1);
}
exitWithError(`错误: 请求失败 - e.message || String(e)`);
}
} else {
const payload = {
model: 'gpt-image-2',
prompt: args.prompt,
};
if (args.size != null) payload.size = args.size;
if (args.quality != null) payload.quality = args.quality;
if (args.outputFormat != null) payload.output_format = args.outputFormat;
if (args.outputCompression != null) payload.output_compression = parseInt(args.outputCompression);
const url = 'https://api.apiyi.com/v1/images/generations';
process.stdout.write('🎨 图片生成已启动!\n');
process.stdout.write(`⏱️ 预计时间: 约120-150秒,请耐心等待\n`);
process.stdout.write(`正在modeStr...\n`);
process.stdout.write(`提示词: args.prompt\n`);
if (args.size) {
process.stdout.write(`尺寸: args.size\n`);
}
if (args.quality) {
process.stdout.write(`画质: args.quality\n`);
}
process.stdout.write('image generation in progress...\n');
const startTime = Date.now();
checkProgress = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
process.stdout.write(`🔄 已进行 elapsed秒...\n`);
}, 5000);
try {
data = await postJson(url, headers, payload, 500_000);
} catch (e) {
clearProgressTimer();
if (e && e.message === 'timeout') {
exitWithError('错误: 请求超时,请稍后重试');
}
if (e && e.statusCode) {
process.stderr.write(`错误: 请求失败 - HTTP e.statusCode\n`);
if (e.responseText) {
try {
const detail = JSON.parse(e.responseText);
process.stderr.write(`错误详情: JSON.stringify(detail, null, 2)\n`);
} catch {
process.stderr.write(`响应内容: e.responseText\n`);
}
}
process.exit(1);
}
exitWithError(`错误: 请求失败 - e.message || String(e)`);
}
}
clearProgressTimer();
const b64Json =
data &&
data.data &&
Array.isArray(data.data) &&
data.data[0] &&
data.data[0].b64_json;
if (!b64Json) {
process.stderr.write('错误: 响应中未找到图片数据\n');
process.stderr.write(`完整响应: JSON.stringify(data, null, 2)\n`);
process.exit(1);
}
const imageBytes = Buffer.from(b64Json, 'base64');
const outputFile = path.resolve(args.filename);
const outputDir = path.dirname(outputFile);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputFile, imageBytes);
process.stdout.write(`✓ 图片已成功modeStr并保存到: args.filename\n`);
process.stdout.write('✅ 生成完成!\n');
}
main().catch((e) => {
exitWithError(`错误: String(e)`);
});csdn blog article publish skills,CSDN博客文章生成与发布技能。实现CSDN博客网站的文章的创建、保存草稿、更新和发布。适用场景:(1) 用户请求"帮我写一篇XX文章,保存到CSDN" (2) 用户请求"更新我CSDN上的某篇文章" (3) 用户请求"发布我的CSDN文章" 时调...
---
name: csdn-article-publish
description: csdn blog article publish skills,CSDN博客文章生成与发布技能。实现CSDN博客网站的文章的创建、保存草稿、更新和发布。适用场景:(1) 用户请求"帮我写一篇XX文章,保存到CSDN" (2) 用户请求"更新我CSDN上的某篇文章" (3) 用户请求"发布我的CSDN文章" 时调用此技能
---
# CSDN Blog Article Publish Skills
## 技能概述
- 支持读取指定目录下的 Markdown 文件内容,并将其保存为 CSDN 草稿
- 生成 Markdown 文章并保存到本地文件
- 保存 Markdown 文章为 CSDN 草稿
- 支持本地前置校验,在请求发送前检查配置缺项、标题、标签数量、摘要长度、发布必填项等问题
- 自动维护本地文章映射文件 `csdn_article_map.json`,记录 `file -> articleId -> url`
- 根据文章 ID 或已保存过的 Markdown 文件更新文章
- 发布文章(需额外字段)
## ⚠️ 重要注意事项
- **防限流**:CSDN 有接口限流机制,请勿频繁调用 API
- **建议操作**:
- 单次保存/更新操作间隔至少 5-10 秒
- 每天保存/更新文章数量建议不超过5篇
- 批量操作时适当增加间隔时间
- 优先使用草稿状态,确认无误后再发布
## 使用流程
### 步骤 1:配置请求头
判断当前目录下是否存在 `csdn_config.json` 文件,若文件不存在请复制 `config/config_example.json`到工作目录下,并重命名为 `csdn_config.json`,文件需要根据示例文档让用户进行更改,填写请求头信息: [config_example.json](config/config_example.json)
#### 获取请求头方法
1. 登录 CSDN 并打开 https://editor.csdn.net/md/ Markdown风格的文章编辑器
2. 填写标题和内容,点击保存为草稿
3. 打开浏览器开发者工具(F12)
4. 切换到 Network(网络)标签
5. 找到 `saveArticle` 请求,右键 → Copy → Copy as cURL
6. 从 curl 命令中提取以下请求头:
- `Cookie`: 用户登录Cookie
- `x-ca-nonce`: 请求唯一标识(UUID)
- `x-ca-signature`: 签名
- `x-ca-signature-headers`: 签名头列表
- `x-ca-key`: API Key
#### 配置文件格式
```json
{
"headers": {
"Cookie": "用户Cookie",
"x-ca-nonce": "UUID",
"x-ca-signature": "签名",
"x-ca-signature-headers": "x-ca-key,x-ca-nonce",
"x-ca-key": "API Key"
},
"defaults": {
"readType": "public",
"type": "original",
"pubStatus": "draft",
"creation_statement": 0,
"tags": "",
"categories": ""
}
}
```
详细字段说明见 [config_example.json](config/config_example.json)
脚本会在发送请求前先做本地校验:
- 检查必需请求头是否缺失或仍是示例值
- 检查 Cookie 是否明显不完整
- 检查 `x-ca-signature-headers` 是否包含 `x-ca-key,x-ca-nonce`
- 检查默认配置中的 `readType`、`type`、`pubStatus`、`creation_statement` 是否合法
### 步骤 2:生成 Markdown 文章
1. 根据用户需求生成文章内容(Markdown格式)
2. 保存到当前工作目录的 `csdnarticle/` 文件夹中
3. 文件命名建议:`{文章标题}.md`
```bash
# 确保 csdnarticle 目录存在
mkdir -p csdnarticle
```
### 步骤 3:保存草稿
将本地 Markdown 文件保存为 CSDN 草稿:
```bash
# 方式1:通过 --file 参数指定 Markdown 文件(推荐)
node {skills目录}/scripts/csdn_article.js save \
--title "Python 异步编程实战" \
--file csdnarticle/Python异步编程实战.md
# 方式2:通过 --content 参数直接传递内容
node {skills目录}/scripts/csdn_article.js save \
--title "Python 异步编程实战" \
--content "# Python 异步编程实战\n\n## 简介\n\n本文介绍Python异步编程..."
# 方式3:仅使用 --file 参数,自动提取文件名作为标题
node {skills目录}/scripts/csdn_article.js save \
--file csdnarticle/Python异步编程实战.md
```
**参数说明:**
- `--title`: 文章标题(使用 --file 时可选,未提供则使用文件名)
- `--content`: Markdown 内容(与 --file 二选一)
- `--file`: Markdown 文件路径(与 --content 二选一,推荐使用)
- `--config`: 配置文件路径(默认: csdn_config.json)
当使用 `--file` 保存成功后,脚本会在当前工作目录生成 `csdn_article_map.json`,记录该 Markdown 文件对应的文章 ID 和 URL。原有的 `--id` 参数仍然保留,后续 `update` / `publish` 既可以继续显式传 `--id`,也可以复用该映射。
### 步骤 4:检查草稿
引导用户在 CSDN 编辑器中检查文章排版、格式、内容是否正确,确认无误后,进入下一步。
### 步骤 5:发布文章(如需发布)
使用 `publish` 命令,通过 `--extra` 参数传递发布配置:
```bash
node {skills目录}/scripts/csdn_article.js publish \
--id 159048943 \
--title "Python 异步编程实战" \
--file csdnarticle/Python异步编程实战.md \
--extra '{"tags":"python,async","readType":"public","type":"original","creation_statement":1,"description":"Python 异步编程实战发布摘要"}'
```
**--extra 参数说明:**
| 字段 | 说明 | 可选值 |
|------|------|--------|
| tags | 标签(逗号分隔,最多5个) | python,async |
| readType | 可见范围 | public(默认值), private, read_need_fans, read_need_vip |
| type | 文章类型 | original(默认值), repost, translated |
| creation_statement | 创作声明 | 0=无声明(默认值), 1=部分内容由AI辅助生成, 2=内容来源网络进行整合创作, 3=个人观点,仅供参考 |
| description | 文章摘要(最大256字) | - |
详细 API 参数见 [api_reference.md](references/api_reference.md)
### 本地前置校验
脚本会在发送请求前拦截以下常见问题,并给出修复建议:
- 配置缺项:缺少 `Cookie`、`x-ca-nonce`、`x-ca-signature`、`x-ca-signature-headers`、`x-ca-key`
- 配置占位符未替换:仍保留 `your_cookie_here`、`xxxxxx` 等示例值
- 标题为空:未提供 `--title` 且文件名无法生成标题
- 标签过多:`tags` 超过 5 个
- 摘要过长:`description` 超过 256 字
- 发布缺字段:`publish` 模式下未提供摘要,或发布枚举值不合法
- 更新/发布找不到文章:既未传 `--id`,也没有可复用的本地映射
### 本地文章映射文件
- 文件名:`csdn_article_map.json`
- 生成时机:使用 `save` / `update` / `publish` 且传入 `--file` 并成功请求后
- 用途:记录 Markdown 文件与文章 ID、文章 URL 的对应关系
- 效果:后续执行 `update` / `publish` 时,如果继续使用同一个 `--file`,可以继续显式传 `--id`,也可以省略 `--id` 改为自动复用映射
示例:
```bash
# 首次保存,生成本地映射
node {skills目录}/scripts/csdn_article.js save \
--file csdnarticle/Python异步编程实战.md
# 之后基于同一个文件更新,既可以继续传 --id,也可以直接复用映射
node {skills目录}/scripts/csdn_article.js update \
--id 159048943 \
--file csdnarticle/Python异步编程实战.md
# 或者省略 --id,自动从本地映射读取
node {skills目录}/scripts/csdn_article.js update \
--file csdnarticle/Python异步编程实战.md
# 发布时同样既支持显式传 --id,也支持直接复用映射
node {skills目录}/scripts/csdn_article.js publish \
--id 159048943 \
--file csdnarticle/Python异步编程实战.md \
--extra '{"tags":"python,async","description":"Python 异步编程的实战经验总结","creation_statement":1}'
# 或者省略 --id,自动从本地映射读取
node {skills目录}/scripts/csdn_article.js publish \
--file csdnarticle/Python异步编程实战.md \
--extra '{"tags":"python,async","description":"Python 异步编程的实战经验总结","creation_statement":1}'
```
## 目录结构
```
csdn-article-publish/
├── SKILL.md # 技能说明文档
├── scripts/
│ └── csdn_article.js # Node.js 脚本(核心执行脚本)
├── config/
│ ├── config_example.json # 用户配置文件示例
│ └── user_agents.json # 随机User-Agent列表
└── references/
├── api_reference.md # CSDN API 详细文档
└── troubleshooting.md # 常见问题排查指南
```
运行时还会在当前工作目录生成 `csdn_article_map.json`,该文件不在仓库中维护。
## 场景样例
### 样例 1:生成并保存草稿
> "帮我写一篇关于 Python 异步编程的文章,标题是《Python 异步编程实战》,保存到 CSDN 草稿箱"
执行流程:
1. 生成文章内容(Markdown格式)
2. 保存到 `csdnarticle/Python异步编程实战.md`
3. 调用 `save` 命令:
```bash
# 方式1:使用 --file 参数(推荐)
node scripts/csdn_article.js save \
--title "Python 异步编程实战" \
--file csdnarticle/Python异步编程实战.md
# 方式2:使用 --content 参数(内容较短时)
node scripts/csdn_article.js save \
--title "Python 异步编程实战" \
--content "# Python 异步编程实战\n\n## 简介\n\n本文介绍..."
```
4. 返回文章 ID 供用户后续操作
### 样例 2:更新草稿
> "更新我之前那篇 CSDN 草稿(ID: 159048943),把标题改成《Python 异步编程进阶》"
执行流程:
1. 根据新需求更新 Markdown 内容
2. 保存到 `csdnarticle/Python异步编程进阶.md`
3. 调用 `update` 命令:
```bash
# 方式1:使用 --file 参数(推荐,若已有本地映射可省略 --id)
node scripts/csdn_article.js update \
--id 159048943 \
--title "Python 异步编程进阶" \
--file csdnarticle/Python异步编程进阶.md
# 方式2:使用 --content 参数
node scripts/csdn_article.js update \
--id 159048943 \
--title "Python 异步编程进阶" \
--content "# Python 异步编程进阶\n\n## 新增内容..."
```
### 样例 3:发布文章
> "帮我把 ID 159048943 的文章发布到 CSDN,标签是 python,async"
执行流程:
1. 引导用户确认 creation_statement、readType、type 等字段
2. 调用 `publish` 命令:
```bash
# 方式1:使用 --file 参数(推荐,若已有本地映射可省略 --id)
node scripts/csdn_article.js publish \
--id 159048943 \
--title "Python 异步编程实战" \
--file csdnarticle/Python异步编程实战.md \
--extra '{"tags":"python,async","creation_statement":1,"readType":"public","type":"original","description":"Python 异步编程的发布版摘要"}'
# 方式2:使用 --content 参数
node scripts/csdn_article.js publish \
--id 159048943 \
--title "Python 异步编程实战" \
--content "# Python 异步编程实战\n\n..." \
--extra '{"tags":"python,async","creation_statement":1,"description":"Python 异步编程的发布版摘要"}'
```
## 故障排查
常见的请求头过期、签名失效、限流、发布失败等问题,可参考 [troubleshooting.md](references/troubleshooting.md)
FILE:config/config_example.json
{
"headers": {
"Cookie": "your_cookie_here",
"x-ca-nonce": "xxxxxx",
"x-ca-signature": "xxxxxxxx",
"x-ca-signature-headers": "x-ca-key,x-ca-nonce",
"x-ca-key": "xxxxxxxx"
},
"defaults": {
"readType": "public",
"type": "original",
"pubStatus": "draft",
"creation_statement": 0,
"tags": "",
"categories": "",
"description": ""
}
}
FILE:config/user_agents.json
[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:119.0) Gecko/20100101 Firefox/119.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:118.0) Gecko/20100101 Firefox/118.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15"
]
FILE:references/api_reference.md
# CSDN Blog API Reference
## API Endpoint
- **URL**: `https://bizapi.csdn.net/blog-console-api/v3/mdeditor/saveArticle`
- **Method**: POST
- **Content-Type**: application/json
## Request Headers
备注:可以使用浏览器开发者工具中,先尝试保存一次草稿状态的文章的操作后,从请求头中进行获取
| Header | Required | Description |
|--------|----------|-------------|
| Cookie | Yes | 用户登录Cookie |
| x-ca-nonce | Yes | UUID字符串 |
| x-ca-signature | Yes | Base64签名 |
| x-ca-signature-headers | Yes | 签名头列表,如 "x-ca-key,x-ca-nonce" |
| x-ca-key | Yes | API Key |
## Request Body Fields
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| id | Update Only | - | 文章ID,更新时必需 |
| title | Yes | - | 文章标题 |
| content | Yes | - | HTML 格式正文内容 |
| markdowncontent | Yes | - | Markdown格式文章内容 |
| pubStatus | No | draft | 发布状态: draft/publish |
| readType | No | public | 可见范围 |
| type | No | original | 文章类型 |
| tags | No | - | 标签,逗号分隔,最多5个 |
| categories | No | - | 分类 |
| Description | No | - | 摘要,最大256字,发布时需要填写 |
| creation_statement | No | 0 | 创作声明 0=无声明(默认值), 1=部分内容由AI辅助生成, 2=内容来源网络进行整合创作, 3=个人观点,仅供参考 |
| status | No | 2 | 状态 2-草稿 0-发布 |
| cover_type | No | 1 | 封面类型 |
| authorized_status | No | false | 授权状态 |
| source | No | pc_mdeditor | 来源 |
## Field Values
### pubStatus
- `draft` - 草稿状态
- `publish` - 发布状态
### readType
- `public` - 全部可见
- `private` - 仅我可见
- `read_need_fans` - 粉丝可见
- `read_need_vip` - VIP可见
### type
- `original` - 原创
- `repost` - 转载
- `translated` - 翻译
### creation_statement
- `0` - 无声明
- `1` - 部分内容由AI辅助生成
- `2` - 内容来源于网络,进行整合再创作
- `3` - 个人看法,仅供参考
## Response
```json
{
"code": 200,
"traceId": "xxx",
"data": {
"url": "https://blog.csdn.net/xxx/article/details/xxx",
"id": 123456,
"qrcode": "xxx",
"title": "文章标题",
"description": ""
},
"msg": "success"
}
```
FILE:references/troubleshooting.md
# CSDN Article Publish Troubleshooting
## 1. 配置校验直接失败
如果脚本在发送请求前直接退出,通常是本地前置校验拦住了常见错误:
- `headers.Cookie` 缺失或仍是示例值:重新登录 CSDN 后,从 `saveArticle` 请求复制最新 Cookie
- `x-ca-nonce` / `x-ca-signature` / `x-ca-key` 缺失:说明请求头没有完整复制
- `x-ca-signature-headers` 不包含 `x-ca-key,x-ca-nonce`:签名链不完整,请重新抓包
- `tags` 超过 5 个:删减为最多 5 个标签
- `description` 超过 256 字:压缩摘要内容
- `publish` 模式缺少 `description`:补充摘要后再发布
## 2. 哪些请求头最容易过期
以下字段最容易失效或变化:
- `Cookie`:登录态过期后会失效
- `x-ca-nonce`:通常与当前请求相关,重新抓一次最稳妥
- `x-ca-signature`:签名字段,经常随请求变化
相对更稳定但仍建议一起更新的字段:
- `x-ca-signature-headers`
- `x-ca-key`
最稳妥的做法不是只替换单个字段,而是重新打开编辑器并重新抓取一整组 `saveArticle` 请求头。
## 3. 如何刷新签名相关字段
建议按以下流程刷新:
1. 登录 CSDN,并打开 Markdown 编辑器页面
2. 随便填写一个标题和一段正文
3. 点击一次“保存草稿”
4. 打开浏览器开发者工具,进入 Network
5. 找到 `saveArticle` 请求
6. 从请求头中完整复制以下字段到 `csdn_config.json`
- `Cookie`
- `x-ca-nonce`
- `x-ca-signature`
- `x-ca-signature-headers`
- `x-ca-key`
不要只更新其中一个字段,否则经常会出现签名不匹配的问题。
## 4. 保存草稿失败时先看什么
优先排查顺序:
1. 本地前置校验是否已经报错
2. `Cookie` 是否还是当前登录会话
3. `x-ca-nonce` 和 `x-ca-signature` 是否是最新抓取的值
4. 是否短时间内频繁保存导致限流
5. Markdown 文件是否为空、标题是否为空
如果接口返回了 `traceId`,建议保留它,方便后续定位具体请求。
## 5. 发布失败时先看什么
相比草稿保存,发布更容易因为字段不完整失败。重点检查:
1. `description` 是否已提供,且长度不超过 256 字
2. `readType`、`type`、`creation_statement` 是否是支持的值
3. `tags` 是否超过 5 个
4. 当前文章 ID 是否正确,或本地 `csdn_article_map.json` 是否映射到了正确文章
如果你是基于 `--file` 自动复用文章 ID,先检查 `csdn_article_map.json` 里该文件对应的 `id` 和 `url` 是否正确。
## 6. 限流怎么处理
CSDN 接口会出现限流。建议:
- 单次保存或更新之间至少间隔 5 到 10 秒
- 不要在短时间内批量连续发布多篇文章
- 一旦提示限流,先等待一段时间再继续
## 7. 本地文章映射失效怎么办
`csdn_article_map.json` 用来保存 Markdown 文件与文章 ID 的对应关系。如果映射错了:
- 直接删除错误条目后重新执行一次 `save`
- 或者在 `update` / `publish` 时显式传入 `--id` 覆盖映射
如果你移动了 Markdown 文件路径,映射键也会变化。最简单的修复方式是用新路径重新执行一次 `save` 或带 `--id` 的 `update`。
FILE:scripts/csdn_article.js
#!/usr/bin/env node
/**
* CSDN Blog Article Management Script
* Usage:
* node csdn_article.js save --title "标题" --content "内容"
* node csdn_article.js save --title "标题" --file path/to/article.md
* node csdn_article.js update --id 123456 --title "标题" --content "内容"
* node csdn_article.js publish --id 123456 --title "标题" --content "内容" --extra '{"tags":"python,async","creation_statement":1}'
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { markdownToHtml } = require('./markdown_to_html');
const DEFAULT_CONFIG_FILE = 'csdn_config.json';
const DEFAULT_ARTICLE_MAP_FILE = 'csdn_article_map.json';
const USER_AGENTS_FILE = path.join(__dirname, '../config/user_agents.json');
const API_URL = 'https://bizapi.csdn.net/blog-console-api/v3/mdeditor/saveArticle';
const MAX_RETRIES = 3;
const RETRY_DELAY = 3000;
const MAX_TAG_COUNT = 5;
const MAX_DESCRIPTION_LENGTH = 256;
const REQUIRED_HEADERS = ['Cookie', 'x-ca-nonce', 'x-ca-signature', 'x-ca-signature-headers', 'x-ca-key'];
const VALID_READ_TYPES = new Set(['public', 'private', 'read_need_fans', 'read_need_vip']);
const VALID_ARTICLE_TYPES = new Set(['original', 'repost', 'translated']);
const VALID_CREATION_STATEMENTS = new Set([0, 1, 2, 3]);
const VALID_PUB_STATUS = new Set(['draft', 'publish']);
const TROUBLESHOOTING_DOC = 'skills/csdn-article-generator-publish/references/troubleshooting.md';
const log = {
info: (msg) => console.log(`\x1b[36m[INFO]\x1b[0m msg`),
success: (msg) => console.log(`\x1b[32m[SUCCESS]\x1b[0m msg`),
error: (msg) => console.error(`\x1b[31m[ERROR]\x1b[0m msg`),
warn: (msg) => console.warn(`\x1b[33m[WARN]\x1b[0m msg`),
step: (msg) => console.log(`\x1b[90m → msg\x1b[0m`)
};
function parseArgs() {
const args = process.argv.slice(2);
const result = { command: null, options: {} };
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].replace('--', '');
const value = args[i + 1];
if (value && !value.startsWith('--')) {
if (key === 'id') {
result.options.id = parseInt(value, 10);
} else if (key === 'config') {
result.options.config = value;
} else {
result.options[key] = value;
}
i++;
} else {
result.options[key] = true;
}
} else if (!result.command) {
result.command = args[i];
}
}
return result;
}
function loadConfig(configPath) {
const resolvedPath = path.resolve(configPath);
if (!fs.existsSync(resolvedPath)) {
log.error(`Config file 'resolvedPath' not found`);
process.exit(1);
}
return JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
}
function parseExtra(extraValue) {
if (!extraValue) {
return {};
}
try {
return JSON.parse(extraValue);
} catch (error) {
log.error('Invalid JSON in --extra parameter');
log.step("Fix suggestion: ensure --extra uses valid JSON, for example --extra '{\"tags\":\"python,async\"}'");
process.exit(1);
}
}
function getArticleMapPath() {
return path.resolve(process.cwd(), DEFAULT_ARTICLE_MAP_FILE);
}
function loadArticleMap() {
const articleMapPath = getArticleMapPath();
if (!fs.existsSync(articleMapPath)) {
return {
path: articleMapPath,
data: {
version: 1,
articles: {}
}
};
}
try {
const data = JSON.parse(fs.readFileSync(articleMapPath, 'utf-8'));
return {
path: articleMapPath,
data: {
version: data.version || 1,
articles: data.articles || {}
}
};
} catch (error) {
log.error(`Failed to parse article map 'articleMapPath'`);
log.step('Fix suggestion: repair or remove the local map file, then run save again to recreate it');
process.exit(1);
}
}
function saveArticleMap(articleMap) {
fs.writeFileSync(articleMap.path, `JSON.stringify(articleMap.data, null, 2)\n`, 'utf-8');
}
function normalizeFileKey(filePath) {
const absolutePath = path.resolve(filePath);
const relativePath = path.relative(process.cwd(), absolutePath);
if (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) {
return relativePath.split(path.sep).join('/');
}
return absolutePath;
}
function updateArticleMap(articleMap, filePath, articleData, command) {
if (!filePath || !articleData || !articleData.id) {
return;
}
const key = normalizeFileKey(filePath);
const existing = articleMap.data.articles[key] || {};
articleMap.data.articles[key] = {
...existing,
id: String(articleData.id),
url: articleData.url || existing.url || '',
title: articleData.title || existing.title || '',
lastCommand: command,
updatedAt: new Date().toISOString()
};
}
function findArticleIdByFile(articleMap, filePath) {
if (!filePath) {
return null;
}
const key = normalizeFileKey(filePath);
return articleMap.data.articles[key] || null;
}
function isPlaceholderValue(value) {
if (typeof value !== 'string') {
return false;
}
const trimmed = value.trim();
const normalized = trimmed.toLowerCase();
const placeholderValues = new Set([
'your_cookie_here',
'your cookie',
'用户cookie',
'uuid',
'签名',
'api key',
'xxxxxxxx',
'xxxxxx'
]);
return placeholderValues.has(normalized) || /^x{6,}$/i.test(trimmed) || /^your[_ -]/i.test(trimmed);
}
function printValidationErrors(title, errors) {
log.error(title);
errors.forEach((error, index) => {
log.step(`index + 1. error`);
});
process.exit(1);
}
function validateConfig(config, configFile) {
const errors = [];
const headers = config.headers || {};
const defaults = config.defaults || {};
REQUIRED_HEADERS.forEach((headerName) => {
const value = headers[headerName];
if (typeof value !== 'string' || !value.trim()) {
errors.push(`配置文件 configFile 缺少 headers.headerName,请从 saveArticle 请求头补齐该字段`);
return;
}
if (isPlaceholderValue(value)) {
errors.push(`配置文件 configFile 中 headers.headerName 仍是示例值,请替换为你自己的真实请求头`);
}
});
if (typeof headers.Cookie === 'string' && headers.Cookie.trim() && headers.Cookie.trim().length < 20) {
errors.push('headers.Cookie 长度异常偏短,通常表示 Cookie 未完整复制,建议重新从浏览器复制 saveArticle 请求头');
}
if (typeof headers['x-ca-signature-headers'] === 'string') {
const signatureHeaders = headers['x-ca-signature-headers'];
if (!signatureHeaders.includes('x-ca-key') || !signatureHeaders.includes('x-ca-nonce')) {
errors.push('headers.x-ca-signature-headers 必须包含 x-ca-key,x-ca-nonce,否则签名校验大概率失败');
}
}
if (defaults.readType && !VALID_READ_TYPES.has(defaults.readType)) {
errors.push(`defaults.readType='defaults.readType' 不合法,可选值:public/private/read_need_fans/read_need_vip`);
}
if (defaults.type && !VALID_ARTICLE_TYPES.has(defaults.type)) {
errors.push(`defaults.type='defaults.type' 不合法,可选值:original/repost/translated`);
}
if (defaults.pubStatus && !VALID_PUB_STATUS.has(defaults.pubStatus)) {
errors.push(`defaults.pubStatus='defaults.pubStatus' 不合法,可选值:draft/publish`);
}
if (defaults.creation_statement !== undefined && !VALID_CREATION_STATEMENTS.has(Number(defaults.creation_statement))) {
errors.push(`defaults.creation_statement='defaults.creation_statement' 不合法,可选值:0/1/2/3`);
}
return errors;
}
function resolveContentAndTitle(args) {
let content = args.content;
let title = args.title ? String(args.title).trim() : '';
let resolvedFilePath = null;
if (args.file) {
resolvedFilePath = path.resolve(args.file);
log.step(`Reading file: resolvedFilePath`);
if (!fs.existsSync(resolvedFilePath)) {
printValidationErrors('Input validation failed', [`Markdown file 'resolvedFilePath' not found,请检查 --file 路径是否正确`]);
}
content = fs.readFileSync(resolvedFilePath, 'utf-8');
log.step(`File loaded, size: content.length characters`);
if (!title) {
title = path.basename(resolvedFilePath, path.extname(resolvedFilePath));
log.step(`Using filename as title: title`);
}
}
return {
content,
title,
resolvedFilePath
};
}
function splitTags(tagsValue) {
if (!tagsValue) {
return [];
}
return String(tagsValue)
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
}
function validateResolvedInput(command, payload, resolvedFilePath) {
const errors = [];
const tags = splitTags(payload.tags);
const description = typeof payload.Description === 'string' ? payload.Description.trim() : '';
if (!payload.markdowncontent || !String(payload.markdowncontent).trim()) {
errors.push('文章内容为空,请提供 --content 或使用 --file 指向一个非空 Markdown 文件');
}
if (!payload.title || !String(payload.title).trim()) {
errors.push('文章标题为空,请提供 --title,或者使用有文件名的 --file');
}
if ((command === 'update' || command === 'publish') && !payload.id) {
if (resolvedFilePath) {
errors.push('未找到文章 ID。请先用 save 保存该文件建立映射,或在本次命令中显式传入 --id');
} else {
errors.push('update/publish 需要文章 ID,请传入 --id,或改用 --file 并确保该文件已经保存过草稿');
}
}
if (tags.length > MAX_TAG_COUNT) {
errors.push(`tags 超过 MAX_TAG_COUNT 个,当前 tags.length 个,请删减后重试`);
}
if (description.length > MAX_DESCRIPTION_LENGTH) {
errors.push(`description 超过 MAX_DESCRIPTION_LENGTH 字,当前 description.length 字,请压缩摘要后重试`);
}
if (!VALID_READ_TYPES.has(payload.readType)) {
errors.push(`readType='payload.readType' 不合法,可选值:public/private/read_need_fans/read_need_vip`);
}
if (!VALID_ARTICLE_TYPES.has(payload.type)) {
errors.push(`type='payload.type' 不合法,可选值:original/repost/translated`);
}
if (!VALID_CREATION_STATEMENTS.has(Number(payload.creation_statement))) {
errors.push(`creation_statement='payload.creation_statement' 不合法,可选值:0/1/2/3`);
}
if (!VALID_PUB_STATUS.has(payload.pubStatus)) {
errors.push(`pubStatus='payload.pubStatus' 不合法,可选值:draft/publish`);
}
if (command === 'publish') {
if (!description) {
errors.push('publish 模式要求提供 description 摘要。请在 --extra 中传入 description,或在配置 defaults.description 中设置默认值');
}
}
return errors;
}
function buildFailureHints(result) {
const message = typeof result === 'string' ? result : `result.msg || '' result.code || ''`;
const normalized = message.toLowerCase();
const hints = [];
if (normalized.includes('signature') || normalized.includes('签名')) {
hints.push('签名相关字段通常会随请求变化,请重新从浏览器里复制最新的 saveArticle 请求头');
}
if (normalized.includes('cookie') || normalized.includes('登录') || normalized.includes('unauthorized') || normalized.includes('403')) {
hints.push('Cookie 可能已过期。建议重新登录 CSDN 编辑器后,再抓取一次 saveArticle 请求头');
}
if (normalized.includes('nonce')) {
hints.push('x-ca-nonce 通常是一次性值,刷新页面后重新抓取请求头更稳妥');
}
if (normalized.includes('429') || normalized.includes('限流')) {
hints.push('已触发限流,建议至少等待 5 到 10 秒后再重试');
}
return hints;
}
function loadUserAgents() {
if (fs.existsSync(USER_AGENTS_FILE)) {
return JSON.parse(fs.readFileSync(USER_AGENTS_FILE, 'utf-8'));
}
return ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'];
}
function getRandomUserAgent() {
const agents = loadUserAgents();
return agents[Math.floor(Math.random() * agents.length)];
}
function buildHeaders(config) {
return {
'accept': '*/*',
'accept-language': 'zh-CN,zh;q=0.9',
'content-type': 'application/json',
'origin': 'https://editor.csdn.net',
'referer': 'https://editor.csdn.net/',
'user-agent': getRandomUserAgent(),
...config
};
}
function buildPayload(args, config) {
const defaults = config.defaults || {};
const extra = args.extra || {};
const isPublish = extra.pubStatus === 'publish' || args.command === 'publish';
const htmlContent = markdownToHtml(args.content);
const payload = {
id: args.id ? String(args.id) : undefined,
title: args.title,
content: htmlContent,
markdowncontent: args.content,
Description: extra.description || defaults.description || '',
readType: extra.readType || defaults.readType || 'public',
level: 0,
tags: extra.tags || defaults.tags || '',
status: isPublish ? 0 : 2,
categories: extra.categories || defaults.categories || '',
type: extra.type || defaults.type || 'original',
original_link: '',
authorized_status: false,
not_auto_saved: '1',
source: 'pc_mdeditor',
cover_images: [],
cover_type: 1,
is_new: 1,
vote_id: 0,
resource_id: '',
pubStatus: extra.pubStatus || defaults.pubStatus || 'draft',
creation_statement: extra.creation_statement !== undefined ? extra.creation_statement : (defaults.creation_statement !== undefined ? defaults.creation_statement : 0),
creator_activity_id: ''
};
return payload;
}
function post(url, headers, data) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname,
method: 'POST',
headers: {
...headers,
'Content-Length': Buffer.byteLength(JSON.stringify(data))
}
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch {
resolve(body);
}
});
});
req.on('error', reject);
req.write(JSON.stringify(data));
req.end();
});
}
async function postWithRetry(url, headers, data, retries = MAX_RETRIES) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const result = await post(url, headers, data);
if (result.code === 200) {
return result;
}
if (result.code === 429 || (result.msg && result.msg.includes('限流'))) {
log.warn(`Rate limited, retrying in RETRY_DELAY / 1000s... (attempt attempt/retries)`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
continue;
}
return result;
} catch (err) {
if (attempt < retries) {
log.warn(`Request failed: err.message, retrying... (attempt attempt/retries)`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
} else {
throw err;
}
}
}
throw new Error('Max retries exceeded');
}
function printUsage() {
console.log('');
console.log('CSDN Article Management Script');
console.log('');
console.log('Usage:');
console.log(' node csdn_article.js save --title "标题" --content "内容"');
console.log(' node csdn_article.js save --title "标题" --file path/to/article.md');
console.log(' node csdn_article.js update --id 123456 --title "标题" --content "内容"');
console.log(' node csdn_article.js update --id 123456 --file path/to/article.md');
console.log(' node csdn_article.js update --file path/to/article.md');
console.log(' node csdn_article.js publish --id 123456 --title "标题" --content "内容" --extra \'{"tags":"python,async","description":"文章摘要"}\'');
console.log(' node csdn_article.js publish --file path/to/article.md --extra \'{"tags":"python,async","description":"文章摘要"}\'');
console.log('');
console.log('Options:');
console.log(' --title: 文章标题');
console.log(' --content: Markdown内容(与--file二选一)');
console.log(' --file: Markdown文件路径(与--content二选一,推荐)');
console.log(' --id: 文章ID(原有参数,继续支持显式传入;若--file已有本地映射也可省略)');
console.log(' --extra: JSON格式扩展参数');
console.log(' --config: 配置文件路径(默认: csdn_config.json)');
console.log('');
console.log('Extra options (via --extra JSON):');
console.log(' tags: 标签(逗号分隔)');
console.log(' readType: 可见范围 (public/private/read_need_fans/read_need_vip)');
console.log(' type: 文章类型 (original/repost/translated),默认值为original原创');
console.log(' creation_statement: 创作者声明 (0/1/2/3) 默认值为0,即不声明');
console.log(' pubStatus: 发布状态 (draft/publish)');
console.log(' description: 摘要(发布时必填,最多256字)');
console.log('');
console.log(`Local article map: DEFAULT_ARTICLE_MAP_FILE`);
console.log('');
}
async function main() {
const { command, options } = parseArgs();
if (!command || command === '--help' || command === '-h') {
printUsage();
process.exit(0);
}
if (!['save', 'update', 'publish'].includes(command)) {
log.error(`Unknown command: command`);
printUsage();
process.exit(1);
}
const configFile = options.config || DEFAULT_CONFIG_FILE;
log.info(`Using config file: configFile`);
log.step('Loading configuration...');
const config = loadConfig(configFile);
const configErrors = validateConfig(config, configFile);
if (configErrors.length > 0) {
printValidationErrors('Configuration validation failed', configErrors);
}
const articleMap = loadArticleMap();
const extra = parseExtra(options.extra);
if (command === 'publish') {
extra.pubStatus = 'publish';
log.step('Publish mode enabled');
}
const resolvedInput = resolveContentAndTitle(options);
const articleRecord = findArticleIdByFile(articleMap, resolvedInput.resolvedFilePath);
if (!options.id && articleRecord && (command === 'update' || command === 'publish')) {
options.id = articleRecord.id;
log.step(`Resolved article ID from DEFAULT_ARTICLE_MAP_FILE: options.id`);
}
const buildArgs = {
command,
id: options.id,
title: resolvedInput.title,
content: resolvedInput.content,
extra
};
log.step('Building request headers...');
const headers = buildHeaders(config.headers || {});
log.step(`User-Agent: headers['user-agent'].substring(0, 60)...`);
log.step('Building request payload...');
let payload;
try {
payload = buildPayload(buildArgs, config);
} catch (error) {
log.error(`Failed to convert Markdown to HTML: error.message`);
process.exit(1);
}
const inputErrors = validateResolvedInput(command, payload, resolvedInput.resolvedFilePath);
if (inputErrors.length > 0) {
printValidationErrors('Input validation failed', inputErrors);
}
log.step(`Article title: payload.title`);
log.step(`Content size: payload.markdowncontent.length characters`);
log.step(`PubStatus: payload.pubStatus`);
log.info(`Executing command command...`);
try {
const result = await postWithRetry(API_URL, headers, payload);
if (result.code === 200) {
log.success('Article saved successfully!');
updateArticleMap(articleMap, resolvedInput.resolvedFilePath, result.data, command);
if (resolvedInput.resolvedFilePath) {
saveArticleMap(articleMap);
log.step(`Updated local article map: articleMap.path`);
}
console.log('');
console.log(` Article URL: result.data.url`);
console.log(` Article ID: result.data.id`);
console.log(` Title: result.data.title`);
console.log('');
} else {
log.error(`Failed: result.msg`);
if (result.traceId) {
log.error(`Trace ID: result.traceId`);
}
const failureHints = buildFailureHints(result);
failureHints.forEach((hint) => log.step(`Troubleshooting: hint`));
if (failureHints.length > 0) {
log.step(`See TROUBLESHOOTING_DOC for more details`);
}
process.exit(1);
}
} catch (err) {
log.error(`Request failed: err.message`);
log.step(`See TROUBLESHOOTING_DOC for common recovery steps`);
process.exit(1);
}
}
main();
FILE:scripts/markdown_to_html.js
const { marked } = require('./marked.umd.js');
if (!marked || typeof marked.parse !== 'function') {
throw new Error('Local marked.umd.js does not export marked.parse');
}
function markdownToHtml(markdown) {
return marked.parse(String(markdown || ''));
}
module.exports = {
markdownToHtml
};
FILE:scripts/marked.umd.js
/**
* marked v17.0.4 - a markdown parser
* Copyright (c) 2018-2026, MarkedJS. (MIT License)
* Copyright (c) 2011-2018, Christopher Jeffrey. (MIT License)
* https://github.com/markedjs/marked
*/
/**
* DO NOT EDIT THIS FILE
* The code in this file is generated from files in ./src/
*/
(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports};
"use strict";var G=Object.defineProperty;var Te=Object.getOwnPropertyDescriptor;var Oe=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)G(l,t,{get:e[t],enumerable:!0})},Pe=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of Oe(e))!we.call(l,r)&&r!==t&&G(l,r,{get:()=>e[r],enumerable:!(n=Te(e,r))||n.enumerable});return l};var Se=l=>Pe(G({},"__esModule",{value:!0}),l);var Tt={};ye(Tt,{Hooks:()=>P,Lexer:()=>x,Marked:()=>I,Parser:()=>b,Renderer:()=>y,TextRenderer:()=>S,Tokenizer:()=>w,defaults:()=>R,getDefaults:()=>_,lexer:()=>Rt,marked:()=>g,options:()=>kt,parse:()=>xt,parseInline:()=>mt,parser:()=>bt,setOptions:()=>dt,use:()=>gt,walkTokens:()=>ft});module.exports=Se(Tt);function _(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var R=_();function Z(l){R=l}var L={exec:()=>null};function k(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(r,i)=>{let s=typeof i=="string"?i:i.source;return s=s.replace(m.caret,"$1"),t=t.replace(r,s),n},getRegex:()=>new RegExp(t,e)};return n}var $e=(()=>{try{return!!new RegExp("(?<=1)(?<!1)")}catch{return!1}})(),m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^<a /i,endATag:/^<\/a>/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^</,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}l)((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,Math.min(3,l-1)}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,Math.min(3,l-1)}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,Math.min(3,l-1)}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,Math.min(3,l-1)}#`),htmlBeginRegex:l=>new RegExp(`^ {0,Math.min(3,l-1)}<(?:[a-z].*>|!--)`,"i"),blockquoteBeginRegex:l=>new RegExp(`^ {0,Math.min(3,l-1)}>`)},_e=/^(?:[ \t]*(?:\n|$))+/,Le=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Me=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,C=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,Q=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,se=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,ie=k(se).replace(/bull/g,Q).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Ee=k(se).replace(/bull/g,Q).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),j=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Ie=/^[^\n]+/,F=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,Ae=k(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",F).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ce=k(/^(bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,Q).getRegex(),q="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",U=/<!--(?:-?>|[\s\S]*?(?:-->|$))/,Be=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|<![A-Z][\\s\\S]*?(?:>\\n*|$)|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)|</?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",U).replace("tag",q).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),oe=k(j).replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",q).getRegex(),De=k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",oe).getRegex(),K={blockquote:De,code:Le,def:Ae,fences:Me,heading:ze,hr:C,html:Be,lheading:ie,list:Ce,newline:_e,paragraph:oe,table:L,text:Ie},ne=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",q).getRegex(),qe={...K,lheading:Ee,table:ne,paragraph:k(j).replace("hr",C).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",ne).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html","</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",q).getRegex()},ve={...K,html:k(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?</\\1> *(?:\\n{2,}|\\s*$)|<tag(?:"[^"]*"|'[^']*'|\\s[^'"/>\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",U).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:L,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(j).replace("hr",C).replace("heading",` *#{1,6} *[^
]`).replace("lheading",ie).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},He=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Ge=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,Ze=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/,v=/[\p{P}\p{S}]/u,W=/[\s\p{P}\p{S}]/u,le=/[^\s\p{P}\p{S}]/u,Ne=k(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,W).getRegex(),ue=/(?!~)[\p{P}\p{S}]/u,Qe=/(?!~)[\s\p{P}\p{S}]/u,je=/(?:[^\s\p{P}\p{S}]|~)/u,pe=/(?![*_])[\p{P}\p{S}]/u,Fe=/(?![*_])[\s\p{P}\p{S}]/u,Ue=/(?:[^\s\p{P}\p{S}]|[*_])/u,Ke=k(/link|precode-code|html/,"g").replace("link",/\[(?:[^\[\]`]|(?<a>`+)[^`]+\k<a>(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",$e?"(?<!`)()":"(^^|[^`])").replace("code",/(?<b>`+)[^`]+\k<b>(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),ce=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,We=k(ce,"u").replace(/punct/g,v).getRegex(),Xe=k(ce,"u").replace(/punct/g,ue).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Je=k(he,"gu").replace(/notPunctSpace/g,le).replace(/punctSpace/g,W).replace(/punct/g,v).getRegex(),Ve=k(he,"gu").replace(/notPunctSpace/g,je).replace(/punctSpace/g,Qe).replace(/punct/g,ue).getRegex(),Ye=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,le).replace(/punctSpace/g,W).replace(/punct/g,v).getRegex(),et=k(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,pe).getRegex(),tt="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",nt=k(tt,"gu").replace(/notPunctSpace/g,Ue).replace(/punctSpace/g,Fe).replace(/punct/g,pe).getRegex(),rt=k(/\\(punct)/,"gu").replace(/punct/g,v).getRegex(),st=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),it=k(U).replace("(?:-->|$)","-->").getRegex(),ot=k("^comment|^</[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^<![a-zA-Z]+\\s[\\s\\S]*?>|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>").replace("comment",it).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),D=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,at=k(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",D).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=k(/^!?\[(label)\]\[(ref)\]/).replace("label",D).replace("ref",F).getRegex(),de=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",F).getRegex(),lt=k("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",de).getRegex(),re=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,X={_backpedal:L,anyPunctuation:rt,autolink:st,blockSkip:Ke,br:ae,code:Ge,del:L,delLDelim:L,delRDelim:L,emStrongLDelim:We,emStrongRDelimAst:Je,emStrongRDelimUnd:Ye,escape:He,link:at,nolink:de,punctuation:Ne,reflink:ke,reflinkSearch:lt,tag:ot,text:Ze,url:L},ut={...X,link:k(/^!?\[(label)\]\((.*?)\)/).replace("label",D).getRegex(),reflink:k(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",D).getRegex()},N={...X,emStrongRDelimAst:Ve,emStrongLDelim:Xe,delLDelim:et,delRDelim:nt,url:k(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",re).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:k(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|protocol:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/).replace("protocol",re).getRegex()},pt={...N,br:k(ae).replace("{2,}","*").getRegex(),text:k(N.text).replace("\\b_","\\b_| {2,}\\n").replace(/\{2,\}/g,"*").getRegex()},B={normal:K,gfm:qe,pedantic:ve},z={normal:X,gfm:N,breaks:pt,pedantic:ut};var ct={"&":"&","<":"<",">":">",'"':""","'":"'"},ge=l=>ct[l];function O(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,ge)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,ge);return l}function J(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function V(l,e){let t=l.replace(m.findPipe,(i,s,a)=>{let o=!1,u=s;for(;--u>=0&&a[u]==="\\";)o=!o;return o?"|":" |"}),n=t.split(m.splitPipe),r=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length<e;)n.push("");for(;r<n.length;r++)n[r]=n[r].trim().replace(m.slashPipe,"|");return n}function E(l,e,t){let n=l.length;if(n===0)return"";let r=0;for(;r<n;){let i=l.charAt(n-r-1);if(i===e&&!t)r++;else if(i!==e&&t)r++;else break}return l.slice(0,n-r)}function fe(l,e){if(l.indexOf(e[1])===-1)return-1;let t=0;for(let n=0;n<l.length;n++)if(l[n]==="\\")n++;else if(l[n]===e[0])t++;else if(l[n]===e[1]&&(t--,t<0))return n;return t>0?-2:-1}function me(l,e=0){let t=e,n="";for(let r of l)if(r===" "){let i=4-t%4;n+=" ".repeat(i),t+=i}else n+=r,t++;return n}function xe(l,e,t,n,r){let i=e.href,s=e.title||null,a=l[1].replace(r.other.outputLinkReplace,"$1");n.state.inLink=!0;let o={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:s,text:a,tokens:n.inlineTokens(a)};return n.state.inLink=!1,o}function ht(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let r=n[1];return e.split(`
`).map(i=>{let s=i.match(t.other.beginningSpace);if(s===null)return i;let[a]=s;return a.length>=r.length?i.slice(r.length):i}).join(`
`)}var w=class{options;rules;lexer;constructor(e){this.options=e||R}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:E(n,`
`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],r=ht(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:r}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let r=E(n,"#");(this.options.pedantic||!r||this.rules.other.endingSpaceChar.test(r))&&(n=r.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:E(t[0],`
`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=E(t[0],`
`).split(`
`),r="",i="",s=[];for(;n.length>0;){let a=!1,o=[],u;for(u=0;u<n.length;u++)if(this.rules.other.blockquoteStart.test(n[u]))o.push(n[u]),a=!0;else if(!a)o.push(n[u]);else break;n=n.slice(u);let p=o.join(`
`),c=p.replace(this.rules.other.blockquoteSetextReplace,`
$1`).replace(this.rules.other.blockquoteSetextReplace2,"");r=r?`r
p`:p,i=i?`i
c`:c;let d=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(c,s,!0),this.lexer.state.top=d,n.length===0)break;let h=s.at(-1);if(h?.type==="code")break;if(h?.type==="blockquote"){let T=h,f=T.raw+`
`+n.join(`
`),$=this.blockquote(f);s[s.length-1]=$,r=r.substring(0,r.length-T.raw.length)+$.raw,i=i.substring(0,i.length-T.text.length)+$.text;break}else if(h?.type==="list"){let T=h,f=T.raw+`
`+n.join(`
`),$=this.list(f);s[s.length-1]=$,r=r.substring(0,r.length-h.raw.length)+$.raw,i=i.substring(0,i.length-T.raw.length)+$.raw,n=f.substring(s.at(-1).raw.length).split(`
`);continue}}return{type:"blockquote",raw:r,tokens:s,text:i}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim(),r=n.length>1,i={type:"list",raw:"",ordered:r,start:r?+n.slice(0,-1):"",loose:!1,items:[]};n=r?`\\d{1,9}\\n.slice(-1)`:`\\n`,this.options.pedantic&&(n=r?n:"[*+-]");let s=this.rules.other.listItemRegex(n),a=!1;for(;e;){let u=!1,p="",c="";if(!(t=s.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=me(t[2].split(`
`,1)[0],t[1].length),h=e.split(`
`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,c=d.trimStart()):T?f=t[1].length+1:(f=d.search(this.rules.other.nonSpaceChar),f=f>4?1:f,c=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(h)&&(p+=h+`
`,e=e.substring(h.length+1),u=!0),!u){let $=this.rules.other.nextBulletRegex(f),Y=this.rules.other.hrRegex(f),ee=this.rules.other.fencesBeginRegex(f),te=this.rules.other.headingBeginRegex(f),be=this.rules.other.htmlBeginRegex(f),Re=this.rules.other.blockquoteBeginRegex(f);for(;e;){let H=e.split(`
`,1)[0],A;if(h=H,this.options.pedantic?(h=h.replace(this.rules.other.listReplaceNesting," "),A=h):A=h.replace(this.rules.other.tabCharGlobal," "),ee.test(h)||te.test(h)||be.test(h)||Re.test(h)||$.test(h)||Y.test(h))break;if(A.search(this.rules.other.nonSpaceChar)>=f||!h.trim())c+=`
`+A.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ee.test(d)||te.test(d)||Y.test(d))break;c+=`
`+h}T=!h.trim(),p+=H+`
`,e=e.substring(H.length+1),d=A.slice(f)}}i.loose||(a?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(a=!0)),i.items.push({type:"list_item",raw:p,task:!!this.options.gfm&&this.rules.other.listIsTask.test(c),loose:!1,text:c,tokens:[]}),i.raw+=p}let o=i.items.at(-1);if(o)o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let u of i.items){if(this.lexer.state.top=!1,u.tokens=this.lexer.blockTokens(u.text,[]),u.task){if(u.text=u.text.replace(this.rules.other.listReplaceTask,""),u.tokens[0]?.type==="text"||u.tokens[0]?.type==="paragraph"){u.tokens[0].raw=u.tokens[0].raw.replace(this.rules.other.listReplaceTask,""),u.tokens[0].text=u.tokens[0].text.replace(this.rules.other.listReplaceTask,"");for(let c=this.lexer.inlineQueue.length-1;c>=0;c--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[c].src)){this.lexer.inlineQueue[c].src=this.lexer.inlineQueue[c].src.replace(this.rules.other.listReplaceTask,"");break}}let p=this.rules.other.listTaskCheckbox.exec(u.raw);if(p){let c={type:"checkbox",raw:p[0]+" ",checked:p[0]!=="[ ]"};u.checked=c.checked,i.loose?u.tokens[0]&&["paragraph","text"].includes(u.tokens[0].type)&&"tokens"in u.tokens[0]&&u.tokens[0].tokens?(u.tokens[0].raw=c.raw+u.tokens[0].raw,u.tokens[0].text=c.raw+u.tokens[0].text,u.tokens[0].tokens.unshift(c)):u.tokens.unshift({type:"paragraph",raw:c.raw,text:c.raw,tokens:[c]}):u.tokens.unshift(c)}}if(!i.loose){let p=u.tokens.filter(d=>d.type==="space"),c=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=c}}if(i.loose)for(let u of i.items){u.loose=!0;for(let p of u.tokens)p.type==="text"&&(p.type="paragraph")}return i}}html(e){let t=this.rules.block.html.exec(e);if(t)return{type:"html",block:!0,raw:t[0],pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:t[0]}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),r=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:t[0],href:r,title:i}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=V(t[1]),r=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),i=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(`
`):[],s={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===r.length){for(let a of r)this.rules.other.tableAlignRight.test(a)?s.align.push("right"):this.rules.other.tableAlignCenter.test(a)?s.align.push("center"):this.rules.other.tableAlignLeft.test(a)?s.align.push("left"):s.align.push(null);for(let a=0;a<n.length;a++)s.header.push({text:n[a],tokens:this.lexer.inline(n[a]),header:!0,align:s.align[a]});for(let a of i)s.rows.push(V(a,s.header.length).map((o,u)=>({text:o,tokens:this.lexer.inline(o),header:!1,align:s.align[u]})));return s}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let s=E(n.slice(0,-1),"\\");if((n.length-s.length)%2===0)return}else{let s=fe(t[2],"()");if(s===-2)return;if(s>-1){let o=(t[0].indexOf("!")===0?5:4)+t[1].length+s;t[2]=t[2].substring(0,s),t[0]=t[0].substring(0,o).trim(),t[3]=""}}let r=t[2],i="";if(this.options.pedantic){let s=this.rules.other.pedanticHrefTitle.exec(r);s&&(r=s[1],i=s[3])}else i=t[3]?t[3].slice(1,-1):"";return r=r.trim(),this.rules.other.startAngleBracket.test(r)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?r=r.slice(1):r=r.slice(1,-1)),xe(t,{href:r&&r.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let r=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[r.toLowerCase()];if(!i){let s=n[0].charAt(0);return{type:"text",raw:s,text:s}}return xe(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let r=this.rules.inline.emStrongLDelim.exec(e);if(!r||r[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(r[1]||r[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let s=[...r[0]].length-1,a,o,u=s,p=0,c=r[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(c.lastIndex=0,t=t.slice(-1*e.length+s);(r=c.exec(t))!=null;){if(a=r[1]||r[2]||r[3]||r[4]||r[5]||r[6],!a)continue;if(o=[...a].length,r[3]||r[4]){u+=o;continue}else if((r[5]||r[6])&&s%3&&!((s+o)%3)){p+=o;continue}if(u-=o,u>0)continue;o=Math.min(o,o+u+p);let d=[...r[0]][0].length,h=e.slice(0,s+r.index+d+o);if(Math.min(s,o)%2){let f=h.slice(1,-1);return{type:"em",raw:h,text:f,tokens:this.lexer.inlineTokens(f)}}let T=h.slice(2,-2);return{type:"strong",raw:h,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),r=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return r&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e,t,n=""){let r=this.rules.inline.delLDelim.exec(e);if(!r)return;if(!(r[1]||"")||!n||this.rules.inline.punctuation.exec(n)){let s=[...r[0]].length-1,a,o,u=s,p=this.rules.inline.delRDelim;for(p.lastIndex=0,t=t.slice(-1*e.length+s);(r=p.exec(t))!=null;){if(a=r[1]||r[2]||r[3]||r[4]||r[5]||r[6],!a||(o=[...a].length,o!==s))continue;if(r[3]||r[4]){u+=o;continue}if(u-=o,u>0)continue;o=Math.min(o,o+u);let c=[...r[0]][0].length,d=e.slice(0,s+r.index+c+o),h=d.slice(s,-s);return{type:"del",raw:d,text:h,tokens:this.lexer.inlineTokens(h)}}}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,r;return t[2]==="@"?(n=t[1],r="mailto:"+n):(n=t[1],r=n),{type:"link",raw:t[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,r;if(t[2]==="@")n=t[0],r="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?r="http://"+t[0]:r=t[0]}return{type:"link",raw:t[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;inlineQueue;tokenizer;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||R,this.options.tokenizer=this.options.tokenizer||new w,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:z.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=z.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=z.breaks:t.inline=z.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:z}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
`),this.blockTokens(e,this.tokens);for(let t=0;t<this.inlineQueue.length;t++){let n=this.inlineQueue[t];this.inlineTokens(n.src,n.tokens)}return this.inlineQueue=[],this.tokens}blockTokens(e,t=[],n=!1){for(this.options.pedantic&&(e=e.replace(m.tabCharGlobal," ").replace(m.spaceLine,""));e;){let r;if(this.options.extensions?.block?.some(s=>(r=s.call({lexer:this},e,t))?(e=e.substring(r.raw.length),t.push(r),!0):!1))continue;if(r=this.tokenizer.space(e)){e=e.substring(r.raw.length);let s=t.at(-1);r.raw.length===1&&s!==void 0?s.raw+=`
`:t.push(r);continue}if(r=this.tokenizer.code(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(`
`)?"":`
`)+r.raw,s.text+=`
`+r.text,this.inlineQueue.at(-1).src=s.text):t.push(r);continue}if(r=this.tokenizer.fences(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.heading(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.hr(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.blockquote(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.list(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.html(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.def(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(`
`)?"":`
`)+r.raw,s.text+=`
`+r.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},t.push(r));continue}if(r=this.tokenizer.table(e)){e=e.substring(r.raw.length),t.push(r);continue}if(r=this.tokenizer.lheading(e)){e=e.substring(r.raw.length),t.push(r);continue}let i=e;if(this.options.extensions?.startBlock){let s=1/0,a=e.slice(1),o;this.options.extensions.startBlock.forEach(u=>{o=u.call({lexer:this},a),typeof o=="number"&&o>=0&&(s=Math.min(s,o))}),s<1/0&&s>=0&&(i=e.substring(0,s+1))}if(this.state.top&&(r=this.tokenizer.paragraph(i))){let s=t.at(-1);n&&s?.type==="paragraph"?(s.raw+=(s.raw.endsWith(`
`)?"":`
`)+r.raw,s.text+=`
`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r),n=i.length!==e.length,e=e.substring(r.raw.length);continue}if(r=this.tokenizer.text(e)){e=e.substring(r.raw.length);let s=t.at(-1);s?.type==="text"?(s.raw+=(s.raw.endsWith(`
`)?"":`
`)+r.raw,s.text+=`
`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):t.push(r);continue}if(e){let s="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(s);break}else throw new Error(s)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,r=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(r=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(r[0].slice(r[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(r=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,r.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let i;for(;(r=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)i=r[2]?r[2].length:0,n=n.slice(0,r.index+i)+"["+"a".repeat(r[0].length-i-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);n=this.options.hooks?.emStrongMask?.call({lexer:this},n)??n;let s=!1,a="";for(;e;){s||(a=""),s=!1;let o;if(this.options.extensions?.inline?.some(p=>(o=p.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let p=t.at(-1);o.type==="text"&&p?.type==="text"?(p.raw+=o.raw,p.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,a)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e,n,a)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let u=e;if(this.options.extensions?.startInline){let p=1/0,c=e.slice(1),d;this.options.extensions.startInline.forEach(h=>{d=h.call({lexer:this},c),typeof d=="number"&&d>=0&&(p=Math.min(p,d))}),p<1/0&&p>=0&&(u=e.substring(0,p+1))}if(o=this.tokenizer.inlineText(u)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(a=o.raw.slice(-1)),s=!0;let p=t.at(-1);p?.type==="text"?(p.raw+=o.raw,p.text+=o.text):t.push(o);continue}if(e){let p="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(p);break}else throw new Error(p)}}return t}};var y=class{options;parser;constructor(e){this.options=e||R}space(e){return""}code({text:e,lang:t,escaped:n}){let r=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
`;return r?'<pre><code class="language-'+O(r)+'">'+(n?i:O(i,!0))+`</code></pre>
`:"<pre><code>"+(n?i:O(i,!0))+`</code></pre>
`}blockquote({tokens:e}){return`<blockquote>
this.parser.parse(e)</blockquote>
`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`<ht>this.parser.parseInline(e)</ht>
`}hr(e){return`<hr>
`}list(e){let t=e.ordered,n=e.start,r="";for(let a=0;a<e.items.length;a++){let o=e.items[a];r+=this.listitem(o)}let i=t?"ol":"ul",s=t&&n!==1?' start="'+n+'"':"";return"<"+i+s+`>
`+r+"</"+i+`>
`}listitem(e){return`<li>this.parser.parse(e.tokens)</li>
`}checkbox({checked:e}){return"<input "+(e?'checked="" ':"")+'disabled="" type="checkbox"> '}paragraph({tokens:e}){return`<p>this.parser.parseInline(e)</p>
`}table(e){let t="",n="";for(let i=0;i<e.header.length;i++)n+=this.tablecell(e.header[i]);t+=this.tablerow({text:n});let r="";for(let i=0;i<e.rows.length;i++){let s=e.rows[i];n="";for(let a=0;a<s.length;a++)n+=this.tablecell(s[a]);r+=this.tablerow({text:n})}return r&&(r=`<tbody>r</tbody>`),`<table>
<thead>
`+t+`</thead>
`+r+`</table>
`}tablerow({text:e}){return`<tr>
e</tr>
`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<n align="e.align">`:`<n>`)+t+`</n>
`}strong({tokens:e}){return`<strong>this.parser.parseInline(e)</strong>`}em({tokens:e}){return`<em>this.parser.parseInline(e)</em>`}codespan({text:e}){return`<code>O(e,!0)</code>`}br(e){return"<br>"}del({tokens:e}){return`<del>this.parser.parseInline(e)</del>`}link({href:e,title:t,tokens:n}){let r=this.parser.parseInline(n),i=J(e);if(i===null)return r;e=i;let s='<a href="'+e+'"';return t&&(s+=' title="'+O(t)+'"'),s+=">"+r+"</a>",s}image({href:e,title:t,text:n,tokens:r}){r&&(n=this.parser.parseInline(r,this.parser.textRenderer));let i=J(e);if(i===null)return O(n);e=i;let s=`<img src="e" alt="O(n)"`;return t&&(s+=` title="O(t)"`),s+=">",s}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:O(e.text)}};var S=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}checkbox({raw:e}){return e}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||R,this.options.renderer=this.options.renderer||new y,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new S}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e){let t="";for(let n=0;n<e.length;n++){let r=e[n];if(this.options.extensions?.renderers?.[r.type]){let s=r,a=this.options.extensions.renderers[s.type].call({parser:this},s);if(a!==!1||!["space","hr","heading","code","table","blockquote","list","html","def","paragraph","text"].includes(s.type)){t+=a||"";continue}}let i=r;switch(i.type){case"space":{t+=this.renderer.space(i);break}case"hr":{t+=this.renderer.hr(i);break}case"heading":{t+=this.renderer.heading(i);break}case"code":{t+=this.renderer.code(i);break}case"table":{t+=this.renderer.table(i);break}case"blockquote":{t+=this.renderer.blockquote(i);break}case"list":{t+=this.renderer.list(i);break}case"checkbox":{t+=this.renderer.checkbox(i);break}case"html":{t+=this.renderer.html(i);break}case"def":{t+=this.renderer.def(i);break}case"paragraph":{t+=this.renderer.paragraph(i);break}case"text":{t+=this.renderer.text(i);break}default:{let s='Token with "'+i.type+'" type was not found.';if(this.options.silent)return console.error(s),"";throw new Error(s)}}}return t}parseInline(e,t=this.renderer){let n="";for(let r=0;r<e.length;r++){let i=e[r];if(this.options.extensions?.renderers?.[i.type]){let a=this.options.extensions.renderers[i.type].call({parser:this},i);if(a!==!1||!["escape","html","link","image","strong","em","codespan","br","del","text"].includes(i.type)){n+=a||"";continue}}let s=i;switch(s.type){case"escape":{n+=t.text(s);break}case"html":{n+=t.html(s);break}case"link":{n+=t.link(s);break}case"image":{n+=t.image(s);break}case"checkbox":{n+=t.checkbox(s);break}case"strong":{n+=t.strong(s);break}case"em":{n+=t.em(s);break}case"codespan":{n+=t.codespan(s);break}case"br":{n+=t.br(s);break}case"del":{n+=t.del(s);break}case"text":{n+=t.text(s);break}default:{let a='Token with "'+s.type+'" type was not found.';if(this.options.silent)return console.error(a),"";throw new Error(a)}}}return n}};var P=class{options;block;constructor(e){this.options=e||R}static passThroughHooks=new Set(["preprocess","postprocess","processAllTokens","emStrongMask"]);static passThroughHooksRespectAsync=new Set(["preprocess","postprocess","processAllTokens"]);preprocess(e){return e}postprocess(e){return e}processAllTokens(e){return e}emStrongMask(e){return e}provideLexer(){return this.block?x.lex:x.lexInline}provideParser(){return this.block?b.parse:b.parseInline}};var I=class{defaults=_();options=this.setOptions;parse=this.parseMarkdown(!0);parseInline=this.parseMarkdown(!1);Parser=b;Renderer=y;TextRenderer=S;Lexer=x;Tokenizer=w;Hooks=P;constructor(...e){this.use(...e)}walkTokens(e,t){let n=[];for(let r of e)switch(n=n.concat(t.call(this,r)),r.type){case"table":{let i=r;for(let s of i.header)n=n.concat(this.walkTokens(s.tokens,t));for(let s of i.rows)for(let a of s)n=n.concat(this.walkTokens(a.tokens,t));break}case"list":{let i=r;n=n.concat(this.walkTokens(i.items,t));break}default:{let i=r;this.defaults.extensions?.childTokens?.[i.type]?this.defaults.extensions.childTokens[i.type].forEach(s=>{let a=i[s].flat(1/0);n=n.concat(this.walkTokens(a,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let r={...n};if(r.async=this.defaults.async||r.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let s=t.renderers[i.name];s?t.renderers[i.name]=function(...a){let o=i.renderer.apply(this,a);return o===!1&&(o=s.apply(this,a)),o}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let s=t[i.level];s?s.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),r.extensions=t),n.renderer){let i=this.defaults.renderer||new y(this.defaults);for(let s in n.renderer){if(!(s in i))throw new Error(`renderer 's' does not exist`);if(["options","parser"].includes(s))continue;let a=s,o=n.renderer[a],u=i[a];i[a]=(...p)=>{let c=o.apply(i,p);return c===!1&&(c=u.apply(i,p)),c||""}}r.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new w(this.defaults);for(let s in n.tokenizer){if(!(s in i))throw new Error(`tokenizer 's' does not exist`);if(["options","rules","lexer"].includes(s))continue;let a=s,o=n.tokenizer[a],u=i[a];i[a]=(...p)=>{let c=o.apply(i,p);return c===!1&&(c=u.apply(i,p)),c}}r.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new P;for(let s in n.hooks){if(!(s in i))throw new Error(`hook 's' does not exist`);if(["options","block"].includes(s))continue;let a=s,o=n.hooks[a],u=i[a];P.passThroughHooks.has(s)?i[a]=p=>{if(this.defaults.async&&P.passThroughHooksRespectAsync.has(s))return(async()=>{let d=await o.call(i,p);return u.call(i,d)})();let c=o.call(i,p);return u.call(i,c)}:i[a]=(...p)=>{if(this.defaults.async)return(async()=>{let d=await o.apply(i,p);return d===!1&&(d=await u.apply(i,p)),d})();let c=o.apply(i,p);return c===!1&&(c=u.apply(i,p)),c}}r.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,s=n.walkTokens;r.walkTokens=function(a){let o=[];return o.push(s.call(this,a)),i&&(o=o.concat(i.call(this,a))),o}}this.defaults={...this.defaults,...r}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,r)=>{let i={...r},s={...this.defaults,...i},a=this.onError(!!s.silent,!!s.async);if(this.defaults.async===!0&&i.async===!1)return a(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return a(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return a(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(s.hooks&&(s.hooks.options=s,s.hooks.block=e),s.async)return(async()=>{let o=s.hooks?await s.hooks.preprocess(n):n,p=await(s.hooks?await s.hooks.provideLexer():e?x.lex:x.lexInline)(o,s),c=s.hooks?await s.hooks.processAllTokens(p):p;s.walkTokens&&await Promise.all(this.walkTokens(c,s.walkTokens));let h=await(s.hooks?await s.hooks.provideParser():e?b.parse:b.parseInline)(c,s);return s.hooks?await s.hooks.postprocess(h):h})().catch(a);try{s.hooks&&(n=s.hooks.preprocess(n));let u=(s.hooks?s.hooks.provideLexer():e?x.lex:x.lexInline)(n,s);s.hooks&&(u=s.hooks.processAllTokens(u)),s.walkTokens&&this.walkTokens(u,s.walkTokens);let c=(s.hooks?s.hooks.provideParser():e?b.parse:b.parseInline)(u,s);return s.hooks&&(c=s.hooks.postprocess(c)),c}catch(o){return a(o)}}}onError(e,t){return n=>{if(n.message+=`
Please report this to https://github.com/markedjs/marked.`,e){let r="<p>An error occurred:</p><pre>"+O(n.message+"",!0)+"</pre>";return t?Promise.resolve(r):r}if(t)return Promise.reject(n);throw n}}};var M=new I;function g(l,e){return M.parse(l,e)}g.options=g.setOptions=function(l){return M.setOptions(l),g.defaults=M.defaults,Z(g.defaults),g};g.getDefaults=_;g.defaults=R;g.use=function(...l){return M.use(...l),g.defaults=M.defaults,Z(g.defaults),g};g.walkTokens=function(l,e){return M.walkTokens(l,e)};g.parseInline=M.parseInline;g.Parser=b;g.parser=b.parse;g.Renderer=y;g.TextRenderer=S;g.Lexer=x;g.lexer=x.lex;g.Tokenizer=w;g.Hooks=P;g.parse=g;var kt=g.options,dt=g.setOptions,gt=g.use,ft=g.walkTokens,mt=g.parseInline,xt=g,bt=b.parse,Rt=x.lex;
if(__exports != exports)module.exports = exports;return module.exports}));
//# sourceMappingURL=marked.umd.js.map
将PDF文件的每一页转换为图片文件;支持自定义图片格式(PNG/JPG)和分辨率;适用于文档处理、图片化存档等场景
---
name: pdf-to-image-preview
description: 将PDF文件的每一页转换为图片文件;支持自定义图片格式(PNG/JPG)和分辨率;适用于文档处理、图片化存档等场景
dependency:
python:
- pymupdf>=1.23.0
---
# PDF转图片Skill
## 任务目标
- 本Skill用于:将PDF文件的每一页转换为独立的图片文件
- 能力包含:PDF文件解析、图片格式转换(PNG/JPG)、可调分辨率输出
- 触发条件:用户需要将PDF转换为图片、提取PDF页面、图片化PDF内容等场景
## 前置准备
- 依赖说明:scripts脚本所需的依赖包及版本
```
pymupdf>=1.23.0
```
## 操作步骤
- 标准流程:
1. **准备PDF文件**
- 确认PDF文件路径(使用 `./` 表示当前工作目录)
- 例如:`./document.pdf`
2. **执行转换**
- 调用脚本将PDF文件的每一页转换为图片
- 命令示例:
```bash
python scripts/convert_pdf_to_images.py \
--input ./document.pdf \
--output-dir ./images
```
- 可选参数:
- `--image-format`: 图片格式,支持 `png` 或 `jpg`,默认为 `png`
- `--dpi`: 图片分辨率(DPI),默认为 `200`
- `--zip`: 生成ZIP压缩包
- `--zip-output`: ZIP压缩包输出路径(默认:images.zip)
3. **查看输出**
- 图片文件保存在指定的输出目录中
- 文件命名格式:`page_001.png`、`page_002.png`...
- 可选择是否生成ZIP压缩包
## 资源索引
- 必要脚本:见 [scripts/convert_pdf_to_images.py](scripts/convert_pdf_to_images.py)(用途与参数:PDF转图片脚本)
## 注意事项
- 输入PDF文件必须存在且可读
- 输出目录必须具有写入权限
- **PDF页数限制**:暂支持100页以内的PDF文件,超过100页请拆分后转换
- 大型PDF文件转换可能需要较长时间,请耐心等待
## 故障排查
- **脚本找不到错误**:确保在Skill目录下执行,或使用相对路径 `scripts/xxx.py`
- **Python版本问题**:确保使用Python 3.6或更高版本
- **依赖缺失**:执行 `pip install pymupdf>=1.23.0` 安装依赖
- **页数超限错误**:PDF文件超过100页,请使用PDF工具拆分为多个小文件
## 使用示例
### 示例1:基本转换(PNG格式)
```bash
python scripts/convert_pdf_to_images.py \
--input ./report.pdf \
--output-dir ./images
```
### 示例2:使用JPG格式
```bash
python scripts/convert_pdf_to_images.py \
--input ./document.pdf \
--output-dir ./images \
--image-format jpg
```
### 示例3:高分辨率输出
```bash
python scripts/convert_pdf_to_images.py \
--input ./document.pdf \
--output-dir ./images \
--dpi 300
```
### 示例4:生成ZIP压缩包
```bash
python scripts/convert_pdf_to_images.py \
--input ./document.pdf \
--output-dir ./images \
--zip \
--zip-output ./images.zip
```
### 示例5:完整配置
```bash
python scripts/convert_pdf_to_images.py \
--input ./report.pdf \
--output-dir ./images \
--image-format jpg \
--dpi 200 \
--zip
```
FILE:references/usage-guide.md
# PDF转图片预览使用指南
## 目录
- [功能概述](#功能概述)
- [快速开始](#快速开始)
- [参数说明](#参数说明)
- [使用场景](#使用场景)
- [常见问题](#常见问题)
## 功能概述
本Skill提供以下核心功能:
1. **PDF转图片**:将PDF文件的每一页转换为高质量图片
2. **HTML预览生成**:自动生成包含所有图片的HTML预览文件
3. **格式自定义**:支持PNG和JPG两种图片格式
4. **分辨率控制**:可调整图片DPI,平衡质量和文件大小
## 快速开始
### 基础使用
将PDF文件转换为PNG图片并生成预览:
```bash
python /workspace/projects/pdf-to-image-preview/scripts/pdf_to_images.py \
--input ./document.pdf \
--output-dir ./images \
--html-output ./preview.html
```
### 高级选项
使用JPG格式和更高的分辨率:
```bash
python /workspace/projects/pdf-to-image-preview/scripts/pdf_to_images.py \
--input ./document.pdf \
--output-dir ./images \
--html-output ./preview.html \
--image-format jpg \
--dpi 300
```
## 参数说明
### 必需参数
| 参数 | 说明 | 示例 |
|------|------|------|
| `--input` | 输入PDF文件路径 | `./report.pdf` |
| `--output-dir` | 图片输出目录 | `./images` |
| `--html-output` | HTML输出文件路径 | `./preview.html` |
### 可选参数
| 参数 | 说明 | 可选值 | 默认值 |
|------|------|--------|--------|
| `--image-format` | 图片格式 | `png`, `jpg` | `png` |
| `--dpi` | 图片分辨率 | 整数,建议150-300 | `200` |
## 使用场景
### 1. 文档预览
将PDF文档转换为在线可预览的HTML文件,方便用户在浏览器中查看。
### 2. 内容展示
将PDF内容嵌入网页或应用中,提供更好的用户体验。
### 3. 图片提取
从PDF中提取特定页面或所有页面的图片内容。
### 4. 移动端查看
将PDF转换为图片,更适合移动设备浏览。
## 常见问题
### Q1: 转换后的图片不清晰怎么办?
**A:** 提高 `--dpi` 参数的值,例如:
```bash
--dpi 300 # 生成更清晰的图片
```
### Q2: 生成的图片文件太大怎么办?
**A:** 降低DPI或使用JPG格式:
```bash
--image-format jpg --dpi 150 # 减小文件大小
```
### Q3: 支持哪些PDF版本?
**A:** 支持所有符合PDF标准的文档,包括PDF 1.0到PDF 2.0。
### Q4: 如何处理加密的PDF文件?
**A:** 当前版本不支持加密PDF。如需处理加密文档,请先解密。
### Q5: 生成的HTML文件可以在本地直接打开吗?
**A:** 可以。HTML文件包含相对路径引用,双击即可在浏览器中预览。
## 技术细节
### 图片格式对比
| 格式 | 优点 | 缺点 | 推荐场景 |
|------|------|------|----------|
| PNG | 无损压缩,支持透明度 | 文件较大 | 需要高质量输出 |
| JPG | 文件较小,兼容性好 | 有损压缩 | 网络传输,存储优化 |
### DPI建议值
| DPI | 效果 | 适用场景 |
|-----|------|----------|
| 72 | 网页显示 | 在线快速预览 |
| 150 | 标准质量 | 一般文档查看 |
| 200 | 高质量(默认) | 文档存档 |
| 300 | 印刷质量 | 打印、出版 |
## 输出文件结构
```
./
├── images/ # 图片输出目录
│ ├── page_001.png # 第1页图片
│ ├── page_002.png # 第2页图片
│ └── ...
└── preview.html # HTML预览文件
```
## 性能参考
- 小型文档(<10页):1-3秒
- 中型文档(10-50页):5-15秒
- 大型文档(>50页):20秒以上
*注:实际时间取决于文档复杂度和机器性能*
FILE:scripts/convert_pdf_to_images.py
#!/usr/bin/env python3
"""
PDF转图片脚本
功能:
将PDF文件的每一页转换为图片(PNG或JPG)
"""
import argparse
import os
import sys
import zipfile
try:
import fitz # PyMuPDF
except ImportError:
print("错误:未安装pymupdf库,请执行:pip install pymupdf>=1.23.0")
sys.exit(1)
# PDF最大页数限制
MAX_PAGES = 100
def pdf_to_images(
pdf_path: str,
output_dir: str,
image_format: str = "png",
dpi: int = 200
) -> list:
"""
将PDF文件的每一页转换为图片
参数:
pdf_path: PDF文件路径
output_dir: 图片输出目录
image_format: 图片格式(png或jpg)
dpi: 图片分辨率
返回:
生成的图片文件路径列表
"""
# 打开PDF文件
pdf_document = fitz.open(pdf_path)
total_pages = len(pdf_document)
# 检查页数限制
if total_pages > MAX_PAGES:
pdf_document.close()
print(f"错误:PDF文件超过{MAX_PAGES}页(当前{total_pages}页),暂不支持转换")
print(f"提示:请拆分PDF文件为多个小于{MAX_PAGES}页的文件后再转换")
sys.exit(1)
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
print(f"正在处理PDF文件,共 {total_pages} 页...")
image_paths = []
zoom = dpi / 72.0 # 计算缩放比例
for page_num in range(total_pages):
page = pdf_document.load_page(page_num)
mat = fitz.Matrix(zoom, zoom) # 缩放矩阵
pix = page.get_pixmap(matrix=mat)
# 生成文件名
file_ext = "png" if image_format.lower() == "png" else "jpg"
filename = f"page_{page_num + 1:03d}.{file_ext}"
image_path = os.path.join(output_dir, filename)
# 保存图片
if file_ext == "jpg":
pix.save(image_path, jpg_quality=95)
else:
pix.save(image_path)
image_paths.append(filename)
print(f"已转换第 {page_num + 1}/{total_pages} 页 -> {filename}")
pdf_document.close()
print(f"图片转换完成,共生成 {len(image_paths)} 张图片")
return image_paths
def create_zip(
images_dir: str,
zip_output: str
):
"""
将图片目录打包成ZIP文件
参数:
images_dir: 图片目录路径
zip_output: ZIP输出文件路径
"""
print(f"\n正在创建ZIP压缩包...")
# 检查图片目录是否存在
if not os.path.exists(images_dir):
print(f"错误:图片目录不存在 - {images_dir}")
sys.exit(1)
# 获取所有图片文件
image_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp')
image_files = sorted(
[f for f in os.listdir(images_dir) if f.lower().endswith(image_extensions)]
)
if not image_files:
print(f"错误:在目录 {images_dir} 中未找到图片文件")
sys.exit(1)
# 创建ZIP文件
try:
with zipfile.ZipFile(zip_output, 'w', zipfile.ZIP_DEFLATED) as zipf:
for image_file in image_files:
image_path = os.path.join(images_dir, image_file)
# 添加到ZIP,保持文件名
zipf.write(image_path, image_file)
print(f" 已添加: {image_file}")
# 获取ZIP文件大小
zip_size = os.path.getsize(zip_output)
zip_size_mb = zip_size / (1024 * 1024)
print(f"\nZIP压缩包创建成功!")
print(f"文件路径: {zip_output}")
print(f"包含文件: {len(image_files)} 个")
print(f"文件大小: {zip_size_mb:.2f} MB")
except Exception as e:
print(f"错误:ZIP压缩包创建失败 - {str(e)}")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
description="将PDF文件转换为图片"
)
parser.add_argument(
'--input',
required=True,
help='输入PDF文件路径'
)
parser.add_argument(
'--output-dir',
default='images',
help='图片输出目录(默认:images)'
)
parser.add_argument(
'--image-format',
choices=['png', 'jpg'],
default='png',
help='图片格式(png或jpg),默认为png'
)
parser.add_argument(
'--dpi',
type=int,
default=200,
help='图片分辨率(DPI),默认为200'
)
parser.add_argument(
'--zip',
action='store_true',
help='生成ZIP压缩包'
)
parser.add_argument(
'--zip-output',
default='images.zip',
help='ZIP压缩包输出路径(默认:images.zip)'
)
args = parser.parse_args()
# 检查输入文件是否存在
if not os.path.exists(args.input):
print(f"错误:文件不存在 - {args.input}")
sys.exit(1)
# 检查是否为PDF文件
if not args.input.lower().endswith('.pdf'):
print("警告:输入文件可能不是PDF文件")
# 转换PDF为图片
try:
image_paths = pdf_to_images(
pdf_path=args.input,
output_dir=args.output_dir,
image_format=args.image_format,
dpi=args.dpi
)
except Exception as e:
print(f"错误:PDF转换失败 - {str(e)}")
sys.exit(1)
print("\n转换完成!")
print(f"图片保存位置: {args.output_dir}")
print(f"生成的图片文件: {', '.join(image_paths[:5])}{'...' if len(image_paths) > 5 else ''}")
# 生成ZIP压缩包
if args.zip:
try:
create_zip(
images_dir=args.output_dir,
zip_output=args.zip_output
)
except Exception as e:
print(f"错误:ZIP压缩失败 - {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
图片生成技能,当用户需要生成图片、视觉信息图、创建图像、编辑/修改/调整已有图片时使用此技能。基于中国的API易代理站(https://api.apiyi.com/)的NanoBanana2模型的图片生成服务,无需访问外网。支持14种宽高比的图片比例(`1:1`、`16:9`、`9:16`、`4:3`、`3:4`...
---
name: nano-banana-2-image-gen
description: 图片生成技能,当用户需要生成图片、视觉信息图、创建图像、编辑/修改/调整已有图片时使用此技能。基于中国的API易代理站(https://api.apiyi.com/)的NanoBanana2模型的图片生成服务,无需访问外网。支持14种宽高比的图片比例(`1:1`、`16:9`、`9:16`、`4:3`、`3:4`、`3:2`、`2:3`、`5:4`、`4:5`、`1:4`、`4:1`、`1:8`、`8:1`、`21:9`等)和3种分辨率(1K、2K、4K),支持文生图和图生图编辑。基于谷歌的NanoBanana2模型(快速模型),使用API易国内代理服务访问。
---
# 图片生成与编辑
基于谷歌的Gemini 3.1 Flash模型实现图片生成技能,可以通过自然语言帮助用户生成图片,通过API易国内代理服务访问,支持Node.js和Python两种运行环境。Nano Banana 2(代号)是谷歌于 2026 年 2 月 26 日发布的最新图像生成模型,模型 ID 为 gemini-3.1-flash-image-preview。它以 Pro 级画质 + Flash 级速度和成本 重新定义了图像生成的性价比,是 Nano Banana 系列的最新旗舰。
## 使用指引
遵循以下步骤:
### 第1步:分析需求与参数提取
1. **明确意图**:区分用户是需要【文生图】(生成新图片)还是【图生图】(编辑/修改现有图片)。
2. **提示词(Prompt)分析**:
- **使用用户原始完整输入**:把用户输入的原始完整问题需求描述(原文)直接作为 `-p` 提示词的主体,避免自行改写、总结或二次创作,防止细节丢失。
- **需要补充时先确认**:如果信息不足(例如缺少风格、主体数量、镜头语言、场景细节、文字内容、禁止元素等),先向用户提问确认;用户确认后,再把补充内容**以“追加”的方式**拼接到原始提示词后。
- 样例:
- 用户输入:“帮我生成一张猫的图片,风格要可爱一点。”
- 正例说明:直接使用用户输入作为提示词:`-p "帮我生成一张猫的图片,风格要可爱一点。"`
- 反例说明:擅自改写为“生成一张可爱风格的猫的图片”会丢失用户原始输入的细节和语气。
- 如果需要补充细节(例如颜色、背景等),先提问确认:“你希望猫是什么颜色的?背景有什么要求吗?”用户回答后,再追加到提示词中:`-p "帮我生成一张猫的图片,风格要可爱一点。猫是橘色的,背景是草地。"`
3. **关键参数整理**:
- **Prompt(必需)**:提示词分析后的最终提示词(默认=用户原始完整且一致的输入;仅在用户确认后才追加补充信息)。
- **Filename(可选)**:输出图片文件名/路径(需包含文件随机标识,避免重复)。不传则脚本会自动生成带时间戳的文件名。建议根据内容生成合理文件名(例如 `cat_in_garden.png`),避免使用通用名。
- **Aspect Ratio(可选)**:根据用户描述推断比例。例如:
- "手机壁纸" -> `9:16`
- "电脑壁纸/视频封面" -> `16:9`
- "头像" -> `1:1`
- 默认若用户未明确不指定图片比例,保持图片比例为空。
- **Resolution(可选)**:
- 默认图片比例使用 `2K`。
- 仅在极端高清需求或用户指定时使用 `4K`,并通过友好性提示,提示用户生成较慢,耐心等待。
- **注意**:参数值必须大写(`1K`, `2K`, `4K`)。
### 第2步:环境检查与命令执行
1. **检查环境**:确认 `APIYI_API_KEY` 环境变量是否已设置(通常假定已设置,若运行失败再提示用户)。
2. **构建并运行命令**:
- **优先尝试 Node.js 版本**:如果环境有 Node(`node` 命令可用),优先使用 `scripts/generate_image.js`(零依赖,参数与 Python 保持一致)。
- **Node 不可用再用 Python 版本**:使用 `scripts/generate_image.py`。
**文生图命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "{prompt}" -f "{filename}" [-a {ratio}] [-r {res}]
```
**图生图命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "{edit_instruction}" -i "{input_path}" -f "{output_filename}" [-r {res}]
```
**(可选)Python 版本命令模板(Node 不可用时)**:
```bash
python scripts/generate_image.py -p "{prompt}" -f "{filename}" [-a {ratio}] [-r {res}]
python scripts/generate_image.py -p "{edit_instruction}" -i "{input_path}" -f "{output_filename}" [-r {res}]
```
## ⏱️ 长时间任务处理策略
### 1. 任务前提示
**执行前必须告知用户**:
- "图片生成已启动,预计需要25秒到5分钟"
### 2. 🎨 最佳实践示例
1. 快速生成场景(1K分辨率)
> "快速模式:1K分辨率生成,预计30秒内完成"
2. 高质量生成场景(2K/4K分辨率)
> "高质量模式:2K分辨率生成,预计1-4分钟\n⏳ 开始生成... 🔄"
### 第3步:结果反馈
1. **执行反馈**:等待终端命令执行完毕。
2. **成功**:告知用户图片已生成,并指出保存路径。
3. **失败**:
- 若提示 API Key 缺失,请指导用户设置环境变量。
- 若提示网络错误,建议用户检查网络或稍后重试。
## 命令行使用样例
### 生成新图片
```bash
python scripts/generate_image.py -p "图片描述文本" -f "output.png" [-a 1:1] [-r 1K]
```
**示例:**
```bash
# 基础生成
python scripts/generate_image.py -p "一只可爱的橘猫在草地上玩耍" -f "cat.png"
# 指定比例和分辨率
python scripts/generate_image.py -p "日落山脉风景" -f "sunset.png" -a 16:9 -r 4K
# 竖版高清图片(适合手机壁纸)
python scripts/generate_image.py -p "城市夜景" -f "city.png" -a 9:16 -r 2K
```
**(可选)Node.js 版本示例:**
```bash
# 基础生成
node scripts/generate_image.js -p "一只可爱的橘猫在草地上玩耍" -f "cat.png"
# 指定比例和分辨率
node scripts/generate_image.js -p "日落山脉风景" -f "sunset.png" -a 16:9 -r 4K
```
### 编辑已有图片
```bash
python scripts/generate_image.py -p "编辑指令" -f "output.png" -i "path/to/input.png" [-a 1:1] [-r 1K]
```
**示例:**
```bash
# 修改风格
python scripts/generate_image.py -p "将图片转换成水彩画风格" -f "watercolor.png" -i "original.png"
# 添加元素
python scripts/generate_image.py -p "在天空添加彩虹" -f "rainbow.png" -i "landscape.png" -r 2K
# 替换背景
python scripts/generate_image.py -p "将背景换成海滩" -f "beach-bg.png" -i "portrait.png" -a 3:4
```
**(可选)Node.js 版本示例:**
```bash
# 修改风格
node scripts/generate_image.js -p "将图片转换成水彩画风格" -f "watercolor.png" -i "original.png"
# 多张参考图(最多14张)
node scripts/generate_image.js -p "参考多张图片融合风格" -i ref1.png ref2.png ref3.png -f "merged.png"
```
## 附加资源
- 常见使用场景文档:references/scene.md
## 命令行参数说明
> Python 与 Node.js 版本参数保持一致(短参数与长参数等价)。
| 参数 | 必填 | 说明 |
|------|------|------|
| `-p` / `--prompt` | 是 | 图片描述(文生图)或编辑指令(图生图)。保留用户原始完整输入。 |
| `-f` / `--filename` | 否 | 输出图片路径/文件名;不传则自动生成带时间戳的 PNG 文件名,并写入当前目录。 |
| `-a` / `--aspect-ratio` | 否 | 图片比例:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`、`3:2`、`2:3`、`5:4`、`4:5`、`1:4`、`4:1`、`1:8`、`8:1`、`21:9`。 |
| `-r` / `--resolution` | 否 | 图片分辨率:`1K` / `2K` / `4K`(必须大写)。不传则不在请求中指定,由 API 侧决定。 |
| `-i` / `--input-image` | 否 | 图生图输入图片路径;可传多张(最多 14 张)。传入该参数即进入编辑模式。 |
## 图片参数说明
### aspect_ratio - 图片比例
支持以下14种比例:
| 比例 | 方向 | 适用场景 |
|------|------|----------|
| 1:1 | 正方形 | 头像、Instagram帖子 |
| 16:9 | 横版 | YouTube缩略图、桌面壁纸、演示文稿 |
| 9:16 | 竖版 | 抖音/TikTok、Instagram Stories、手机壁纸 |
| 4:3 | 横版 | 经典照片、演示文稿 |
| 3:4 | 竖版 | Pinterest、人像摄影 |
| 3:2 | 横版 | 单反相机标准、印刷媒体 |
| 2:3 | 竖版 | 人像海报 |
| 5:4 | 横版 | 大幅面打印、艺术印刷 |
| 4:5 | 竖版 | Instagram帖子、社交媒体 |
| 21:9 | 超宽 | 电影感、横幅、全景 |
| 1:4 | 超竖 | 手机长图、漫画 |
| 4:1 | 超横 | 横幅、网站头图 |
| 1:8 | 超竖 | 手机长图、漫画 |
| 8:1 | 超横 | 横幅、网站头图 |
### resolution - 图片分辨率
1K、2K、4K三种分辨率选项
**注意:** 分辨率值必须大写(1K、2K、4K)
**默认:** 2K
## 注意事项
- API密钥必须设置,可通过环境变量或命令行参数提供
- 分辨率参数必须大写(1K/2K/4K),小写会默认使用1K
- 图片生成时间:25秒到5分钟不等,取决于分辨率和服务器负载
- 编辑图片时,输入图片会自动转换为base64编码
- 确保输出目录有写入权限
### API Key设置与获取
#### 如何获取API Key
如果你还没有API密钥,请前往 **https://api.apiyi.com** 注册账号并申请API Key。
获取步骤:
1. 访问 https://api.apiyi.com
2. 注册/登录你的账号
3. 在控制台中创建API密钥
4. 复制密钥并设置环境变量或在命令行中使用
#### 设置API Key
脚本按以下顺序查找API密钥:
1. `--api-key` 命令行参数(临时使用)
2. `APIYI_API_KEY` 环境变量(推荐)
**设置环境变量(推荐):**
```bash
# Linux/Mac
export APIYI_API_KEY="your-api-key-here"
# Windows CMD
我的电脑高级设置中设置环境变量或者执行set APIYI_API_KEY=your-api-key-here
# Windows PowerShell
在我的电脑中设置环境变量:$env:APIYI_API_KEY="your-api-key-here"
```
**命令行参数方式(临时):**
```bash
python scripts/generate_image.py -p "一只猫" -k "your-api-key-here"
```
## 作者介绍
- 爱海贼的无处不在
- 我的微信公众号:无处不在的技术
FILE:references/scene.md
## 常见使用场景
### 社交媒体内容
- **微信朋友圈/公众号**: `-a 1:1` 或 `-a 3:4`, 或 `-a 9:16`,配图文案图、封面图
- **小红书笔记**: `-a 3:4` 或 `-a 4:5`,竖版配图更受欢迎
- **抖音/视频号**: `-a 9:16`,短视频封面、竖版内容图
- **B站/知乎**: `-a 16:9 -r 2K`,视频封面、专栏头图
- **微博**: `-a 1:1` 或 `-a 3:4`,九宫格配图、话题图
### 电商与营销
- **淘宝/京东主图**: `-a 1:1`,白底产品图、场景图
- **电商详情页**: `-a 3:4`,产品展示、卖点图、对比图
- **活动海报**: `-a 2:3` 或 `-a 9:16`,促销海报、节日海报
- **PPT配图**: `-a 16:9`,演讲背景、数据可视化插图
### 个人创作
- **艺术创作**: 视觉信息图、笔记图、插画图等等
- **头像/壁纸**: `-a 1:1`(头像)、`-a 9:16`(手机壁纸)
- **表情包/梗图**: `-a 1:1`,搞笑图片、表情包素材
- **节日贺卡**: `-a 3:4` 或 `-a 4:5`,春节、中秋祝福图
- **LOGO/图标**: `-a 1:1`,品牌标识、应用图标设计
### 编辑任务
- **风格转换**: "转换成国潮/水墨/二次元/像素风格"
- **元素添加**: "添加春节元素/红包/灯笼/烟花"
- **背景替换**: "将背景换成故宫/西湖/长城/现代都市"
- **色彩调整**: "调整为莫兰迪色系/中国传统色"
- **对象移除**: "去除水印/路人/杂物"
- **画质修复**: "修复老照片/提升清晰度/去噪点"
FILE:scripts/generate_image.py
#!/usr/bin/env python3
"""
基于NanoBanana2的图片生成与编辑脚本
使用API易国内代理服务
支持功能:
- 文生图:根据提示词生成图片
- 图生图:根据编辑指令修改已有图片
参数说明:
- aspect_ratio: 图片比例 (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 5:4, 4:5, 1:4, 4:1, 1:8, 8:1, 21:9)
- resolution: 图片分辨率 (1K, 2K, 4K),必须大写
"""
import os
import sys
import json
import base64
import argparse
from unittest import result
import requests
from pathlib import Path
from datetime import datetime
# 支持的比例列表
SUPPORTED_ASPECT_RATIOS = [
"1:1",
"16:9",
"9:16",
"4:3",
"3:4",
"3:2",
"2:3",
"5:4",
"4:5",
"1:4",
"4:1",
"1:8",
"8:1",
"21:9",
]
SUPPORTED_RESOLUTIONS = ["1K", "2K", "4K"]
def get_api_key(args_key=None):
"""获取API密钥,优先使用命令行参数"""
if args_key:
return args_key
api_key = os.environ.get("APIYI_API_KEY")
if not api_key:
print("错误: 未设置 APIYI_API_KEY 环境变量")
print("请前往 https://api.apiyi.com 注册申请API Key")
print("或使用 --api-key 参数临时指定")
sys.exit(1)
return api_key
def generate_filename(prompt):
"""根据提示词生成带时间戳的文件名"""
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
# 从提示词提取关键词(简化处理)
keywords = prompt.split()[:3] # 取前3个词
keyword_str = "-".join(keywords)
# 清理文件名中的特殊字符
keyword_str = "".join(c if c.isalnum() or c in "-_." else "-" for c in keyword_str)
keyword_str = keyword_str.lower()[:30] # 限制长度
return f"{timestamp}-{keyword_str}.png"
def add_timestamp_to_filename(filename: str, timestamp: str) -> str:
p = Path(filename)
stem = p.stem or "image"
suffix = p.suffix
# If suffix is empty, keep it empty (caller may intentionally omit extension)
new_name = f"{stem}-{timestamp}{suffix}"
return str(p.with_name(new_name))
def encode_image_to_base64(image_path):
"""将图片文件转换为base64编码"""
try:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
print(f"错误: 无法读取图片文件 {image_path} - {e}")
sys.exit(1)
def generate_image(
prompt,
filename,
aspect_ratio=None,
resolution=None,
input_image=None,
api_key=None,
):
"""
生成或编辑图片
Args:
prompt: 图片描述或编辑指令文本
filename: 输出图片路径
aspect_ratio: 图片比例 (可选,默认由API决定)
resolution: 图片分辨率 (可选,默认由API决定)
input_image: 输入图片路径(编辑模式时使用)
api_key: API密钥
"""
# 验证参数(仅在提供了参数时验证)
if aspect_ratio is not None and aspect_ratio not in SUPPORTED_ASPECT_RATIOS:
print(f"错误: 不支持的比例 '{aspect_ratio}'")
print(f"支持的比例: {', '.join(SUPPORTED_ASPECT_RATIOS)}")
sys.exit(1)
if resolution is not None and resolution not in SUPPORTED_RESOLUTIONS:
print(f"错误: 不支持的分辨率 '{resolution}'")
print(f"支持的分辨率: {', '.join(SUPPORTED_RESOLUTIONS)} (必须大写)")
sys.exit(1)
api_key = get_api_key(api_key)
url = (
"https://api.apiyi.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent"
)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
# 构建content部分
parts = [{"text": prompt}]
# 如果提供了输入图片,添加图片数据(图生图模式)
# NanoBanana2最多支持14张参考图片
if input_image:
if isinstance(input_image, (list, tuple)):
input_images = list(input_image)
else:
input_images = [input_image]
if len(input_images) > 14:
print(f"错误: 输入图片最多支持14张,当前为 {len(input_images)} 张")
sys.exit(1)
for image_path in input_images:
if not os.path.exists(image_path):
print(f"错误: 输入图片不存在: {image_path}")
sys.exit(1)
image_base64 = encode_image_to_base64(image_path)
parts.append({"inlineData": {"mimeType": "image/png", "data": image_base64}})
mode_str = "编辑图片"
else:
mode_str = "生成图片"
# 构建payload,只添加用户指定的参数
generation_config = {
"responseModalities": ["IMAGE"],
}
image_config = {}
if aspect_ratio is not None:
image_config["aspectRatio"] = aspect_ratio
if resolution is not None:
image_config["imageSize"] = resolution
if image_config:
generation_config["imageConfig"] = image_config
payload = {
"contents": [{"parts": parts}],
"generationConfig": generation_config,
}
print(f"正在{mode_str}...")
print(f"提示词: {prompt}")
if generation_config.get("imageConfig", {}).get("aspectRatio"):
print(f"比例: {generation_config['imageConfig']['aspectRatio']}")
if generation_config.get("imageConfig", {}).get("imageSize"):
print(f"分辨率: {generation_config['imageConfig']['imageSize']}")
# 输出请求参数(脱敏:不直接输出base64图片数据,避免刷屏)
payload_log = {
"generationConfig": generation_config,
"contents": [],
}
for content in payload.get("contents", []):
parts_log = []
for part in content.get("parts", []):
if isinstance(part, dict) and "inlineData" in part and isinstance(part["inlineData"], dict):
inline_data = dict(part["inlineData"])
data_value = inline_data.get("data")
if isinstance(data_value, str):
inline_data["data"] = f"<omitted base64: {len(data_value)} chars>"
parts_log.append({"inlineData": inline_data})
else:
parts_log.append(part)
payload_log["contents"].append({"parts": parts_log})
print(f"输出请求参数: {json.dumps(payload_log, indent=2, ensure_ascii=False)}")
print(f"image generation in progress...")
try:
response = requests.post(url, headers=headers, json=payload, timeout=400)
response.raise_for_status()
data = response.json()
# 解析响应,查找图片数据
image_data = None
text_response = ""
if "candidates" in data and len(data["candidates"]) > 0:
candidate = data["candidates"][0]
image_data = candidate["content"]["parts"][0]["inlineData"]["data"]
if image_data:
# 解码base64图片数据
image_bytes = base64.b64decode(image_data)
# 确保输出目录存在
output_file = Path(filename)
output_file.parent.mkdir(parents=True, exist_ok=True)
# 保存图片
with open(output_file, "wb") as f:
f.write(image_bytes)
print(f"✓ 图片已成功{mode_str}并保存到: {filename}")
if text_response.strip():
print(f"模型响应: {text_response.strip()}")
return filename
else:
print("错误: 响应中未找到图片数据")
print(f"完整响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
sys.exit(1)
except requests.exceptions.Timeout:
print("错误: 请求超时,请稍后重试")
sys.exit(1)
except requests.exceptions.RequestException as e:
print(f"错误: 请求失败 - {e}")
if hasattr(e, "response") and e.response is not None:
try:
error_detail = e.response.json()
print(
f"错误详情: {json.dumps(error_detail, indent=2, ensure_ascii=False)}"
)
except:
print(f"响应状态码: {e.response.status_code}")
print(f"响应内容: {e.response.text}")
sys.exit(1)
except Exception as e:
print(f"错误: {str(e)}")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
description="基于Gemini 3 Pro的图片生成与编辑工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
【生成新图片】
python generate_image.py -p "一只可爱的橘猫"
python generate_image.py -p "日落山脉" -a 16:9 -r 4K
python generate_image.py -p "城市夜景" -a 9:16 -r 2K -f wallpaper.png
【编辑已有图片】
python generate_image.py -p "转换成油画风格" -i original.png
python generate_image.py -p "添加彩虹到天空" -i photo.jpg -f edited.png
python generate_image.py -p "将背景换成海滩" -i portrait.png -a 3:4 -r 2K
python generate_image.py -p "参考多张图片融合风格" -i ref1.png ref2.png ref3.png -f merged.png
【支持的参数值】
--aspect-ratio: 可选 (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 5:4, 4:5, 1:4, 4:1, 1:8, 8:1, 21:9)
--resolution: 可选 (1K, 2K, 4K,必须大写)
【环境变量】
export APIYI_API_KEY="your-api-key"
""",
)
parser.add_argument("--prompt", "-p", required=True, help="图片描述或编辑指令文本")
parser.add_argument(
"--filename",
"-f",
default=None,
help="输出图片路径 (默认: 自动生成时间戳文件名)",
)
parser.add_argument(
"--aspect-ratio",
"-a",
default=None,
choices=SUPPORTED_ASPECT_RATIOS,
help="图片比例 (可选: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 5:4, 4:5, 1:4, 4:1, 1:8, 8:1, 21:9)",
)
parser.add_argument(
"--resolution",
"-r",
default=None,
choices=SUPPORTED_RESOLUTIONS,
help="图片分辨率 (可选: 1K, 2K, 4K,必须大写)",
)
parser.add_argument(
"--input-image",
"-i",
nargs="+",
default=None,
help="输入图片路径(编辑模式,可传多张,最多14张)",
)
parser.add_argument("--api-key", "-k", default=None, help="API密钥(覆盖环境变量)")
args = parser.parse_args()
run_timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
# 如果没有指定文件名,自动生成
if args.filename is None:
args.filename = generate_filename(args.prompt)
else:
out_path = Path(args.filename)
if out_path.exists():
adjusted = add_timestamp_to_filename(args.filename, run_timestamp)
print(f"警告: 输出文件已存在,将避免覆盖并改为: {adjusted}")
args.filename = adjusted
generate_image(
prompt=args.prompt,
filename=args.filename,
aspect_ratio=args.aspect_ratio,
resolution=args.resolution,
input_image=args.input_image,
api_key=args.api_key,
)
if __name__ == "__main__":
main()
FILE:scripts/generate_image.js
#!/usr/bin/env node
/*
基于NanoBanana2的图片生成与编辑脚本(Node.js版)
使用API易国内代理服务
支持功能:
- 文生图:根据提示词生成图片
- 图生图:根据编辑指令修改已有图片
参数说明:
- -p, --prompt 图片描述或编辑指令文本(必需)
- -f, --filename 输出图片路径(可选,默认自动生成时间戳文件名)
- -a, --aspect-ratio 图片比例(可选)
- -r, --resolution 图片分辨率(可选:1K/2K/4K,必须大写)
- -i, --input-image 输入图片路径(可选,可多张,最多14张)
- -k, --api-key API密钥(可选,覆盖环境变量 APIYI_API_KEY)
使用示例:
【生成新图片】
node generate_image.js -p "一只可爱的橘猫"
node generate_image.js -p "日落山脉" -a 16:9 -r 4K
node generate_image.js -p "城市夜景" -a 9:16 -r 2K -f wallpaper.png
【编辑已有图片】
node generate_image.js -p "转换成油画风格" -i original.png
node generate_image.js -p "添加彩虹到天空" -i photo.jpg -f edited.png
node generate_image.js -p "将背景换成海滩" -i portrait.png -a 3:4 -r 2K
node generate_image.js -p "参考多张图片融合风格" -i ref1.png ref2.png ref3.png -f merged.png
【环境变量】
export APIYI_API_KEY="your-api-key"
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const SUPPORTED_ASPECT_RATIOS = [
'1:1',
'16:9',
'9:16',
'4:3',
'3:4',
'3:2',
'2:3',
'5:4',
'4:5',
'1:4',
'4:1',
'1:8',
'8:1',
'21:9',
];
const SUPPORTED_RESOLUTIONS = ['1K', '2K', '4K'];
function printHelpAndExit(exitCode = 0) {
const help = `usage: generate_image.js [-h] --prompt PROMPT [--filename FILENAME]
[--aspect-ratio SUPPORTED_ASPECT_RATIOS.join(', ')]
[--resolution SUPPORTED_RESOLUTIONS.join(', ')]
[--input-image INPUT_IMAGE [INPUT_IMAGE ...]]
[--api-key API_KEY]
基于NanoBanana2的图片生成与编辑工具(Node.js版)
options:
-h, --help show this help message and exit
-p, --prompt PROMPT 图片描述或编辑指令文本(必需)
-f, --filename FILE 输出图片路径 (默认: 自动生成时间戳文件名)
-a, --aspect-ratio 图片比例 (可选)
-r, --resolution 图片分辨率 (可选: 1K, 2K, 4K,必须大写)
-i, --input-image 输入图片路径(编辑模式,可传多张,最多14张)
-k, --api-key API密钥(覆盖环境变量)
运行示例:
node scripts/generate_image.js -p "一只可爱的橘猫"
node scripts/generate_image.js -p "日落山脉" -a 16:9 -r 4K
node scripts/generate_image.js -p "城市夜景" -a 9:16 -r 2K -f wallpaper.png
node scripts/generate_image.js -p "转换成油画风格" -i original.png
node scripts/generate_image.js -p "参考多张图片融合风格" -i ref1.png ref2.png -f merged.png
`;
process.stdout.write(help);
process.exit(exitCode);
}
function exitWithError(message) {
process.stderr.write(`message\n`);
process.exit(1);
}
function pad2(n) {
return String(n).padStart(2, '0');
}
function formatTimestamp(dateObj) {
const d = dateObj || new Date();
return `d.getFullYear()-pad2(d.getMonth() + 1)-pad2(d.getDate())-pad2(d.getHours())-pad2(d.getMinutes())-pad2(d.getSeconds())`;
}
function addTimestampToFilename(filePath, timestamp) {
const ts = timestamp || formatTimestamp(new Date());
const parsed = path.parse(filePath);
const base = parsed.name ? `parsed.name-ts` : ts;
return path.join(parsed.dir || '.', `baseparsed.ext || ''`);
}
function generateFilename(prompt) {
const now = new Date();
const timestamp = formatTimestamp(now);
const keywords = String(prompt).split(/\s+/).filter(Boolean).slice(0, 3);
const keywordStrRaw = keywords.join('-') || 'image';
const keywordStr = keywordStrRaw
.split('')
.map((c) => (/^[a-zA-Z0-9\-_.]$/.test(c) ? c : '-'))
.join('')
.toLowerCase()
.slice(0, 30);
return `timestamp-keywordStr.png`;
}
function getApiKey(argsKey) {
if (argsKey) return argsKey;
const apiKey = process.env.APIYI_API_KEY;
if (!apiKey) {
exitWithError(
'错误: 未设置 APIYI_API_KEY 环境变量\n' +
'请前往 https://api.apiyi.com 注册申请API Key\n' +
'或使用 -k/--api-key 参数临时指定'
);
}
return apiKey;
}
function encodeImageToBase64(imagePath) {
try {
const bytes = fs.readFileSync(imagePath);
return bytes.toString('base64');
} catch (e) {
exitWithError(`错误: 无法读取图片文件 imagePath - e.message || String(e)`);
}
}
function postJson(urlString, headers, payload, timeoutMs) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const body = Buffer.from(JSON.stringify(payload), 'utf8');
const req = https.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: 'POST',
headers: {
...headers,
'Content-Length': body.length,
},
},
(res) => {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
const statusCode = res.statusCode || 0;
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(`HTTP statusCode`);
err.statusCode = statusCode;
err.responseText = text;
return reject(err);
}
try {
resolve(JSON.parse(text));
} catch (e) {
const err = new Error('响应不是有效的JSON');
err.responseText = text;
return reject(err);
}
});
}
);
req.on('error', reject);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error('timeout'));
});
req.write(body);
req.end();
});
}
function parseArgs(argv) {
const args = {
prompt: null,
filename: null,
aspectRatio: null,
resolution: null,
inputImages: null,
apiKey: null,
};
const knownFlags = new Set([
'-h',
'--help',
'-p',
'--prompt',
'-f',
'--filename',
'-a',
'--aspect-ratio',
'-r',
'--resolution',
'-i',
'--input-image',
'-k',
'--api-key',
]);
function requireValue(i, flag) {
const v = argv[i + 1];
if (!v || (v.startsWith('-') && knownFlags.has(v))) {
exitWithError(`错误: 参数 flag 需要一个值`);
}
return v;
}
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '-h' || a === '--help') {
printHelpAndExit(0);
}
if (a === '-p' || a === '--prompt') {
args.prompt = requireValue(i, a);
i++;
continue;
}
if (a === '-f' || a === '--filename') {
args.filename = requireValue(i, a);
i++;
continue;
}
if (a === '-a' || a === '--aspect-ratio') {
args.aspectRatio = requireValue(i, a);
i++;
continue;
}
if (a === '-r' || a === '--resolution') {
args.resolution = requireValue(i, a);
i++;
continue;
}
if (a === '-k' || a === '--api-key') {
args.apiKey = requireValue(i, a);
i++;
continue;
}
if (a === '-i' || a === '--input-image') {
const images = [];
let j = i + 1;
while (j < argv.length) {
const v = argv[j];
if (v.startsWith('-') && knownFlags.has(v)) break;
images.push(v);
j++;
}
if (images.length === 0) {
exitWithError(`错误: 参数 a 需要至少一个图片路径`);
}
args.inputImages = images;
i = j - 1;
continue;
}
if (a.startsWith('-')) {
exitWithError(`错误: 未知参数 a,请使用 --help 查看帮助`);
}
}
if (!args.prompt) {
exitWithError('错误: 缺少必需参数 -p/--prompt');
}
return args;
}
async function main() {
const argv = process.argv.slice(2);
const args = parseArgs(argv);
const runTimestamp = formatTimestamp(new Date());
let checkProgress = null;
const clearProgressTimer = () => {
if (checkProgress) {
clearInterval(checkProgress);
checkProgress = null;
}
};
if (args.aspectRatio != null && !SUPPORTED_ASPECT_RATIOS.includes(args.aspectRatio)) {
exitWithError(
`错误: 不支持的比例 'args.aspectRatio'\n支持的比例: SUPPORTED_ASPECT_RATIOS.join(', ')`
);
}
if (args.resolution != null && !SUPPORTED_RESOLUTIONS.includes(args.resolution)) {
exitWithError(
`错误: 不支持的分辨率 'args.resolution'\n支持的分辨率: SUPPORTED_RESOLUTIONS.join(', ') (必须大写)`
);
}
if (!args.filename) {
args.filename = generateFilename(args.prompt);
} else {
const resolved = path.resolve(args.filename);
if (fs.existsSync(resolved)) {
const adjusted = addTimestampToFilename(args.filename, runTimestamp);
process.stdout.write(`⚠️ 输出文件已存在,将避免覆盖并改为: adjusted\n`);
args.filename = adjusted;
}
}
const apiKey = getApiKey(args.apiKey);
const url =
'https://api.apiyi.com/v1beta/models/gemini-3.1-flash-image-preview:generateContent';
const headers = {
Authorization: `Bearer apiKey`,
'Content-Type': 'application/json',
};
const parts = [{ text: args.prompt }];
let modeStr = '生成图片';
if (args.inputImages && args.inputImages.length > 0) {
if (args.inputImages.length > 14) {
exitWithError(`错误: 输入图片最多支持14张,当前为 args.inputImages.length 张`);
}
for (const imgPath of args.inputImages) {
if (!fs.existsSync(imgPath)) {
exitWithError(`错误: 输入图片不存在: imgPath`);
}
const imageBase64 = encodeImageToBase64(imgPath);
parts.push({
inlineData: {
mimeType: 'image/png',
data: imageBase64,
},
});
}
modeStr = '编辑图片';
}
const generationConfig = {
responseModalities: ['IMAGE'],
};
const imageConfig = {};
if (args.aspectRatio != null) imageConfig.aspectRatio = args.aspectRatio;
if (args.resolution != null) imageConfig.imageSize = args.resolution;
if (Object.keys(imageConfig).length > 0) generationConfig.imageConfig = imageConfig;
const payload = {
contents: [{ parts }],
generationConfig,
};
// 生成前通知 + 生成中实时日志(避免长时间无输出导致体验不佳)
const resolutionHint = args.resolution;
const etaText = resolutionHint === '4K' ? '1-6分钟' : '30-120秒';
process.stdout.write('🎨 图片生成已启动!\n');
process.stdout.write(`⏱️ 预计时间: etaText\n`);
process.stdout.write('📊 我会定期给您发送进度更新\n');
process.stdout.write(`正在modeStr...\n`);
process.stdout.write(`提示词: args.prompt\n`);
if (generationConfig.imageConfig && generationConfig.imageConfig.aspectRatio) {
process.stdout.write(`比例: generationConfig.imageConfig.aspectRatio\n`);
}
if (generationConfig.imageConfig && generationConfig.imageConfig.imageSize) {
process.stdout.write(`分辨率: generationConfig.imageConfig.imageSize\n`);
}
// 输出请求参数(脱敏:不直接输出base64图片数据,避免刷屏)
const payloadLog = {
generationConfig,
contents: [],
};
for (const content of payload.contents || []) {
const partsLog = [];
for (const part of content.parts || []) {
if (part && typeof part === 'object' && part.inlineData && typeof part.inlineData === 'object') {
const inlineData = { ...part.inlineData };
if (typeof inlineData.data === 'string') {
inlineData.data = `<omitted base64: inlineData.data.length chars>`;
}
partsLog.push({ inlineData });
} else {
partsLog.push(part);
}
}
payloadLog.contents.push({ parts: partsLog });
}
process.stdout.write(`输出请求参数: JSON.stringify(payloadLog, null, 2)\n`);
process.stdout.write('image generation in progress...\n');
const startTime = Date.now();
checkProgress = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
process.stdout.write(`🔄 已进行 elapsed秒...\n`);
}, 5000);
let data;
try {
data = await postJson(url, headers, payload, 120_000);
} catch (e) {
clearProgressTimer();
if (e && e.message === 'timeout') {
exitWithError('错误: 请求超时,请稍后重试');
}
if (e && e.statusCode) {
process.stderr.write(`错误: 请求失败 - HTTP e.statusCode\n`);
if (e.responseText) {
try {
const detail = JSON.parse(e.responseText);
process.stderr.write(`错误详情: JSON.stringify(detail, null, 2)\n`);
} catch {
process.stderr.write(`响应内容: e.responseText\n`);
}
}
process.exit(1);
}
exitWithError(`错误: 请求失败 - e.message || String(e)`);
}
clearProgressTimer();
const imageData =
data &&
data.candidates &&
Array.isArray(data.candidates) &&
data.candidates[0] &&
data.candidates[0].content &&
data.candidates[0].content.parts &&
data.candidates[0].content.parts[0] &&
data.candidates[0].content.parts[0].inlineData &&
data.candidates[0].content.parts[0].inlineData.data;
if (!imageData) {
process.stderr.write('错误: 响应中未找到图片数据\n');
process.stderr.write(`完整响应: JSON.stringify(data, null, 2)\n`);
process.exit(1);
}
const imageBytes = Buffer.from(imageData, 'base64');
const outputFile = path.resolve(args.filename);
const outputDir = path.dirname(outputFile);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputFile, imageBytes);
process.stdout.write(`✓ 图片已成功modeStr并保存到: args.filename\n`);
process.stdout.write('✅ 生成完成!\n');
}
main().catch((e) => {
exitWithError(`错误: String(e)`);
});
图片尺寸调整和压缩工具技能。用于按指定像素宽高、比例或最大尺寸限制调整图片大小,并支持智能压缩到指定文件大小。适用于需要批量处理图片、生成特定尺寸缩略图、压缩图片以满足文件大小限制等场景。
---
name: image-resizer
description: 图片尺寸调整和压缩工具技能。用于按指定像素宽高、比例或最大尺寸限制调整图片大小,并支持智能压缩到指定文件大小。适用于需要批量处理图片、生成特定尺寸缩略图、压缩图片以满足文件大小限制等场景。
---
## 技能概述
**技能名称:** image-resizer
**核心功能:** 图片尺寸调整和压缩,支持按像素宽高、比例、最大尺寸进行调整,以及智能压缩到指定文件大小
**适用场景:**
- 按指定像素尺寸调整图片(如 800×600)
- 按比例缩放图片(如 50% 缩小、200% 放大)
- 限制最大尺寸(保持比例,如最大 1920×1080)
- 指定宽高比裁剪(如 16:9、4:3)
- 压缩图片到指定大小(如压缩到 ≤500KB)
- 批量处理多张图片
## 脚本使用说明
### 安装依赖
```bash
cd scripts
npm install
```
### 基本用法
```bash
node resize_image.js <输入图片> [选项]
```
### 选项说明
| 参数 | 简写 | 说明 | 示例 |
|------|------|------|------|
| `--width` | `-w` | 目标宽度(像素) | `-w 800` |
| `--height` | `-h` | 目标高度(像素) | `-h 600` |
| `--scale` | `-s` | 缩放比例 | `-s 0.5` |
| `--max-width` | - | 最大宽度(保持比例) | `--max-width 1920` |
| `--max-height` | - | 最大高度(保持比例) | `--max-height 1080` |
| `--quality` | `-q` | 输出质量 1-100 | `-q 80` |
| `--size` | `-S` | 目标文件大小(KB),自动压缩 | `-S 500` |
| `--format` | `-f` | 输出格式:png\|jpg\|webp\|original | `-f webp` |
| `--output` | `-o` | 输出路径 | `-o output.png` |
| `--aspect-ratio` | `-a` | 目标宽高比 | `-a 16:9` |
| `--fit` | - | 适应模式 | `--fit cover` |
### 适应模式说明
| 模式 | 说明 |
|------|------|
| `cover` | 填充整个区域(可能裁剪,默认) |
| `contain` | 完整放入区域内(可能留白) |
| `fill` | 拉伸填充 |
| `inside` | 完整放入(仅缩小) |
| `outside` | 完全覆盖(仅放大) |
## 使用示例
### 示例 1:按指定尺寸调整
```bash
node resize_image.js input.png -w 800 -h 600 -o output.png
```
### 示例 2:按比例缩放
```bash
node resize_image.js input.png -s 0.5 -o output.png
```
### 示例 3:压缩到指定大小
```bash
node resize_image.js input.png -S 500 -o output.jpg
```
### 示例 4:指定宽高比裁剪
```bash
node resize_image.js input.png -a 16:9 -o output.png
```
### 示例 5:最大尺寸限制(保持比例)
```bash
node resize_image.png input.png --max-width 1920 --max-height 1080 -o output.png
```
### 示例 6:转换为 WebP 并压缩
```bash
node resize_image.js input.png -f webp -q 80 -o output.webp
```
## 典型工作流程
### 流程 1:生成网站缩略图
1. 读取原始图片
2. 限制最大宽度为 1200px,保持比例
3. 压缩到 ≤300KB
4. 输出为 JPEG 格式
```bash
node resize_image.js photo.jpg --max-width 1200 -S 300 -f jpg -o thumbnail.jpg
```
### 流程 2:生成社交媒体图片
1. 读取原始图片
2. 调整为 1080×1080 正方形
3. 压缩到 ≤500KB
```bash
node resize_image.js input.png -w 1080 -h 1080 -S 500 -o instagram.png
```
### 流程 3:生成 16:9 横版图片
1. 读取原始图片
2. 按 16:9 比例裁剪(居中)
3. 压缩到 ≤200KB
```bash
node resize_image.js input.png -a 16:9 -S 200 -o 16_9.jpg
```
## 技能文件结构
```
image-resizer/
├── SKILL.md # 技能主文件
└── scripts/
├── resize_image.js # 图片处理脚本
└── package.json # 依赖配置
```
## 依赖说明
- **sharp**: 图片处理库,支持 PNG、JPEG、WebP 等格式的转换、裁剪和压缩
FILE:scripts/package.json
{
"name": "image-resizer",
"version": "1.0.0",
"description": "图片尺寸调整和压缩工具 - 支持按像素宽高、比例、尺寸限制进行调整和压缩",
"main": "resize_image.js",
"scripts": {
"resize": "node resize_image.js"
},
"dependencies": {
"sharp": "^0.32.6"
},
"keywords": [
"image",
"resize",
"compress",
"thumbnail",
"crop"
],
"author": "",
"license": "MIT"
}
FILE:scripts/resize_image.js
#!/usr/bin/env node
/**
* 图片尺寸调整和压缩工具
*
* 功能:
* - 按指定像素宽高调整图片尺寸
* - 按比例缩放图片
* - 智能压缩图片到指定大小
* - 批量处理多张图片
*
* 使用方法:
* node resize_image.js <输入图片> [选项]
*
* 选项:
* -w, --width <像素> 目标宽度(像素)
* -h, --height <像素> 目标高度(像素)
* -s, --scale <比例> 缩放比例(如 0.5, 2)
* -m, --max-width <像素> 最大宽度(保持比例)
* -m, --max-height <像素> 最大高度(保持比例)
* -q, --quality <质量> JPEG质量 1-100(默认 80)
* -S, --size <KB> 目标文件大小(KB),自动压缩
* -f, --format <格式> 输出格式:png|jpg|webp|original(默认 original)
* -o, --output <路径> 输出路径
* -a, --aspect-ratio <比例> 目标宽高比(如 16:9, 4:3)
* --fit <模式> 适应模式:cover|contain|fill|inside|outside(默认 cover)
* --help 显示帮助
*
* 示例:
* # 按指定尺寸调整
* node resize_image.js input.png -w 800 -h 600
*
* # 按比例缩放
* node resize_image.js input.png -s 0.5
*
* # 压缩到指定大小
* node resize_image.js input.png -S 500 -o output.jpg
*
* # 指定宽高比裁剪
* node resize_image.js input.png -a 16:9 -o output.png
*
* # 最大尺寸限制(保持比例)
* node resize_image.js input.png --max-width 1920 --max-height 1080
*/
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
function showHelp() {
console.log(`
图片尺寸调整和压缩工具
使用方法:
node resize_image.js <输入图片> [选项]
选项:
-w, --width <像素> 目标宽度(像素)
-h, --height <像素> 目标高度(像素)
-s, --scale <比例> 缩放比例(如 0.5, 2)
-m, --max-width <像素> 最大宽度(保持比例)
-m, --max-height <像素> 最大高度(保持比例)
-q, --quality <质量> JPEG质量 1-100(默认 80)
-S, --size <KB> 目标文件大小(KB),自动压缩
-f, --format <格式> 输出格式:png|jpg|webp|original(默认 original)
-o, --output <路径> 输出路径
-a, --aspect-ratio <比例> 目标宽高比(如 16:9, 4:3)
--fit <模式> 适应模式:cover|contain|fill|inside|outside(默认 cover)
--help 显示帮助
适应模式说明:
cover - 填充整个区域(可能裁剪)
contain - 完整放入区域内(可能留白)
fill - 拉伸填充
inside - 完整放入(缩小)
outside - 完全覆盖(放大)
示例:
# 按指定尺寸调整
node resize_image.js input.png -w 800 -h 600 -o output.png
# 按比例缩放
node resize_image.js input.png -s 0.5 -o output.png
# 压缩到指定大小(如 500KB)
node resize_image.js input.png -S 500 -o output.jpg
# 指定宽高比裁剪(16:9)
node resize_image.js input.png -a 16:9 -o output.png
# 最大尺寸限制(保持比例)
node resize_image.js input.png --max-width 1920 --max-height 1080 -o output.png
# 转换为 WebP 格式并压缩
node resize_image.js input.png -f webp -q 80 -o output.webp
# 批量处理(通配符)
node resize_image.js "*.png" -w 800 -h 600 -o ./output/
`);
}
function parseArgs(argv) {
const args = {
input: null,
width: null,
height: null,
scale: null,
maxWidth: null,
maxHeight: null,
quality: 80,
targetSize: null,
format: 'original',
output: null,
aspectRatio: null,
fit: 'cover'
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = argv[i + 1];
switch (arg) {
case '-w':
case '--width':
args.width = parseInt(next, 10);
i++;
break;
case '-h':
case '--height':
args.height = parseInt(next, 10);
i++;
break;
case '-s':
case '--scale':
args.scale = parseFloat(next);
i++;
break;
case '--max-width':
args.maxWidth = parseInt(next, 10);
i++;
break;
case '--max-height':
args.maxHeight = parseInt(next, 10);
i++;
break;
case '-q':
case '--quality':
args.quality = parseInt(next, 10);
i++;
break;
case '-S':
case '--size':
args.targetSize = parseInt(next, 10);
i++;
break;
case '-f':
case '--format':
args.format = next;
i++;
break;
case '-o':
case '--output':
args.output = next;
i++;
break;
case '-a':
case '--aspect-ratio':
args.aspectRatio = next;
i++;
break;
case '--fit':
args.fit = next;
i++;
break;
case '--help':
case '-h':
showHelp();
process.exit(0);
break;
default:
if (!arg.startsWith('-')) {
args.input = arg;
}
}
}
return args;
}
async function getImageInfo(inputPath) {
try {
const metadata = await sharp(inputPath).metadata();
return {
width: metadata.width,
height: metadata.height,
format: metadata.format,
size: fs.statSync(inputPath).size
};
} catch (error) {
throw new Error(`无法读取图片: error.message`);
}
}
function parseAspectRatio(ratio) {
const parts = ratio.split(':');
if (parts.length === 2) {
return {
width: parseInt(parts[0], 10),
height: parseInt(parts[1], 10)
};
}
return null;
}
function calculateDimensions(imgWidth, imgHeight, args) {
let targetWidth = args.width;
let targetHeight = args.height;
// 按比例缩放
if (args.scale) {
targetWidth = Math.round(imgWidth * args.scale);
targetHeight = Math.round(imgHeight * args.scale);
}
// 最大尺寸限制
if (args.maxWidth || args.maxHeight) {
let maxW = args.maxWidth || Infinity;
let maxH = args.maxHeight || Infinity;
const scaleW = maxW / imgWidth;
const scaleH = maxH / imgHeight;
const scale = Math.min(scaleW, scaleH);
if (scale < 1) {
targetWidth = Math.round(imgWidth * scale);
targetHeight = Math.round(imgHeight * scale);
}
}
// 宽高比处理
if (args.aspectRatio) {
const ratio = parseAspectRatio(args.aspectRatio);
if (ratio) {
const targetRatio = ratio.width / ratio.height;
if (!targetWidth && targetHeight) {
targetWidth = Math.round(targetHeight * targetRatio);
} else if (targetWidth && !targetHeight) {
targetHeight = Math.round(targetWidth / targetRatio);
} else if (!targetWidth && !targetHeight) {
// 根据原图尺寸和目标比例计算
const currentRatio = imgWidth / imgHeight;
if (currentRatio > targetRatio) {
targetHeight = imgHeight;
targetWidth = Math.round(imgHeight * targetRatio);
} else {
targetWidth = imgWidth;
targetHeight = Math.round(imgWidth / targetRatio);
}
}
}
}
return {
width: targetWidth || imgWidth,
height: targetHeight || imgHeight
};
}
async function compressToSize(buffer, targetSizeKB, format, originalFormat) {
const targetBytes = targetSizeKB * 1024;
if (buffer.length <= targetBytes) {
return { buffer, quality: null };
}
console.log(` 原始大小: (buffer.length / 1024).toFixed(2)KB,需要压缩...`);
const finalFormat = format === 'original' ? originalFormat : format;
// JPEG 压缩
if (finalFormat === 'jpeg' || finalFormat === 'jpg') {
const qualities = [90, 85, 80, 75, 70, 65, 60, 55, 50, 45, 40];
for (const quality of qualities) {
const compressed = await sharp(buffer)
.jpeg({ quality, progressive: true, mozjpeg: true })
.toBuffer();
if (compressed.length <= targetBytes) {
return { buffer: compressed, quality };
}
}
}
// WebP 压缩
if (finalFormat === 'webp') {
const qualities = [90, 85, 80, 75, 70, 65, 60, 55, 50];
for (const quality of qualities) {
const compressed = await sharp(buffer)
.webp({ quality })
.toBuffer();
if (compressed.length <= targetBytes) {
return { buffer: compressed, quality };
}
}
}
// PNG 压缩
if (finalFormat === 'png') {
// 尝试调色板 PNG
const paletteOptions = [
{ palette: true, quality: 80, compressionLevel: 9 },
{ palette: true, quality: 60, compressionLevel: 9 },
{ palette: true, quality: 40, compressionLevel: 9 },
];
for (const opts of paletteOptions) {
const compressed = await sharp(buffer)
.png(opts)
.toBuffer();
if (compressed.length <= targetBytes) {
return { buffer: compressed, quality: opts.quality };
}
}
// 限制颜色数量
const colorsOptions = [
{ palette: true, colours: 256, effort: 10 },
{ palette: true, colours: 128, effort: 10 },
{ palette: true, colours: 64, effort: 10 },
{ palette: true, colours: 32, effort: 10 },
];
for (const opts of colorsOptions) {
const compressed = await sharp(buffer)
.png(opts)
.toBuffer();
if (compressed.length <= targetBytes) {
return { buffer: compressed, quality: opts.colours };
}
}
}
// 最后尝试:降低分辨率
const img = sharp(buffer);
const metadata = await img.metadata();
const scale = Math.sqrt(targetBytes / buffer.length) * 0.9;
const newWidth = Math.floor(metadata.width * scale);
const newHeight = Math.floor(metadata.height * scale);
const resized = await sharp(buffer)
.resize(newWidth, newHeight, { fit: 'inside' })
.toBuffer();
return {
buffer: resized,
quality: null,
note: ` resized to newWidthxnewHeight`
};
}
async function processImage(inputPath, args) {
const imgInfo = await getImageInfo(inputPath);
const { width, height } = calculateDimensions(imgInfo.width, imgInfo.height, args);
const outputFormat = args.format === 'original' ? imgInfo.format : args.format;
console.log(`\n📸 处理图片: path.basename(inputPath)`);
console.log(` 原始: imgInfo.width×imgInfo.heightpx, (imgInfo.size / 1024).toFixed(2)KB`);
console.log(` 目标: width×heightpx, 格式: outputFormat`);
// 处理透明通道
let pipeline = sharp(inputPath);
// 调整尺寸
if (args.width || args.height || args.scale || args.aspectRatio || args.maxWidth || args.maxHeight) {
pipeline = pipeline.resize(width, height, { fit: args.fit });
}
// 转换为目标格式
if (outputFormat === 'jpeg' || outputFormat === 'jpg') {
pipeline = pipeline.jpeg({ quality: args.quality, progressive: true, mozjpeg: true });
} else if (outputFormat === 'png') {
pipeline = pipeline.png({ compressionLevel: 9 });
} else if (outputFormat === 'webp') {
pipeline = pipeline.webp({ quality: args.quality });
}
let buffer = await pipeline.toBuffer();
// 压缩到目标大小
if (args.targetSize) {
const { buffer: compressed, quality, note } = await compressToSize(
buffer,
args.targetSize,
outputFormat,
imgInfo.format
);
buffer = compressed;
if (quality) {
console.log(` 压缩: outputFormat, 质量 quality%`);
}
if (note) {
console.log(` 降级: note`);
}
}
// 确定输出路径
let outputPath = args.output;
if (!outputPath) {
const dir = path.dirname(inputPath) || '.';
const ext = outputFormat === 'jpeg' ? 'jpg' : outputFormat;
const baseName = path.basename(inputPath, path.extname(inputPath));
outputPath = path.join(dir, `baseName_resized.ext`);
}
// 确保输出目录存在
const outputDir = path.dirname(outputPath);
if (outputDir && !fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 保存文件
fs.writeFileSync(outputPath, buffer);
const finalSizeKB = buffer.length / 1024;
const status = !args.targetSize || finalSizeKB <= args.targetSize ? '✅' : '⚠️';
console.log(` status 已保存: outputPath`);
console.log(` 大小: finalSizeKB.toFixed(2)KB`);
if (args.targetSize && finalSizeKB > args.targetSize) {
console.warn(` ⚠️ 警告: 仍超出目标大小 args.targetSizeKB`);
}
return outputPath;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.input) {
showHelp();
process.exit(1);
}
// 检查 sharp
try {
require.resolve('sharp');
} catch (e) {
console.error('❌ 错误: 缺少依赖 "sharp"');
console.error(' 请先安装: npm install sharp');
process.exit(1);
}
const sharp = require('sharp');
// 检查输入文件
if (!fs.existsSync(args.input)) {
console.error(`❌ 错误: 找不到文件 "args.input"`);
process.exit(1);
}
await processImage(args.input, args);
}
main().catch(error => {
console.error(`\n❌ 处理失败: error.message`);
process.exit(1);
});
Fetch today's Toutiao trending news with titles, hotness scores, links, covers, labels, and category tags across multiple topics in Chinese.
---
name: toutiao-news-trends
description: 获取今日头条(www.toutiao.com)新闻热榜/热搜榜数据,包含时政要闻、财经、社会事件、国际新闻、科技发展及娱乐八卦等多领域的热门中文资讯,并输出热点标题、热度值与跳转链接。
---
# 今日头条新闻热榜
## 技能概述
此技能用于抓取今日头条 PC 端热榜(hot-board)数据,包括:
- 热点标题
- 热度值(HotValue)
- 详情跳转链接(去除冗余查询参数,便于分享)
- 封面图(如有)
- 标签(如“热门事件”等)
数据来源:今日头条 (www.toutiao.com)
## 获取热榜
获取热榜(默认 50 条,按榜单顺序返回):
```bash
node scripts/toutiao.js hot
```
获取热榜前 N 条:
```bash
node scripts/toutiao.js hot 10
```
## 返回数据字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| rank | number | 榜单排名(从 1 开始) |
| title | string | 热点标题 |
| popularity | number | 热度值(HotValue,已转为数字;解析失败时为 0) |
| link | string | 热点详情链接(已清理 query/hash) |
| cover | string \| null | 封面图 URL(如有) |
| label | string \| null | 标签/标识(如有) |
| clusterId | string | 聚合 ID(字符串化) |
| categories | string[] | 兴趣分类(如有) |
## 注意事项
- 该接口为网页端公开接口,返回结构可能变动;若字段缺失可适当容错
- 访问频繁可能触发风控,脚本内置随机 User-Agent 与超时控制
## 作者介绍
- 爱海贼的无处不在
- 我的微信公众号:无处不在的技术
FILE:scripts/toutiao.js
#!/usr/bin/env node
/**
* 今日头条热榜获取工具
* 抓取 https://www.toutiao.com/hot-event/hot-board/ 返回的热点榜单数据
*/
const https = require('https');
const zlib = require('zlib');
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/123.0.0.0 Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
];
function getRandomUserAgent() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
const DEFAULT_HEADERS = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://www.toutiao.com/',
'Origin': 'https://www.toutiao.com',
};
function decompressBody(buffer, contentEncoding) {
if (!contentEncoding) return buffer;
const encoding = String(contentEncoding).toLowerCase();
if (encoding.includes('gzip')) return zlib.gunzipSync(buffer);
if (encoding.includes('deflate')) return zlib.inflateSync(buffer);
if (encoding.includes('br')) return zlib.brotliDecompressSync(buffer);
return buffer;
}
/**
* 发起 HTTP GET 请求并解析 JSON
* @param {string} url
* @param {object} headers
*/
function httpGetJson(url, headers = {}) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
...DEFAULT_HEADERS,
'User-Agent': getRandomUserAgent(),
...headers,
},
};
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const decompressed = decompressBody(buffer, res.headers['content-encoding']);
const text = decompressed.toString('utf-8');
const data = JSON.parse(text);
resolve(data);
} catch (e) {
const status = res.statusCode || 0;
reject(new Error(`Failed to parse JSON (status=status): e.message`));
}
});
});
req.on('error', reject);
req.setTimeout(15000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
async function getHotBoard(limit = 50) {
const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.floor(limit))) : 50;
const url = 'https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc';
const resp = await httpGetJson(url);
if (!resp || !Array.isArray(resp.data)) {
throw new Error('获取今日头条热榜失败:返回结构不符合预期');
}
const items = resp.data.map((item, index) => {
let cleanedLink = '';
try {
const u = new URL(item.Url);
u.search = '';
u.hash = '';
cleanedLink = u.toString();
} catch {
cleanedLink = typeof item.Url === 'string' ? item.Url : '';
}
const popularity = Number.parseInt(item.HotValue, 10);
return {
rank: index + 1,
title: item.Title || '',
popularity: Number.isFinite(popularity) ? popularity : 0,
link: cleanedLink,
cover: item.Image && item.Image.url ? item.Image.url : null,
label: item.LabelDesc || item.Label || null,
clusterId: String(item.ClusterIdStr || item.ClusterId || ''),
categories: Array.isArray(item.InterestCategory) ? item.InterestCategory : [],
};
});
return items.slice(0, safeLimit);
}
function printHelp() {
console.log(`
今日头条热榜工具
用法:
node scripts/toutiao.js <command> [args]
命令:
hot, list 获取热榜(可选: limit)
示例:
# 获取热榜(默认50条)
node scripts/toutiao.js hot
# 获取热榜前10条
node scripts/toutiao.js hot 10
`);
}
async function main() {
const args = process.argv.slice(2);
const command = args[0];
try {
switch (command) {
case 'hot':
case 'list':
case '--hot':
case '-h': {
const limitArg = args[1];
const limit = limitArg ? Number.parseInt(limitArg, 10) : 50;
const data = await getHotBoard(limit);
console.log(JSON.stringify(data, null, 2));
break;
}
case 'help':
case '--help':
case '-?':
default:
printHelp();
process.exit(0);
}
} catch (error) {
console.error(`Error: error.message`);
process.exit(1);
}
}
module.exports = { getHotBoard };
if (require.main === module) {
main();
}
获取掘金网站热门文章排行榜,支持查询文章分类列表和各分类的热门文章趋势。当用户需要了解掘金技术文章排行榜、获取前端/后端/AI等领域的热门文章时使用此技能。
---
name: juejin-article-trends
description: 获取掘金网站热门文章排行榜,支持查询文章分类列表和各分类的热门文章趋势。当用户需要了解掘金技术文章排行榜、获取前端/后端/AI等领域的热门文章时使用此技能。
---
# 掘金热门文章排行榜
## 功能概述
此技能用于获取掘金(juejin.cn)网站的技术文章排行榜数据,包括:
- 文章分类列表(前端、后端、AI、移动开发等)
- 各分类的热门文章排行榜
- 文章详细信息(标题、作者、阅读量、点赞数等)
## 工作流程
### 1. 获取分类列表
当用户需要了解掘金文章分类时:
```bash
node scripts/juejin.js categories
```
返回示例:
```json
[
{ "id": "6809637769959178254", "name": "前端" },
{ "id": "6809637769959178255", "name": "后端" },
{ "id": "6809637769959178256", "name": "Android" },
{ "id": "6809637769959178257", "name": "iOS" },
{ "id": "6809637769959178258", "name": "人工智能" },
{ "id": "6809637769959178260", "name": "开发工具" },
{ "id": "6809637769959178261", "name": "代码人生" },
{ "id": "6809637769959178262", "name": "阅读" }
]
```
### 2. 获取热门文章
当用户需要获取特定分类的热门文章时:
```bash
node scripts/juejin.js articles <category_id> [type] [limit]
```
参数:
- `category_id`: 分类ID(从分类列表获取)
- `type`: 排序类型,可选 `hot`(热门) 或 `new`(最新),默认 `hot`
- `limit`: 返回文章数量,默认20
返回示例:
```json
[
{
"title": "文章标题",
"brief": "文章摘要...",
"author": "作者名",
"articleId": "123456789",
"popularity": 100,
"viewCount": 5000,
"likeCount": 200,
"collectCount": 150,
"commentCount": 50,
"url": "https://juejin.cn/post/123456789",
"tags": ["JavaScript", "Vue"]
}
]
```
## 使用示例
### 查看所有分类
```bash
node scripts/juejin.js categories
```
### 获取前端热门文章(前10篇)
```bash
node scripts/juejin.js articles 6809637769959178254 hot 10
```
### 获取后端最新文章
```bash
node scripts/juejin.js articles 6809637769959178255 new 15
```
## 作者介绍
- 爱海贼的无处不在
- 我的微信公众号:无处不在的技术
FILE:scripts/juejin.js
#!/usr/bin/env node
/**
* 掘金热门文章排行榜获取工具
* 用于获取掘金网站的文章分类和热门文章排行榜
*/
const https = require('https');
const zlib = require('zlib');
// 可配置 User-Agent 池(固定 15 个),每次请求随机选一个,避免固定 UA
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/123.0.0.0 Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
];
function getRandomUserAgent() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
const HEADERS = {
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://juejin.cn/',
'Origin': 'https://juejin.cn'
};
/**
* 发起HTTP GET请求
* @param {string} url - 请求URL
* @param {Object} headers - 请求头
* @returns {Promise<Object>} 响应数据
*/
function httpGet(url, headers = {}) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: { ...HEADERS, 'User-Agent': getRandomUserAgent(), ...headers }
};
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => { chunks.push(chunk); });
res.on('end', () => {
const buffer = Buffer.concat(chunks);
const encoding = res.headers['content-encoding'];
// 解压数据
let decompressed;
try {
if (encoding === 'gzip') {
decompressed = zlib.gunzipSync(buffer);
} else if (encoding === 'deflate') {
decompressed = zlib.inflateSync(buffer);
} else if (encoding === 'br') {
decompressed = zlib.brotliDecompressSync(buffer);
} else {
decompressed = buffer;
}
const data = JSON.parse(decompressed.toString('utf-8'));
resolve(data);
} catch (e) {
reject(new Error(`Failed to parse response: e.message`));
}
});
});
req.on('error', reject);
req.setTimeout(15000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
/**
* 获取掘金文章分类列表
* @returns {Promise<Array>} 分类列表
*/
async function getCategories() {
try {
const response = await httpGet('https://api.juejin.cn/tag_api/v1/query_category_briefs');
if (response.err_no !== 0) {
throw new Error(response.err_msg || '获取分类失败');
}
return response.data.map(cat => ({
id: cat.category_id,
name: cat.category_name
}));
} catch (error) {
throw new Error(`获取分类失败: error.message`);
}
}
/**
* 获取掘金热门文章排行榜
* @param {string} categoryId - 分类ID
* @param {string} type - 排行榜类型: hot(热门), new(最新)
* @param {number} limit - 返回文章数量限制
* @returns {Promise<Array>} 文章列表
*/
async function getArticles(categoryId, type = 'hot', limit = 20) {
try {
const url = `https://api.juejin.cn/content_api/v1/content/article_rank?category_id=categoryId&type=type`;
const response = await httpGet(url);
if (response.err_no !== 0) {
throw new Error(response.err_msg || '获取文章失败');
}
const articles = response.data.map(item => ({
title: item.content.title,
brief: item.content.brief || '',
author: item.author.name,
authorId: item.author.user_id,
articleId: item.content.content_id,
popularity: item.content_counter.hot_rank || 0,
viewCount: item.content_counter.view || 0,
likeCount: item.content_counter.like || 0,
collectCount: item.content_counter.collect || 0,
commentCount: item.content_counter.comment_count || 0,
interactCount: item.content_counter.interact_count || 0,
url: `https://juejin.cn/post/item.content.content_id`,
publishTime: item.content.ctime,
tags: item.tags ? item.tags.map(tag => tag.tag_name) : []
}));
return limit ? articles.slice(0, limit) : articles;
} catch (error) {
throw new Error(`获取文章失败: error.message`);
}
}
/**
* 主函数 - 处理命令行参数
*/
async function main() {
const args = process.argv.slice(2);
const command = args[0];
try {
switch (command) {
case 'categories':
case '--categories':
case '-c': {
const categories = await getCategories();
console.log(JSON.stringify(categories, null, 2));
break;
}
case 'articles':
case '--articles':
case '-a': {
const categoryId = args[1];
const type = args[2] || 'hot';
const limit = parseInt(args[3]) || 20;
if (!categoryId) {
console.error('Error: 请提供分类ID');
console.error('用法: node juejin.js articles <category_id> [type] [limit]');
process.exit(1);
}
const articles = await getArticles(categoryId, type, limit);
console.log(JSON.stringify(articles, null, 2));
break;
}
default:
console.log(`
掘金热门文章排行榜工具
用法:
node juejin.js <command> [options]
命令:
categories, -c, --categories 获取文章分类列表
articles, -a, --articles 获取热门文章排行榜
示例:
# 获取所有分类
node juejin.js categories
# 获取指定分类的热门文章 (默认20篇)
node juejin.js articles 6809637769959178254
# 获取指定分类的最新文章,限制10篇
node juejin.js articles 6809637769959178254 new 10
参数说明:
category_id 分类ID (可通过 categories 命令获取)
type 排序类型: hot(热门) 或 new(最新), 默认hot
limit 返回文章数量, 默认20
`);
process.exit(0);
}
} catch (error) {
console.error(`Error: error.message`);
process.exit(1);
}
}
// 导出模块供其他脚本使用
module.exports = { getCategories, getArticles };
// 如果直接运行此脚本
if (require.main === module) {
main();
}
搜索微信公众号文章技能。通过微信搜索获取文章列表,覆盖科技/AI、社会热点、财经、教育、职场等各类中文资讯;可按关键词检索并返回标题、概要、发布时间、来源公众号与链接。当用户需要查找微信公众号文章、整理参考资料或快速获取文章信息时使用此技能。
---
name: wechat-article-search
description: 搜索微信公众号文章技能。通过微信搜索获取文章列表,覆盖科技/AI、社会热点、财经、教育、职场等各类中文资讯;可按关键词检索并返回标题、概要、发布时间、来源公众号与链接。当用户需要查找微信公众号文章、整理参考资料或快速获取文章信息时使用此技能。
---
# 微信公众号文章搜索说明
## 适用场景
- 用户说“帮我搜某个关键词的公众号文章/最近文章”
- 需要快速拿到:标题、摘要、发布时间、公众号名称、可访问链接
## 工作流程
### 步骤1: 确认已安装依赖包
该脚本依赖NodeJS依赖包 `cheerio`,建议先执行全局安装或在项目中安装:
```bash
npm install -g cheerio
```
### 步骤2: 确认搜索词语数量
1、 确认关键词与数量
### 步骤3: 执行搜索命令
1、执行常规搜索命令
```bash
node scripts/search_wechat.js "关键词"
```
## 特殊流程(可选)
1) 执行包含数量限制的搜索命令
```bash
node scripts/search_wechat.js "关键词" -n 15
```
2) 如果用户需要保存结果到文件,执行命令
```bash
node scripts/search_wechat.js "关键词" -n 20 -o result.json
```
3) 若想要获取微信文章域名的真实链接”,执行如下命令
```bash
node scripts/search_wechat.js "关键词" -n 5 -r
```
## 参数说明
- `query`:搜索关键词(必填)
- `-n, --num`:返回数量(默认 10,最大 50)
- `-o, --output`:输出 JSON 文件路径(可选)
- `-r, --resolve-url`:尝试把中间链接解析成微信文章真实链接(会额外请求每条结果)
## 输出字段(文章对象)
文章标题、文章地址、文章概要、发布时间、来源公众号名称
## 常见问题处理
- 结果为空:尝试更换关键词、更少的特殊字符、或稍后重试
- 解析真实 URL 失败:这是常态(反爬限制);可提示用户用浏览器打开中间链接
## 注意事项
- 本工具仅用于学习和研究目的,请勿用于商业用途或大规模爬取。
- 使用本工具时请遵守相关网站的使用条款和规定。
- 过度使用可能导致 IP 被封禁,请谨慎使用。
FILE:scripts/search_wechat.js
#!/usr/bin/env node
/**
* 微信公众号文章搜索工具
* 通过搜狗微信搜索获取微信公众号文章
*/
const https = require('https');
const cheerio = require('cheerio');
const zlib = require('zlib');
// 可配置 User-Agent 池(固定 20 个),每次请求随机选一个,避免固定 UA
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/123.0.0.0 Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/122.0.0.0 Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Mi 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
];
function getRandomUserAgent() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
const HEADERS = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Encoding': 'identity',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Host': 'weixin.sogou.com',
'Referer': 'https://weixin.sogou.com/',
};
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function decompressBody(buffer, contentEncoding) {
if (!contentEncoding) return buffer;
const encoding = String(contentEncoding).toLowerCase();
try {
if (encoding.includes('gzip')) return zlib.gunzipSync(buffer);
if (encoding.includes('deflate')) return zlib.inflateSync(buffer);
if (encoding.includes('br')) return zlib.brotliDecompressSync(buffer);
} catch {
// 解压失败时直接返回原始数据,避免影响主流程
}
return buffer;
}
/**
* 统一的网络请求工具(仅 https),带超时与重试,可处理 gzip/deflate/br 解压。
* @param {{
* url: string,
* method?: string,
* headers?: Object,
* timeoutMs?: number,
* retries?: number
* }} options
* @returns {Promise<{statusCode: number, headers: Object, body: Buffer}>}
*/
async function request(options) {
const {
url,
method = 'GET',
headers = {},
timeoutMs = 15000,
retries = 0,
} = options;
const lastErrorPrefix = `Request failed: method url`;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const result = await new Promise((resolve, reject) => {
const urlObj = new URL(url);
const reqOptions = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method,
headers,
};
const req = https.request(reqOptions, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks);
const body = decompressBody(raw, res.headers['content-encoding']);
resolve({
statusCode: res.statusCode || 0,
headers: res.headers,
body,
});
});
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
return result;
} catch (e) {
if (attempt >= retries) {
throw new Error(`lastErrorPrefix: e.message`);
}
await sleep(300 + attempt * 300);
}
}
throw new Error(`lastErrorPrefix: unexpected`);
}
async function requestText(options) {
const resp = await request(options);
return {
...resp,
text: resp.body.toString('utf-8'),
};
}
/**
* 从响应头中提取cookie
* @param {Object} headers - HTTP响应头
* @returns {string} cookie字符串
*/
function extractCookies(headers) {
const cookies = [];
const setCookieHeader = headers['set-cookie'];
if (setCookieHeader) {
setCookieHeader.forEach(cookie => {
const cookieValue = cookie.split(';')[0];
if (cookieValue) {
cookies.push(cookieValue);
}
});
}
return cookies.join('; ');
}
/**
* 从搜狗视频页面获取cookie
* @returns {Promise<{cookieStr: string, cookieObj: Object}>} cookie字符串与对象
*/
async function getSogouCookie() {
try {
const resp = await request({
url: 'https://v.sogou.com/v?ie=utf8&query=&p=40030600',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Encoding': 'identity',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'User-Agent': getRandomUserAgent(),
},
timeoutMs: 10000,
retries: 1,
});
const cookies = extractCookies(resp.headers);
const cookieObj = {};
if (cookies) {
cookies.split('; ').forEach(cookie => {
const [key, value] = cookie.split('=');
if (key && value) {
cookieObj[key.trim()] = value.trim();
}
});
}
return { cookieStr: cookies || '', cookieObj };
} catch {
return { cookieStr: '', cookieObj: {} };
}
}
/**
* 发起HTTP GET请求
* @param {string} url - 请求URL
* @param {string} cookieStr - cookie字符串(可选)
* @returns {Promise<string>} 响应HTML内容
*/
async function httpGet(url, cookieStr = '') {
const headers = {
...HEADERS,
'User-Agent': getRandomUserAgent(),
};
if (cookieStr) {
headers['Cookie'] = cookieStr;
}
const resp = await requestText({
url,
headers,
timeoutMs: 30000,
retries: 1,
});
return resp.text;
}
/**
* 从搜狗搜索页 HTML 中解析文章列表
* @param {string} html
* @param {number} maxResults
*/
function parseArticlesFromSearchHtml(html, maxResults) {
const articles = [];
const $ = cheerio.load(html);
const $newsList = $('ul.news-list');
if ($newsList.length === 0) return [];
$newsList.find('li').each((_, element) => {
if (articles.length >= maxResults) return false;
const article = parseArticle($, element);
if (article) {
articles.push(article);
}
});
return articles;
}
/**
* 从HTML中提取跳转URL(处理JavaScript跳转或meta refresh)
* @param {string} html - HTML内容
* @returns {string|null} 跳转URL
*/
function extractRedirectUrlFromHtml(html) {
// 尝试匹配 meta refresh
const metaMatch = html.match(/<meta[^>]*http-equiv=["']refresh["'][^>]*content=["']\d+;\s*url=([^"']+)["'][^>]*>/i);
if (metaMatch) {
return metaMatch[1];
}
// 尝试匹配 JavaScript 跳转
const jsMatch = html.match(/location\.href\s*=\s*["']([^"']+)["']/i) ||
html.match(/location\s*=\s*["']([^"']+)["']/i) ||
html.match(/window\.location\s*=\s*["']([^"']+)["']/i);
if (jsMatch) {
return jsMatch[1];
}
// 尝试匹配“拼接 url 变量 + location.replace(url)”的跳转方式
// 典型形态:
// var url = '';
// url += 'https://mp.';
// url += 'weixin.qq.com/...';
// window.location.replace(url)
// 参考截图中的 Python 思路:re.findall("url\s*\+=\s*'([^']*)'") 后 join
const urlParts = [];
for (const m of html.matchAll(/url\s*\+=\s*'([^']*)'/g)) {
urlParts.push(m[1]);
}
for (const m of html.matchAll(/url\s*\+=\s*"([^"]*)"/g)) {
urlParts.push(m[1]);
}
if (urlParts.length > 0) {
const joined = urlParts.join('');
if (joined.includes('mp.weixin.qq.com')) {
return joined;
}
}
return null;
}
/**
* 获取URL重定向后的真实地址(参考Python实现)
* @param {string} url - 原始URL
* @param {Object} cookieObj - cookie对象
* @param {number} retries - 重试次数
* @returns {Promise<string>} 重定向后的真实URL
*/
function getRealUrl(url, cookieObj = {}, retries = 3) {
return new Promise((resolve) => {
// 如果不是搜狗链接,直接返回原URL
if (!url.includes('weixin.sogou.com')) {
resolve(url);
return;
}
(async () => {
// 构建Cookie字符串
const baseCookies = 'ABTEST=7|1716888919|v1; IPLOC=CN5101; ariaDefaultTheme=default; ariaFixed=true; ariaReadtype=1; ariaStatus=false';
const snuid = cookieObj['SNUID'] || '';
const cookieStr = snuid ? `baseCookies; SNUID=snuid` : baseCookies;
const headers = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Encoding': 'identity',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Cookie': cookieStr,
'User-Agent': getRandomUserAgent(),
};
for (let attempt = 0; attempt < retries; attempt++) {
try {
const resp = await request({
url,
headers,
timeoutMs: 5000,
retries: 0,
});
// 检查重定向(不跟随重定向,直接获取Location)
if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
const redirectUrl = resp.headers.location;
if (redirectUrl.includes('mp.weixin.qq.com')) {
resolve(redirectUrl);
return;
}
resolve(url);
return;
}
if (resp.statusCode === 200) {
const html = resp.body.toString('utf-8');
console.error(` 获取到HTML内容(长度: html.length),尝试解析跳转URL...`);
const redirectUrl = extractRedirectUrlFromHtml(html);
if (redirectUrl && redirectUrl.includes('mp.weixin.qq.com')) {
resolve(redirectUrl);
return;
}
resolve(url);
return;
}
} catch {
// 忽略错误,进入重试
}
if (attempt < retries - 1) {
await sleep(1000);
}
}
resolve(url);
})();
});
}
function parseCliArgs(args) {
let query = '';
let num = 10;
let output = '';
let resolveRealUrl = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '-n' || args[i] === '--num') {
num = parseInt(args[i + 1]) || 10;
i++;
} else if (args[i] === '-o' || args[i] === '--output') {
output = args[i + 1] || '';
i++;
} else if (args[i] === '-r' || args[i] === '--resolve-url') {
resolveRealUrl = true;
} else if (!args[i].startsWith('-')) {
query = args[i];
}
}
return { query, num, output, resolveRealUrl };
}
/**
* 批量获取文章的真实URL
* @param {Array} articles - 文章列表
* @returns {Promise<Array>} 包含真实URL的文章列表
*/
async function resolveRealUrls(articles) {
// 获取cookie用于解析URL
const { cookieObj } = await getSogouCookie();
console.error(`获取到 articles.length 篇文章,开始解析真实URL...`);
console.error('注意:搜狗微信有严格的反爬虫机制,可能无法获取真实URL');
const results = [];
let successCount = 0;
let failCount = 0;
for (let i = 0; i < articles.length; i++) {
const article = articles[i];
try {
console.error(`[i + 1/articles.length] 解析: article.title.substring(0, 30)...`);
const realUrl = await getRealUrl(article.url, cookieObj);
// 检查是否成功获取到真实URL(不是搜狗链接,也不是antispider页面)
const isSuccess = !realUrl.includes('weixin.sogou.com') && !realUrl.includes('antispider');
results.push({
...article,
url: isSuccess ? realUrl : article.url,
url_resolved: isSuccess
});
if (isSuccess) {
successCount++;
} else {
failCount++;
}
// 添加延迟避免请求过快
if (i < articles.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1000));
}
} catch (error) {
console.error(` 解析失败: error.message`);
failCount++;
results.push({
...article,
url: article.url,
url_resolved: false
});
}
}
console.error(`\n解析完成: 成功 successCount, 失败 failCount`);
return results;
}
/**
* 解析相对时间为绝对时间
* @param {string} timeText - 时间文本(如"1天前"、"2小时前"、"30分钟前")
* @returns {Object} 包含datetime和dateText的对象
*/
function parseRelativeTime(timeText) {
if (!timeText) return { datetime: '', dateText: '' };
const now = new Date();
let targetDate = new Date(now);
// 匹配各种相对时间格式
const dayMatch = timeText.match(/(\d+)天前/);
const hourMatch = timeText.match(/(\d+)小时前/);
const minuteMatch = timeText.match(/(\d+)分钟前/);
if (dayMatch) {
const days = parseInt(dayMatch[1]);
targetDate.setDate(now.getDate() - days);
} else if (hourMatch) {
const hours = parseInt(hourMatch[1]);
targetDate.setHours(now.getHours() - hours);
} else if (minuteMatch) {
const minutes = parseInt(minuteMatch[1]);
targetDate.setMinutes(now.getMinutes() - minutes);
} else {
// 尝试匹配标准日期格式(如"2024-01-15")
const dateMatch = timeText.match(/(\d{4})-(\d{2})-(\d{2})/);
if (dateMatch) {
targetDate = new Date(
parseInt(dateMatch[1]),
parseInt(dateMatch[2]) - 1,
parseInt(dateMatch[3])
);
} else {
return { datetime: '', dateText: timeText };
}
}
const datetime = targetDate.toISOString().slice(0, 19).replace('T', ' ');
const dateText = `targetDate.getFullYear()年String(targetDate.getMonth() + 1).padStart(2, '0')月String(targetDate.getDate()).padStart(2, '0')日`;
return { datetime, dateText };
}
/**
* 将Date对象格式化为中国时区(UTC+8)的datetime字符串
* @param {Date} date - Date对象
* @returns {string} YYYY-MM-DD HH:mm:ss 格式的中国时间
*/
function formatChinaDateTime(date) {
// 转换为中国时间(UTC+8)
const chinaTime = new Date(date.getTime() + 8 * 60 * 60 * 1000);
const year = chinaTime.getUTCFullYear();
const month = String(chinaTime.getUTCMonth() + 1).padStart(2, '0');
const day = String(chinaTime.getUTCDate()).padStart(2, '0');
const hours = String(chinaTime.getUTCHours()).padStart(2, '0');
const minutes = String(chinaTime.getUTCMinutes()).padStart(2, '0');
const seconds = String(chinaTime.getUTCSeconds()).padStart(2, '0');
return `year-month-day hours:minutes:seconds`;
}
/**
* 解析单篇文章
* @param {Object} $ - cheerio实例
* @param {Object} element - 文章DOM元素
* @returns {Object|null} 文章数据对象
*/
function parseArticle($, element) {
try {
const $elem = $(element);
// 获取标题和URL
const $titleLink = $elem.find('h3 a');
if ($titleLink.length === 0) return null;
const title = $titleLink.text().trim();
let url = $titleLink.attr('href') || '';
// 处理相对URL
if (url.startsWith('/')) {
url = `https://weixin.sogou.comurl`;
}
// 获取概要
const summary = $elem.find('p.txt-info').text().trim();
// 获取日期和来源
let datetime = '';
let dateText = '';
let source = '';
let timeDescription = ''; // 原始时间文字描述(如"2小时前")
const $sourceBox = $elem.find('.s-p');
if ($sourceBox.length > 0) {
// 获取日期 - 优先从script标签获取时间戳
const $dateScript = $sourceBox.find('.s2 script');
if ($dateScript.length > 0) {
const scriptText = $dateScript.text();
const timestampMatch = scriptText.match(/(\d{10})/);
if (timestampMatch) {
const timestamp = parseInt(timestampMatch[1]) * 1000;
const date = new Date(timestamp);
datetime = formatChinaDateTime(date);
dateText = `date.getFullYear()年String(date.getMonth() + 1).padStart(2, '0')月String(date.getDate()).padStart(2, '0')日`;
}
}
// 尝试从文本获取时间描述(优先保存原始描述)
const $timeElem = $sourceBox.find('.s2');
if ($timeElem.length > 0) {
// 获取script中的时间戳用于计算
const scriptText = $timeElem.find('script').text();
const timestampMatch = scriptText.match(/(\d{10})/);
if (timestampMatch) {
// 如果有时间戳,计算相对时间描述
const timestamp = parseInt(timestampMatch[1]) * 1000;
const articleDate = new Date(timestamp);
const now = new Date();
const diffMs = now - articleDate;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays > 0) {
timeDescription = `diffDays天前`;
} else if (diffHours > 0) {
timeDescription = `diffHours小时前`;
} else {
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes > 0) {
timeDescription = `diffMinutes分钟前`;
} else {
timeDescription = '刚刚';
}
}
} else {
// 如果没有时间戳,尝试从文本获取
const timeText = $timeElem.clone().children('script').remove().end().text().trim();
if (timeText && !datetime) {
timeDescription = timeText;
const parsedTime = parseRelativeTime(timeText);
datetime = parsedTime.datetime;
dateText = parsedTime.dateText;
}
}
}
// 获取来源公众号名称 - 从 .all-time-y2 或 a.account 获取
const $sourceSpan = $sourceBox.find('.all-time-y2');
const $sourceLink = $sourceBox.find('a.account');
if ($sourceSpan.length > 0) {
source = $sourceSpan.text().trim();
} else if ($sourceLink.length > 0) {
source = $sourceLink.text().trim();
}
}
return {
title,
url,
summary,
datetime,
date_text: dateText,
date_description: timeDescription || dateText,
source
};
} catch (error) {
console.error('解析文章失败:', error.message);
return null;
}
}
/**
* 搜索微信公众号文章
* @param {string} query - 搜索关键词
* @param {number} maxResults - 最大返回结果数(默认10,最大50)
* @returns {Promise<Array>} 文章列表
*/
async function searchWechatArticles(query, maxResults = 10, resolveRealUrl = false) {
// 限制最大结果数
maxResults = Math.min(maxResults, 50);
const articles = [];
let page = 1;
const pagesNeeded = Math.ceil(maxResults / 10);
while (articles.length < maxResults && page <= pagesNeeded) {
try {
// 先获取cookie
const { cookieStr } = await getSogouCookie();
// 构建搜索URL
const encodedQuery = encodeURIComponent(query);
const url = `https://weixin.sogou.com/weixin?query=encodedQuery&s_from=input&_sug_=n&type=2&page=page&ie=utf8`;
const html = await httpGet(url, cookieStr);
const remaining = maxResults - articles.length;
const parsed = parseArticlesFromSearchHtml(html, remaining);
if (parsed.length === 0) break;
articles.push(...parsed);
page++;
// 添加短暂延迟避免请求过快
if (page <= pagesNeeded) {
await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1000));
}
} catch (error) {
console.error(`请求第page页失败:`, error.message);
break;
}
}
const result = articles.slice(0, maxResults);
// 如果需要解析真实URL
if (resolveRealUrl && result.length > 0) {
console.error('正在解析真实URL...');
return await resolveRealUrls(result);
}
return result;
}
/**
* 主函数 - 处理命令行参数
*/
async function main() {
const args = process.argv.slice(2);
const { query, num, output, resolveRealUrl } = parseCliArgs(args);
if (!query) {
console.log(`
微信公众号文章搜索工具
用法:
node search_wechat.js <关键词> [选项]
选项:
-n, --num <数量> 返回结果数量(默认10,最大50)
-o, --output <文件> 输出JSON文件路径
-r, --resolve-url 解析真实的微信文章URL(会额外请求每个链接)
示例:
node search_wechat.js "人工智能" -n 20
node search_wechat.js "ChatGPT" -n 10 -o result.json
node search_wechat.js "人工智能" -n 5 -r
`);
process.exit(0);
}
try {
console.error(`正在搜索: "query"...`);
const articles = await searchWechatArticles(query, num, resolveRealUrl);
const result = {
query,
total: articles.length,
articles
};
const jsonOutput = JSON.stringify(result, null, 2);
if (output) {
const fs = require('fs');
fs.writeFileSync(output, jsonOutput, 'utf-8');
console.error(`结果已保存到: output`);
}
console.log(jsonOutput);
} catch (error) {
console.error('搜索失败:', error.message);
process.exit(1);
}
}
// 导出模块供其他脚本使用
module.exports = {
searchWechatArticles
};
// 如果直接运行此脚本
if (require.main === module) {
main();
}
图片生成技能,当用户需要生成图片、视觉信息图、创建图像、编辑/修改/调整已有图片时使用此技能。基于中国的API易代理站(https://api.apiyi.com/)的NanoBananaPro模型的图片生成服务,无需访问外网。支持10种宽高比的图片比例(`1:1`、`16:9`、`9:16`、`4:3`、`3:...
---
name: nano-banana-pro-image-gen
description: 图片生成技能,当用户需要生成图片、视觉信息图、创建图像、编辑/修改/调整已有图片时使用此技能。基于中国的API易代理站(https://api.apiyi.com/)的NanoBananaPro模型的图片生成服务,无需访问外网。支持10种宽高比的图片比例(`1:1`、`16:9`、`9:16`、`4:3`、`3:4`、`3:2`、`2:3`、`5:4`、`4:5`、`21:9`等)和3种分辨率(1K、2K、4K),支持文生图和图生图编辑。基于谷歌的NanoBananaPro模型(快速模型),使用API易国内代理服务访问。
---
# 图片生成与编辑
图片生成技能,可以通过自然语言帮助用户生成图片,通过API易国内代理服务访问,支持Node.js和Python两种运行环境。
## 使用指引
遵循以下步骤:
### 第1步:分析需求与参数提取
1. **明确意图**:区分用户是需要【文生图】(生成新图片)还是【图生图】(编辑/修改现有图片)。
2. **提示词(Prompt)分析**:
- **使用用户原始完整输入**:把用户输入的原始完整问题需求描述(原文)直接作为 `-p` 提示词的主体,避免自行改写、总结或二次创作,防止细节丢失。
- **需要补充时先确认**:如果信息不足(例如缺少风格、主体数量、镜头语言、场景细节、文字内容、禁止元素等),先向用户提问确认;用户确认后,再把补充内容**以“追加”的方式**拼接到原始提示词后。
- 样例:
- 用户输入:“帮我生成一张猫的图片,风格要可爱一点。”
- 正例说明:直接使用用户输入作为提示词:`-p "帮我生成一张猫的图片,风格要可爱一点。"`
- 反例说明:擅自改写为“生成一张可爱风格的猫的图片”会丢失用户原始输入的细节和语气。
- 如果需要补充细节(例如颜色、背景等),先提问确认:“你希望猫是什么颜色的?背景有什么要求吗?”用户回答后,再追加到提示词中:`-p "帮我生成一张猫的图片,风格要可爱一点。猫是橘色的,背景是草地。"`
3. **关键参数整理**:
- **Prompt(必需)**:提示词分析后的最终提示词(默认=用户原始完整且一致的输入;仅在用户确认后才追加补充信息)。
- **Filename(可选)**:输出图片文件名/路径(需包含文件随机标识,避免重复)。不传则脚本会自动生成带时间戳的文件名。建议根据内容生成合理文件名(例如 `cat_in_garden.png`),避免使用通用名。
- **Aspect Ratio(可选)**:根据用户描述推断比例。例如:
- "手机壁纸" -> `9:16`
- "电脑壁纸/视频封面" -> `16:9`
- "头像" -> `1:1`
- 默认若用户未明确不指定图片比例,保持图片比例为空。
- **Resolution(可选)**:
- 默认图片比例使用 `2K`。
- 仅在极端高清需求或用户指定时使用 `4K`,并通过友好性提示,提示用户生成较慢,耐心等待。
- **注意**:参数值必须大写(`1K`, `2K`, `4K`)。
### 第2步:环境检查与命令执行
1. **检查环境**:确认 `APIYI_API_KEY` 环境变量是否已设置(通常假定已设置,若运行失败再提示用户)。
2. **构建并运行命令**:
- **优先尝试 Node.js 版本**:如果环境有 Node(`node` 命令可用),优先使用 `scripts/generate_image.js`(零依赖,参数与 Python 保持一致)。
- **Node 不可用再用 Python 版本**:使用 `scripts/generate_image.py`。
**文生图命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "{prompt}" -f "{filename}" [-a {ratio}] [-r {res}]
```
**图生图命令模板(优先 Node.js):**
```bash
node scripts/generate_image.js -p "{edit_instruction}" -i "{input_path}" -f "{output_filename}" [-r {res}]
```
**(可选)Python 版本命令模板(Node 不可用时)**:
```bash
python scripts/generate_image.py -p "{prompt}" -f "{filename}" [-a {ratio}] [-r {res}]
python scripts/generate_image.py -p "{edit_instruction}" -i "{input_path}" -f "{output_filename}" [-r {res}]
```
## ⏱️ 长时间任务处理策略
### 1. 任务前提示
**执行前必须告知用户**:
- "图片生成已启动,预计需要25秒到5分钟"
### 2. 🎨 最佳实践示例
1. 快速生成场景(1K分辨率)
> "快速模式:1K分辨率生成,预计30秒内完成"
2. 高质量生成场景(2K/4K分辨率)
> "高质量模式:2K分辨率生成,预计1-4分钟\n⏳ 开始生成... 🔄"
### 第3步:结果反馈
1. **执行反馈**:等待终端命令执行完毕。
2. **成功**:告知用户图片已生成,并指出保存路径。
3. **失败**:
- 若提示 API Key 缺失,请指导用户设置环境变量。
- 若提示网络错误,建议用户检查网络或稍后重试。
## 命令行使用样例
### 生成新图片
```bash
python scripts/generate_image.py -p "图片描述文本" -f "output.png" [-a 1:1] [-r 1K]
```
**示例:**
```bash
# 基础生成
python scripts/generate_image.py -p "一只可爱的橘猫在草地上玩耍" -f "cat.png"
# 指定比例和分辨率
python scripts/generate_image.py -p "日落山脉风景" -f "sunset.png" -a 16:9 -r 4K
# 竖版高清图片(适合手机壁纸)
python scripts/generate_image.py -p "城市夜景" -f "city.png" -a 9:16 -r 2K
```
**(可选)Node.js 版本示例:**
```bash
# 基础生成
node scripts/generate_image.js -p "一只可爱的橘猫在草地上玩耍" -f "cat.png"
# 指定比例和分辨率
node scripts/generate_image.js -p "日落山脉风景" -f "sunset.png" -a 16:9 -r 4K
```
### 编辑已有图片
```bash
python scripts/generate_image.py -p "编辑指令" -f "output.png" -i "path/to/input.png" [-a 1:1] [-r 1K]
```
**示例:**
```bash
# 修改风格
python scripts/generate_image.py -p "将图片转换成水彩画风格" -f "watercolor.png" -i "original.png"
# 添加元素
python scripts/generate_image.py -p "在天空添加彩虹" -f "rainbow.png" -i "landscape.png" -r 2K
# 替换背景
python scripts/generate_image.py -p "将背景换成海滩" -f "beach-bg.png" -i "portrait.png" -a 3:4
```
**(可选)Node.js 版本示例:**
```bash
# 修改风格
node scripts/generate_image.js -p "将图片转换成水彩画风格" -f "watercolor.png" -i "original.png"
# 多张参考图(最多14张)
node scripts/generate_image.js -p "参考多张图片融合风格" -i ref1.png ref2.png ref3.png -f "merged.png"
```
## 附加资源
- 常见使用场景文档:references/scene.md
## 命令行参数说明
> Python 与 Node.js 版本参数保持一致(短参数与长参数等价)。
| 参数 | 必填 | 说明 |
|------|------|------|
| `-p` / `--prompt` | 是 | 图片描述(文生图)或编辑指令(图生图)。保留用户原始完整输入。 |
| `-f` / `--filename` | 否 | 输出图片路径/文件名;不传则自动生成带时间戳的 PNG 文件名,并写入当前目录。 |
| `-a` / `--aspect-ratio` | 否 | 图片比例:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`、`3:2`、`2:3`、`5:4`、`4:5`、`21:9`。 |
| `-r` / `--resolution` | 否 | 图片分辨率:`1K` / `2K` / `4K`(必须大写)。不传则不在请求中指定,由 API 侧决定。 |
| `-i` / `--input-image` | 否 | 图生图输入图片路径;可传多张(最多 14 张)。传入该参数即进入编辑模式。 |
## 图片参数说明
### aspect_ratio - 图片比例
支持以下10种比例:
| 比例 | 方向 | 适用场景 |
|------|------|----------|
| 1:1 | 正方形 | 头像、Instagram帖子 |
| 16:9 | 横版 | YouTube缩略图、桌面壁纸、演示文稿 |
| 9:16 | 竖版 | 抖音/TikTok、Instagram Stories、手机壁纸 |
| 4:3 | 横版 | 经典照片、演示文稿 |
| 3:4 | 竖版 | Pinterest、人像摄影 |
| 3:2 | 横版 | 单反相机标准、印刷媒体 |
| 2:3 | 竖版 | 人像海报 |
| 5:4 | 横版 | 大幅面打印、艺术印刷 |
| 4:5 | 竖版 | Instagram帖子、社交媒体 |
| 21:9 | 超宽 | 电影感、横幅、全景 |
### resolution - 图片分辨率
1K、2K、4K三种分辨率选项
**注意:** 分辨率值必须大写(1K、2K、4K)
**默认:** 2K
## 注意事项
- API密钥必须设置,可通过环境变量或命令行参数提供
- 分辨率参数必须大写(1K/2K/4K),小写会默认使用1K
- 图片生成时间:25秒到5分钟不等,取决于分辨率和服务器负载
- 编辑图片时,输入图片会自动转换为base64编码
- 确保输出目录有写入权限
### API Key设置与获取
#### 如何获取API Key
如果你还没有API密钥,请前往 **https://api.apiyi.com** 注册账号并申请API Key。
获取步骤:
1. 访问 https://api.apiyi.com
2. 注册/登录你的账号
3. 在控制台中创建API密钥
4. 复制密钥并设置环境变量或在命令行中使用
#### 设置API Key
脚本按以下顺序查找API密钥:
1. `--api-key` 命令行参数(临时使用)
2. `APIYI_API_KEY` 环境变量(推荐)
**设置环境变量(推荐):**
```bash
# Linux/Mac
export APIYI_API_KEY="your-api-key-here"
# Windows CMD
我的电脑高级设置中设置环境变量或者执行set APIYI_API_KEY=your-api-key-here
# Windows PowerShell
在我的电脑中设置环境变量:$env:APIYI_API_KEY="your-api-key-here"
```
**命令行参数方式(临时):**
```bash
python scripts/generate_image.py -p "一只猫" -k "your-api-key-here"
```
## 作者介绍
- 爱海贼的无处不在
- 我的微信公众号:无处不在的技术
FILE:references/scene.md
## 常见使用场景
### 社交媒体内容
- **微信朋友圈/公众号**: `-a 1:1` 或 `-a 3:4`, 或 `-a 9:16`,配图文案图、封面图
- **小红书笔记**: `-a 3:4` 或 `-a 4:5`,竖版配图更受欢迎
- **抖音/视频号**: `-a 9:16`,短视频封面、竖版内容图
- **B站/知乎**: `-a 16:9 -r 2K`,视频封面、专栏头图
- **微博**: `-a 1:1` 或 `-a 3:4`,九宫格配图、话题图
### 电商与营销
- **淘宝/京东主图**: `-a 1:1`,白底产品图、场景图
- **电商详情页**: `-a 3:4`,产品展示、卖点图、对比图
- **活动海报**: `-a 2:3` 或 `-a 9:16`,促销海报、节日海报
- **PPT配图**: `-a 16:9`,演讲背景、数据可视化插图
### 个人创作
- **艺术创作**: 视觉信息图、笔记图、插画图等等
- **头像/壁纸**: `-a 1:1`(头像)、`-a 9:16`(手机壁纸)
- **表情包/梗图**: `-a 1:1`,搞笑图片、表情包素材
- **节日贺卡**: `-a 3:4` 或 `-a 4:5`,春节、中秋祝福图
- **LOGO/图标**: `-a 1:1`,品牌标识、应用图标设计
### 编辑任务
- **风格转换**: "转换成国潮/水墨/二次元/像素风格"
- **元素添加**: "添加春节元素/红包/灯笼/烟花"
- **背景替换**: "将背景换成故宫/西湖/长城/现代都市"
- **色彩调整**: "调整为莫兰迪色系/中国传统色"
- **对象移除**: "去除水印/路人/杂物"
- **画质修复**: "修复老照片/提升清晰度/去噪点"
FILE:scripts/generate_image.py
#!/usr/bin/env python3
"""
基于NanoBananaPro/Gemini 3 Pro的图片生成与编辑脚本
使用API易国内代理服务
支持功能:
- 文生图:根据提示词生成图片
- 图生图:根据编辑指令修改已有图片
参数说明:
- aspect_ratio: 图片比例 (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 5:4, 4:5, 21:9)
- resolution: 图片分辨率 (1K, 2K, 4K),必须大写
"""
import os
import sys
import json
import base64
import argparse
from unittest import result
import requests
from pathlib import Path
from datetime import datetime
# 支持的比例列表
SUPPORTED_ASPECT_RATIOS = [
"1:1",
"16:9",
"9:16",
"4:3",
"3:4",
"3:2",
"2:3",
"5:4",
"4:5",
"21:9",
]
SUPPORTED_RESOLUTIONS = ["1K", "2K", "4K"]
def get_api_key(args_key=None):
"""获取API密钥,优先使用命令行参数"""
if args_key:
return args_key
api_key = os.environ.get("APIYI_API_KEY")
if not api_key:
print("错误: 未设置 APIYI_API_KEY 环境变量")
print("请前往 https://api.apiyi.com 注册申请API Key")
print("或使用 --api-key 参数临时指定")
sys.exit(1)
return api_key
def generate_filename(prompt):
"""根据提示词生成带时间戳的文件名"""
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
# 从提示词提取关键词(简化处理)
keywords = prompt.split()[:3] # 取前3个词
keyword_str = "-".join(keywords)
# 清理文件名中的特殊字符
keyword_str = "".join(c if c.isalnum() or c in "-_." else "-" for c in keyword_str)
keyword_str = keyword_str.lower()[:30] # 限制长度
return f"{timestamp}-{keyword_str}.png"
def add_timestamp_to_filename(filename: str, timestamp: str) -> str:
p = Path(filename)
stem = p.stem or "image"
suffix = p.suffix
# If suffix is empty, keep it empty (caller may intentionally omit extension)
new_name = f"{stem}-{timestamp}{suffix}"
return str(p.with_name(new_name))
def encode_image_to_base64(image_path):
"""将图片文件转换为base64编码"""
try:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
print(f"错误: 无法读取图片文件 {image_path} - {e}")
sys.exit(1)
def generate_image(
prompt,
filename,
aspect_ratio=None,
resolution=None,
input_image=None,
api_key=None,
):
"""
生成或编辑图片
Args:
prompt: 图片描述或编辑指令文本
filename: 输出图片路径
aspect_ratio: 图片比例 (可选,默认由API决定)
resolution: 图片分辨率 (可选,默认由API决定)
input_image: 输入图片路径(编辑模式时使用)
api_key: API密钥
"""
# 验证参数(仅在提供了参数时验证)
if aspect_ratio is not None and aspect_ratio not in SUPPORTED_ASPECT_RATIOS:
print(f"错误: 不支持的比例 '{aspect_ratio}'")
print(f"支持的比例: {', '.join(SUPPORTED_ASPECT_RATIOS)}")
sys.exit(1)
if resolution is not None and resolution not in SUPPORTED_RESOLUTIONS:
print(f"错误: 不支持的分辨率 '{resolution}'")
print(f"支持的分辨率: {', '.join(SUPPORTED_RESOLUTIONS)} (必须大写)")
sys.exit(1)
api_key = get_api_key(api_key)
url = (
"https://api.apiyi.com/v1beta/models/gemini-3-pro-image-preview:generateContent"
)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
# 构建content部分
parts = [{"text": prompt}]
# 如果提供了输入图片,添加图片数据(图生图模式)
# NanoBananaPro最多支持14张参考图片
if input_image:
if isinstance(input_image, (list, tuple)):
input_images = list(input_image)
else:
input_images = [input_image]
if len(input_images) > 14:
print(f"错误: 输入图片最多支持14张,当前为 {len(input_images)} 张")
sys.exit(1)
for image_path in input_images:
if not os.path.exists(image_path):
print(f"错误: 输入图片不存在: {image_path}")
sys.exit(1)
image_base64 = encode_image_to_base64(image_path)
parts.append({"inlineData": {"mimeType": "image/png", "data": image_base64}})
mode_str = "编辑图片"
else:
mode_str = "生成图片"
# 构建payload,只添加用户指定的参数
generation_config = {
"responseModalities": ["IMAGE"],
}
image_config = {}
if aspect_ratio is not None:
image_config["aspectRatio"] = aspect_ratio
if resolution is not None:
image_config["imageSize"] = resolution
if image_config:
generation_config["imageConfig"] = image_config
payload = {
"contents": [{"parts": parts}],
"generationConfig": generation_config,
}
print(f"正在{mode_str}...")
print(f"提示词: {prompt}")
if generation_config.get("imageConfig", {}).get("aspectRatio"):
print(f"比例: {generation_config['imageConfig']['aspectRatio']}")
if generation_config.get("imageConfig", {}).get("imageSize"):
print(f"分辨率: {generation_config['imageConfig']['imageSize']}")
# 输出请求参数(脱敏:不直接输出base64图片数据,避免刷屏)
payload_log = {
"generationConfig": generation_config,
"contents": [],
}
for content in payload.get("contents", []):
parts_log = []
for part in content.get("parts", []):
if isinstance(part, dict) and "inlineData" in part and isinstance(part["inlineData"], dict):
inline_data = dict(part["inlineData"])
data_value = inline_data.get("data")
if isinstance(data_value, str):
inline_data["data"] = f"<omitted base64: {len(data_value)} chars>"
parts_log.append({"inlineData": inline_data})
else:
parts_log.append(part)
payload_log["contents"].append({"parts": parts_log})
print(f"输出请求参数: {json.dumps(payload_log, indent=2, ensure_ascii=False)}")
print(f"image generation in progress...")
try:
response = requests.post(url, headers=headers, json=payload, timeout=400)
response.raise_for_status()
data = response.json()
# 解析响应,查找图片数据
image_data = None
text_response = ""
if "candidates" in data and len(data["candidates"]) > 0:
candidate = data["candidates"][0]
image_data = candidate["content"]["parts"][0]["inlineData"]["data"]
if image_data:
# 解码base64图片数据
image_bytes = base64.b64decode(image_data)
# 确保输出目录存在
output_file = Path(filename)
output_file.parent.mkdir(parents=True, exist_ok=True)
# 保存图片
with open(output_file, "wb") as f:
f.write(image_bytes)
print(f"✓ 图片已成功{mode_str}并保存到: {filename}")
if text_response.strip():
print(f"模型响应: {text_response.strip()}")
return filename
else:
print("错误: 响应中未找到图片数据")
print(f"完整响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
sys.exit(1)
except requests.exceptions.Timeout:
print("错误: 请求超时,请稍后重试")
sys.exit(1)
except requests.exceptions.RequestException as e:
print(f"错误: 请求失败 - {e}")
if hasattr(e, "response") and e.response is not None:
try:
error_detail = e.response.json()
print(
f"错误详情: {json.dumps(error_detail, indent=2, ensure_ascii=False)}"
)
except:
print(f"响应状态码: {e.response.status_code}")
print(f"响应内容: {e.response.text}")
sys.exit(1)
except Exception as e:
print(f"错误: {str(e)}")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
description="基于Gemini 3 Pro的图片生成与编辑工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
【生成新图片】
python generate_image.py -p "一只可爱的橘猫"
python generate_image.py -p "日落山脉" -a 16:9 -r 4K
python generate_image.py -p "城市夜景" -a 9:16 -r 2K -f wallpaper.png
【编辑已有图片】
python generate_image.py -p "转换成油画风格" -i original.png
python generate_image.py -p "添加彩虹到天空" -i photo.jpg -f edited.png
python generate_image.py -p "将背景换成海滩" -i portrait.png -a 3:4 -r 2K
python generate_image.py -p "参考多张图片融合风格" -i ref1.png ref2.png ref3.png -f merged.png
【支持的参数值】
--aspect-ratio: 可选 (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 5:4, 4:5, 21:9)
--resolution: 可选 (1K, 2K, 4K,必须大写)
【环境变量】
export APIYI_API_KEY="your-api-key"
""",
)
parser.add_argument("--prompt", "-p", required=True, help="图片描述或编辑指令文本")
parser.add_argument(
"--filename",
"-f",
default=None,
help="输出图片路径 (默认: 自动生成时间戳文件名)",
)
parser.add_argument(
"--aspect-ratio",
"-a",
default=None,
choices=SUPPORTED_ASPECT_RATIOS,
help="图片比例 (可选: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3, 5:4, 4:5, 21:9)",
)
parser.add_argument(
"--resolution",
"-r",
default=None,
choices=SUPPORTED_RESOLUTIONS,
help="图片分辨率 (可选: 1K, 2K, 4K,必须大写)",
)
parser.add_argument(
"--input-image",
"-i",
nargs="+",
default=None,
help="输入图片路径(编辑模式,可传多张,最多14张)",
)
parser.add_argument("--api-key", "-k", default=None, help="API密钥(覆盖环境变量)")
args = parser.parse_args()
run_timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
# 如果没有指定文件名,自动生成
if args.filename is None:
args.filename = generate_filename(args.prompt)
else:
out_path = Path(args.filename)
if out_path.exists():
adjusted = add_timestamp_to_filename(args.filename, run_timestamp)
print(f"警告: 输出文件已存在,将避免覆盖并改为: {adjusted}")
args.filename = adjusted
generate_image(
prompt=args.prompt,
filename=args.filename,
aspect_ratio=args.aspect_ratio,
resolution=args.resolution,
input_image=args.input_image,
api_key=args.api_key,
)
if __name__ == "__main__":
main()
FILE:scripts/generate_image.js
#!/usr/bin/env node
/*
基于NanoBananaPro/Gemini 3 Pro的图片生成与编辑脚本(Node.js版)
使用API易国内代理服务
支持功能:
- 文生图:根据提示词生成图片
- 图生图:根据编辑指令修改已有图片
参数说明:
- -p, --prompt 图片描述或编辑指令文本(必需)
- -f, --filename 输出图片路径(可选,默认自动生成时间戳文件名)
- -a, --aspect-ratio 图片比例(可选)
- -r, --resolution 图片分辨率(可选:1K/2K/4K,必须大写)
- -i, --input-image 输入图片路径(可选,可多张,最多14张)
- -k, --api-key API密钥(可选,覆盖环境变量 APIYI_API_KEY)
使用示例:
【生成新图片】
node generate_image.js -p "一只可爱的橘猫"
node generate_image.js -p "日落山脉" -a 16:9 -r 4K
node generate_image.js -p "城市夜景" -a 9:16 -r 2K -f wallpaper.png
【编辑已有图片】
node generate_image.js -p "转换成油画风格" -i original.png
node generate_image.js -p "添加彩虹到天空" -i photo.jpg -f edited.png
node generate_image.js -p "将背景换成海滩" -i portrait.png -a 3:4 -r 2K
node generate_image.js -p "参考多张图片融合风格" -i ref1.png ref2.png ref3.png -f merged.png
【环境变量】
export APIYI_API_KEY="your-api-key"
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const SUPPORTED_ASPECT_RATIOS = [
'1:1',
'16:9',
'9:16',
'4:3',
'3:4',
'3:2',
'2:3',
'5:4',
'4:5',
'21:9',
];
const SUPPORTED_RESOLUTIONS = ['1K', '2K', '4K'];
function printHelpAndExit(exitCode = 0) {
const help = `usage: generate_image.js [-h] --prompt PROMPT [--filename FILENAME]
[--aspect-ratio SUPPORTED_ASPECT_RATIOS.join(', ')]
[--resolution SUPPORTED_RESOLUTIONS.join(', ')]
[--input-image INPUT_IMAGE [INPUT_IMAGE ...]]
[--api-key API_KEY]
基于Gemini 3 Pro的图片生成与编辑工具(Node.js版)
options:
-h, --help show this help message and exit
-p, --prompt PROMPT 图片描述或编辑指令文本(必需)
-f, --filename FILE 输出图片路径 (默认: 自动生成时间戳文件名)
-a, --aspect-ratio 图片比例 (可选)
-r, --resolution 图片分辨率 (可选: 1K, 2K, 4K,必须大写)
-i, --input-image 输入图片路径(编辑模式,可传多张,最多14张)
-k, --api-key API密钥(覆盖环境变量)
运行示例:
node scripts/generate_image.js -p "一只可爱的橘猫"
node scripts/generate_image.js -p "日落山脉" -a 16:9 -r 4K
node scripts/generate_image.js -p "城市夜景" -a 9:16 -r 2K -f wallpaper.png
node scripts/generate_image.js -p "转换成油画风格" -i original.png
node scripts/generate_image.js -p "参考多张图片融合风格" -i ref1.png ref2.png -f merged.png
`;
process.stdout.write(help);
process.exit(exitCode);
}
function exitWithError(message) {
process.stderr.write(`message\n`);
process.exit(1);
}
function pad2(n) {
return String(n).padStart(2, '0');
}
function formatTimestamp(dateObj) {
const d = dateObj || new Date();
return `d.getFullYear()-pad2(d.getMonth() + 1)-pad2(d.getDate())-pad2(d.getHours())-pad2(d.getMinutes())-pad2(d.getSeconds())`;
}
function addTimestampToFilename(filePath, timestamp) {
const ts = timestamp || formatTimestamp(new Date());
const parsed = path.parse(filePath);
const base = parsed.name ? `parsed.name-ts` : ts;
return path.join(parsed.dir || '.', `baseparsed.ext || ''`);
}
function generateFilename(prompt) {
const now = new Date();
const timestamp = formatTimestamp(now);
const keywords = String(prompt).split(/\s+/).filter(Boolean).slice(0, 3);
const keywordStrRaw = keywords.join('-') || 'image';
const keywordStr = keywordStrRaw
.split('')
.map((c) => (/^[a-zA-Z0-9\-_.]$/.test(c) ? c : '-'))
.join('')
.toLowerCase()
.slice(0, 30);
return `timestamp-keywordStr.png`;
}
function getApiKey(argsKey) {
if (argsKey) return argsKey;
const apiKey = process.env.APIYI_API_KEY;
if (!apiKey) {
exitWithError(
'错误: 未设置 APIYI_API_KEY 环境变量\n' +
'请前往 https://api.apiyi.com 注册申请API Key\n' +
'或使用 -k/--api-key 参数临时指定'
);
}
return apiKey;
}
function encodeImageToBase64(imagePath) {
try {
const bytes = fs.readFileSync(imagePath);
return bytes.toString('base64');
} catch (e) {
exitWithError(`错误: 无法读取图片文件 imagePath - e.message || String(e)`);
}
}
function postJson(urlString, headers, payload, timeoutMs) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const body = Buffer.from(JSON.stringify(payload), 'utf8');
const req = https.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: 'POST',
headers: {
...headers,
'Content-Length': body.length,
},
},
(res) => {
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
const statusCode = res.statusCode || 0;
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(`HTTP statusCode`);
err.statusCode = statusCode;
err.responseText = text;
return reject(err);
}
try {
resolve(JSON.parse(text));
} catch (e) {
const err = new Error('响应不是有效的JSON');
err.responseText = text;
return reject(err);
}
});
}
);
req.on('error', reject);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error('timeout'));
});
req.write(body);
req.end();
});
}
function parseArgs(argv) {
const args = {
prompt: null,
filename: null,
aspectRatio: null,
resolution: null,
inputImages: null,
apiKey: null,
};
const knownFlags = new Set([
'-h',
'--help',
'-p',
'--prompt',
'-f',
'--filename',
'-a',
'--aspect-ratio',
'-r',
'--resolution',
'-i',
'--input-image',
'-k',
'--api-key',
]);
function requireValue(i, flag) {
const v = argv[i + 1];
if (!v || (v.startsWith('-') && knownFlags.has(v))) {
exitWithError(`错误: 参数 flag 需要一个值`);
}
return v;
}
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '-h' || a === '--help') {
printHelpAndExit(0);
}
if (a === '-p' || a === '--prompt') {
args.prompt = requireValue(i, a);
i++;
continue;
}
if (a === '-f' || a === '--filename') {
args.filename = requireValue(i, a);
i++;
continue;
}
if (a === '-a' || a === '--aspect-ratio') {
args.aspectRatio = requireValue(i, a);
i++;
continue;
}
if (a === '-r' || a === '--resolution') {
args.resolution = requireValue(i, a);
i++;
continue;
}
if (a === '-k' || a === '--api-key') {
args.apiKey = requireValue(i, a);
i++;
continue;
}
if (a === '-i' || a === '--input-image') {
const images = [];
let j = i + 1;
while (j < argv.length) {
const v = argv[j];
if (v.startsWith('-') && knownFlags.has(v)) break;
images.push(v);
j++;
}
if (images.length === 0) {
exitWithError(`错误: 参数 a 需要至少一个图片路径`);
}
args.inputImages = images;
i = j - 1;
continue;
}
if (a.startsWith('-')) {
exitWithError(`错误: 未知参数 a,请使用 --help 查看帮助`);
}
}
if (!args.prompt) {
exitWithError('错误: 缺少必需参数 -p/--prompt');
}
return args;
}
async function main() {
const argv = process.argv.slice(2);
const args = parseArgs(argv);
const runTimestamp = formatTimestamp(new Date());
let checkProgress = null;
const clearProgressTimer = () => {
if (checkProgress) {
clearInterval(checkProgress);
checkProgress = null;
}
};
if (args.aspectRatio != null && !SUPPORTED_ASPECT_RATIOS.includes(args.aspectRatio)) {
exitWithError(
`错误: 不支持的比例 'args.aspectRatio'\n支持的比例: SUPPORTED_ASPECT_RATIOS.join(', ')`
);
}
if (args.resolution != null && !SUPPORTED_RESOLUTIONS.includes(args.resolution)) {
exitWithError(
`错误: 不支持的分辨率 'args.resolution'\n支持的分辨率: SUPPORTED_RESOLUTIONS.join(', ') (必须大写)`
);
}
if (!args.filename) {
args.filename = generateFilename(args.prompt);
} else {
const resolved = path.resolve(args.filename);
if (fs.existsSync(resolved)) {
const adjusted = addTimestampToFilename(args.filename, runTimestamp);
process.stdout.write(`⚠️ 输出文件已存在,将避免覆盖并改为: adjusted\n`);
args.filename = adjusted;
}
}
const apiKey = getApiKey(args.apiKey);
const url =
'https://api.apiyi.com/v1beta/models/gemini-3-pro-image-preview:generateContent';
const headers = {
Authorization: `Bearer apiKey`,
'Content-Type': 'application/json',
};
const parts = [{ text: args.prompt }];
let modeStr = '生成图片';
if (args.inputImages && args.inputImages.length > 0) {
if (args.inputImages.length > 14) {
exitWithError(`错误: 输入图片最多支持14张,当前为 args.inputImages.length 张`);
}
for (const imgPath of args.inputImages) {
if (!fs.existsSync(imgPath)) {
exitWithError(`错误: 输入图片不存在: imgPath`);
}
const imageBase64 = encodeImageToBase64(imgPath);
parts.push({
inlineData: {
mimeType: 'image/png',
data: imageBase64,
},
});
}
modeStr = '编辑图片';
}
const generationConfig = {
responseModalities: ['IMAGE'],
};
const imageConfig = {};
if (args.aspectRatio != null) imageConfig.aspectRatio = args.aspectRatio;
if (args.resolution != null) imageConfig.imageSize = args.resolution;
if (Object.keys(imageConfig).length > 0) generationConfig.imageConfig = imageConfig;
const payload = {
contents: [{ parts }],
generationConfig,
};
// 生成前通知 + 生成中实时日志(避免长时间无输出导致体验不佳)
const resolutionHint = args.resolution;
const etaText = resolutionHint === '4K' ? '1-6分钟' : '30-120秒';
process.stdout.write('🎨 图片生成已启动!\n');
process.stdout.write(`⏱️ 预计时间: etaText\n`);
process.stdout.write('📊 我会定期给您发送进度更新\n');
process.stdout.write(`正在modeStr...\n`);
process.stdout.write(`提示词: args.prompt\n`);
if (generationConfig.imageConfig && generationConfig.imageConfig.aspectRatio) {
process.stdout.write(`比例: generationConfig.imageConfig.aspectRatio\n`);
}
if (generationConfig.imageConfig && generationConfig.imageConfig.imageSize) {
process.stdout.write(`分辨率: generationConfig.imageConfig.imageSize\n`);
}
// 输出请求参数(脱敏:不直接输出base64图片数据,避免刷屏)
const payloadLog = {
generationConfig,
contents: [],
};
for (const content of payload.contents || []) {
const partsLog = [];
for (const part of content.parts || []) {
if (part && typeof part === 'object' && part.inlineData && typeof part.inlineData === 'object') {
const inlineData = { ...part.inlineData };
if (typeof inlineData.data === 'string') {
inlineData.data = `<omitted base64: inlineData.data.length chars>`;
}
partsLog.push({ inlineData });
} else {
partsLog.push(part);
}
}
payloadLog.contents.push({ parts: partsLog });
}
process.stdout.write(`输出请求参数: JSON.stringify(payloadLog, null, 2)\n`);
process.stdout.write('image generation in progress...\n');
const startTime = Date.now();
checkProgress = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
process.stdout.write(`🔄 已进行 elapsed秒...\n`);
}, 5000);
let data;
try {
data = await postJson(url, headers, payload, 120_000);
} catch (e) {
clearProgressTimer();
if (e && e.message === 'timeout') {
exitWithError('错误: 请求超时,请稍后重试');
}
if (e && e.statusCode) {
process.stderr.write(`错误: 请求失败 - HTTP e.statusCode\n`);
if (e.responseText) {
try {
const detail = JSON.parse(e.responseText);
process.stderr.write(`错误详情: JSON.stringify(detail, null, 2)\n`);
} catch {
process.stderr.write(`响应内容: e.responseText\n`);
}
}
process.exit(1);
}
exitWithError(`错误: 请求失败 - e.message || String(e)`);
}
clearProgressTimer();
const imageData =
data &&
data.candidates &&
Array.isArray(data.candidates) &&
data.candidates[0] &&
data.candidates[0].content &&
data.candidates[0].content.parts &&
data.candidates[0].content.parts[0] &&
data.candidates[0].content.parts[0].inlineData &&
data.candidates[0].content.parts[0].inlineData.data;
if (!imageData) {
process.stderr.write('错误: 响应中未找到图片数据\n');
process.stderr.write(`完整响应: JSON.stringify(data, null, 2)\n`);
process.exit(1);
}
const imageBytes = Buffer.from(imageData, 'base64');
const outputFile = path.resolve(args.filename);
const outputDir = path.dirname(outputFile);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputFile, imageBytes);
process.stdout.write(`✓ 图片已成功modeStr并保存到: args.filename\n`);
process.stdout.write('✅ 生成完成!\n');
}
main().catch((e) => {
exitWithError(`错误: String(e)`);
});
获取2026年米兰冬奥会数据技能,包括奖牌榜排名、现场新闻报道和赛程安排。从百度体育网页抓取实时的奖牌排行榜信息、最新新闻资讯和比赛赛程。当用户需要获取米兰冬奥会需求,需要查询冬奥会奖牌榜、了解各国奖牌数量、获取现场新闻、查看赛程安排时使用此技能。能够根据指定时间(今天、明天、yyyy-MM-dd日期格式)或指定运动项目获取赛程安排。A skill for retrieving 2026 Milan Winter Olympics data, including medal standings, live news reports, and competition schedules. Scrapes real-time medal rankings, latest news, and match schedules from Baidu Sports. Use this skill when users need to query Winter Olympics medal standings, check medal counts by country, get live news
---
name: baidu-milan-winter-olympics-2026
description: 获取2026年米兰冬奥会数据技能,包括奖牌榜排名、现场新闻报道和赛程安排。从百度体育网页抓取实时的奖牌排行榜信息、最新新闻资讯和比赛赛程。当用户需要获取米兰冬奥会需求,需要查询冬奥会奖牌榜、了解各国奖牌数量、获取现场新闻、查看赛程安排时使用此技能。能够根据指定时间(今天、明天、yyyy-MM-dd日期格式)或指定运动项目获取赛程安排。A skill for retrieving 2026 Milan Winter Olympics data, including medal standings, live news reports, and competition schedules. Scrapes real-time medal rankings, latest news, and match schedules from Baidu Sports. Use this skill when users need to query Winter Olympics medal standings, check medal counts by country, get live news, or view competition schedules.
---
# 2026年米兰冬奥会数据获取
## 技能概述
此技能用于获取2026年米兰冬奥会的以下数据:
### 1. 奖牌榜数据
- 各国/地区奖牌排名
- 金牌、银牌、铜牌数量
- 奖牌总数统计
- 国旗图片链接
- 详情页面链接
### 2. 现场新闻报道
- 最新赛事新闻
- 精彩瞬间
- 赛后采访
- 视频资讯
- 赛事集锦
### 3. 赛程数据
- 全部赛程安排
- 中国相关赛程
- 金牌赛赛程
- 热门赛程
- 比赛时间、状态、项目信息
### 4. 中国队获奖名单数据
- 中国队所有获奖运动员名单
- 奖牌类型(金牌/银牌/铜牌)
- 运动员姓名
- 比赛项目(大项和小项)
- 获奖时间
- 视频集锦链接
- 奖牌统计信息
数据来源:百度体育 (tiyu.baidu.com)
## 获取奖牌榜数据
### 获取奖牌榜TOP30
当用户需要查看奖牌榜前30名时:
```bash
node scripts/milan-olympics.js top
```
### 获取奖牌榜TOP N
获取指定数量的排名:
```bash
node scripts/milan-olympics.js top 10
```
### 获取完整奖牌榜
```bash
node scripts/milan-olympics.js all
```
### 奖牌榜返回数据字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| rank | number | 排名 |
| country | string | 国家/地区名称(中文) |
| countryEn | string | 国家/地区名称(英文) |
| gold | number | 金牌数 |
| silver | number | 银牌数 |
| bronze | number | 铜牌数 |
| total | number | 奖牌总数 |
| flagUrl | string | 国旗图片URL |
| detailUrl | string | 详情页面URL |
## 获取现场新闻数据
### 获取最新新闻列表
当用户需要查看冬奥会现场新闻时:
```bash
node scripts/milan-news.js list
```
### 获取指定数量的新闻
获取20条最新新闻:
```bash
node scripts/milan-news.js list 20
```
### 按类型筛选新闻
获取"赛事集锦"类型的新闻:
```bash
node scripts/milan-news.js list 10 赛事集锦
```
### 获取可用的内容类型
```bash
node scripts/milan-news.js types
```
可用类型包括:
- 全部
- 热门内容
- 赛事集锦
- 精彩瞬间
- 选手集锦
- 赛后采访
- 赛前采访
- 项目介绍
- 专栏节目
- 其他
## 新闻数据字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 新闻唯一标识 |
| title | string | 新闻标题 |
| type | string | 内容类型:article(文章)、video(视频)、post(动态) |
| subType | string | 内容子类型 |
| source | string | 新闻来源 |
| url | string | 详情页面URL |
| images | array | 图片URL数组 |
| videoDuration | string | 视频时长(仅视频类型) |
| videoUrl | string | 视频播放链接(仅视频类型) |
| matchId | array | 关联的赛事ID |
## 获取中国队获奖名单数据
### 获取全部获奖名单
当用户需要查看中国队所有获奖运动员时:
```bash
node scripts/milan-china-medals.js list
```
### 按奖牌类型筛选
获取中国队的金牌获奖名单:
```bash
node scripts/milan-china-medals.js list gold
```
获取中国队的银牌获奖名单:
```bash
node scripts/milan-china-medals.js list silver
```
获取中国队的铜牌获奖名单:
```bash
node scripts/milan-china-medals.js list bronze
```
### 获取奖牌统计
获取中国队奖牌统计信息(按项目和类型统计):
```bash
node scripts/milan-china-medals.js stats
```
### 中国队获奖名单数据字段说明
**代表团信息(delegationInfo):**
| 字段 | 类型 | 说明 |
|------|------|------|
| country | string | 国家名称(中文) |
| countryEn | string | 国家名称(英文) |
| rank | string | 当前排名 |
| gold | string | 金牌数 |
| silver | string | 银牌数 |
| bronze | string | 铜牌数 |
| delegationId | string | 代表团ID |
**获奖记录(medals):**
| 字段 | 类型 | 说明 |
|------|------|------|
| playerName | string | 运动员姓名 |
| medal | string | 奖牌名称(如"第1银") |
| medalType | string | 奖牌类型:gold/silver/bronze |
| medalRank | number | 奖牌序号 |
| bigMatch | string | 大项(如"自由式滑雪") |
| smallMatch | string | 小项(如"自由式滑雪女子坡面障碍技巧") |
| date | string | 日期(如"02月09日") |
| time | string | 时间(如"21:00") |
| medalTime | string | 时间戳 |
| rank | number | 比赛排名 |
| detailUrl | string | 详情页面URL |
| loc | string | 本地链接 |
| videoInfo | object | 视频信息(含播放链接) |
| playIconArr | array | 播放图标数组 |
| country | string | 国家 |
| olympicEventId | string | 赛事ID |
## 获取赛程数据
### 获取全部赛程
```bash
node scripts/milan-schedule.js all
```
### 获取特定日期的赛程
```bash
node scripts/milan-schedule.js all 2026-02-08
```
### 获取中国相关赛程
```bash
node scripts/milan-schedule.js china
```
获取特定日期的中国赛程:
```bash
node scripts/milan-schedule.js china 2026-02-08
```
### 获取金牌赛赛程
```bash
node scripts/milan-schedule.js gold
```
获取特定日期的金牌赛:
```bash
node scripts/milan-schedule.js gold 2026-02-08
```
### 获取热门赛程
```bash
node scripts/milan-schedule.js hot
```
### 获取今天的赛程(综合TAB)
自动获取今天日期的全部赛程,无需手动指定日期:
```bash
node scripts/milan-schedule.js today
```
### 获取明天的赛程(综合TAB)
自动获取明天日期的全部赛程,无需手动指定日期:
```bash
node scripts/milan-schedule.js tomorrow
```
### 获取可用的日期列表
```bash
node scripts/milan-schedule.js dates
```
### 赛程数据字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| matchId | string | 比赛唯一标识 |
| matchName | string | 比赛名称 |
| sportName | string | 项目大类名称 |
| eventName | string | 具体小项名称 |
| startTime | string | 开始时间(HH:mm) |
| startDate | string | 开始日期(YYYY-MM-DD) |
| startDateTime | string | 完整开始时间 |
| status | string | 比赛状态(未开赛、进行中、已结束等) |
| statusId | string | 状态ID |
| desc | string | 比赛描述/备注 |
| isChina | boolean | 是否中国相关赛程 |
| isGold | boolean | 是否金牌赛 |
| isHot | boolean | 是否热门赛程 |
| isMedal | boolean | 是否奖牌赛 |
| hasLive | boolean | 是否有直播 |
| participant | string | 参赛类型(单人/团体) |
| detailUrl | string | 详情页面URL |
| iconArr | array | 图标标签数组 |
### 获取运动项目列表
查看所有可用的运动项目及其ID:
```bash
node scripts/milan-schedule.js sports
```
返回数据结构:
- **hot**: 热门项目列表(包含热度值)
- **other**: 其他项目列表
常见运动项目ID对照:
| 项目名称 | ID |
|---------|-----|
| 短道速滑 | 302 |
| 花样滑冰 | 217 |
| 速度滑冰 | 103 |
| 单板滑雪 | 222 |
| 自由式滑雪 | 221 |
| 冰壶 | 212 |
| 冰球 | 113 |
| 高山滑雪 | 115 |
| 雪车 | 213 |
| 雪橇 | 214 |
| 钢架雪车 | 307 |
| 跳台滑雪 | 215 |
| 越野滑雪 | 220 |
| 滑雪登山 | 615 |
| 北欧两项 | 216 |
| 冬季两项 | 218 |
### 获取指定运动项目的赛程
查询特定运动项目的赛程安排:
```bash
# 获取短道速滑所有赛程
node scripts/milan-schedule.js sport 302
# 获取特定日期的短道速滑赛程
node scripts/milan-schedule.js sport 302 2026-02-10
```
### 获取中国指定运动项目的赛程
查询中国队在特定运动项目的赛程:
```bash
# 获取中国短道速滑赛程
node scripts/milan-schedule.js china-sport 302
# 获取特定日期中国短道速滑赛程
node scripts/milan-schedule.js china-sport 302 2026-02-10
```
## 作者介绍
- 爱海贼的无处不在
- 我的微信公众号:无处不在的技术
## 注意事项
- 数据从百度体育网页实时抓取,可能存在短暂延迟
- 奖牌榜数据会随着比赛进行不断更新
- 排名规则遵循国际奥委会标准(先按金牌数,再按银牌数,再按铜牌数)
- 新闻内容实时更新,包含文字报道、图片和视频
- 赛程数据包含比赛时间、项目、状态等信息
FILE:scripts/milan-china-medals.js
#!/usr/bin/env node
/**
* 2026年米兰冬奥会中国队获奖名单获取工具
* 从百度体育网页抓取中国队的获奖名单数据
*/
const https = require('https');
// 可配置 User-Agent 池(固定 20 个),每次请求随机选一个,避免固定 UA
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/123.0.0.0 Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/122.0.0.0 Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Mi 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
];
function getRandomUserAgent() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
const HEADERS = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Encoding': 'identity',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://tiyu.baidu.com/',
'Origin': 'https://tiyu.baidu.com'
};
// 中国队获奖名单页面URL(id=26为中国队)
const CHINA_MEDAL_URL = 'https://tiyu.baidu.com/al/major/delegation?id=26&match=2026年米兰冬奥会&tab=获奖名单';
/**
* 发起HTTP GET请求
* @param {string} url - 请求URL
* @returns {Promise<string>} 响应HTML内容
*/
function httpGet(url) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: { ...HEADERS, 'User-Agent': getRandomUserAgent() }
};
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => { chunks.push(chunk); });
res.on('end', () => {
const buffer = Buffer.concat(chunks);
resolve(buffer.toString('utf-8'));
});
});
req.on('error', reject);
req.setTimeout(15000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
/**
* 从HTML中提取JSON数据(获奖名单数据)
* @param {string} html - HTML内容
* @returns {Object|null} 获奖名单数据对象
*/
function extractJsonFromHtml(html) {
try {
// 查找包含页面数据的script标签
const scriptRegex = /<script id="atom-data-[^"]*" type="application\/json">([\s\S]*?)<\/script>/;
const match = html.match(scriptRegex);
if (match && match[1]) {
const parsed = JSON.parse(match[1]);
// 数据在 data.data.data.tabsList 中
const pageData = parsed.data && parsed.data.data ? parsed.data.data : null;
if (pageData && pageData.tabsList) {
// 查找获奖名单标签页 (rootTab === 'medalDetail')
const medalTab = pageData.tabsList.find(tab => tab.rootTab === 'medalDetail');
if (medalTab && medalTab.data) {
return {
delegationInfo: {
country: pageData.header?.title || '中国',
countryEn: pageData.header?.subtitle || 'China(CHN)',
rank: pageData.header?.rankInfo?.rank || '',
gold: pageData.header?.medalInfo?.gold || '0',
silver: pageData.header?.medalInfo?.silver || '0',
bronze: pageData.header?.medalInfo?.bronze || '0',
delegationId: pageData.header?.delegationId || '26'
},
medalList: medalTab.data
};
}
}
}
} catch (e) {
console.error('解析JSON数据失败:', e.message);
}
return null;
}
/**
* 解析获奖名单数据
* @param {Object} data - 从JSON中提取的原始数据
* @returns {Array} 结构化的获奖名单数组
*/
function parseMedalList(data) {
if (!data || !data.medalList || !Array.isArray(data.medalList)) {
return [];
}
const medals = [];
// data.medalList 是一个数组,每个元素代表一个筛选标签(如"全部"、"金牌"、"银牌"等)
// 我们取第一个元素(通常是"全部")
const allMedalsTab = data.medalList[0];
if (allMedalsTab && allMedalsTab.tabData) {
// tabData 是按日期分组的数据
allMedalsTab.tabData.forEach(dateGroup => {
const date = dateGroup.date || '';
if (dateGroup.dateList && Array.isArray(dateGroup.dateList)) {
dateGroup.dateList.forEach(item => {
medals.push({
// 运动员信息
playerName: item.playerName || '',
// 奖牌信息
medal: item.medal || '', // 如"第1银"
medalType: item.medalType || '', // gold/silver/bronze
medalRank: item.medal ? parseInt(item.medal.replace(/[^0-9]/g, '')) || 0 : 0,
// 赛事信息
bigMatch: item.bigMatch || '', // 大项,如"自由式滑雪"
smallMatch: item.smallMatch || '', // 小项,如"自由式滑雪女子坡面障碍技巧"
// 时间和地点
date: date, // 日期,如"02月09日"
time: item.time || '', // 时间,如"21:00"
medalTime: item.medalTime || '', // 时间戳
// 排名信息
rank: item.rank || 0, // 比赛排名
// 链接信息
detailUrl: item.link ? `https://tiyu.baidu.comitem.link` : '',
loc: item.loc || '', // 本地链接
// 媒体信息
videoInfo: item.videoInfo || null, // 视频信息
playIconArr: item.playIconArr || [], // 播放图标
// 其他信息
country: item.country || '中国',
olympicEventId: item.olympicEventId || '',
backgroundColor: item.backgroundColor || '',
color: item.color || '',
iconType: item.iconType || ''
});
});
}
});
}
return medals;
}
/**
* 获取中国队获奖名单
* @param {string} medalType - 奖牌类型过滤(可选):gold(金牌)、silver(银牌)、bronze(铜牌)、all(全部)
* @returns {Promise<Object>} 获奖名单数据对象
*/
async function getChinaMedals(medalType = 'all') {
try {
const html = await httpGet(CHINA_MEDAL_URL);
const data = extractJsonFromHtml(html);
if (!data) {
throw new Error('未能从页面解析出获奖名单数据');
}
// 解析获奖名单
let medals = parseMedalList(data);
// 按奖牌类型过滤
if (medalType && medalType !== 'all') {
medals = medals.filter(item => item.medalType === medalType);
}
// 按奖牌时间排序(降序,最新的在前)
medals.sort((a, b) => {
const timeA = parseInt(a.medalTime) || 0;
const timeB = parseInt(b.medalTime) || 0;
return timeB - timeA;
});
return {
delegationInfo: data.delegationInfo,
medals: medals,
total: medals.length
};
} catch (error) {
throw new Error(`获取中国队获奖名单失败: error.message`);
}
}
/**
* 获取奖牌统计数据
* @returns {Promise<Object>} 奖牌统计数据
*/
async function getMedalStats() {
try {
const html = await httpGet(CHINA_MEDAL_URL);
const data = extractJsonFromHtml(html);
if (!data || !data.delegationInfo) {
throw new Error('未能获取奖牌统计数据');
}
const info = data.delegationInfo;
const medals = parseMedalList(data);
// 按类型统计
const goldCount = medals.filter(m => m.medalType === 'gold').length;
const silverCount = medals.filter(m => m.medalType === 'silver').length;
const bronzeCount = medals.filter(m => m.medalType === 'bronze').length;
// 按大项统计
const sportStats = {};
medals.forEach(m => {
const sport = m.bigMatch || '其他';
if (!sportStats[sport]) {
sportStats[sport] = { gold: 0, silver: 0, bronze: 0, total: 0 };
}
sportStats[sport][m.medalType]++;
sportStats[sport].total++;
});
return {
delegationInfo: info,
summary: {
gold: goldCount,
silver: silverCount,
bronze: bronzeCount,
total: medals.length
},
bySport: sportStats
};
} catch (error) {
throw new Error(`获取奖牌统计失败: error.message`);
}
}
/**
* 主函数 - 处理命令行参数
*/
async function main() {
const args = process.argv.slice(2);
const command = args[0];
try {
switch (command) {
case 'list':
case '--list':
case '-l': {
const medalType = args[1] || 'all';
const result = await getChinaMedals(medalType);
console.log(JSON.stringify(result, null, 2));
break;
}
case 'stats':
case '--stats':
case '-s': {
const stats = await getMedalStats();
console.log(JSON.stringify(stats, null, 2));
break;
}
default:
console.log(`
2026年米兰冬奥会中国队获奖名单获取工具
用法:
node milan-china-medals.js <command> [options]
命令:
list, -l, --list [type] 获取中国队获奖名单
stats, -s, --stats 获取奖牌统计数据
参数:
type 奖牌类型过滤,可选值:
all(全部,默认)、gold(金牌)、silver(银牌)、bronze(铜牌)
示例:
# 获取全部获奖名单
node milan-china-medals.js list
# 获取金牌获奖名单
node milan-china-medals.js list gold
# 获取银牌获奖名单
node milan-china-medals.js list silver
# 获取铜牌获奖名单
node milan-china-medals.js list bronze
# 获取奖牌统计
node milan-china-medals.js stats
数据字段说明:
playerName 运动员姓名
medal 奖牌名称(如"第1银")
medalType 奖牌类型(gold/silver/bronze)
bigMatch 大项(如"自由式滑雪")
smallMatch 小项(如"自由式滑雪女子坡面障碍技巧")
date 日期
time 时间
detailUrl 详情页面URL
videoInfo 视频信息
`);
process.exit(0);
}
} catch (error) {
console.error(`Error: error.message`);
process.exit(1);
}
}
// 导出模块供其他脚本使用
module.exports = { getChinaMedals, getMedalStats };
// 如果直接运行此脚本
if (require.main === module) {
main();
}
FILE:scripts/milan-news.js
#!/usr/bin/env node
/**
* 2026年米兰冬奥会现场新闻获取工具
* 从百度体育网页抓取最新的现场新闻报道
*/
const https = require('https');
// 可配置 User-Agent 池(固定 20 个),每次请求随机选一个,避免固定 UA
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/123.0.0.0 Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/122.0.0.0 Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Mi 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
];
function getRandomUserAgent() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
const HEADERS = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Encoding': 'identity',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://tiyu.baidu.com/',
'Origin': 'https://tiyu.baidu.com'
};
const NEWS_URL = 'https://tiyu.baidu.com/al/major/home?match=2026年米兰冬奥会&tab=直击现场';
/**
* 发起HTTP GET请求
* @param {string} url - 请求URL
* @returns {Promise<string>} 响应HTML内容
*/
function httpGet(url) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: { ...HEADERS, 'User-Agent': getRandomUserAgent() }
};
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => { chunks.push(chunk); });
res.on('end', () => {
const buffer = Buffer.concat(chunks);
resolve(buffer.toString('utf-8'));
});
});
req.on('error', reject);
req.setTimeout(15000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
/**
* 从HTML中提取JSON数据
* @param {string} html - HTML内容
* @returns {Array|null} 新闻数据数组
*/
function extractNewsFromHtml(html) {
try {
// 查找包含页面数据的script标签
const scriptRegex = /<script id="atom-data-[^"]*" type="application\/json">([\s\S]*?)<\/script>/;
const match = html.match(scriptRegex);
if (match && match[1]) {
const parsed = JSON.parse(match[1]);
// 数据在 data.data.data.tabsList 中
const pageData = parsed.data && parsed.data.data ? parsed.data.data : null;
if (pageData && pageData.tabsList) {
// 查找"直击现场"标签页的数据 (rootTab === 'video')
const liveTab = pageData.tabsList.find(tab => tab.rootTab === 'video');
if (liveTab && liveTab.data && liveTab.data.list) {
return liveTab.data.list.map(item => ({
id: item.dataId || '',
title: item.title || '',
type: item.type || 'article', // article, video, post
subType: item.subType || '',
source: item.provider || '',
url: item.jumpUrl || '',
images: item.imgs || (item.img ? [item.img] : []),
videoDuration: item.durationText || '',
videoUrl: item.playUrl || '',
matchId: item.matchId || []
}));
}
}
}
} catch (e) {
console.error('解析JSON数据失败:', e.message);
}
return null;
}
/**
* 通过正则表达式解析新闻数据(备用方案)
* @param {string} html - HTML内容
* @returns {Array} 新闻数据数组
*/
function parseNewsFromHtml(html) {
const news = [];
// 匹配新闻项
const itemRegex = /<a[^>]*data-index="(\d+)"[^>]*data-key="([^"]+)"[^>]*href="([^"]+)"[^>]*>[\s\S]*?<\/a>/g;
let match;
while ((match = itemRegex.exec(html)) !== null) {
try {
const index = match[1];
const key = match[2];
const href = match[3];
const itemHtml = match[0];
// 提取标题
const titleMatch = itemHtml.match(/class="title[^"]*"[^>]*>([^<]+)<\/div>/);
const title = titleMatch ? titleMatch[1].trim() : '';
// 提取来源
const sourceMatch = itemHtml.match(/class="source[^"]*"[^>]*>([^<]+)<\/div>/);
const source = sourceMatch ? sourceMatch[1].trim() : '';
// 提取图片
const imgMatches = itemHtml.match(/style="background-image:url\(([^)]+)\)/g);
const images = imgMatches ? imgMatches.map(m => {
const urlMatch = m.match(/url\(([^)]+)\)/);
return urlMatch ? urlMatch[1].replace(/&/g, '&') : '';
}).filter(url => url) : [];
// 提取视频时长
const durationMatch = itemHtml.match(/class="time[^"]*"[^>]*>([^<]+)<\/span>/);
const videoDuration = durationMatch ? durationMatch[1].trim() : '';
// 判断类型
const type = itemHtml.includes('video') ? 'video' : 'article';
if (title) {
news.push({
id: key,
title,
type,
subType: '',
source,
url: href.startsWith('http') ? href : `https://tiyu.baidu.comhref`,
images,
videoDuration,
videoUrl: '',
matchId: []
});
}
} catch (e) {
continue;
}
}
return news;
}
/**
* 获取现场新闻列表
* @param {number} limit - 返回数量限制,默认10
* @param {string} subType - 子类型过滤(可选):全部、热门内容、赛事集锦、精彩瞬间、选手集锦、赛后采访、赛前采访、项目介绍、专栏节目、其他
* @returns {Promise<Array>} 新闻数组
*/
async function getLiveNews(limit = 10, subType = '') {
try {
const html = await httpGet(NEWS_URL);
// 首先尝试从JSON提取
let news = extractNewsFromHtml(html);
// 如果JSON提取失败,使用正则解析
if (!news || news.length === 0) {
news = parseNewsFromHtml(html);
}
if (news.length === 0) {
throw new Error('未能从页面解析出新闻数据');
}
// 按子类型过滤
if (subType && subType !== '全部') {
news = news.filter(item => item.subType === subType);
}
// 限制数量
return news.slice(0, limit);
} catch (error) {
throw new Error(`获取现场新闻失败: error.message`);
}
}
/**
* 获取可用的子类型列表
* @returns {Promise<Array>} 子类型数组
*/
async function getSubTypes() {
try {
const html = await httpGet(NEWS_URL);
const data = extractNewsFromHtml(html);
if (data && data.subTabs) {
return data.subTabs;
}
// 默认子类型
return [
'全部',
'热门内容',
'赛事集锦',
'精彩瞬间',
'选手集锦',
'赛后采访',
'赛前采访',
'项目介绍',
'专栏节目',
'其他'
];
} catch (error) {
return ['全部'];
}
}
/**
* 主函数 - 处理命令行参数
*/
async function main() {
const args = process.argv.slice(2);
const command = args[0];
try {
switch (command) {
case 'list':
case '--list':
case '-l': {
const limit = parseInt(args[1]) || 10;
const subType = args[2] || '';
const news = await getLiveNews(limit, subType);
console.log(JSON.stringify(news, null, 2));
break;
}
case 'types':
case '--types':
case '-t': {
const types = await getSubTypes();
console.log(JSON.stringify(types, null, 2));
break;
}
default:
console.log(`
2026年米兰冬奥会现场新闻获取工具
用法:
node milan-news.js <command> [options]
命令:
list, -l, --list [n] [subtype] 获取现场新闻列表(默认10条)
types, -t, --types 获取可用的内容类型列表
参数:
n 返回的新闻数量(默认10)
subtype 内容类型过滤,可选值:
全部、热门内容、赛事集锦、精彩瞬间、选手集锦、
赛后采访、赛前采访、项目介绍、专栏节目、其他
示例:
# 获取最新的10条新闻
node milan-news.js list
# 获取最新的20条新闻
node milan-news.js list 20
# 获取赛事集锦类新闻
node milan-news.js list 10 赛事集锦
# 查看所有可用的内容类型
node milan-news.js types
`);
process.exit(0);
}
} catch (error) {
console.error(`Error: error.message`);
process.exit(1);
}
}
// 导出模块供其他脚本使用
module.exports = { getLiveNews, getSubTypes };
// 如果直接运行此脚本
if (require.main === module) {
main();
}
FILE:scripts/milan-olympics.js
#!/usr/bin/env node
/**
* 2026年米兰冬奥会奖牌榜获取工具
* 从百度体育网页抓取奖牌榜数据
*/
const https = require('https');
// 可配置 User-Agent 池(固定 20 个),每次请求随机选一个,避免固定 UA
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/123.0.0.0 Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/122.0.0.0 Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Mi 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
];
function getRandomUserAgent() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
const HEADERS = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
// 不要 gzip/br/deflate 等压缩处理:请求服务器返回明文
'Accept-Encoding': 'identity',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://tiyu.baidu.com/',
'Origin': 'https://tiyu.baidu.com'
};
const MEDAL_URL = 'https://tiyu.baidu.com/al/major/home?match=2026年米兰冬奥会&tab=奖牌榜';
/**
* 发起HTTP GET请求
* @param {string} url - 请求URL
* @returns {Promise<string>} 响应HTML内容
*/
function httpGet(url) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: { ...HEADERS, 'User-Agent': getRandomUserAgent() }
};
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => { chunks.push(chunk); });
res.on('end', () => {
const buffer = Buffer.concat(chunks);
resolve(buffer.toString('utf-8'));
});
});
req.on('error', reject);
req.setTimeout(15000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
/**
* 解析奖牌榜HTML,提取结构化数据
* @param {string} html - HTML内容
* @returns {Array} 奖牌榜数据数组
*/
function parseMedalRankings(html) {
const rankings = [];
// 提取排名项的正则表达式
// 匹配模式: class="rankContainer rankTable" 开头的<a>标签内的数据
const rankItemRegex = /<a[^>]*class="rankContainer[^"]*"[^>]*>[\s\S]*?<\/a>/g;
const items = html.match(rankItemRegex) || [];
for (const item of items) {
try {
// 提取排名
const rankMatch = item.match(/class="rankHeaderRanking[^"]*"[^>]*>.*?<span[^>]*>(\d+)<\/span>/s);
const rank = rankMatch ? parseInt(rankMatch[1]) : 0;
// 提取国家/地区名称
const countryMatch = item.match(/class="rankHeaderAreaName[^"]*"[^>]*>([^<]+)<\/span>/);
const country = countryMatch ? countryMatch[1].trim() : '';
// 提取奖牌数 - 匹配 gold/silver/copper 类的 div
const goldMatch = item.match(/class="medalImg gold"[^>]*>(\d+)<\/div>/);
const silverMatch = item.match(/class="medalImg silver"[^>]*>(\d+)<\/div>/);
const copperMatch = item.match(/class="medalImg copper"[^>]*>(\d+)<\/div>/);
const gold = goldMatch ? parseInt(goldMatch[1]) : 0;
const silver = silverMatch ? parseInt(silverMatch[1]) : 0;
const bronze = copperMatch ? parseInt(copperMatch[1]) : 0;
// 提取总数
const totalMatch = item.match(/class="rankHeaderSum[^"]*"[^>]*>(\d+)<\/div>/);
const total = totalMatch ? parseInt(totalMatch[1]) : (gold + silver + bronze);
// 提取国旗URL
const flagMatch = item.match(/class="rankHeaderFlag"[^>]*style="background-image:url\('([^']+)'/);
const flagUrl = flagMatch ? flagMatch[1] : '';
// 提取详情链接
const linkMatch = item.match(/href="([^"]+)"/);
const detailUrl = linkMatch ? `https://tiyu.baidu.comlinkMatch[1]` : '';
if (country && rank > 0) {
rankings.push({
rank,
country,
countryEn: '', // 从HTML中无法直接获取英文名
gold,
silver,
bronze,
total,
flagUrl,
detailUrl
});
}
} catch (e) {
// 解析单个项目失败,继续处理下一个
continue;
}
}
// 如果正则匹配失败,尝试从JSON数据中提取
if (rankings.length === 0) {
const jsonData = extractJsonFromHtml(html);
if (jsonData && jsonData.length > 0) {
return jsonData;
}
}
return rankings;
}
/**
* 尝试从HTML中提取JSON格式的奖牌榜数据
* @param {string} html - HTML内容
* @returns {Array|null} 奖牌榜数据数组
*/
function extractJsonFromHtml(html) {
try {
// 查找包含奖牌榜数据的script标签或JSON数据
const jsonMatch = html.match(/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/);
if (jsonMatch) {
const data = JSON.parse(jsonMatch[1]);
// 尝试找到奖牌榜数据
if (data && data.medalRankings) {
return data.medalRankings.map(item => ({
rank: item.rank || 0,
country: item.country || item.name || '',
countryEn: item.countryEn || '',
gold: item.gold || 0,
silver: item.silver || 0,
bronze: item.bronze || 0,
total: item.total || 0,
flagUrl: item.flagUrl || '',
detailUrl: item.detailUrl || ''
}));
}
}
} catch (e) {
// JSON解析失败
}
return null;
}
/**
* 获取奖牌榜TOP N
* @param {number} limit - 返回数量限制,默认30
* @returns {Promise<Array>} 奖牌榜数组
*/
async function getTopMedals(limit = 30) {
try {
const html = await httpGet(MEDAL_URL);
const rankings = parseMedalRankings(html);
if (rankings.length === 0) {
throw new Error('未能从页面解析出奖牌榜数据');
}
// 按排名排序并限制数量
return rankings
.sort((a, b) => a.rank - b.rank)
.slice(0, limit);
} catch (error) {
throw new Error(`获取奖牌榜失败: error.message`);
}
}
/**
* 获取完整奖牌榜
* @returns {Promise<Array>} 完整奖牌榜数组
*/
async function getAllMedals() {
return getTopMedals(100);
}
/**
* 主函数 - 处理命令行参数
*/
async function main() {
const args = process.argv.slice(2);
const command = args[0];
try {
switch (command) {
case 'top':
case '--top':
case '-t': {
const limit = parseInt(args[1]) || 30;
const rankings = await getTopMedals(limit);
console.log(JSON.stringify(rankings, null, 2));
break;
}
case 'all':
case '--all':
case '-a': {
const rankings = await getAllMedals();
console.log(JSON.stringify(rankings, null, 2));
break;
}
default:
console.log(`
2026年米兰冬奥会奖牌榜获取工具
用法:
node milan-olympics.js <command> [options]
命令:
top, -t, --top [n] 获取奖牌榜前N名(默认30)
all, -a, --all 获取完整奖牌榜
示例:
# 获取奖牌榜前30名
node milan-olympics.js top
# 获取奖牌榜前10名
node milan-olympics.js top 10
# 获取完整奖牌榜
node milan-olympics.js all
`);
process.exit(0);
}
} catch (error) {
console.error(`Error: error.message`);
process.exit(1);
}
}
// 导出模块供其他脚本使用
module.exports = { getTopMedals, getAllMedals };
// 如果直接运行此脚本
if (require.main === module) {
main();
}
FILE:scripts/milan-schedule.js
#!/usr/bin/env node
/**
* 2026年米兰冬奥会赛程获取工具
* 从百度体育异步API获取赛程安排数据
*/
const https = require('https');
// 可配置 User-Agent 池(固定 20 个),每次请求随机选一个,避免固定 UA
const USER_AGENTS = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/123.0.0.0 Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Edg/122.0.0.0 Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 13; Mi 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
];
function getRandomUserAgent() {
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}
const HEADERS = {
'Accept': 'application/json, text/plain, */*',
'Accept-Encoding': 'identity',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://tiyu.baidu.com/',
'Origin': 'https://tiyu.baidu.com'
};
// API基础URL
const API_BASE_URL = 'https://tiyu.baidu.com/al/major/schedule/list';
// 赛程类型映射
const SCHEDULE_TYPES = {
all: 'all', // 综合
hot: 'hot', // 热门
china: 'china', // 中国
gold: 'gold' // 金牌
};
// 2026年米兰冬奥会日期范围 (2月6日 - 2月22日)
const OLYMPICS_START_DATE = '2026-02-06';
const OLYMPICS_END_DATE = '2026-02-22';
/**
* 发起HTTP GET请求
* @param {string} url - 请求URL
* @returns {Promise<Object>} 响应JSON数据
*/
function httpGet(url) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: { ...HEADERS, 'User-Agent': getRandomUserAgent() }
};
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', (chunk) => { chunks.push(chunk); });
res.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const text = buffer.toString('utf-8');
const data = JSON.parse(text);
resolve(data);
} catch (e) {
reject(new Error(`解析JSON失败: e.message`));
}
});
});
req.on('error', reject);
req.setTimeout(15000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
/**
* 构建API URL
* @param {string} date - 日期 (YYYY-MM-DD)
* @param {string} scheduleType - 赛程类型: all, hot, china, gold
* @param {string} sportId - 运动项目ID,默认为'all'
* @returns {string} 完整的API URL
*/
function buildApiUrl(date, scheduleType = 'all', sportId = 'all') {
const type = SCHEDULE_TYPES[scheduleType] || 'all';
return `API_BASE_URL?date=date&scheduleType=type&sportId=sportId&page=home&from=landing&isAsync=1`;
}
/**
* 从API响应中提取赛程数据
* @param {Object} response - API响应数据
* @returns {Array|null} 赛程数据数组
*/
function extractScheduleFromResponse(response) {
try {
if (!response || response.status !== '0' || !response.data) {
return null;
}
const data = response.data;
if (!data.dateList || !Array.isArray(data.dateList)) {
return null;
}
// 转换数据结构
const schedules = data.dateList.map(dateItem => ({
date: dateItem.date,
dateFmt: dateItem.dateFmt,
week: dateItem.week,
countText: dateItem.countText,
display: dateItem.display,
matches: dateItem.scheduleList ? dateItem.scheduleList.map(match => ({
matchId: match.matchId || '',
matchName: match.matchName || '',
sportName: match.discipline ? match.discipline.sportName : '',
eventName: match.discipline ? match.discipline.eventName : '',
subSportName: match.discipline ? match.discipline.subSportName : '',
startTime: match.startTime || '',
startDate: match.startDate || '',
startDateTime: match.startDateTime || '',
startTimestamp: match.startTimestamp || null,
status: match.eventStatusName || '',
statusId: match.eventStatusId || '',
desc: match.desc || '',
isChina: match.isChina === '1',
isGold: match.isGold === '1',
isHot: match.isHot === '1',
isMedal: match.isMedal === '1',
isPk: match.isPk === '1',
hasLive: match.hasLive || false,
participant: match.participant || '',
detailUrl: match.fullLink || '',
iconArr: match.iconArr || [],
result: match.result || null,
dataSource: match.dataSource || null
})) : []
}));
return schedules;
} catch (e) {
console.error('解析赛程数据失败:', e.message);
return null;
}
}
/**
* 获取指定日期的赛程数据
* @param {string} date - 日期 (YYYY-MM-DD)
* @param {string} scheduleType - 赛程类型: all, hot, china, gold
* @returns {Promise<Array>} 该日期的赛程数据数组
*/
async function getScheduleByDate(date, scheduleType = 'all') {
try {
const url = buildApiUrl(date, scheduleType);
const response = await httpGet(url);
const schedules = extractScheduleFromResponse(response);
return schedules || [];
} catch (error) {
console.warn(`获取 date 的赛程失败: error.message`);
return [];
}
}
/**
* 生成日期范围内的所有日期
* @param {string} startDate - 开始日期 (YYYY-MM-DD)
* @param {string} endDate - 结束日期 (YYYY-MM-DD)
* @returns {Array<string>} 日期数组
*/
function generateDateRange(startDate, endDate) {
const dates = [];
const start = new Date(startDate);
const end = new Date(endDate);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
dates.push(d.toISOString().split('T')[0]);
}
return dates;
}
/**
* 获取全部赛程
* @param {string} date - 日期过滤(可选),格式:2026-02-08。如果为空,则获取所有日期的赛程
* @param {boolean} fetchAll - 是否获取所有日期的数据(默认为true)
* @returns {Promise<Array>} 赛程数组
*/
async function getAllSchedule(date = '', fetchAll = true) {
try {
// 如果指定了具体日期,只获取该日期
if (date) {
return await getScheduleByDate(date, 'all');
}
// 获取所有日期的赛程
if (fetchAll) {
const allSchedules = [];
const dates = generateDateRange(OLYMPICS_START_DATE, OLYMPICS_END_DATE);
// 并发请求所有日期的数据(限制并发数)
const batchSize = 5;
for (let i = 0; i < dates.length; i += batchSize) {
const batch = dates.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(date => getScheduleByDate(date, 'all'))
);
results.forEach(daySchedules => {
if (daySchedules && daySchedules.length > 0) {
daySchedules.forEach(day => {
if (day.matches && day.matches.length > 0) {
allSchedules.push(day);
}
});
}
});
}
return allSchedules;
}
// 默认只获取今天的数据
const today = getTodayDate();
return await getScheduleByDate(today, 'all');
} catch (error) {
throw new Error(`获取赛程失败: error.message`);
}
}
/**
* 获取中国相关赛程
* @param {string} date - 日期过滤(可选),格式:2026-02-08。如果为空,则获取所有日期的中国赛程
* @param {boolean} fetchAll - 是否获取所有日期的数据(默认为true)
* @returns {Promise<Array>} 中国相关赛程数组
*/
async function getChinaSchedule(date = '', fetchAll = true) {
try {
// 如果指定了具体日期,只获取该日期
if (date) {
const schedules = await getScheduleByDate(date, 'china');
// 过滤出中国相关的比赛
return schedules.map(day => ({
...day,
matches: day.matches.filter(match => match.isChina)
})).filter(day => day.matches.length > 0);
}
// 获取所有日期的中国赛程
if (fetchAll) {
const allSchedules = [];
const dates = generateDateRange(OLYMPICS_START_DATE, OLYMPICS_END_DATE);
// 并发请求所有日期的数据(限制并发数)
const batchSize = 5;
for (let i = 0; i < dates.length; i += batchSize) {
const batch = dates.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(date => getScheduleByDate(date, 'china'))
);
results.forEach(daySchedules => {
if (daySchedules && daySchedules.length > 0) {
daySchedules.forEach(day => {
if (day.matches && day.matches.length > 0) {
// API返回的china类型数据已经是过滤过的,但再过滤一次确保安全
const chinaMatches = day.matches.filter(match => match.isChina);
if (chinaMatches.length > 0) {
allSchedules.push({
...day,
matches: chinaMatches
});
}
}
});
}
});
}
return allSchedules;
}
// 默认只获取今天的数据
const today = getTodayDate();
const schedules = await getScheduleByDate(today, 'china');
return schedules.map(day => ({
...day,
matches: day.matches.filter(match => match.isChina)
})).filter(day => day.matches.length > 0);
} catch (error) {
throw new Error(`获取中国赛程失败: error.message`);
}
}
/**
* 获取金牌赛赛程
* @param {string} date - 日期过滤(可选),格式:2026-02-08。如果为空,则获取所有日期的金牌赛
* @param {boolean} fetchAll - 是否获取所有日期的数据(默认为true)
* @returns {Promise<Array>} 金牌赛赛程数组
*/
async function getGoldSchedule(date = '', fetchAll = true) {
try {
// 如果指定了具体日期,只获取该日期
if (date) {
const schedules = await getScheduleByDate(date, 'gold');
return schedules.map(day => ({
...day,
matches: day.matches.filter(match => match.isGold)
})).filter(day => day.matches.length > 0);
}
// 获取所有日期的金牌赛
if (fetchAll) {
const allSchedules = [];
const dates = generateDateRange(OLYMPICS_START_DATE, OLYMPICS_END_DATE);
const batchSize = 5;
for (let i = 0; i < dates.length; i += batchSize) {
const batch = dates.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(date => getScheduleByDate(date, 'gold'))
);
results.forEach(daySchedules => {
if (daySchedules && daySchedules.length > 0) {
daySchedules.forEach(day => {
if (day.matches && day.matches.length > 0) {
const goldMatches = day.matches.filter(match => match.isGold);
if (goldMatches.length > 0) {
allSchedules.push({
...day,
matches: goldMatches
});
}
}
});
}
});
}
return allSchedules;
}
// 默认只获取今天的数据
const today = getTodayDate();
const schedules = await getScheduleByDate(today, 'gold');
return schedules.map(day => ({
...day,
matches: day.matches.filter(match => match.isGold)
})).filter(day => day.matches.length > 0);
} catch (error) {
throw new Error(`获取金牌赛赛程失败: error.message`);
}
}
/**
* 获取热门赛程
* @param {string} date - 日期过滤(可选),格式:2026-02-08。如果为空,则获取所有日期的热门赛程
* @param {boolean} fetchAll - 是否获取所有日期的数据(默认为true)
* @returns {Promise<Array>} 热门赛程数组
*/
async function getHotSchedule(date = '', fetchAll = true) {
try {
// 如果指定了具体日期,只获取该日期
if (date) {
const schedules = await getScheduleByDate(date, 'hot');
return schedules.map(day => ({
...day,
matches: day.matches.filter(match => match.isHot)
})).filter(day => day.matches.length > 0);
}
// 获取所有日期的热门赛程
if (fetchAll) {
const allSchedules = [];
const dates = generateDateRange(OLYMPICS_START_DATE, OLYMPICS_END_DATE);
const batchSize = 5;
for (let i = 0; i < dates.length; i += batchSize) {
const batch = dates.slice(i, i + batchSize);
const results = await Promise.all(
batch.map(date => getScheduleByDate(date, 'hot'))
);
results.forEach(daySchedules => {
if (daySchedules && daySchedules.length > 0) {
daySchedules.forEach(day => {
if (day.matches && day.matches.length > 0) {
const hotMatches = day.matches.filter(match => match.isHot);
if (hotMatches.length > 0) {
allSchedules.push({
...day,
matches: hotMatches
});
}
}
});
}
});
}
return allSchedules;
}
// 默认只获取今天的数据
const today = getTodayDate();
const schedules = await getScheduleByDate(today, 'hot');
return schedules.map(day => ({
...day,
matches: day.matches.filter(match => match.isHot)
})).filter(day => day.matches.length > 0);
} catch (error) {
throw new Error(`获取热门赛程失败: error.message`);
}
}
/**
* 获取可用的日期列表
* @returns {Promise<Array>} 日期数组
*/
async function getAvailableDates() {
try {
const today = getTodayDate();
const url = buildApiUrl(today, 'all');
const response = await httpGet(url);
if (!response || response.status !== '0' || !response.data) {
return [];
}
const data = response.data;
if (data.select && data.select.labels) {
return data.select.labels.map(label => ({
date: label.date,
suffix: label.suffix,
desc: label.desc,
disabled: label.disabled === '1',
icon: label.icon
}));
}
return [];
} catch (error) {
console.warn('获取可用日期失败:', error.message);
return [];
}
}
/**
* 获取所有运动项目列表
* @returns {Promise<Object>} 包含热门项目和其他项目的对象
*/
async function getAllSports() {
try {
const today = getTodayDate();
const url = buildApiUrl(today, 'all');
const response = await httpGet(url);
if (!response || response.status !== '0' || !response.data) {
return { hot: [], other: [] };
}
const data = response.data;
if (data.select && data.select.sport) {
const sports = data.select.sport;
return {
hot: (sports.hot || []).map(sport => ({
name: sport.name,
value: sport.value,
selected: sport.selected,
hot: sport.hot || 0
})),
other: (sports.other || []).map(sport => ({
name: sport.name,
value: sport.value,
selected: sport.selected
}))
};
}
return { hot: [], other: [] };
} catch (error) {
console.warn('获取运动项目列表失败:', error.message);
return { hot: [], other: [] };
}
}
/**
* 获取指定运动项目的赛程数据
* @param {string} sportId - 运动项目ID (如 '302' 表示短道速滑,'all' 表示全部项目)
* @param {string} date - 日期 (YYYY-MM-DD),如果为空则获取今天
* @returns {Promise<Array>} 该运动项目的赛程数据数组
*/
async function getScheduleBySport(sportId, date = '') {
try {
const targetDate = date || getTodayDate();
const url = buildApiUrl(targetDate, 'all', sportId);
const response = await httpGet(url);
const schedules = extractScheduleFromResponse(response);
return schedules || [];
} catch (error) {
console.warn(`获取运动项目 sportId 的赛程失败: error.message`);
return [];
}
}
/**
* 获取中国指定日期的指定运动项目赛程
* @param {string} sportId - 运动项目ID (如 '302' 表示短道速滑)
* @param {string} date - 日期 (YYYY-MM-DD),如果为空则获取今天
* @returns {Promise<Array>} 中国相关的指定运动项目赛程数组
*/
async function getChinaScheduleBySport(sportId, date = '') {
try {
const targetDate = date || getTodayDate();
const url = buildApiUrl(targetDate, 'china', sportId);
const response = await httpGet(url);
const schedules = extractScheduleFromResponse(response);
if (!schedules || schedules.length === 0) {
return [];
}
// 过滤出中国相关的比赛
return schedules.map(day => ({
...day,
matches: day.matches.filter(match => match.isChina)
})).filter(day => day.matches.length > 0);
} catch (error) {
console.warn(`获取中国运动项目 sportId 的赛程失败: error.message`);
return [];
}
}
/**
* 获取今天的日期字符串(YYYY-MM-DD)
* @returns {string} 今天的日期
*/
function getTodayDate() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `year-month-day`;
}
/**
* 获取明天的日期字符串(YYYY-MM-DD)
* @returns {string} 明天的日期
*/
function getTomorrowDate() {
const now = new Date();
now.setDate(now.getDate() + 1);
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `year-month-day`;
}
/**
* 获取今天的赛程(综合TAB下全部赛程)
* @returns {Promise<Array>} 今天的赛程数组
*/
async function getTodaySchedule() {
const today = getTodayDate();
return getAllSchedule(today);
}
/**
* 获取明天的赛程(综合TAB下全部赛程)
* @returns {Promise<Array>} 明天的赛程数组
*/
async function getTomorrowSchedule() {
const tomorrow = getTomorrowDate();
return getAllSchedule(tomorrow);
}
/**
* 主函数 - 处理命令行参数
*/
async function main() {
const args = process.argv.slice(2);
const command = args[0];
try {
switch (command) {
case 'all':
case '--all':
case '-a': {
const date = args[1] || '';
const schedules = await getAllSchedule(date);
console.log(JSON.stringify(schedules, null, 2));
break;
}
case 'china':
case '--china':
case '-c': {
const date = args[1] || '';
const schedules = await getChinaSchedule(date);
console.log(JSON.stringify(schedules, null, 2));
break;
}
case 'gold':
case '--gold':
case '-g': {
const date = args[1] || '';
const schedules = await getGoldSchedule(date);
console.log(JSON.stringify(schedules, null, 2));
break;
}
case 'hot':
case '--hot':
case '-h': {
const date = args[1] || '';
const schedules = await getHotSchedule(date);
console.log(JSON.stringify(schedules, null, 2));
break;
}
case 'dates':
case '--dates':
case '-d': {
const dates = await getAvailableDates();
console.log(JSON.stringify(dates, null, 2));
break;
}
case 'today':
case '--today':
case '-t': {
const schedules = await getTodaySchedule();
console.log(JSON.stringify(schedules, null, 2));
break;
}
case 'tomorrow':
case '--tomorrow':
case '-m': {
const schedules = await getTomorrowSchedule();
console.log(JSON.stringify(schedules, null, 2));
break;
}
case 'sports':
case '--sports':
case '-s': {
const sports = await getAllSports();
console.log(JSON.stringify(sports, null, 2));
break;
}
case 'sport':
case '--sport': {
const sportId = args[1];
const date = args[2] || '';
if (!sportId) {
console.error('Error: 请指定运动项目ID');
console.log('用法: node milan-schedule.js sport <sportId> [date]');
console.log('示例: node milan-schedule.js sport 302 2026-02-10');
process.exit(1);
}
const schedules = await getScheduleBySport(sportId, date);
console.log(JSON.stringify(schedules, null, 2));
break;
}
case 'china-sport':
case '--china-sport': {
const sportId = args[1];
const date = args[2] || '';
if (!sportId) {
console.error('Error: 请指定运动项目ID');
console.log('用法: node milan-schedule.js china-sport <sportId> [date]');
console.log('示例: node milan-schedule.js china-sport 302 2026-02-10');
process.exit(1);
}
const schedules = await getChinaScheduleBySport(sportId, date);
console.log(JSON.stringify(schedules, null, 2));
break;
}
default:
console.log(`
2026年米兰冬奥会赛程获取工具
用法:
node milan-schedule.js <command> [options]
命令:
all, -a, --all [date] 获取全部赛程(默认获取所有日期)
china, -c, --china [date] 获取中国相关赛程(默认获取所有日期)
gold, -g, --gold [date] 获取金牌赛赛程(默认获取所有日期)
hot, -h, --hot [date] 获取热门赛程(默认获取所有日期)
today, -t, --today 获取今天的赛程(无需指定日期)
tomorrow, -m, --tomorrow 获取明天的赛程(无需指定日期)
dates, -d, --dates 获取可用的日期列表
sports, -s, --sports 获取所有运动项目列表
sport <sportId> [date] 获取指定运动项目的赛程
china-sport <sportId> [date] 获取中国指定运动项目的赛程
参数:
date 日期过滤,格式:2026-02-08(可选)。
不指定date时默认获取2026-02-06至2026-02-22所有日期的数据
sportId 运动项目ID,可通过 sports 命令查看
示例:
# 获取全部赛程(所有日期)
node milan-schedule.js all
# 获取特定日期的赛程
node milan-schedule.js all 2026-02-08
# 获取今天的赛程
node milan-schedule.js today
# 获取明天的赛程
node milan-schedule.js tomorrow
# 获取中国相关赛程(所有日期)
node milan-schedule.js china
# 获取特定日期的中国赛程
node milan-schedule.js china 2026-02-08
# 获取金牌赛赛程(所有日期)
node milan-schedule.js gold
# 查看所有可用日期
node milan-schedule.js dates
# 查看所有运动项目
node milan-schedule.js sports
# 获取短道速滑赛程(sportId: 302)
node milan-schedule.js sport 302
# 获取特定日期短道速滑赛程
node milan-schedule.js sport 302 2026-02-10
# 获取中国短道速滑赛程
node milan-schedule.js china-sport 302
# 获取特定日期中国短道速滑赛程
node milan-schedule.js china-sport 302 2026-02-10
`);
process.exit(0);
}
} catch (error) {
console.error(`Error: error.message`);
process.exit(1);
}
}
// 导出模块供其他脚本使用
module.exports = {
getAllSchedule,
getChinaSchedule,
getGoldSchedule,
getHotSchedule,
getAvailableDates,
getAllSports,
getScheduleBySport,
getChinaScheduleBySport,
getTodaySchedule,
getTomorrowSchedule,
getScheduleByDate,
generateDateRange,
OLYMPICS_START_DATE,
OLYMPICS_END_DATE
};
// 如果直接运行此脚本
if (require.main === module) {
main();
}