@clawhub-aoturlab-0708f2eaf1
AI 图片生成与提示词优化工具。支持通义万相、Banana (Flux)、DALL-E 等多模型。用于:根据用户简单描述生成高质量图片提示词、优化已有提示词、直接调用配置好的模型出图。触发场景:「生成图片」「画一个 XXX」「优化这个提示词」「设置 API key」「切换模型」。用户可直接发送密钥给 bot,自动...
---
name: pic-gen
description: Tired of getting mediocre AI images? This skill solves the problem: you describe what you want in plain language, and pic-gen (1) picks the best model for your scene, and (2) rewrites your description into professional-grade prompts that actually produce stunning results. Supports Qwen Wanxiang, Banana/Flux, DALL-E 3 — or just the prompt output if you prefer your own pipeline. Trigger: "draw a...", "generate image of...", "optimize this prompt", "AI art".
requires:
python:
packages: ["requests", "pyyaml", "banana-dev"]
bins: []
---
# pic-gen — AI 图片生成与提示词优化
## 产品对话交互策略
pic-gen 的核心理念:**像和产品经理对话一样,一步一步引导用户完成图片生成**,而不是堆参数。
### 对话状态机
```
IDLE
│
├─ 用户描述图片需求 ──────────────────────────→ ASK_MODEL
│
ASK_MODEL
│
├─ 用户说「通义」/「qwen」 ─→ 读取 config,默认用 qwen ─→ CONFIRM_PROMPT
├─ 用户说「banana」/「flux」 ─→ 用 banana ─→ CONFIRM_PROMPT
├─ 用户说「dalle」 ─→ 用 dalle ─→ CONFIRM_PROMPT
├─ 用户说「直接生成」 ─→ 用 config 默认模型 ─→ CONFIRM_PROMPT
│
CONFIRM_PROMPT
│
├─ 用户说「可以/好/生成」 ─→ OPTIMIZE → GENERATE
├─ 用户说「改一下 XXX」 ─→ 修改提示词 ─→ CONFIRM_PROMPT
│
GENERATE
│
├─ 图片生成成功 ─→ 返回图片 + 操作选项 ─→ IDLE
└─ 生成失败 ─→ 错误原因 + 重试选项 ─→ GENERATE
```
### 首次使用流程
```
用户:「画一只猫」
↓
Bot:「好的!想用什么模型生成?」
「1. 通义万相(默认)2. Banana (Flux) 3. DALL-E」
↓
用户:「1」
↓
Bot 检测到 config 里没有 API key,询问用户:
「请提供你的 DashScope API Key」
同时告知用户也可以手动配置:
「💡 也可以手动配置:编辑 pic-gen/config/models.yaml,填入 api_key 字段。
⚠️ 注意:不要把包含真实 Key 的配置文件分享给他人。」
↓
用户:「sk-xxxxxxxx」
↓
Bot 写入 config/models.yaml,并回复:
「✅ Key 已保存!正在生成…」
↓
Bot 优化提示词 → 生成图片 → 返回
```
### 密钥更新流程
用户可以说:
- 「更新通义 key 为 sk-xxx」
- 「换成 banana」
- 「设置默认模型为 dalle」
Bot 自动修改 `config/models.yaml`,无需重启。
### 专家模式(一步直达)
用户也可以一句话搞定所有参数:
- 「用 flux 生成赛博朋克城市,16:9,高细节」
- 「qwen, 梵高风格画向日葵」
### 提示词确认流程
```
Bot:「为你优化后的提示词如下:」
📝 通义万相版:「一片向日葵花田,梵高后印象派风格,浓烈的黄色和蓝色对比,笔触感,星空下的夜晚」
🎨 Midjourney 版:「a sunflower field, post-impressionist style Van Gogh, vivid yellow and blue contrast, brushstroke texture, starry night, --ar 16:9 --s 400」
⚡ Stable Diffusion 版:「masterpiece, best quality, sunflower field, Van Gogh style, post-impressionist, vivid colors, starry night background, oil painting」
[✅ 生成] [✏️ 修改提示词] [⚙️ 调整参数]
```
---
## 目录结构
```
pic-gen/
├── SKILL.md ← 本文件
├── config/
│ └── models.yaml ← 模型配置文件(用户 API Key 在此)
├── scripts/
│ ├── optimize.py ← 核心:提示词优化
│ ├── generate_qwen.py ← 通义万相生成器
│ ├── generate_banana.py ← Banana/Flux 生成器
│ └── generate_dalle.py ← DALL-E 生成器
└── references/
├── midjourney.md ← MJ 格式参考
├── stable-diffusion.md ← SD 格式参考
├── flux.md ← Flux 格式参考
└── dalle.md ← DALL-E 格式参考
```
---
## 提示词优化规则(optimize.py)
输入:用户简单描述(中文或英文)
输出:各平台优化后的提示词
### 优化维度
| 维度 | 说明 | 示例 |
|---|---|---|
| 主体 | 具体物种/颜色/动作/表情 | 「猫」→「橘猫,坐姿,眯眼打盹」 |
| 场景 | 地点/时间/天气/背景 | 「在户外」→「京都寺庙庭院,春日午后」 |
| 风格 | 艺术家/流派/渲染方式 | 「好看」→「宫崎骏动画风格,柔和光影」 |
| 光影 | 光源方向/软硬/色温 | 「亮」→「逆光,金色边缘光,暖色调」 |
| 构图 | 视角/焦距/景深/比例 | 「拍猫」→「低角度平视,浅景深,85mm」 |
| 氛围 | 情绪词/色调关键词 | 「开心」→「活泼,明亮,童趣」 |
| 技术参数 | 平台专属参数 | --ar 16:9 / --s 400 / negative prompt |
### 平台输出格式
```python
# platforms: ["qwen", "midjourney", "stable_diffusion", "flux", "dalle"]
# each platform returns optimized string
```
---
## 配置管理(config/models.yaml)
### API Key 安全说明 ⚠️
**API Key = 你的账号密码,禁止泄露或分享。**
- **不要**把包含真实 Key 的配置文件发到 GitHub、Discord、群聊等任何公开或 semi-public 地方
- 提交到 GitHub 前,确保 `config/models.yaml` 中 api_key 字段为空或使用环境变量
- 建议使用环境变量方式引用 Key,而非直接写在 yaml 里:
```yaml
# 方式一:直接填写(仅本地使用,不提交到 Git)
models:
qwen:
api_key: "sk-xxxxxxxx"
# 方式二:留空,通过环境变量注入(推荐)
models:
qwen:
api_key: ""
```
```bash
# 运行前设置环境变量
export DASHSCOPE_API_KEY="sk-xxxxxxxx"
export BANANA_API_KEY="your-banana-key"
export OPENAI_API_KEY="sk-xxxxxxxx"
```
### 配置文件位置
```
pic-gen/config/models.yaml
```
用户可通过以下任一方式配置:
| 方式 | 说明 |
|---|---|
| **对话提供** | 直接发送 Key 给 Bot,Bot 自动写入配置文件 |
| **手动编辑** | 编辑 `config/models.yaml`,填入 api_key |
| **环境变量** | 设置 `DASHSCOPE_API_KEY` / `BANANA_API_KEY` / `OPENAI_API_KEY` |
### 配置文件格式
```yaml
default: qwen
models:
qwen:
enabled: true
api_key: "" # 填写你的 DashScope API Key
model: "qwen-image-2.0-pro"
default_size: "1024*1024"
default_style: "auto"
banana:
enabled: false
api_key: "" # 填写你的 Banana API Key
model: "flux-dev"
default_size: "1024*1024"
dalle:
enabled: false
api_key: "" # 填写你的 OpenAI API Key
model: "dall-e-3"
default_size: "1024*1024"
```
### 配置操作命令
| 用户说 | Bot 操作 |
|---|---|
| 「设置通义 key 为 xxx」 | 写入 `models.qwen.api_key` |
| 「开启 banana」 | 写入 `models.banana.enabled: true` |
| 「设置默认模型为 flux」 | 写入 `default: banana` |
| 「查看当前配置」 | 读取并展示(非敏感字段) |
---
## 脚本说明
### optimize.py
```bash
python3 scripts/optimize.py --input "一只猫" --platform qwen
```
把简单描述转化为各平台最优提示词。
### generate_qwen.py
```bash
python3 scripts/generate_qwen.py \
--prompt "优化后的提示词" \
--size 1024*1024 \
--count 1 \
--download \
--output ./output
```
需要 `DASHSCOPE_API_KEY` 环境变量或 config 中的 api_key。
### generate_banana.py
```bash
python3 scripts/generate_banana.py \
--prompt "optimized prompt" \
--model flux-dev \
--download \
--output ./output
```
需要 `BANANA_API_KEY` 环境变量。
### generate_dalle.py
```bash
python3 scripts/generate_dalle.py \
--prompt "optimized prompt" \
--size 1024x1024 \
--download \
--output ./output
```
需要 `OPENAI_API_KEY` 环境变量。
---
## 平台格式参考(references/)
详细格式说明、参数列表、示例提示词见各参考文件。
---
## 版本
- v0.1.0 - 初始骨架
FILE:config/models.yaml
# pic-gen 模型配置
# 用户首次使用需要填写各模型 API Key
# 支持平台:qwen(通义万相)、banana(Flux)、dalle(DALL-E 3)
default: qwen
models:
qwen:
enabled: true
api_key: "" # 填入 DashScope API Key
model: "qwen-image-2.0-pro"
default_size: "1024*1024"
default_style: "auto"
banana:
enabled: false
api_key: "" # 填入 Banana API Key
model: "flux-dev"
default_size: "1024*1024"
dalle:
enabled: false
api_key: "" # 填入 OpenAI API Key
model: "dall-e-3"
default_size: "1024*1024"
FILE:references/dalle.md
# DALL-E 3 提示词格式参考
## 基本信息
DALL-E 3 是 OpenAI 的图像生成模型,通过 ` dall-e-3` 模型名调用。仅支持英文提示词,对自然语言理解很强。
## 调用方式
```bash
curl https://api.openai.com/v1/images/generations \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "dall-e-3",
"prompt": "your detailed prompt in English",
"n": 1,
"size": "1024x1024",
"style": "vivid",
"quality": "standard"
}'
```
## 核心参数
| 参数 | 说明 | 可选值 |
|---|---|---|
| `n` | 生成数量(1~10) | 1(dall-e-3 默认) |
| `size` | 图片尺寸 | `1024x1024`, `1024x1792`, `1792x1024` |
| `style` | 风格 | `vivid`(默认)/ `natural` |
| `quality` | 质量 | `standard`(默认)/ `hd`(更高细节) |
| `response_format` | 返回格式 | `url` / `b64_json` |
## 提示词特点
DALL-E 3 **不需要负面提示词**(系统会自动避免生成不合适内容)。
### 写作原则
1. **用自然句子描述**,不需要标签堆叠
2. **场景要具体**:地点、光照、天气、情绪都要写
3. **人物要描述外貌+服装+动作**
4. **风格放在句子最后**:如 `in the style of...` 或 `photorealistic, ...`
### 结构模板
```
[主体:谁/什么] + [动作/状态] + [场景:地点/环境/背景] + [光照/时间] + [风格/媒介]
```
## 风格关键词
| 风格 | 英文表达 |
|---|---|
| 摄影 | `photorealistic`, `shot on 35mm`, `cinematic photography` |
| 插画 | `digital illustration`, `children's book illustration` |
| 油画 | `oil painting`, `impasto` |
| 水彩 | `watercolor painting` |
| 宫崎骏 | `in the style of Studio Ghibli, Hayao Miyazaki` |
| 皮克斯 | `3D render, Pixar style` |
| 电影感 | `cinematic, film still, widescreen aspect ratio` |
| 广告 | `advertising photography, commercial, product shot` |
## 分辨率选择
| 尺寸 | 比例 | 最佳用途 |
|---|---|---|
| `1024x1024` | 1:1 | 方形构图、头像、方形海报 |
| `1024x1792` | 9:16 | 手机壁纸、Instagram Stories |
| `1792x1024` | 16:9 | 横版海报、Banner、电影感场景 |
## HD 模式特点
`quality: "hd"` 会:
- 更多细节和纹理
- 更准确的构图
- 更强的光照效果
- 但生成速度更慢,成本更高
## 常见错误
1. **文字渲染**:DALL-E 3 对图片内文字支持较好,但仍然不稳定,避免长段落文字
2. **人脸**:默认已经很少出现扭曲人脸,不需要额外说明
3. **中文提示词**:不支持,请确保输入为英文
## 示例
**输入**:「咖啡馆里认真看书的女孩,窗外是巴黎街道,阳光明媚」
**输出(DALL-E 3)**:
```
A young woman reading a book in a cozy Parisian cafe, warm sunlight streaming through the window, a view of the bustling Paris street outside, brass fixtures and wooden tables, relaxed and contemplative mood, soft bokeh, cinematic composition, shot on 35mm film, natural lighting, advertisement photography style
```
**输入**:「梵高风格的星空下向日葵花田」
**输出(DALL-E 3)**:
```
A field of sunflowers under a starry night sky, swirling Milky Way galaxy visible above, thick impasto brushstrokes visible in the sky, vivid blues and yellows, Van Gogh's post-impressionist style, dramatic and emotional, oil painting texture, wide landscape composition
```
## 参考
- https://platform.openai.com/docs/guides/images
- https://platform.openai.com/docs/api-reference/images-create
FILE:references/flux.md
# Flux 提示词格式参考
## 基本信息
Flux 是 Black Forest Labs 发布的开源图像生成模型,在 Banana、Replicate 等平台托管。
## 平台调用
Banana:
```bash
curl -X POST https://api.banana.dev/start/fetch/v4/{your-model-key} \
-H "Content-Type: application/json" \
-d '{
"modelInputs": {
"prompt": "your prompt here",
"num_inference_steps": 50,
"guidance": 3.5,
"width": 1024,
"height": 1024,
"seed": -1
}
}'
```
## 提示词风格
Flux 对提示词的**理解更接近自然语言**,不需要像 Stable Diffusion 那样堆标签,但以下结构仍然最优:
```
[主体] in [场景/环境], [风格描述], [光线描述], [氛围/情绪]
```
### 推荐写法
**✅ 好的示例**:
```
A golden retriever running on a beach at sunset, warm orange light reflecting on the water, cinematic, photorealistic
```
**❌ 过度标签化的示例(Flux 不需要)**:
```
masterpiece, best quality, high quality, golden retriever, beach, sunset, warm tones, cinematic lighting, (perfect fur:1.2)
```
## 关键参数
| 参数 | 说明 | 推荐值 |
|---|---|---|
| `num_inference_steps` | 推理步数,越高质量越好 | 50(默认) |
| `guidance` | 提示词引导强度 | 3.5 |
| `seed` | 随机种子,`-1` 为随机 | -1 或固定值 |
| `width/height` | 图片尺寸 | 1024x1024 |
## Flux 特色能力
- **强提示词跟随**:提示词中写什么基本就会生成什么
- **文字渲染**:Flux-dev 支持图片内文字生成(需准确写出)
- **构图控制**:Flux 对镜头语言理解好,可以写 `wide angle lens`, `close-up on face` 等
- **长提示词**:Flux 不怕长提示词,可以写得很详细
## 镜头语言参考
```
wide shot, medium shot, close-up, extreme close-up
low angle, high angle, bird's eye view, worm's eye view
shallow depth of field, bokeh background, sharp focus
cinematic framing, film grain, vintage tone
```
## 光照描述
```
natural sunlight, golden hour, blue hour
studio lighting, soft box, rim light
neon light, volumetric lighting, god rays
backlit, silhouette, dramatic shadows
```
## 示例
**输入**:「未来城市,雨夜,霓虹灯倒映水面」
**输出(Flux)**:
```
A futuristic cyberpunk cityscape at night, heavy rain falling on the streets, neon signs with Chinese and English text reflected in puddles of water, flying vehicles in the distance, dense fog, volumetric lighting, cinematic, Blade Runner atmosphere, 8k photorealistic
```
## 分辨率建议
- `1024x1024`(方形,默认)
- `1024x768`(横向)
- `768x1024`(纵向)
- `1440x720`(宽屏)
- Banana 最高支持 `2048x2048`
## 参考
- https://blackforestlabs.ai/flux/
- https://docs.banana.dev/banana-docs
FILE:references/midjourney.md
# Midjourney 提示词格式参考
## 基本结构
```
[主体描述], [场景], [风格], [光照], [色调], [构图], [参数]
```
## 常用参数
| 参数 | 说明 | 示例值 |
|---|---|---|
| `--ar 16:9` | 宽高比 | `--ar 16:9`, `--ar 1:1`, `--ar 9:16` |
| `--s 250` | 风格化强度 | `--s 100`(低)~ `--s 1000`(高) |
| `--q .25` | 质量(渲染时间) | `--q .25`(快)~ `--q 2`(慢) |
| `--v 6` | 版本 | `--v 6`, `--v 5.2`, `--v niji` |
| `--style` | 风格切换 | `--style raw`(原始) |
| `--no` | 负向词 | `--no people, text, watermark` |
| `--seed` | 种子数 | `--seed 12345` |
| `--tile` | 重复平铺 | `--tile` |
| `--chaos 20` | 变化程度 | `--chaos 0`~`--chaos 100` |
## 风格关键词
### 艺术家风格
- `by Greg Rutkowski`(魔幻写实)
- `by Studio Ghibli`(吉卜力)
- `by Pixar`(皮克斯)
- `by Van Gogh`(梵高)
- `by Makoto Shinkai`(新海诚)
- `by Wes Anderson`(韦斯·安德森)
### 渲染风格
- `photorealistic, 8k, high detail`
- `digital painting, concept art`
- `oil painting, masterpiece`
- `anime style, cel shading`
- `3d render, octane render`
- `watercolor style`
### 光影
- `cinematic lighting`
- `golden hour, warm tones`
- `neon lighting, cyberpunk`
- `soft diffused light`
- `dramatic rim lighting`
- `backlit, silhouette`
## 负面提示词模板
```
--no people, human, text, watermark, signature, low quality, blurry, distorted, deformed, extra limbs, bad anatomy
```
## 示例
**输入**:「赛博朋克城市,雨夜,霓虹灯」
**输出**:
```
a futuristic cyberpunk city at night, heavy rain, neon signs reflecting on wet streets, flying cars, dense fog, cinematic lighting, blade runner atmosphere, --ar 16:9 --s 400 --v 6 --no people, text, watermark
```
## 提示词顺序原则
1. 主体最先(猫、人物、风景)
2. 环境/背景其次
3. 光照和色调
4. 风格和艺术家引用
5. 技术参数最后
6. 负面词单独一行
## 参考网站
- https://docs.midjourney.com/docs/parameter-simplified
- https://promptguide.com/midjourney
FILE:references/stable-diffusion.md
# Stable Diffusion / ComfyUI 提示词格式参考
## 基本结构
```
正面提示词:
masterpiece, best quality, [主体], [场景], [风格], [光照], [细节], [技术参数]
负面提示词:
low quality, worst quality, blurry, watermark, text, signature, extra limbs, deformed, bad anatomy
```
## 提示词权重语法
| 语法 | 说明 | 示例 |
|---|---|---|
| `(word)` | 加权 x1.1 | `(cat)` = 猫的权重提升 |
| `(word:1.5)` | 显式权重数值 | `(cat:1.5)` |
| `[word]` | 降低权重 | `[cat]` |
| `word::0.5` | 步进衰减 | 逐步衰减效果 |
## 常用质量标签(必加)
```
masterpiece, best quality, high quality, official art, extremely detailed CG unity 8k wallpaper
```
## 常用负面提示词
```
low quality, worst quality, normal quality, blurry, watermark, text, signature
extra fingers, extra limbs, deformed, bad anatomy, mutated hands
bad proportions, gross proportions, malformed limbs
bad hands, missing fingers, fused fingers
bad teeth, bad eyes, bad face
```
## 构图与视角
```
front view, back view, side view
from above, from below, bird's eye view, worm's eye view
wide shot, medium shot, close-up, extreme close-up
dynamic angle, low angle, high angle, Dutch angle
shallow depth of field, bokeh, defocused
full body, half body, portrait, headshot
```
## 光影
```
natural lighting, cinematic lighting
sunlight, moonlight, starlight
soft lighting, hard lighting
dramatic lighting, rim lighting, backlighting
volumetric lighting, god rays
neon glow, light particles
```
## 风格标签参考
| 风格 | 正面标签 | 适用场景 |
|---|---|---|
| 写实 | `photorealistic, realistic, 8k, detailed photograph` | 人物、风景 |
| 动漫 | `anime, 2d, cel shading, vibrant colors` | 二次元 |
| 厚涂 | `oil painting, impasto, painterly` | 艺术感 |
| 赛博朋克 | `cyberpunk, neon, holographic, futuristic` | 科幻场景 |
| 水彩 | `watercolor, soft edges, delicate` | 清新风格 |
| 像素 | `pixel art, 8-bit, retro game` | 游戏风格 |
| 概念艺术 | `concept art, matte painting, artstation` | 设定、场景 |
## ComfyUI 节点建议
- `KSampler`:`denoise: 0.7~0.9`(越高细节越多)
- `KSampler Advanced`:`sigma_start, sigma_end` 控制采样范围
- `ControlNet`:可加 `canny, openpose, depth` 控制构图
## 示例
**输入**:「一只橘猫在咖啡馆窗边晒太阳」
**输出(SD)**:
```
masterpiece, best quality, high quality, an orange tabby cat sitting on a windowsill in a cozy cafe, sunlight streaming through the window, warm tones, soft bokeh background, European cafe interior, morning light, relaxing atmosphere, oil painting style, detailed fur, expressive eyes
Negative: low quality, worst quality, blurry, watermark, text, deformed, bad anatomy, extra limbs
```
## 参考网站
- https://stable-diffusion-art.com/prompts
- https://publicprompts.art
- https://arthub.ai
FILE:requirements.txt
requests>=2.28.0
pyyaml>=6.0
banana-dev>=1.0.0
FILE:scripts/generate_banana.py
#!/usr/bin/env python3
"""
pic-gen: Banana (Flux) 图片生成器
"""
import argparse
import os
import sys
import json
import time
import yaml
import requests
try:
import banana_dev as banana
BANANA_SDK_AVAILABLE = True
except ImportError:
BANANA_SDK_AVAILABLE = False
banana = None
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
CONFIG_PATH = os.path.join(SKILL_DIR, "config", "models.yaml")
def load_config() -> dict:
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
return {}
def get_api_key(config: dict) -> str:
api_key = config.get("models", {}).get("banana", {}).get("api_key", "")
if not api_key:
api_key = os.environ.get("BANANA_API_KEY", "")
return api_key
def generate_image(prompt: str, api_key: str = None, model_key: str = None,
size: str = "1024x1024", steps: int = 50, guidance: float = 3.5,
seed: int = -1, download: bool = False, output: str = ".") -> dict:
"""
通过 Banana API 调用 Flux 模型生成图片
"""
if not api_key:
return {"error": "Banana API Key 未设置,请在 config/models.yaml 中配置 banana.api_key"}
if not BANANA_SDK_AVAILABLE:
return {"error": "banana-dev 未安装,请运行: pip install banana-dev"}
if not model_key:
return {"error": "Banana Model Key 未设置,请在 config/models.yaml 中配置 banana.model_key"}
# 尺寸映射 (width x height)
size_map = {
"1024*1024": (1024, 1024),
"1024*768": (1024, 768),
"768*1024": (768, 1024),
"1280*720": (1280, 720),
"720*1280": (720, 1280),
}
w, h = size_map.get(size, (1024, 1024))
payload = {
"modelInputs": {
"prompt": prompt,
"num_inference_steps": steps,
"guidance": guidance,
"width": w,
"height": h,
"seed": seed
}
}
headers = {
"Content-Type": "application/json"
}
url = f"https://api.banana.dev/start/fetch/v4/{model_key}"
try:
response = requests.post(url, headers=headers, json=payload, timeout=60)
data = response.json()
if response.status_code != 200:
return {"error": f"请求失败 ({response.status_code}): {data}"}
# Banana 返回 call_id 用于查询
call_id = data.get("call_id", "")
if not call_id:
return {"error": f"无法获取 call_id: {data}"}
result = {"call_id": call_id, "status": "submitted"}
if download or True: # 默认等待
result = wait_for_completion(call_id, api_key, download, output)
return result
except Exception as e:
return {"error": f"生成失败: {str(e)}"}
def wait_for_completion(call_id: str, api_key: str, download: bool, output: str,
max_wait: int = 180) -> dict:
"""轮询 Banana 查询结果"""
if not BANANA_SDK_AVAILABLE:
return {"error": "banana-dev 未安装,请运行: pip install banana-dev"}
start_time = time.time()
while time.time() - start_time < max_wait:
try:
result = banana.check_model(api_key, call_id)
outputs = result.get("modelOutputs", [])
if outputs:
output_data = outputs[0]
if "images" in output_data:
urls = output_data["images"]
res = {"status": "succeeded", "images": urls, "call_id": call_id}
if download and urls:
res["files"] = download_images(urls, output)
return res
elif "image" in output_data:
url = output_data["image"]
res = {"status": "succeeded", "images": [url], "call_id": call_id}
if download:
res["files"] = download_images([url], output)
return res
# 检查是否还在运行
state = result.get("state", "")
if state in ["started", "running"]:
time.sleep(10)
else:
return {"status": state, "raw": result}
except Exception as e:
return {"error": f"查询失败: {str(e)}"}
return {"status": "timeout", "error": "等待超时"}
def download_images(urls: list, output_dir: str) -> list:
os.makedirs(output_dir, exist_ok=True)
files = []
for i, url in enumerate(urls):
try:
resp = requests.get(url, timeout=30)
if resp.status_code == 200:
ext = "png"
filepath = os.path.join(output_dir, f"pic_gen_flux_{int(time.time())}_{i+1}.{ext}")
with open(filepath, "wb") as f:
f.write(resp.content)
files.append(filepath)
except Exception as e:
files.append({"index": i, "error": str(e)})
return files
def main():
parser = argparse.ArgumentParser(description="pic-gen Banana/Flux 图片生成")
parser.add_argument("--prompt", "-p", required=True, help="图片提示词")
parser.add_argument("--model", default="flux-dev", help="Banana 模型名")
parser.add_argument("--size", "-s", default="1024x1024", help="图片尺寸")
parser.add_argument("--steps", type=int, default=50, help="推理步数")
parser.add_argument("--guidance", type=float, default=3.5, help="引导强度")
parser.add_argument("--seed", type=int, default=-1, help="随机种子(-1 为随机)")
parser.add_argument("--download", "-d", action="store_true", help="下载图片")
parser.add_argument("--output", "-o", default="./output", help="下载目录")
parser.add_argument("--api-key", "-k", help="Banana API Key")
parser.add_argument("--model-key", "-m", help="Banana Model Key")
args = parser.parse_args()
config = load_config()
banana_cfg = config.get("models", {}).get("banana", {})
api_key = args.api_key or get_api_key(config) or banana_cfg.get("api_key")
model_key = (
args.model_key
or os.environ.get("BANANA_MODEL_KEY")
or banana_cfg.get("model_key", "flux-dev")
)
result = generate_image(
prompt=args.prompt,
api_key=api_key,
model_key=model_key,
size=args.size.replace("*", "x"),
steps=args.steps,
guidance=args.guidance,
seed=args.seed,
download=args.download,
output=args.output
)
if "error" in result:
print(f"❌ 错误: {result['error']}", file=sys.stderr)
sys.exit(1)
elif result.get("status") == "succeeded":
for url in result.get("images", []):
print(f"✅ 图片生成成功: {url}")
for f in result.get("files", []):
if isinstance(f, str):
print(f"📁 已保存: {f}")
else:
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/generate_dalle.py
#!/usr/bin/env python3
"""
pic-gen: DALL-E 3 图片生成器
"""
import argparse
import os
import sys
import json
import time
import yaml
import requests
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
CONFIG_PATH = os.path.join(SKILL_DIR, "config", "models.yaml")
def load_config() -> dict:
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
return {}
def get_api_key(config: dict) -> str:
api_key = config.get("models", {}).get("dalle", {}).get("api_key", "")
if not api_key:
api_key = os.environ.get("OPENAI_API_KEY", "")
return api_key
def generate_image(prompt: str, api_key: str = None,
size: str = "1024x1024", style: str = "vivid",
quality: str = "standard", n: int = 1,
download: bool = False, output: str = ".") -> dict:
"""
调用 DALL-E 3 生成图片(仅支持英文提示词)
"""
if not api_key:
return {"error": "OpenAI API Key 未设置,请在 config/models.yaml 中配置 dalle.api_key"}
url = "https://api.openai.com/v1/images/generations"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
size_map = {
"1024x1024": "1024x1024",
"1024x1792": "1024x1792",
"1792x1024": "1792x1024",
}
actual_size = size_map.get(size, "1024x1024")
payload = {
"model": "dall-e-3",
"prompt": prompt,
"n": n,
"size": actual_size,
"style": style,
"quality": quality,
"response_format": "url"
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=60)
data = response.json()
if response.status_code != 200:
return {"error": f"请求失败 ({response.status_code}): {data}"}
images = data.get("data", [])
urls = [item["url"] for item in images if "url" in item]
result = {"status": "succeeded", "images": urls, "model": "dall-e-3"}
if download and urls:
result["files"] = download_images(urls, output)
return result
except Exception as e:
return {"error": f"生成失败: {str(e)}"}
def download_images(urls: list, output_dir: str) -> list:
os.makedirs(output_dir, exist_ok=True)
files = []
for i, url in enumerate(urls):
try:
resp = requests.get(url, timeout=30)
if resp.status_code == 200:
ext = "png"
filepath = os.path.join(output_dir, f"pic_gen_dalle_{int(time.time())}_{i+1}.{ext}")
with open(filepath, "wb") as f:
f.write(resp.content)
files.append(filepath)
except Exception as e:
files.append({"index": i, "error": str(e)})
return files
import time
def main():
parser = argparse.ArgumentParser(description="pic-gen DALL-E 3 图片生成")
parser.add_argument("--prompt", "-p", required=True, help="图片提示词(建议英文)")
parser.add_argument("--size", "-s", default="1024x1024",
choices=["1024x1024", "1024x1792", "1792x1024"],
help="图片尺寸")
parser.add_argument("--style", default="vivid",
choices=["vivid", "natural"],
help="风格 vivid=鲜动 natural=自然")
parser.add_argument("--quality", default="standard",
choices=["standard", "hd"],
help="质量 hd=更高细节")
parser.add_argument("--count", "-c", type=int, default=1, help="生成数量(仅 dall-e-3 支持多张)")
parser.add_argument("--download", "-d", action="store_true", help="下载图片")
parser.add_argument("--output", "-o", default="./output", help="下载目录")
parser.add_argument("--api-key", "-k", help="OpenAI API Key")
args = parser.parse_args()
config = load_config()
api_key = args.api_key or get_api_key(config)
result = generate_image(
prompt=args.prompt,
api_key=api_key,
size=args.size,
style=args.style,
quality=args.quality,
n=args.count,
download=args.download,
output=args.output
)
if "error" in result:
print(f"❌ 错误: {result['error']}", file=sys.stderr)
sys.exit(1)
elif result.get("status") == "succeeded":
for url in result.get("images", []):
print(f"✅ 图片生成成功: {url}")
for f in result.get("files", []):
if isinstance(f, str):
print(f"📁 已保存: {f}")
else:
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/generate_qwen.py
#!/usr/bin/env python3
"""
pic-gen: 通义万相图片生成器
调用 DashScope API 生成图片
"""
import argparse
import os
import sys
import json
import time
import yaml
import requests
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
CONFIG_PATH = os.path.join(SKILL_DIR, "config", "models.yaml")
def load_config() -> dict:
"""加载配置文件"""
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
return {}
def get_api_key(config: dict) -> str:
"""获取 API Key"""
api_key = config.get("models", {}).get("qwen", {}).get("api_key", "")
if not api_key:
api_key = os.environ.get("DASHSCOPE_API_KEY", "")
return api_key
def generate_image(prompt: str, api_key: str = None,
size: str = "1024*1024", count: int = 1,
negative_prompt: str = "",
download: bool = False, output: str = ".",
wait: bool = True) -> dict:
"""
调用通义万相生成图片
Args:
prompt: 提示词
api_key: DashScope API Key
size: 图片尺寸,如 "1024*1024"
count: 生成数量(1-4)
negative_prompt: 负面提示词
download: 是否下载图片
output: 下载目录
wait: 是否等待完成
Returns:
dict: 结果,包含 task_id 和图片 URL
"""
if not api_key:
return {"error": "API Key 未设置,请在 config/models.yaml 中配置 qwen.api_key"}
url = "https://dashscope.aliyuncs.com/api/v1/services/a2xt2g4x/draw/image-synthesis"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
# 尺寸映射
size_map = {
"1024*1024": [1024, 1024],
"1024*768": [1024, 768],
"768*1024": [768, 1024],
"1280*720": [1280, 720],
"720*1280": [720, 1280],
}
wh = size_map.get(size, [1024, 1024])
payload = {
"model": "qwen-image-2.0-pro",
"input": {
"prompt": prompt,
"negative_prompt": negative_prompt
},
"parameters": {
"size": f"{wh[0]}*{wh[1]}",
"n": count
}
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=30)
resp_json = response.json()
if response.status_code != 200:
return {"error": f"请求失败 ({response.status_code}): {resp_json}"}
# 解析任务 ID
task_id = None
if "output" in resp_json and "task_id" in resp_json["output"]:
task_id = resp_json["output"]["task_id"]
elif "request_id" in resp_json:
task_id = resp_json["request_id"]
if not task_id:
return {"error": f"无法获取 task_id: {resp_json}"}
result = {"task_id": task_id, "status": "submitted"}
if wait:
# 轮询等待完成
result = wait_for_completion(task_id, api_key, download, output)
return result
except requests.exceptions.Timeout:
return {"error": "请求超时,请检查网络或稍后重试"}
except Exception as e:
return {"error": f"生成失败: {str(e)}"}
def wait_for_completion(task_id: str, api_key: str, download: bool, output: str, max_wait: int = 120) -> dict:
"""轮询等待图片生成完成"""
url = f"https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}"
headers = {"Authorization": f"Bearer {api_key}"}
start_time = time.time()
while time.time() - start_time < max_wait:
try:
resp = requests.get(url, headers=headers, timeout=10)
data = resp.json()
if resp.status_code != 200:
return {"error": f"查询失败: {data}"}
status = data.get("output", {}).get("task_status", "UNKNOWN")
if status == "succeeded":
images = data.get("output", {}).get("results", [])
urls = [item.get("url") or item.get("image_url") for item in images if item]
result = {"status": "succeeded", "images": urls, "task_id": task_id}
if download and urls:
result["files"] = download_images(urls, output)
return result
elif status == "failed":
return {"status": "failed", "error": "图片生成失败", "detail": data}
elif status in ("pending", "running", "wait"):
time.sleep(3)
else:
return {"status": status, "raw": data}
except Exception as e:
return {"error": f"查询失败: {str(e)}"}
return {"status": "timeout", "error": "等待超时,请稍后用 task_id 查询"}
def download_images(urls: list, output_dir: str) -> list:
"""下载图片到本地"""
os.makedirs(output_dir, exist_ok=True)
files = []
for i, url in enumerate(urls):
try:
resp = requests.get(url, timeout=30)
if resp.status_code == 200:
ext = "png" # 通义万相返回 PNG
filepath = os.path.join(output_dir, f"pic_gen_{int(time.time())}_{i+1}.{ext}")
with open(filepath, "wb") as f:
f.write(resp.content)
files.append(filepath)
except Exception as e:
files.append({"index": i, "error": str(e)})
return files
def main():
parser = argparse.ArgumentParser(description="pic-gen 通义万相图片生成")
parser.add_argument("--prompt", "-p", required=True, help="图片提示词")
parser.add_argument("--size", "-s", default="1024*1024",
help="图片尺寸,默认 1024*1024")
parser.add_argument("--count", "-c", type=int, default=1,
help="生成数量(1-4)")
parser.add_argument("--negative-prompt", "-n", default="",
help="负面提示词")
parser.add_argument("--download", "-d", action="store_true",
help="生成后下载图片")
parser.add_argument("--output", "-o", default="./output",
help="下载目录")
parser.add_argument("--wait", "-w", action="store_true", default=True,
help="等待生成完成")
parser.add_argument("--no-wait", action="store_true",
help="不等待,立即返回 task_id")
parser.add_argument("--api-key", "-k", help="API Key(也可在 config 中设置)")
args = parser.parse_args()
config = load_config()
api_key = args.api_key or get_api_key(config)
if args.no_wait:
args.wait = False
result = generate_image(
prompt=args.prompt,
api_key=api_key,
size=args.size,
count=args.count,
negative_prompt=args.negative_prompt,
download=args.download,
output=args.output,
wait=args.wait
)
if "error" in result:
print(f"❌ 错误: {result['error']}", file=sys.stderr)
sys.exit(1)
elif result.get("status") == "succeeded":
for url in result.get("images", []):
print(f"✅ 图片生成成功: {url}")
for f in result.get("files", []):
if isinstance(f, str):
print(f"📁 已保存: {f}")
else:
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/optimize.py
#!/usr/bin/env python3
"""
pic-gen: 提示词优化脚本 v2.0
基于全网最佳实践重构,让生成的图片真正「哇塞」
"""
import argparse
import os
import re
import sys
import yaml
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
CONFIG_PATH = os.path.join(SKILL_DIR, "config", "models.yaml")
REF_DIR = os.path.join(SKILL_DIR, "references")
# ============================================================
# 通义万相优化器
# 公式:[主体描述] + [风格设定] + [细节要求] + [视觉氛围] + [分辨率/比例]
# 来源:通义万相官方提示词技巧
# ============================================================
def _qwen_enhance(prompt: str) -> list[str]:
"""生成通义万相增强关键词列表"""
enhancements = []
prompt_lower = prompt.lower()
# 质量基础包
base = ["细节丰富", "高品质", "高精度"]
enhancements.extend(base)
# 风格检测(梵高、宫崎骏、皮克斯、油画等)
if any(k in prompt_lower for k in ["梵高", "van gogh", "油画", "后印象派"]):
enhancements.extend(["浓烈色彩", "笔触感", "后印象派风格", "厚涂感"])
elif any(k in prompt_lower for k in ["宫崎骏", "吉卜力", "新海诚", "动漫", "anime"]):
enhancements.extend(["动画风格", "柔和色调", "宫崎骏光影", "细腻色彩"])
elif any(k in prompt_lower for k in ["皮克斯", "pixar", "3d", "卡通"]):
enhancements.extend(["3D动画风格", "皮克斯质感", "柔和光影", "可爱感"])
elif any(k in prompt_lower for k in ["水彩", "watercolor"]):
enhancements.extend(["水彩渲染", "柔和边缘", "通透感", "艺术感"])
# 光影氛围包(根据场景自动选择)
if any(k in prompt_lower for k in ["雨", "rain", "雨夜"]):
enhancements.extend(["雨丝光束", "湿润质感", "霓虹倒影", "冷色调", "氛围感"])
elif any(k in prompt_lower for k in ["赛博朋克", "cyberpunk", "霓虹", "neon"]):
enhancements.extend(["霓虹灯光", "赛博光效", "冷色调", "科技感"])
elif any(k in prompt_lower for k in ["夜", "星空", "月亮", "night", "star"]):
enhancements.extend(["月光", "星芒", "深邃夜空", "冷色调"])
elif any(k in prompt_lower for k in ["日出", "日落", "sunrise", "sunset", "黄昏"]):
enhancements.extend(["金色时刻", "暖色调", "柔和逆光", "天空渲染"])
elif any(k in prompt_lower for k in ["森林", "绿", "自然"]):
enhancements.extend(["自然光", "森林光影", "斑驳光影", "清新感"])
elif any(k in prompt_lower for k in ["海", "沙滩", "ocean", "beach"]):
enhancements.extend(["海风光影", "水面倒影", "清澈蓝", "空气感"])
else:
enhancements.extend(["自然光", "柔和光影", "氛围感"])
# 风格加成(根据关键词匹配)
if any(k in prompt_lower for k in ["猫", "cat", "狗", "dog", "动物"]):
enhancements.extend(["毛发纹理", "真实感", "大眼睛"])
if any(k in prompt_lower for k in ["人", "girl", "boy", "woman", "man"]):
enhancements.extend(["光影立体感", "轮廓分明", "质感皮肤"])
# 摄影感
if any(k in prompt_lower for k in ["风景", "landscape", "城市", "city", "建筑"]):
enhancements.extend(["电影感构图", "广角视野", "层次分明"])
if any(k in prompt_lower for k in ["食物", "food", "美食"]):
enhancements.extend(["食欲感", "商业摄影", "浅景深"])
return enhancements
def optimize_for_qwen(prompt: str) -> str:
"""通义万相提示词优化"""
enhancements = _qwen_enhance(prompt)
# 去掉原prompt中已有的重复词
parts = [prompt.strip()]
for e in enhancements:
if e not in prompt and e.lower() not in prompt.lower():
parts.append(e)
return ",".join(parts)
# ============================================================
# Midjourney 优化器
# 公式:摄影类型 + 主体描述 + 相机型号 + 打光 + 角度 + 辅助词
# 来源:今日头条「Midjourney神公式直接出大片」
# ============================================================
MJ_PHOTOGRAPHY_TYPES = [
"portrait photography", "fashion photography", "landscape photography",
"street photography", "product photography", "food photography",
"cinematic photography", "editorial photography", "fine art photography",
"documentary photography", "wildlife photography", "macro photography",
]
MJ_CAMERAS = [
"shot on Canon EOS R5", "shot on Sony A7R V", "shot on Hasselblad X2D",
"shot on Leica M11", "shot on Nikon Z9", "shot on Fujifilm GFX 100 II",
"shot on ARRI Alexa", "shot on RED", "shot on iPhone 15 Pro Max",
"85mm lens", "35mm lens", "50mm lens", "135mm lens",
"f/1.4 aperture", "f/2.8 aperture", "shallow depth of field",
]
MJ_LIGHTING = [
"golden hour lighting", "blue hour lighting", "dramatic natural lighting",
"studio lighting", "soft box lighting", "rim lighting", "backlighting",
"cinematic lighting", "volumetric lighting", "neon lighting",
"low key lighting", "high key lighting", "moody lighting",
"practical lighting", "window light", "tungsten lighting",
]
MJ_ANGLES = [
"low angle shot", "high angle shot", "eye level shot",
"bird's eye view", "worm's eye view", "dutch angle",
"over the shoulder", "close-up", "extreme close-up",
"medium shot", "wide shot", "full body shot",
"cowboy shot", "Italian shot",
]
MJ_QUALITY_BOOSTERS = [
"award winning", "masterpiece", "8k resolution", "ultra detailed",
"hyperrealistic", "photorealistic", "cinematic", "film grain",
"professional color grading", "HDR", "RAW photo",
]
def optimize_for_midjourney(prompt: str) -> str:
"""Midjourney 提示词优化 - 神公式"""
parts = [prompt.strip()]
# 检测摄影类型
prompt_lower = prompt.lower()
photo_type = "cinematic photography"
for pt in MJ_PHOTOGRAPHY_TYPES:
if pt.split()[0] in prompt_lower:
photo_type = pt
break
parts.append(photo_type)
# 相机
parts.append(MJ_CAMERAS[0]) # 默认 Canon EOS R5
# 光照(根据场景智能选择)
lighting = MJ_LIGHTING[0] # 默认 golden hour
if any(k in prompt_lower for k in ["雨", "rain", "wet", "雨夜"]):
lighting = "neon reflections on wet streets, rain effect, cinematic"
elif any(k in prompt_lower for k in ["赛博朋克", "cyberpunk", "科幻", "scifi", "霓虹"]):
lighting = "neon lighting, volumetric fog, cyberpunk atmosphere"
elif any(k in prompt_lower for k in ["夜", "night", "星空", "star", "月亮", "moon"]):
lighting = "neon lighting, colorful glow, night atmosphere"
elif any(k in prompt_lower for k in ["日", "sun", "黎明", "dawn", "黄昏", "dusk"]):
lighting = "golden hour, warm tones, soft light"
elif any(k in prompt_lower for k in ["森林", "nature", "绿", "green"]):
lighting = "natural sunlight, dappled light, forest atmosphere"
elif any(k in prompt_lower for k in ["室内", "room", "cafe", "咖啡"]):
lighting = "warm interior lighting, cozy atmosphere"
elif any(k in prompt_lower for k in ["梵高", "van gogh", "油画", "向日葵"]):
lighting = "post-impressionist, bold brushstrokes, vivid colors, artistic"
elif any(k in prompt_lower for k in ["宫崎骏", "吉卜力", "anime", "动漫"]):
lighting = "soft watercolor lighting, Studio Ghibli style, dreamy atmosphere"
elif any(k in prompt_lower for k in ["皮克斯", "pixar", "3d", "卡通"]):
lighting = "bright studio lighting, Pixar color palette, soft shadows"
parts.append(lighting)
# 角度
parts.append(MJ_ANGLES[4]) # 默认 cinematic
# 质量提升
if any(k in prompt_lower for k in ["梵高", "van gogh"]):
parts.append("post-impressionist art style, Van Gogh inspired, bold colors, visible brushstrokes")
elif any(k in prompt_lower for k in ["宫崎骏", "吉卜力"]):
parts.append("Studio Ghibli anime style, Hayao Miyazaki inspired, soft watercolor aesthetic")
elif any(k in prompt_lower for k in ["皮克斯", "pixar"]):
parts.append("Pixar 3D animation style, high quality CGI, vibrant colors")
else:
parts.append("hyperrealistic, ultra detailed, 8k, cinematic, film grain")
# 检查是否已有 --ar 参数
if "--ar" not in prompt:
parts.append("--ar 16:9")
if "--s" not in prompt:
parts.append("--s 250")
if "--v" not in prompt:
parts.append("--v 6")
# 负面词
parts.append("--no blur, watermark, text, signature, low quality")
return ", ".join(parts)
# ============================================================
# Stable Diffusion 优化器
# 公式:[画质前缀] + [主体] + [场景] + [风格/光影]
# 来源:CSDN / 知乎最佳实践
# ============================================================
SD_QUALITY_PREFIX = [
"masterpiece", "best quality", "high quality", "official art",
"extremely detailed CG unity 8k wallpaper", "absurdres",
"incredibly absurdres", "huge filesize",
]
SD_STYLES = [
"photorealistic", "realistic", "digital illustration",
"oil painting", "watercolor", "anime", "manga",
"concept art", "matte painting", "3d render",
"cyberpunk", "artstation", "pixiv",
]
SD_LIGHTING = [
"cinematic lighting", "natural lighting", "dramatic lighting",
"soft lighting", "studio lighting", "rim lighting",
"volumetric lighting", "god rays", "neon glow",
"backlit", "frontlit", "side lighting",
]
SD_CAMERA = [
"wide angle lens", "85mm portrait lens", "35mm lens",
"depth of field", "bokeh", "sharp focus",
"cinematic composition", "film grain", "RAW",
]
SD_NEGATIVE = [
"low quality", "worst quality", "normal quality",
"blurry", "blur", "bokeh", "noise",
"watermark", "text", "signature", "logo",
"deformed", "bad anatomy", "bad hands", "extra limbs",
"missing fingers", "fused fingers", "bad teeth",
"mutated", "malformed", "gross proportions",
"bad shadow", "incorrect anatomy", "poorly drawn face",
]
def optimize_for_stable_diffusion(prompt: str) -> tuple[str, str]:
"""SD 提示词优化 - 质量标签 + 主体 + 场景 + 光影"""
positive_parts = []
# 质量前缀
positive_parts.extend(SD_QUALITY_PREFIX[:4])
# 主体
positive_parts.append(prompt.strip())
# 自动检测风格
prompt_lower = prompt.lower()
detected_styles = []
for style in SD_STYLES:
if style.split()[0] in prompt_lower:
detected_styles.append(style)
if not detected_styles:
positive_parts.append("photorealistic") # 默认写实
# 光影
positive_parts.extend(SD_LIGHTING[:2])
# 相机/构图
positive_parts.append(SD_CAMERA[0])
positive = ", ".join(positive_parts)
negative = ", ".join(SD_NEGATIVE)
return positive, negative
# ============================================================
# Flux 优化器
# 原则:Flux 偏好自然语言,不要堆标签,用完整句子描述场景
# 来源:Flux 官方文档
# ============================================================
FLUX_STYLE_PHRASES = [
"cinematic, photorealistic", "award winning photography",
"professional color grading", "ultra detailed", "8k resolution",
"volumetric lighting", "sharp focus", "shallow depth of field",
]
def optimize_for_flux(prompt: str) -> str:
"""Flux 提示词优化 - 自然语言优先,不要堆标签"""
prompt_lower = prompt.lower()
# 检测是否已有风格词
has_style = any(s in prompt_lower for s in ["cinematic", "photorealistic", "realistic", "style"])
has_quality = any(s in prompt_lower for s in ["detailed", "8k", "4k", "quality", "high"])
parts = [prompt.strip()]
if not has_style:
parts.append("cinematic, photorealistic")
if not has_quality:
parts.append("ultra detailed, 8k resolution")
if "lighting" not in prompt_lower and "light" not in prompt_lower:
parts.append("volumetric lighting")
# Flux 不需要参数,简洁
return ", ".join(parts)
# ============================================================
# DALL-E 3 优化器
# 原则:自然语言,英文,详细场景描述,对中文用户要翻译
# ============================================================
DALLE_STYLE_TERMS = [
"photorealistic", "cinematic", "oil painting", "digital illustration",
"watercolor", "concept art", "editorial photography",
"fine art", "vivid", "natural",
]
DALLE_QUALITY = {
"standard": "vivid colors, high detail",
"hd": "hyper-detailed, ultra realistic, 8k quality",
}
ZH_TO_EN = {
"猫": "cat", "狗": "dog", "女孩": "young woman", "男孩": "young man",
"城市": "cityscape", "风景": "landscape", "日出": "sunrise",
"日落": "sunset", "夜晚": "night", "星空": "starry sky",
"海": "ocean", "沙滩": "beach", "森林": "forest", "山": "mountain",
"春天": "spring", "夏天": "summer", "秋天": "autumn", "冬天": "winter",
"下雨": "rainy", "雪": "snowy", "樱花": "cherry blossoms",
"赛博朋克": "cyberpunk", "梵高": "Van Gogh", "宫崎骏": "Studio Ghibli",
"皮克斯": "Pixar style", "油画": "oil painting", "水彩": "watercolor",
"雨夜": "rainy night", "霓虹灯": "neon lights", "雨": "rain",
}
def _translate_to_en(text: str) -> str:
"""简单中译英辅助"""
result = text
for zh, en in ZH_TO_EN.items():
# 加空格避免连在一起
result = result.replace(zh, " " + en + " ")
# 清理多余空格
import re
result = re.sub(r'\s+', ' ', result).strip()
return result
def optimize_for_dalle(prompt: str, quality: str = "standard") -> str:
"""DALL-E 3 提示词优化 - 自然语言英文,详细场景"""
# 翻译
if any('\u4e00' <= c <= '\u9fff' for c in prompt):
prompt_en = _translate_to_en(prompt)
else:
prompt_en = prompt.strip()
# 质量词
quality_str = DALLE_QUALITY.get(quality, DALLE_QUALITY["standard"])
# 组装:场景化详细描述
parts = [prompt_en]
parts.append(quality_str)
parts.append("professional photography")
parts.append("highly detailed")
parts.append("perfect composition")
return ", ".join(parts)
# ============================================================
# 主入口
# ============================================================
def optimize(prompt: str, platform: str = "all") -> dict:
"""
主优化函数
Args:
prompt: 用户原始描述
platform: 目标平台 ["qwen", "midjourney", "stable_diffusion", "flux", "dalle", "all"]
Returns:
dict: 各平台优化结果
"""
platforms_map = {
"qwen": ("通义万相版", "⚡", "text"),
"midjourney": ("Midjourney 版", "🎨", "text"),
"stable_diffusion": ("Stable Diffusion 版", "🖌️", "text_with_negative"),
"flux": ("Flux 版", "🌊", "text"),
"dalle": ("DALL-E 3 版", "🖼️", "text"),
}
if platform == "all":
results = {}
for key, (label, emoji, fmt) in platforms_map.items():
try:
result = _optimize_single(prompt, key, fmt)
results[key] = {"label": label, "emoji": emoji, **result}
except Exception as e:
results[key] = {"label": label, "emoji": emoji, "error": str(e)}
return results
elif platform in platforms_map:
label, emoji, fmt = platforms_map[platform]
result = _optimize_single(prompt, platform, fmt)
return {platform: {"label": label, "emoji": emoji, **result}}
else:
return {"error": f"Unknown platform: {platform}"}
def _optimize_single(prompt: str, platform: str, fmt: str) -> dict:
if platform == "qwen":
return {"prompt": optimize_for_qwen(prompt)}
elif platform == "midjourney":
return {"prompt": optimize_for_midjourney(prompt)}
elif platform == "stable_diffusion":
pos, neg = optimize_for_stable_diffusion(prompt)
return {"prompt": pos, "negative": neg}
elif platform == "flux":
return {"prompt": optimize_for_flux(prompt)}
elif platform == "dalle":
return {"prompt": optimize_for_dalle(prompt)}
def format_output(results: dict) -> str:
"""格式化输出"""
lines = []
for key, data in results.items():
if "error" in data:
lines.append(f"{data['emoji']} {data['label']}: 错误 - {data['error']}")
continue
lines.append(f"{data['emoji']} **{data['label']}**:")
lines.append(f"「{data['prompt']}」")
if "negative" in data:
lines.append(f" 🚫 负面词:{data['negative']}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="pic-gen 提示词优化器 v2.0")
parser.add_argument("--input", "-i", required=True, help="用户原始描述")
parser.add_argument("--platform", "-p", default="all",
choices=["qwen", "midjourney", "stable_diffusion", "flux", "dalle", "all"])
parser.add_argument("--format", "-f", default="text",
choices=["text", "yaml", "json"])
args = parser.parse_args()
results = optimize(args.input, args.platform)
if args.format == "text":
print(format_output(results))
elif args.format == "yaml":
print(yaml.dump(results, allow_unicode=True, default_flow_style=False))
elif args.format == "json":
import json
print(json.dumps(results, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
FILE:scripts/update_config.py
#!/usr/bin/env python3
"""
pic-gen: 配置管理脚本
支持更新 API Key、切换默认模型、查看当前配置
"""
import argparse
import os
import shutil
import sys
import yaml
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
CONFIG_PATH = os.path.join(SKILL_DIR, "config", "models.yaml")
CONFIG_TEMPLATE = os.path.join(SKILL_DIR, "config", "models.yaml.template")
import shutil # for init_config template copy
def load_config() -> dict:
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
return {}
def save_config(config: dict):
"""原子写入配置文件(先写临时文件再 rename)"""
tmp_path = CONFIG_PATH + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
os.replace(tmp_path, CONFIG_PATH)
def init_config():
"""初始化配置文件(从模板复制)"""
if not os.path.exists(CONFIG_PATH):
if os.path.exists(CONFIG_TEMPLATE):
shutil.copy2(CONFIG_TEMPLATE, CONFIG_PATH)
print(f"✅ 配置文件已创建: {CONFIG_PATH}")
else:
# 模板不存在时回退到内嵌方式
config = {
"default": "qwen",
"models": {
"qwen": {"enabled": True, "api_key": "", "model": "qwen-image-2.0-pro",
"default_size": "1024*1024", "default_style": "auto"},
"banana": {"enabled": False, "api_key": "", "model": "flux-dev", "default_size": "1024*1024"},
"dalle": {"enabled": False, "api_key": "", "model": "dall-e-3", "default_size": "1024x1024"},
}
}
save_config(config)
print(f"✅ 配置文件已创建: {CONFIG_PATH}")
else:
print(f"配置文件已存在: {CONFIG_PATH}")
def update_api_key(model: str, key: str) -> dict:
"""更新指定模型的 API Key"""
config = load_config()
if model not in config.get("models", {}):
return {"error": f"未知模型: {model},支持的模型: qwen, banana, dalle"}
config["models"][model]["api_key"] = key
config["models"][model]["enabled"] = True
save_config(config)
return {"success": True, "model": model, "message": f"{model} API Key 已更新,模型已启用"}
def set_default_model(model: str) -> dict:
"""设置默认模型"""
config = load_config()
if model not in config.get("models", {}):
return {"error": f"未知模型: {model}"}
config["default"] = model
save_config(config)
return {"success": True, "default": model, "message": f"默认模型已设置为 {model}"}
def enable_model(model: str, enabled: bool = True) -> dict:
"""启用/禁用指定模型"""
config = load_config()
if model not in config.get("models", {}):
return {"error": f"未知模型: {model}"}
config["models"][model]["enabled"] = enabled
save_config(config)
status = "已启用" if enabled else "已禁用"
return {"success": True, "model": model, "message": f"{model} {status}"}
def show_config() -> dict:
"""展示当前配置(隐藏 API Key 主体)"""
config = load_config()
display = {
"default": config.get("default", "qwen"),
"models": {}
}
for name, model_config in config.get("models", {}).items():
display_config = dict(model_config)
api_key = display_config.get("api_key", "")
if api_key:
# 隐藏 Key 中间部分,只显示前4和后4位
if len(api_key) > 8:
display_config["api_key"] = f"{api_key[:4]}...{api_key[-4:]}"
else:
display_config["api_key"] = "******"
else:
display_config["api_key"] = "(未设置)"
display_config["enabled"] = display_config.get("enabled", False)
display["models"][name] = display_config
return display
def main():
parser = argparse.ArgumentParser(description="pic-gen 配置管理")
sub = parser.add_subparsers(dest="command")
# init
sub.add_parser("init", help="初始化配置文件")
# set-key
setkey = sub.add_parser("set-key", help="设置 API Key")
setkey.add_argument("model", choices=["qwen", "banana", "dalle"], help="模型名")
setkey.add_argument("key", help="API Key")
# set-default
setdef = sub.add_parser("set-default", help="设置默认模型")
setdef.add_argument("model", choices=["qwen", "banana", "dalle"], help="模型名")
# enable/disable
enable = sub.add_parser("enable", help="启用模型")
enable.add_argument("model", choices=["qwen", "banana", "dalle"])
disable = sub.add_parser("disable", help="禁用模型")
disable.add_argument("model", choices=["qwen", "banana", "dalle"])
# show
sub.add_parser("show", help="显示当前配置")
args = parser.parse_args()
if args.command == "init":
init_config()
elif args.command == "set-key":
result = update_api_key(args.model, args.key)
if "error" in result:
print(f"❌ {result['error']}", file=sys.stderr)
sys.exit(1)
else:
print(f"✅ {result['message']}")
elif args.command == "set-default":
result = set_default_model(args.model)
if "error" in result:
print(f"❌ {result['error']}", file=sys.stderr)
sys.exit(1)
else:
print(f"✅ {result['message']}")
elif args.command == "enable":
result = enable_model(args.model, True)
print(f"✅ {result['message']}")
elif args.command == "disable":
result = enable_model(args.model, False)
print(f"✅ {result['message']}")
elif args.command == "show":
result = show_config()
print(yaml.dump(result, allow_unicode=True, default_flow_style=False))
else:
parser.print_help()
if __name__ == "__main__":
main()
FILE:tests/__init__.py
FILE:tests/test_optimize.py
#!/usr/bin/env python3
"""
pic-gen 测试套件
"""
import sys
import os
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(SCRIPT_DIR)
sys.path.insert(0, os.path.join(SKILL_DIR, "scripts"))
from optimize import (
optimize_for_qwen,
optimize_for_midjourney,
optimize_for_stable_diffusion,
optimize_for_flux,
optimize_for_dalle,
)
def test_qwen():
prompts = ["一只猫", "梵高风格的向日葵", "赛博朋克城市夜景"]
for p in prompts:
result = optimize_for_qwen(p)
assert len(result) > len(p), f"qwen优化后不应变短: {result}"
print(f"[qwen] ✓ {p!r} → {result!r}")
def test_midjourney():
prompts = ["a cat on the moon", "sunflower field"]
for p in prompts:
result = optimize_for_midjourney(p)
assert "--ar" in result, f"MJ 应包含 --ar: {result}"
assert "--v" in result, f"MJ 应包含 --v: {result}"
print(f"[midjourney] ✓ {p!r} → {result!r}")
def test_stable_diffusion():
prompts = ["a cat", "beautiful landscape"]
for p in prompts:
pos, neg = optimize_for_stable_diffusion(p)
assert len(pos) > len(p), f"SD正向不应变短"
assert len(neg) > 0, f"SD应有negative prompt"
print(f"[stable_diffusion] ✓ {p!r} → pos={pos[:60]!r}... neg={neg[:40]!r}...")
def test_flux():
prompts = ["a cat", "cyberpunk city"]
for p in prompts:
result = optimize_for_flux(p)
assert len(result) >= len(p), f"flux优化后不应变短"
print(f"[flux] ✓ {p!r} → {result!r}")
def test_dalle():
prompts = ["一只猫", "van gogh sunflower"]
for p in prompts:
result = optimize_for_dalle(p)
assert len(result) >= len(p), f"dalle优化后不应变短"
print(f"[dalle] ✓ {p!r} → {result!r}")
def main():
print("=" * 60)
print("Running pic-gen tests...")
print("=" * 60)
tests = [
("Qwen", test_qwen),
("Midjourney", test_midjourney),
("Stable Diffusion", test_stable_diffusion),
("Flux", test_flux),
("DALL-E", test_dalle),
]
failed = 0
for name, fn in tests:
try:
print(f"\n--- {name} ---")
fn()
except Exception as e:
print(f"[FAIL] {name}: {e}")
failed += 1
print("\n" + "=" * 60)
if failed:
print(f"FAILED: {failed}/{len(tests)}")
sys.exit(1)
else:
print(f"PASSED: all {len(tests)} tests")
sys.exit(0)
if __name__ == "__main__":
main()
B 站视频下载工具。支持哔哩哔哩视频下载、弹幕下载。用户说"B 站下载"、"哔哩哔哩"、"bilibili"时使用。无需 API Key。纯下载工具,不支持搜索。
---
name: bilibit
version: 0.1.8
description: B 站视频下载工具。支持哔哩哔哩视频下载、弹幕下载。用户说"B 站下载"、"哔哩哔哩"、"bilibili"时使用。无需 API Key。纯下载工具,不支持搜索。
license: MIT
homepage: https://github.com/AoturLab/bilibit
metadata:
openclaw:
emoji: 🎬
requires:
bins: [bbdown, ffmpeg]
aliases: [B 站下载,哔哩哔哩下载,bilibili 下载,B 站视频,哔哩哔哩,bilibili,B 站,b 站,视频下载,弹幕下载]
---
# 🎬 bilibit - B 站视频下载专家
极简 B 站视频下载工具。粘贴 URL,一键下载视频和弹幕。
---
## 📦 快速安装
```bash
# clawhub
clawhub install bilibit
# npm
npm install -g bilibit
```
---
## 🚀 使用示例
### 下载视频
```bash
bilibit https://b23.tv/BV1xx
```
### 下载带弹幕
```bash
bilibit https://b23.tv/BV1xx --danmaku
```
---
## 💬 AI 交互规范(重要!)
### 触发场景
**当用户说这些话时,使用 bilibit**:
- "下载这个 B 站视频" + URL
- "B 站下载"
- "哔哩哔哩视频"
- "下载弹幕"
**不支持的场景**:
- ❌ "搜索 B 站视频" - bilibit 不支持搜索,需要用户提供 URL
- ❌ "找某个 UP 主的视频" - 不支持搜索,需用户先在 B 站找到 URL
### 输出格式规范
**输出格式规范**:
```
📺 下载完成!已保存到:xxx
📌 回复序号查看历史
```
**禁止行为**:
- ❌ 不要转成表格格式
- ❌ 不要重新排序
- ❌ 不要用 `[]()` 包裹 URL
**必须保留**:
- ✅ 原始输出格式
- ✅ 下载完成提示
---
## 📋 完整命令
| 命令 | 说明 | 示例 |
|------|------|------|
| `bilibit <url>` | 下载视频 | `bilibit https://b23.tv/BV1xx` |
| `bilibit <url> --danmaku` | 下载 + 弹幕 | `bilibit ... --danmaku` |
| `bilibit <url> --quality 4K` | 指定画质 | `bilibit ... --quality 4K` |
| `bilibit history` | 下载历史 | `bilibit history` |
---
## ⚠️ 注意事项
- 仅限个人学习使用
- 大会员画质需要 Cookie
- 弹幕保存为 XML 格式
---
## 🔗 相关链接
- GitHub: https://github.com/AoturLab/bilibit
- 问题反馈:https://github.com/AoturLab/bilibit/issues
FILE:PRODUCT_SCOPE.md
# bilibit 产品范围定义
**版本:** 1.0
**日期:** 2026-03-23
**定位:** 纯下载工具
---
## 🎯 产品定位
**bilibit 是一个极简 B 站视频下载工具。**
核心价值:粘贴 URL → 下载完成
---
## 📋 功能清单
### P0 - 核心功能(MVP)
| 功能 | 说明 | 状态 |
|------|------|------|
| **视频下载** | 通过 URL 下载 B 站视频 | ✅ 已实现 |
| **弹幕下载** | 可选下载弹幕文件 | ✅ 已实现 |
| **画质选择** | 指定下载画质 | ✅ 已实现 |
| **下载历史** | 本地记录下载历史 | ✅ 已实现 |
| **命令行界面** | 简洁的 CLI 交互 | ✅ 已实现 |
**MVP 完成标准:**
- 用户输入 URL 即可下载
- 支持 `--danmaku` 选项
- 支持 `--quality` 选项
- 显示清晰的下载完成提示
---
### P1 - 延后功能(可选增强)
| 功能 | 说明 | 优先级 |
|------|------|--------|
| 批量下载 | 支持下载列表/收藏夹 | P1 |
| Cookie 管理 | 大会员画质支持 | P1 |
| 输出目录配置 | 自定义保存路径 | P1 |
| 进度条显示 | 下载进度可视化 | P1 |
**延后原因:** 不影响核心下载体验,可在 MVP 验证后迭代
---
### P2 - 探索功能(根据需求决定)
| 功能 | 说明 | 优先级 |
|------|------|--------|
| 音频提取 | 仅下载音频轨道 | P2 |
| 字幕下载 | 下载 CC 字幕 | P2 |
| 封面下载 | 保存视频封面图 | P2 |
| 元数据嵌入 | 视频信息写入文件 | P2 |
**探索原因:** 属于边缘需求,需用户反馈验证
---
### ❌ 砍掉功能(不承诺)
| 功能 | 原因 |
|------|------|
| **搜索功能** | 超出下载工具边界,且未实现 |
| **视频播放** | 属于播放器范畴 |
| **账号管理** | 增加复杂度,Cookie 足够 |
| **云端存储** | 偏离本地工具定位 |
| **转码/剪辑** | 属于后期工具范畴 |
| **分享功能** | 非下载核心需求 |
**砍掉原因:**
- 增加产品复杂度
- 偏离"纯下载"定位
- 当前 SKILL.md 承诺搜索但代码未实现,造成体验落差
---
## 📐 产品边界
### ✅ 做什么
- B 站视频下载
- 弹幕下载
- 本地下载历史管理
- 极简命令行交互
### ❌ 不做什么
- 视频搜索
- 视频播放
- 账号/登录管理
- 云端同步
- 视频编辑/转码
- 社交分享
---
## 🎯 更新后的定位语
### 对外定位(SKILL.md)
> **bilibit - B 站视频下载专家**
>
> 极简 B 站视频下载工具。粘贴 URL,一键下载视频和弹幕。无需 API Key,支持画质选择。
### 对内定位(团队共识)
> **纯下载工具,聚焦核心体验。**
>
> 不做搜索,不做播放,不做社交。把下载做到最快、最简单。
---
## 📝 待办事项
1. **更新 SKILL.md** - 移除搜索相关承诺
2. **更新 README** - 明确产品边界
3. **更新 package.json** - keywords 聚焦下载
4. **AI 交互规范** - 明确触发场景仅为下载
---
## 🔑 核心原则
1. **URL 驱动** - 用户提供 URL,不主动搜索
2. **零配置** - 默认行为满足 80% 场景
3. **本地优先** - 所有数据保存在本地
4. **下载即完成** - 不延伸播放/编辑/分享
---
**决策记录:**
- 2026-03-23:明确纯下载定位,砍掉搜索功能承诺
FILE:README.md
# 🎬 bilibit - Bilibili Video Downloader
> Simple and fast Bilibili video downloader. Just paste the URL!
[](https://www.npmjs.com/package/bilibit)
[](https://opensource.org/licenses/MIT)
**[🇨🇳 中文文档](README_CN.md)**
---
## ✨ Features
- 🎯 **URL Download** - Paste URL, download video
- 🎬 **Danmaku Support** - Download with danmaku
- 🚀 **Fast & Simple** - One command to get started
- 📦 **Auto Install** - BBDown auto-installs
- 📋 **History** - View download history
---
## 📦 Installation
```bash
npm install -g bilibit
```
**BBDown will auto-install during installation!**
---
## 🚀 Quick Start
### Download Video
```bash
# Basic download
bilibit https://b23.tv/BV1xx
# With quality
bilibit https://b23.tv/BV1xx --quality 4K
# With danmaku
bilibit https://b23.tv/BV1xx --danmaku
```
### View History
```bash
bilibit history
bilibit history --limit 20
```
---
## 📋 Command Reference
| Command | Description |
|---------|-------------|
| `bilibit <url>` | Download video |
| `bilibit history` | View history |
| `bilibit --help` | Show help |
| `bilibit --version` | Show version |
### Download Options
| Option | Short | Description |
|--------|-------|-------------|
| `--quality` | `-q` | Video quality (4K, 1080P, etc.) |
| `--danmaku` | `-d` | Download danmaku |
| `--output` | `-o` | Output directory |
---
## 💡 How to Get URL
1. Open Bilibili in browser
2. Find the video you want
3. Copy URL from address bar
4. Run `bilibit <URL>`
**Example URL**: `https://www.bilibili.com/video/BV1yVwXzGEbL`
---
## ⚠️ Notes
- **Copyright**: For personal learning only
- **BBDown**: Auto-installs with bilibit
- **Premium**: Cookie needed for 1080P+
---
## 🔗 Links
- **GitHub**: https://github.com/AoturLab/bilibit
- **npm**: https://www.npmjs.com/package/bilibit
- **Issues**: https://github.com/AoturLab/bilibit/issues
---
## 📄 License
MIT License
FILE:README_CN.md
# 🎬 bilibit - B 站视频下载工具
> 简单易用的 B 站视频下载工具。复制 URL,一键下载!
[](https://www.npmjs.com/package/bilibit)
[](https://opensource.org/licenses/MIT)
**[🇺🇸 English Documentation](README.md)**
---
## ✨ 特性
- 🎯 **URL 下载** - 粘贴 URL,下载视频
- 🎬 **弹幕支持** - 下载带弹幕
- 🚀 **简单快速** - 一条命令搞定
- 📦 **自动安装** - BBDown 自动安装
- 📋 **历史记录** - 查看下载历史
---
## 📦 安装
```bash
npm install -g bilibit
```
**BBDown 会自动安装!**
---
## 🚀 快速开始
### 下载视频
```bash
# 基础下载
bilibit https://b23.tv/BV1xx
# 指定画质
bilibit https://b23.tv/BV1xx --quality 4K
# 下载弹幕
bilibit https://b23.tv/BV1xx --danmaku
```
### 查看历史
```bash
bilibit history
bilibit history --limit 20
```
---
## 📋 命令参考
| 命令 | 说明 |
|------|------|
| `bilibit <url>` | 下载视频 |
| `bilibit history` | 查看历史 |
| `bilibit --help` | 帮助信息 |
| `bilibit --version` | 版本号 |
### 下载选项
| 参数 | 简写 | 说明 |
|------|------|------|
| `--quality` | `-q` | 视频画质(4K, 1080P 等) |
| `--danmaku` | `-d` | 下载弹幕 |
| `--output` | `-o` | 输出目录 |
---
## 💡 如何获取 URL
1. 浏览器打开 B 站
2. 找到想要的视频
3. 复制地址栏 URL
4. 运行 `bilibit <URL>`
**URL 示例**: `https://www.bilibili.com/video/BV1yVwXzGEbL`
---
## ⚠️ 注意事项
- **版权**: 仅限个人学习使用
- **BBDown**: 安装 bilibit 时自动安装
- **大会员**: 1080P+ 需要 Cookie
---
## 🔗 相关链接
- **GitHub**: https://github.com/AoturLab/bilibit
- **npm**: https://www.npmjs.com/package/bilibit
- **问题反馈**: https://github.com/AoturLab/bilibit/issues
---
## 📄 许可证
MIT License
FILE:bin/bbdown-wrapper.js
#!/usr/bin/env node
/**
* BBDown wrapper
* 调用本地安装的 BBDown
*/
const { spawnSync } = require('child_process');
const path = require('path');
const bbdownPath = path.join(__dirname, '..', 'node_modules', '.bin', 'BBDown');
const args = process.argv.slice(2);
const result = spawnSync(bbdownPath, args, {
stdio: 'inherit',
shell: true
});
process.exit(result.status || 0);
FILE:bin/bilibit.js
#!/usr/bin/env node
/**
* bilibit CLI entry point
* B 站视频下载专家
*/
const cli = require('../src/cli');
// Run CLI with command line arguments
cli.main(process.argv.slice(2)).catch(error => {
console.error('❌ Unexpected error:', error.message);
process.exit(1);
});
FILE:package.json
{
"name": "bilibit",
"version": "0.1.6",
"description": "Simple Bilibili video downloader. Just paste the URL!",
"main": "src/cli.js",
"bin": {
"bilibit": "./bin/bilibit.js",
"bbdown": "./bin/bbdown-wrapper.js"
},
"scripts": {
"test": "node tests/",
"start": "node bin/bilibit.js",
"postinstall": "node scripts/install-bbdown.js"
},
"keywords": [
"bilibili",
"B 站",
"哔哩哔哩",
"video",
"downloader",
"视频下载",
"弹幕",
"danmaku",
"simple",
"fast",
"cli",
"pure-download"
],
"author": "bilibit team",
"license": "MIT",
"type": "commonjs",
"repository": {
"type": "git",
"url": "https://github.com/AoturLab/bilibit.git"
},
"bugs": {
"url": "https://github.com/AoturLab/bilibit/issues"
},
"homepage": "https://github.com/AoturLab/bilibit#readme",
"engines": {
"node": ">=14.0.0"
}
}
FILE:scripts/check-deps.js
#!/usr/bin/env node
/**
* 依赖检查脚本
* 安装后自动检查并安装 BBDown
*/
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
console.log('🔍 检查 bilibit 依赖...\n');
// 检查 BBDown
function checkBBDown() {
try {
execSync('which bbdown', { stdio: 'ignore' });
console.log('✅ BBDown 已安装');
return true;
} catch (error) {
console.log('⚠️ BBDown 未安装');
return false;
}
}
// 安装 BBDown
function installBBDown() {
const platform = process.platform;
console.log('\n📦 正在安装 BBDown...\n');
try {
if (platform === 'darwin') {
// macOS
console.log('检测到 macOS,使用 Homebrew 安装...');
execSync('brew install bbdown', { stdio: 'inherit' });
} else if (platform === 'linux') {
// Linux
console.log('检测到 Linux,使用 apt 安装...');
execSync('sudo apt update && sudo apt install -y bbdown', { stdio: 'inherit' });
} else if (platform === 'win32') {
// Windows
console.log('检测到 Windows,请手动安装:');
console.log('1. 访问 https://github.com/nilaoda/BBDown/releases');
console.log('2. 下载最新版本');
console.log('3. 解压到 PATH 目录');
return false;
}
console.log('\n✅ BBDown 安装成功!\n');
return true;
} catch (error) {
console.log('\n⚠️ BBDown 安装失败,请手动安装:');
console.log(' macOS: brew install bbdown');
console.log(' Linux: sudo apt install bbdown');
console.log(' Windows: https://github.com/nilaoda/BBDown/releases\n');
return false;
}
}
// 检查 ffmpeg
function checkFFmpeg() {
try {
execSync('which ffmpeg', { stdio: 'ignore' });
console.log('✅ ffmpeg 已安装');
return true;
} catch (error) {
console.log('⚠️ ffmpeg 未安装(可选,用于音视频合并)');
return false;
}
}
// 主流程
console.log('════════════════════════════════════════');
console.log('🎬 bilibit 依赖检查');
console.log('════════════════════════════════════════\n');
const hasBBDown = checkBBDown();
const hasFFmpeg = checkFFmpeg();
if (!hasBBDown) {
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('\n💡 是否自动安装 BBDown?(y/n): ', (answer) => {
rl.close();
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
installBBDown();
} else {
console.log('\n⚠️ 跳过 BBDown 安装');
console.log(' 需要下载视频时,请先手动安装 BBDown\n');
}
console.log('════════════════════════════════════════');
console.log('✅ bilibit 已就绪!');
console.log('════════════════════════════════════════\n');
console.log('使用示例:');
console.log(' bilibit search "LOL 集锦"');
console.log(' bilibit https://b23.tv/BV1xx');
console.log(' bilibit --help\n');
});
} else {
console.log('\n════════════════════════════════════════');
console.log('✅ bilibit 已就绪!');
console.log('════════════════════════════════════════\n');
console.log('使用示例:');
console.log(' bilibit search "LOL 集锦"');
console.log(' bilibit https://b23.tv/BV1xx');
console.log(' bilibit --help\n');
}
FILE:scripts/install-bbdown.js
#!/usr/bin/env node
/**
* BBDown 自动安装脚本
* 安装 bilibit 时自动下载 BBDown
*/
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
console.log('🔍 检查 BBDown 依赖...\n');
// 检查 BBDown 是否已安装
function checkBBDown() {
try {
execSync('which BBDown', { stdio: 'ignore' });
console.log('✅ BBDown 已安装\n');
return true;
} catch (error) {
console.log('⚠️ BBDown 未安装\n');
return false;
}
}
// 下载并解压 BBDown
function downloadBBDown() {
const platform = os.platform();
const arch = os.arch();
console.log('📦 正在下载 BBDown...\n');
// 选择正确的版本
let downloadUrl = '';
let binaryName = 'BBDown';
if (platform === 'darwin') {
if (arch === 'arm64') {
downloadUrl = 'https://github.com/nilaoda/BBDown/releases/download/1.6.3/BBDown_1.6.3_20240814_macos-arm64.zip';
} else {
downloadUrl = 'https://github.com/nilaoda/BBDown/releases/download/1.6.3/BBDown_1.6.3_20240814_macos-x64.zip';
}
} else if (platform === 'linux') {
if (arch === 'arm64') {
downloadUrl = 'https://github.com/nilaoda/BBDown/releases/download/1.6.3/BBDown_1.6.3_20240814_linux-arm64.zip';
} else {
downloadUrl = 'https://github.com/nilaoda/BBDown/releases/download/1.6.3/BBDown_1.6.3_20240814_linux-x64.zip';
}
} else if (platform === 'win32') {
binaryName = 'BBDown.exe';
downloadUrl = 'https://github.com/nilaoda/BBDown/releases/download/1.6.3/BBDown_1.6.3_20240814_win-x64.zip';
} else {
console.log('❌ 不支持的操作系统:', platform);
console.log('请手动安装:https://github.com/nilaoda/BBDown/releases\n');
return Promise.resolve(false);
}
const installDir = path.join(__dirname, '..', 'node_modules', '.bin');
const zipPath = path.join(installDir, 'bbdown.zip');
try {
if (!fs.existsSync(installDir)) {
fs.mkdirSync(installDir, { recursive: true });
}
} catch (e) {}
return new Promise((resolve) => {
// 使用 curl 下载(自动跟进重定向)
const curl = spawn('curl', ['-L', '-o', zipPath, downloadUrl], { stdio: 'ignore' });
curl.on('close', (code) => {
if (code !== 0 || !fs.existsSync(zipPath)) {
console.log('❌ 下载失败');
console.log('请手动安装:https://github.com/nilaoda/BBDown/releases\n');
fs.unlink(zipPath, () => {});
resolve(false);
return;
}
console.log('📦 解压中...\n');
// 解压 zip
const unzip = spawn('unzip', ['-o', '-q', zipPath, '-d', installDir], { stdio: 'ignore' });
unzip.on('close', (unzipCode) => {
fs.unlink(zipPath, () => {});
if (unzipCode !== 0) {
console.log('❌ 解压失败');
resolve(false);
return;
}
const installPath = path.join(installDir, binaryName);
if (!fs.existsSync(installPath)) {
console.log('❌ 解压后未找到 BBDown 二进制文件');
console.log('安装目录内容:', fs.readdirSync(installDir));
resolve(false);
return;
}
fs.chmodSync(installPath, '755');
console.log('✅ BBDown 安装成功:', installPath);
console.log('🎉 bilibit 已就绪!\n');
console.log('使用示例:');
console.log(' bilibit https://b23.tv/BV1xx');
console.log(' bilibit https://b23.tv/BV1xx --quality 1080P --danmaku\n');
resolve(true);
});
unzip.on('error', (err) => {
fs.unlink(zipPath, () => {});
console.log('❌ 解压失败:', err.message);
resolve(false);
});
});
curl.on('error', (err) => {
fs.unlink(zipPath, () => {});
console.log('❌ 下载失败:', err.message);
console.log('请手动安装:https://github.com/nilaoda/BBDown/releases\n');
resolve(false);
});
});
}
// 主流程
async function main() {
console.log('════════════════════════════════════════');
console.log('🎬 bilibit 依赖安装');
console.log('════════════════════════════════════════\n');
if (checkBBDown()) {
return;
}
await downloadBBDown();
}
main();
FILE:src/cli.js
#!/usr/bin/env node
/**
* CLI command parser and handler
* @module cli
*/
const bbdown = require('./downloader/bbdown');
const history = require('./utils/history');
/**
* Parse command line arguments
* @param {string[]} args - Command line arguments
* @returns {Object}
*/
function parseArgs(args) {
const command = args[0];
const options = {};
const positional = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const value = args[i + 1];
if (value && !value.startsWith('--')) {
options[key] = value;
i++;
} else {
options[key] = true;
}
} else if (arg.startsWith('-')) {
const key = arg.slice(1);
const value = args[i + 1];
if (value && !value.startsWith('-')) {
options[key] = value;
i++;
} else {
options[key] = true;
}
} else {
positional.push(arg);
}
}
return { command, options, positional };
}
/**
* Handle download command
* @param {string} url - Video URL
* @param {Object} options - Download options
*/
async function handleDownload(url, options) {
const cookieFile = options.cookie || options.c;
const outputDir = options.output || options.o || bbdown.getDefaultDownloadDir();
// Step 1: Fetch video info first
console.log('🔍 正在获取视频信息...\n');
const infoResult = await bbdown.info(url, { cookieFile });
if (!infoResult.success) {
console.error('❌ 获取视频信息失败:', infoResult.error);
process.exit(1);
}
const { title, duration, quality, size, hasAudio } = infoResult.info;
// Step 2: Display info with pretty formatting
console.log('════════════════════════════════════════');
console.log(`📺 【title || '未知标题'】`);
console.log('════════════════════════════════════════');
console.log(`⏱️ 时长:duration || '未知' quality ? `| 📐 ${quality` : ''} ''`);
if (size) {
console.log(`💾 预计大小:size`);
}
console.log(`📁 保存到:outputDir`);
console.log('────────────────────────────────────────');
console.log('⏳ 开始下载...\n');
// Step 3: Download
const result = await bbdown.download(url, {
quality: options.quality || options.q,
danmaku: options.danmaku || options.d,
cookieFile,
outputDir
});
if (result.success) {
console.log('\n✅ Download completed!');
console.log(`📁 File: result.output`);
// Add to history
const videoId = bbdown.extractVideoId(url);
if (videoId) {
history.addRecord({
videoId,
url,
title,
downloadPath: result.output,
quality: options.quality || options.q,
danmaku: options.danmaku || options.d
});
}
} else {
console.error('\n❌ Download failed:', result.error);
process.exit(1);
}
}
/**
* Handle history command
* @param {Object} options - History options
*/
async function handleHistory(options) {
const limit = parseInt(options.limit) || 10;
const records = history.getRecords(limit);
if (records.length === 0) {
console.log('📭 No download history yet\n');
return;
}
console.log('📋 Download History:\n');
records.forEach((record, index) => {
console.log(`index + 1. record.title || 'Unknown'`);
console.log(` 🔗 record.url`);
console.log(` 💾 record.downloadPath`);
console.log(` 📅 new Date(record.downloadAt).toLocaleString()\n`);
});
}
/**
* Show help message
*/
function showHelp() {
console.log(`
🎬 bilibit - Bilibili Video Downloader
Usage:
bilibit <url> [options] Download video
bilibit history [options] View download history
bilibit --help Show this help
bilibit --version Show version
Download Options:
-q, --quality <quality> Video quality (4K, 1080P, etc.)
-d, --danmaku Download danmaku
-c, --cookie <file> Cookie file path
-o, --output <dir> Output directory
History Options:
--limit <num> Number of records (default: 10)
Examples:
bilibit https://b23.tv/BV1xx
bilibit https://b23.tv/BV1xx --quality 4K --danmaku
bilibit history --limit 20
💡 Tip: Find video URL in browser, then use bilibit to download.
`);
}
/**
* Show version
*/
function showVersion() {
const pkg = require('../package.json');
console.log(`bilibit vpkg.version`);
}
/**
* Main entry point
*/
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
showHelp();
process.exit(0);
}
const { command, options, positional } = parseArgs(args);
// Handle commands
switch (command) {
case '--help':
case '-h':
showHelp();
break;
case '--version':
case '-v':
showVersion();
break;
case 'history':
await handleHistory(options);
break;
default:
// Assume it's a URL for download
if (command.startsWith('http') || command.startsWith('BV')) {
await handleDownload(command, options);
} else if (positional.length > 0) {
await handleDownload(positional[0], options);
} else {
console.error('❌ Please provide a video URL.\n');
console.log('Usage: bilibit <url> [options]\n');
console.log('Examples:');
console.log(' bilibit https://b23.tv/BV1xx');
console.log(' bilibit https://www.bilibili.com/video/BV1xx\n');
process.exit(1);
}
break;
}
}
module.exports = {
parseArgs,
handleDownload,
handleHistory,
showHelp,
showVersion,
main
};
if (require.main === module) {
main();
}
FILE:src/downloader/bbdown.js
/**
* BBDown wrapper for Bilibili video downloading
* @module downloader/bbdown
*/
const { execSync, spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
/**
* Error message translation table
* Maps BBDown errors to user-friendly Chinese messages
*/
const ERROR_MESSAGES = {
'你没有权限': '大会员内容,请使用 --cookie 参数',
'稿件不可观看': '视频不存在或已被删除',
'timed out': '网络超时,请检查网络',
'timeout': '网络超时,请检查网络',
'BBDown not found': 'BBDown 未安装,运行: bilibit --check',
'not installed': 'BBDown 未安装,运行: bilibit --check',
'ENOENT': 'BBDown 未安装,运行: bilibit --check',
'permission denied': '权限不足,请检查文件权限',
'Connection refused': '网络连接被拒绝,请检查网络',
'getaddrinfo': 'DNS 解析失败,请检查网络',
};
/**
* Translate raw error to user-friendly message
* @param {string} rawError - Raw error string
* @returns {string} - Translated error message
*/
function translateError(rawError) {
if (!rawError) return '未知错误';
for (const [key, message] of Object.entries(ERROR_MESSAGES)) {
if (rawError.includes(key)) {
return message;
}
}
return rawError;
}
/**
* Check if BBDown is installed
* @returns {boolean}
*/
function isBBDownInstalled() {
try {
// 优先使用本地 BBDown
const localBBDown = path.join(__dirname, '..', '..', 'node_modules', '.bin', 'BBDown');
if (fs.existsSync(localBBDown)) {
return true;
}
// 否则使用全局 BBDown
execSync('BBDown --version', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
}
/**
* Get default download directory
* @returns {string}
*/
function getDefaultDownloadDir() {
const homeDir = process.env.HOME || process.env.USERPROFILE;
const downloadDir = path.join(homeDir, 'Downloads', 'bilibit');
if (!fs.existsSync(downloadDir)) {
fs.mkdirSync(downloadDir, { recursive: true });
}
return downloadDir;
}
/**
* Get the path to BBDown executable
* @returns {string|null}
*/
function getBBDownPath() {
// 优先使用本地 BBDown
const localBBDown = path.join(__dirname, '..', '..', 'node_modules', '.bin', 'BBDown');
if (fs.existsSync(localBBDown)) {
return localBBDown;
}
// 使用全局 BBDown
try {
const result = execSync('which BBDown', { stdio: 'pipe' }).toString().trim();
if (result) return result;
} catch (e) {}
return null;
}
/**
* Parse video info from BBDown --info output
* @param {string} infoOutput - Raw output from BBDown --info
* @returns {Object} - Parsed video info
*/
function parseInfoOutput(infoOutput) {
const info = {
title: '',
duration: '',
quality: '',
size: '',
hasAudio: true,
};
if (!infoOutput) return info;
// Extract title (line starting with "Title: " or similar)
const titleMatch = infoOutput.match(/Title:\s*(.+)/i) || infoOutput.match(/标题:\s*(.+)/i);
if (titleMatch) {
info.title = titleMatch[1].trim();
}
// Extract duration
const durationMatch = infoOutput.match(/Duration:\s*([\d:]+)/i) || infoOutput.match(/时长:\s*([\d:]+)/i);
if (durationMatch) {
info.duration = durationMatch[1].trim();
}
// Extract quality
const qualityMatch = infoOutput.match(/Quality:\s*(.+)/i) || infoOutput.match(/画质:\s*(.+)/i);
if (qualityMatch) {
info.quality = qualityMatch[1].trim();
}
// Extract file size
const sizeMatch = infoOutput.match(/Size:\s*([\d.]+\s*\w+)/i) || infoOutput.match(/大小:\s*([\d.]+\s*\w+)/i);
if (sizeMatch) {
info.size = sizeMatch[1].trim();
}
// Check for audio
if (/no audio|无音频/i.test(infoOutput)) {
info.hasAudio = false;
}
return info;
}
/**
* Parse real file path from BBDown output
* @param {string} output - BBDown stdout/stderr output
* @param {string} outputDir - Expected output directory
* @returns {string|null} - Real file path or null
*/
function parseOutputFilePath(output, outputDir) {
if (!output) return null;
// Try to match "Output: /path/to/file.mp4" pattern
const outputMatch = output.match(/Output:\s*(.+)/i);
if (outputMatch) {
const filePath = outputMatch[1].trim();
if (fs.existsSync(filePath)) {
return filePath;
}
return filePath;
}
// Try to match "Merging to: /path/to/file.mp4" pattern
const mergingMatch = output.match(/Merging to:\s*(.+)/i);
if (mergingMatch) {
const filePath = mergingMatch[1].trim();
if (fs.existsSync(filePath)) {
return filePath;
}
return filePath;
}
// Fallback: look for .mp4/.flv files in output directory modified recently
if (outputDir && fs.existsSync(outputDir)) {
try {
const files = fs.readdirSync(outputDir);
const mp4Files = files.filter(f => f.endsWith('.mp4') || f.endsWith('.flv'));
if (mp4Files.length > 0) {
// Return the most recently modified file
const sorted = mp4Files
.map(f => ({
name: f,
path: path.join(outputDir, f),
mtime: fs.statSync(path.join(outputDir, f)).mtime.getTime()
}))
.sort((a, b) => b.mtime - a.mtime);
return sorted[0].path;
}
} catch (e) {}
}
return null;
}
/**
* Fetch video info without downloading
* @param {string} url - Video URL or BV/AV ID
* @param {Object} options - Options
* @returns {Promise<{success: boolean, info?: Object, error?: string}>}
*/
async function info(url, options = {}) {
return new Promise((resolve) => {
const bbdownPath = getBBDownPath();
if (!bbdownPath) {
resolve({
success: false,
error: translateError('BBDown not found')
});
return;
}
const args = ['--only-show-info', url];
if (options.cookieFile) {
args.push('--cookie', options.cookieFile);
}
const child = spawn(bbdownPath, args, {
stdio: ['inherit', 'pipe', 'pipe']
});
let output = '';
let errorOutput = '';
child.stdout.on('data', (data) => {
output += data.toString();
});
child.stderr.on('data', (data) => {
errorOutput += data.toString();
});
child.on('close', (code) => {
const combined = output + errorOutput;
if (code === 0 || output.includes('Title:') || output.includes('标题:')) {
resolve({
success: true,
info: parseInfoOutput(combined)
});
} else {
resolve({
success: false,
error: translateError(errorOutput.trim() || `BBDown --info failed with code code`)
});
}
});
child.on('error', (err) => {
resolve({
success: false,
error: translateError(err.message)
});
});
});
}
/**
* Build BBDown command arguments
* @param {string} url - Video URL
* @param {Object} options - Download options
* @returns {string[]}
*/
function buildArgs(url, options = {}) {
const args = [];
// Video URL
args.push(url);
// Quality selection
if (options.quality) {
args.push('-p', options.quality);
}
// Download danmaku
if (options.danmaku) {
args.push('--danmaku');
}
// Cookie file
if (options.cookieFile) {
args.push('--cookie', options.cookieFile);
}
// Output directory
const outputDir = options.outputDir || getDefaultDownloadDir();
args.push('-d', outputDir);
// Video format
args.push('--video-accept-ids', '127,126,125,120,116,112,80,74,64,32,16');
// Audio format
args.push('--audio-accept-ids', '30280,30232,30216,30280');
return args;
}
/**
* Download a Bilibili video using BBDown
* @param {string} url - Video URL or BV/AV ID
* @param {Object} options - Download options
* @param {string} options.quality - Video quality (4K, 1080P, etc.)
* @param {boolean} options.danmaku - Download danmaku
* @param {string} options.cookieFile - Path to cookie file
* @param {string} options.outputDir - Output directory
* @returns {Promise<{success: boolean, output?: string, error?: string}>}
*/
async function download(url, options = {}) {
return new Promise((resolve) => {
const bbdownPath = getBBDownPath();
if (!bbdownPath) {
resolve({
success: false,
error: translateError('BBDown not found')
});
return;
}
const args = buildArgs(url, options);
const outputDir = options.outputDir || getDefaultDownloadDir();
const child = spawn(bbdownPath, args, {
stdio: ['inherit', 'pipe', 'pipe']
});
let output = '';
let errorOutput = '';
child.stdout.on('data', (data) => {
const text = data.toString();
output += text;
process.stdout.write(text);
});
child.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
process.stderr.write(text);
});
child.on('close', (code) => {
if (code === 0) {
// Try to parse the real file path from output
const realPath = parseOutputFilePath(output + errorOutput, outputDir);
resolve({
success: true,
output: realPath || output.trim()
});
} else {
resolve({
success: false,
error: translateError(errorOutput.trim() || `BBDown exited with code code`)
});
}
});
child.on('error', (err) => {
resolve({
success: false,
error: translateError(err.message)
});
});
});
}
/**
* Parse video URL to extract BV/AV ID
* @param {string} url - Video URL
* @returns {string|null}
*/
function extractVideoId(url) {
const patterns = [
/\/video\/(BV\w+)/,
/\/video\/(av\d+)/,
/(BV\w+)/,
/(av\d+)/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return match[1];
}
}
return null;
}
module.exports = {
isBBDownInstalled,
getBBDownPath,
info,
download,
extractVideoId,
getDefaultDownloadDir,
parseInfoOutput,
parseOutputFilePath,
translateError
};
FILE:src/utils/history.js
/**
* Download history management
* @module utils/history
*/
const fs = require('fs');
const path = require('path');
/**
* Get history file path
* @returns {string}
*/
function getHistoryFilePath() {
const homeDir = process.env.HOME || process.env.USERPROFILE;
const configDir = path.join(homeDir, '.bilibit');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
return path.join(configDir, 'history.json');
}
/**
* Load history from file
* @returns {Array}
*/
function loadHistory() {
const filePath = getHistoryFilePath();
try {
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, 'utf8');
return JSON.parse(data);
}
} catch (error) {
console.error('Failed to load history:', error.message);
}
return [];
}
/**
* Save history to file
* @param {Array} history - History array
* @returns {boolean}
*/
function saveHistory(history) {
const filePath = getHistoryFilePath();
try {
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf8');
return true;
} catch (error) {
console.error('Failed to save history:', error.message);
return false;
}
}
/**
* Add a download record to history
* @param {Object} record - Download record
* @param {string} record.videoId - Video ID (BV/AV)
* @param {string} record.url - Video URL
* @param {string} record.title - Video title
* @param {string} record.author - Video author
* @param {string} record.downloadPath - Downloaded file path
* @param {string} record.quality - Video quality
* @param {boolean} record.danmaku - Whether danmaku was downloaded
* @returns {boolean}
*/
function addRecord(record) {
const history = loadHistory();
const newRecord = {
...record,
timestamp: Date.now(),
date: new Date().toISOString()
};
// Add to beginning of array
history.unshift(newRecord);
// Keep only last 100 records
if (history.length > 100) {
history.splice(100);
}
return saveHistory(history);
}
/**
* Get recent history
* @param {number} limit - Number of records to return
* @returns {Array}
*/
function getRecent(limit = 10) {
const history = loadHistory();
return history.slice(0, limit);
}
/**
* Search history by keyword
* @param {string} keyword - Search keyword
* @returns {Array}
*/
function searchHistory(keyword) {
const history = loadHistory();
const lowerKeyword = keyword.toLowerCase();
return history.filter(record =>
record.title.toLowerCase().includes(lowerKeyword) ||
record.author.toLowerCase().includes(lowerKeyword) ||
record.videoId.toLowerCase().includes(lowerKeyword)
);
}
/**
* Clear all history
* @returns {boolean}
*/
function clearHistory() {
return saveHistory([]);
}
/**
* Format history record for display
* @param {Object} record - History record
* @returns {string}
*/
function formatRecord(record) {
const date = new Date(record.timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const danmaku = record.danmaku ? '🎬' : '';
const quality = record.quality ? `[record.quality]` : '';
return `date danmaku quality record.title - record.author`;
}
/**
* Print history to console
* @param {number} limit - Number of records to show
*/
function printHistory(limit = 10) {
const history = getRecent(limit);
if (history.length === 0) {
console.log('📭 No download history yet');
return;
}
console.log(`\n📊 Download History (Last Math.min(limit, history.length) records):\n`);
history.forEach((record, index) => {
console.log(`(index + 1).toString().padStart(2). formatRecord(record)`);
});
console.log('');
}
module.exports = {
getHistoryFilePath,
loadHistory,
saveHistory,
addRecord,
getRecent,
searchHistory,
clearHistory,
formatRecord,
printHistory
};