Wiki 图片自动上传系统实施计划
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 构建一个 Node.js CLI 工具,实现 Wiki 图片自动上传 到 File Browser 并更新 Markdown 中的链接
Architecture: 使用 Node.js 创建独立 CLI 工具(位于 .image-upload/ 目录),通过 File Browser API 上传图片,解析 Markdown 文件提取图片引用,替换为远程 URL。采用模块化设计:API 客户端、Markdown 解析器、链接替换器、上传协调器。
Tech Stack: Node.js, Axios, Chalk, Commander, Glob, Inquirer, Ora, Crypto-js
文件结构
wiki-documents/
├── .image-upload/ # 图片上传工具独立目录(新建)
│ ├── package.json # 独立的依赖配置(新建)
│ ├── scripts/
│ │ ├── test-api.js # API 验证脚本(新建)
│ │ └── upload-images.js # 主 CLI 工具(新建)
│ ├── lib/
│ │ ├── api-client.js # File Browser API 封装(新建)
│ │ ├── markdown-parser.js # Markdown 文件解析(新建)
│ │ ├── image-uploader.js # 图片上传逻辑(新建)
│ │ └── link-replacer.js # 链接替换逻辑(新建)
│ ├── test/
│ │ └── fixtures/
│ │ ├── sample.png # 测试图片(新建)
│ │ └── test-article.md # 测试文章(新建)
│ ├── .upload-config.json # 配置文件(新建)
│ └── .upload-cache.json # 上传缓存(新建,git忽略)
└── .gitignore # 修改:添加 .image-upload/ 忽略规则
重要说明:
- 所有开发产出物都在
.image-upload/目录内 - CLI 工具通过相对路径访问父项目的
static/目录 - 不修改主项目的
package.json
Chunk 1: 项目设置和 API 验证
Task 1: 创建 .image-upload 目录和独立 package.json
Files:
-
Create:
.image-upload/package.json -
Step 1: 创建 .image-upload 目录
mkdir -p .image-upload
- Step 2: 创建独立的 package.json
创建 .image-upload/package.json:
{
"name": "wiki-image-upload",
"version": "1.0.0",
"description": "Wiki 图片自动上传工具",
"private": true,
"scripts": {
"test-api": "node scripts/test-api.js",
"upload-images": "node scripts/upload-images.js"
},
"dependencies": {
"axios": "^1.6.0",
"chalk": "^4.1.2",
"commander": "^11.0.0",
"glob": "^10.3.0",
"inquirer": "^8.2.0",
"ora": "^5.4.0",
"crypto-js": "^4.2.0",
"dotenv": "^16.0.0"
}
}
- Step 3: 安装依赖
cd .image-upload && yarn install
Expected: 在 .image-upload/node_modules/ 中安装所有依赖
- Step 4: 提交目录结构
cd .. # 回到项目根目录
git add .image-upload/package.json .image-upload/yarn.lock
git commit -m "feat: 创建独立的图片上传工具目录和配置"
Task 2: 创建配置文件
Files:
-
Create:
.image-upload/.upload-config.json -
Modify:
.gitignore -
Step 1: 创建配置文件
创建 .image-upload/.upload-config.json:
{
"fileBrowser": {
"baseUrl": "https://fsx.camthink.ai",
"username": "harry",
"password": "${FILE_BROWSER_PASSWORD}",
"remoteBasePath": "/wiki/img",
"publicBaseUrl": "https://resources.camthink.ai/wiki/img"
},
"upload": {
"concurrency": 3,
"retryAttempts": 3,
"skipUploaded": true,
"createFolder": true
},
"markdown": {
"fileExtensions": [".md", ".mdx"],
"imageExtensions": [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]
}
}
- Step 2: 更新 .gitignore
在项目根目录的 .gitignore 文件末尾添加:
# 图片上传工具
.image-upload/.upload-cache.json
.image-upload/.upload-config.local.json
.image-upload/.env
.image-upload/test/fixtures/uploaded/
- Step 3: 创建环境变量文件(本地使用,不提交)
创建 .image-upload/.env (不提交到 git):
echo "FILE_BROWSER_PASSWORD=N0ep+\$=WMkz%4vxV" > .image-upload/.env
- Step 4: 提交配置文件
git add .image-upload/.upload-config.json .gitignore
git commit -m "feat: 添加图片上传工具配置文件"
Task 3: 创建测试资源
Files:
-
Create:
.image-upload/test/fixtures/sample.png -
Create:
.image-upload/test/fixtures/test-article.md -
Step 1: 创建测试目录
mkdir -p .image-upload/test/fixtures
- Step 2: 创建测试图片
使用任意 PNG 图片或从现有图片复制:
cp static/img/ne301/application-guide/urban-waste-bin-overflow-monitoring/image1.png .image-upload/test/fixtures/sample.png
如果源文件不存在,创建一个占位图片:
# 创建一个简单的 1x1 像素 PNG
echo "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" | base64 -d > .image-upload/test/fixtures/sample.png
- Step 3: 创建测试文章
创建 .image-upload/test/fixtures/test-article.md:
---
sidebar_label: Test Upload
description: 测试图片上传功能
---
# 测试文章
这是一张测试图片:

这是 JSX 格式的图片:
<img src="/img/test/sample.png" style={{display: "block", margin: "20px auto", maxWidth: "80%"}} />
这是另一张图片:

- Step 4: 提交测试资源
git add .image-upload/test/
git commit -m "test: 添加图片上传测试资源"
Task 4: 创建 API 客户端基础结构
Files:
-
Create:
.image-upload/lib/api-client.js -
Step 1: 创建 lib 目录和 API 客户端框架
创建 .image-upload/lib/api-client.js:
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
/**
* File Browser API 客户端
*/
class FileBrowserAPI {
/**
* @param {Object} config - 配置对象
* @param {string} config.baseUrl - File Browser 服务地址
* @param {string} config.username - 用户名
* @param {string} config.password - 密码
*/
constructor(config) {
this.baseUrl = config.baseUrl;
this.username = config.username;
this.password = config.password;
this.token = null;
this.client = axios.create({
baseURL: config.baseUrl,
timeout: 30000,
});
}
/**
* 登录获取 JWT Token
* @returns {Promise<string>} JWT Token
*/
async login() {
try {
const response = await this.client.post('/api/login', {
username: this.username,
password: this.password,
});
this.token = response.data;
return this.token;
} catch (error) {
throw new Error(`登录失败: ${error.message}`);
}
}
/**
* 获取认证请求头
* @returns {Object} 请求头
*/
getAuthHeaders() {
if (!this.token) {
throw new Error('未登录,请先调用 login() 方法');
}
return {
'X-Auth': this.token,
};
}
}
module.exports = FileBrowserAPI;
- Step 2: 提交 API 客户端框架
git add .image-upload/lib/api-client.js
git commit -m "feat: 添加 File Browser API 客户端基础结构"
Task 5: 实现 API 测试脚本
Files:
-
Create:
.image-upload/scripts/test-api.js -
Step 1: 创建 scripts 目录和 API 测试脚本
创建 .image-upload/scripts/test-api.js:
#!/usr/bin/env node
const FileBrowserAPI = require('../lib/api-client');
const fs = require('fs').promises;
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
// 配置
const config = {
baseUrl: process.env.FILE_BROWSER_URL || 'https://fsx.camthink.ai',
username: process.env.FILE_BROWSER_USERNAME || 'harry',
password: process.env.FILE_BROWSER_PASSWORD,
};
if (!config.password) {
console.error('❌ 错误: 未设置 FILE_BROWSER_PASSWORD 环境变量');
console.error('请创建 .image-upload/.env 文件并设置: FILE_BROWSER_PASSWORD=your_password');
process.exit(1);
}
const api = new FileBrowserAPI(config);
async function testAPI() {
console.log('=== File Browser API 验证测试 ===\n');
try {
// 测试 1: 登录
console.log('1. 测试登录...');
const token = await api.login();
console.log(`✓ 登录成功,Token 长度: ${token.length}\n`);
// 测试 2: 创建测试文件夹
console.log('2. 测试创建文件夹...');
const testFolder = '/wiki/img/test-api';
try {
await api.createFolder(testFolder);
console.log(`✓ 文件夹创建成功: ${testFolder}\n`);
} catch (error) {
if (error.response?.status === 409) {
console.log(`✓ 文件夹已存在: ${testFolder}\n`);
} else {
throw error;
}
}
// 测试 3: 上传文件
console.log('3. 测试上传文件...');
const testImagePath = path.join(__dirname, '../test/fixtures/sample.png');
const remotePath = `${testFolder}/sample.png`;
const imageBuffer = await fs.readFile(testImagePath);
await api.uploadFile(remotePath, imageBuffer);
console.log(`✓ 文件上传成功: ${remotePath}\n`);
// 测试 4: 检查文件存在
console.log('4. 测试检查文件...');
const exists = await api.fileExists(remotePath);
console.log(`✓ 文件存在检查: ${exists}\n`);
// 测试 5: 验证公开访问 URL
console.log('5. 验证公开访问 URL...');
const publicUrl = `https://resources.camthink.ai${remotePath}`;
console.log(`公开 URL: ${publicUrl}`);
console.log('请在浏览器中验证该 URL 是否可访问\n');
console.log('=== 所有测试通过 ===');
} catch (error) {
console.error('❌ 测试失败:', error.message);
if (error.response) {
console.error('响应状态:', error.response.status);
console.error('响应数据:', error.response.data);
}
process.exit(1);
}
}
testAPI();
- Step 2: 运行测试验证
cd .image-upload && yarn test-api
Expected: 输出 "=== 所有测试通过 ==="
- Step 3: 提交测试脚本
cd .. # 回到项目根目录
git add .image-upload/scripts/test-api.js
git commit -m "feat: 添加 File Browser API 测试脚本"
Task 6: 实现 API 客户端的完整功能
Files:
-
Modify:
.image-upload/lib/api-client.js:1-70 -
Step 1: 添加上传文件方法
在 .image-upload/lib/api-client.js 的 getAuthHeaders() 方法后添加:
/**
* 上传文件
* @param {string} remotePath - 远程路径(如 /wiki/img/test/image.png)
* @param {Buffer} fileBuffer - 文件内容
* @param {boolean} override - 是否覆盖已存在的文件
* @returns {Promise<void>}
*/
async uploadFile(remotePath, fileBuffer, override = false) {
try {
const url = `/api/resources${remotePath}${override ? '?override=true' : ''}`;
await this.client.put(url, fileBuffer, {
headers: {
...this.getAuthHeaders(),
'Content-Type': 'application/octet-stream',
},
});
} catch (error) {
throw new Error(`上传文件失败 (${remotePath}): ${error.message}`);
}
}
- Step 2: 添加创建文件夹方法
继续添加:
/**
* 创建文件夹
* @param {string} folderPath - 文件夹路径
* @returns {Promise<void>}
*/
async createFolder(folderPath) {
try {
await this.client.post(
`/api/resources${folderPath}`,
{},
{
headers: this.getAuthHeaders(),
}
);
} catch (error) {
if (error.response?.status === 409) {
// 文件夹已存在,不算错误
return;
}
throw new Error(`创建文件夹失败 (${folderPath}): ${error.message}`);
}
}
- Step 3: 添加检查文件存在方法
继续添加:
/**
* 检查文件或文件夹是否存在
* @param {string} remotePath - 远程路径
* @returns {Promise<boolean>}
*/
async fileExists(remotePath) {
try {
await this.client.head(`/api/resources${remotePath}`, {
headers: this.getAuthHeaders(),
});
return true;
} catch (error) {
if (error.response?.status === 404) {
return false;
}
throw new Error(`检查文件存在失败 (${remotePath}): ${error.message}`);
}
}
- Step 4: 添加列出文件夹内容方法
继续添加:
/**
* 列出文件夹内容
* @param {string} folderPath - 文件夹路径
* @returns {Promise<Array>} 文件列表
*/
async listFolder(folderPath) {
try {
const response = await this.client.get(`/api/resources${folderPath}`, {
headers: this.getAuthHeaders(),
});
return response.data;
} catch (error) {
throw new Error(`列出文件夹内容失败 (${folderPath}): ${error.message}`);
}
}
- Step 5: 提交 API 客户端完整实现
git add .image-upload/lib/api-client.js
git commit -m "feat: 完成 File Browser API 客户端所有方法"
Task 7: 运行 API 验证测试
Files:
-
None
-
Step 1: 确保 .env 文件存在
检查 .image-upload/.env 文件:
cat .image-upload/.env
Expected: 显示 FILE_BROWSER_PASSWORD=...
- Step 2: 运行测试脚本
cd .image-upload && yarn test-api
Expected: 所有测试通 过,输出类似:
=== File Browser API 验证测试 ===
1. 测试登录...
✓ 登录成功,Token 长度: XXX
2. 测试创建文件夹...
✓ 文件夹创建成功: /wiki/img/test-api
3. 测试上传文件...
✓ 文件上传成功: /wiki/img/test-api/sample.png
4. 测试检查文件...
✓ 文件存在检查: true
5. 验证公开访问 URL...
公开 URL: https://resources.camthink.ai/wiki/img/test-api/sample.png
请在浏览器中验证该 URL 是否可访问
=== 所有测试通过 ===
- Step 3: 手动验证公开访问 URL
在浏览器中打开:https://resources.camthink.ai/wiki/img/test-api/sample.png
Expected: 图片正常显示
- Step 4: 如果测试失败
检查以下内容:
- File Browser 服务是否在线
- 用户名密码是否正确
- 网络连接是否正常
- API 端点是否正确
Chunk 2: 核心库实现
Task 8: 实现 Markdown 解析器
Files:
-
Create:
.image-upload/lib/markdown-parser.js -
Step 1: 创建 Markdown 解析器
创建 .image-upload/lib/markdown-parser.js:
/**
* Markdown 文件解析器
* 用于提取 Markdown 文件中的图片引用
*/
class MarkdownParser {
/**
* 从 Markdown 内容中提取所有图片引用
* @param {string} content - Markdown 内容
* @returns {Array<{path: string, alt: string, format: string, fullMatch: string}>}
*/
extractImages(content) {
const images = [];
// 匹配标准 Markdown 格式: 
const mdRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match;
while ((match = mdRegex.exec(content)) !== null) {
const [fullMatch, alt, path] = match;
if (path.startsWith('/img/')) {
images.push({
path,
alt: alt || '',
format: 'markdown',
fullMatch,
});
}
}
// 匹配 JSX 格式: <img src="..." /> 或 <img src={...} />
const jsxRegex = /<img[^>]+src=\{?["']([^"']+)["']\}?[^>]*\/?>/g;
while ((match = jsxRegex.exec(content)) !== null) {
const [fullMatch, src] = match;
if (src.startsWith('/img/')) {
images.push({
path: src,
alt: '',
format: 'jsx',
fullMatch,
});
}
}
// 去重(同一图片可能被引用多次)
const uniqueImages = [];
const seen = new Set();
for (const img of images) {
if (!seen.has(img.path)) {
seen.add(img.path);
uniqueImages.push(img);
}
}
return uniqueImages;
}
/**
* 从文件路径读取并提取图片
* @param {string} filePath - Markdown 文件路径
* @returns {Promise<{content: string, images: Array}>}
*/
async parseFile(filePath) {
const fs = require('fs').promises;
const content = await fs.readFile(filePath, 'utf-8');
const images = this.extractImages(content);
return { content, images };
}
}
module.exports = MarkdownParser;
- Step 2: 提交 Markdown 解析器
git add .image-upload/lib/markdown-parser.js
git commit -m "feat: 添加 Markdown 图片引用解析器"
Task 9: 实现链接替换器
Files:
-
Create:
.image-upload/lib/link-replacer.js -
Step 1: 创建链接替换器
创建 .image-upload/lib/link-replacer.js:
/**
* Markdown 链接替换器
* 用于将本地图片链接替换为远程 URL
*/
class LinkReplacer {
/**
* 替换 Markdown 中的图片链接
* @param {string} content - Markdown 内容
* @param {Object} mapping - 路径映射 {"/img/old.png": "https://...new.png"}
* @returns {string} 替换后的内容
*/
replaceLinks(content, mapping) {
let result = content;
for (const [oldPath, newUrl] of Object.entries(mapping)) {
// 替换标准 Markdown 格式
const mdRegex = new RegExp(
`!\\[([^\\]]*)\\]\\(${this.escapeRegex(oldPath)}\\)`,
'g'
);
result = result.replace(mdRegex, ``);
// 替换 JSX 格式(保持其他属性不变)
const jsxRegex = new RegExp(
`(<img[^>]+src=\\{?["'])${this.escapeRegex(oldPath)}(["'][^>]*\\/?>)`,
'g'
);
result = result.replace(jsxRegex, `$1${newUrl}$2`);
}
return result;
}
/**
* 转义正则表达式特殊字符
* @param {string} string - 要转义的字符串
* @returns {string} 转义后的字符串
*/
escapeRegex(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}
module.exports = LinkReplacer;
- Step 2: 提交链接替换器
git add .image-upload/lib/link-replacer.js
git commit -m "feat: 添加 Markdown 图片链接替换器"
Task 10: 实现图片上传协调器
Files:
-
Create:
.image-upload/lib/image-uploader.js -
Step 1: 创建图片上传协调器
创建 .image-upload/lib/image-uploader.js:
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const FileBrowserAPI = require('./api-client');
/**
* 图片上传协调器
* 负责协调 API 客户端、缓存和用户交互
*/
class ImageUploader {
/**
* @param {Object} config - 配置对象
* @param {FileBrowserAPI} config.api - File Browser API 客户端
* @param {Object} config.uploadConfig - 上传配置
*/
constructor(config) {
this.api = config.api;
this.uploadConfig = config.uploadConfig;
this.cache = { images: {} };
this.stats = {
uploaded: [],
skipped: [],
failed: [],
};
}
/**
* 加载上传缓存
* @param {string} cachePath - 缓存文件路径
*/
async loadCache(cachePath) {
try {
const cacheData = await fs.readFile(cachePath, 'utf-8');
this.cache = JSON.parse(cacheData);
} catch (error) {
// 缓存文件不存在,使用空缓存
this.cache = { images: {} };
}
}
/**
* 保存上传缓存
* @param {string} cachePath - 缓存文件路径
*/
async saveCache(cachePath) {
await fs.writeFile(cachePath, JSON.stringify(this.cache, null, 2), 'utf-8');
}
/**
* 计算文件 SHA256 hash
* @param {string} filePath - 文件路径
* @returns {Promise<string>} hash 字符串
*/
async calculateHash(filePath) {
const content = await fs.readFile(filePath);
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* 检查图片是否已上传
* @param {string} localPath - 本地路径
* @param {string} hash - 文件 hash
* @returns {boolean}
*/
isAlreadyUploaded(localPath, hash) {
const cached = this.cache.images[localPath];
return cached && cached.localHash === hash;
}
/**
* 上传单个图片
* @param {string} localPath - 本地路径(如 /img/ne301/app/image.png)
* @param {string} staticDir - static 目录绝对路径
* @param {boolean} force - 是否强制重新上传
* @returns {Promise<{success: boolean, url?: string, error?: string}>}
*/
async uploadImage(localPath, staticDir, force = false) {
try {
// 构建本地文件完整路径
const absoluteLocalPath = path.join(staticDir, localPath);
// 检查文件是否存在
try {
await fs.access(absoluteLocalPath);
} catch {
return {
success: false,
error: 'FILE_NOT_FOUND',
message: `文件不存在: ${absoluteLocalPath}`,
};
}
// 计算文件 hash
const hash = await this.calculateHash(absoluteLocalPath);
// 检查缓存
if (!force && this.isAlreadyUploaded(localPath, hash)) {
this.stats.skipped.push({
path: localPath,
reason: 'already_uploaded',
url: this.cache.images[localPath].remoteUrl,
});
return {
success: true,
url: this.cache.images[localPath].remoteUrl,
cached: true,
};
}
// 构建远程路径
const remotePath = localPath.replace('/img/', '/wiki/img/');
const publicUrl = `https://resources.camthink.ai${remotePath}`;
// 读取文件内容
const fileBuffer = await fs.readFile(absoluteLocalPath);
// 检查远程文件是否存在
const exists = await this.api.fileExists(remotePath);
if (exists && !force) {
return {
success: false,
error: 'FILE_EXISTS',
message: `远程文件已存在: ${remotePath}`,
needsConfirmation: true,
remotePath,
};
}
// 确保远程文件夹存在
const remoteDir = path.dirname(remotePath);
await this.api.createFolder(remoteDir);
// 上传文件
await this.api.uploadFile(remotePath, fileBuffer, force);
// 更新缓存
this.cache.images[localPath] = {
localHash: hash,
remoteUrl: publicUrl,
remotePath,
uploadedAt: new Date().toISOString(),
};
this.stats.uploaded.push({
path: localPath,
url: publicUrl,
});
return {
success: true,
url: publicUrl,
};
} catch (error) {
this.stats.failed.push({
path: localPath,
error: error.message,
});
return {
success: false,
error: 'UPLOAD_FAILED',
message: error.message,
};
}
}
/**
* 获取统计信息
* @returns {Object}
*/
getStats() {
return this.stats;
}
/**
* 重置统计信息
*/
resetStats() {
this.stats = {
uploaded: [],
skipped: [],
failed: [],
};
}
}
module.exports = ImageUploader;
- Step 2: 提交图片上传协调器
git add .image-upload/lib/image-uploader.js
git commit -m "feat: 添加图片上传协调器"
Chunk 2 完成检查点
✅ 已完成的任务:
- Task 8: 实现 Markdown 解析器
- Task 9: 实现链接替换器
- Task 10: 实现图片上传协调器
关键成果:
- 核心库全部实现完成
- 模块化设计,职责清晰