hexo-deployer-tencent
Version:
A Hexo deployer plugin for uploading files to Tencent COS and refreshing CDN/EdgeOne. | 一个用于将文件上传到腾讯 COS 和刷新 CDN/EdgeOne 的 Hexo 部署器插件。
489 lines (443 loc) • 16.7 kB
JavaScript
const COS = require('cos-nodejs-sdk-v5');
const { CommonClient } = require('tencentcloud-sdk-nodejs-common');
const path = require('path');
const fs = require('fs');
const fsPromises = require('fs').promises;
const crypto = require('crypto');
let _pLimit;
const getPLimit = async () => {
if (!_pLimit) _pLimit = (await import('p-limit')).default;
return _pLimit;
};
const RETRY_OPTIONS = { retries: 3, factor: 2, minTimeout: 1000 };
const CDN_PURGE_BATCH_SIZE = 1000;
const COS_DELETE_BATCH_SIZE = 1000;
const EDGEONE_PURGE_BATCH_SIZE = 500;
const MIN_CONCURRENCY = 1;
const MAX_CONCURRENCY = 50;
/**
* 日志辅助函数
* @param {Object} config - 配置对象
* @param {string} prefix - 日志前缀
* @param {string} message - 日志消息
*/
const log = (config, prefix, message) => {
if (config.enableLog) {
console.info(`[${prefix}] [${new Date().toISOString()}] ${message}`);
}
};
/**
* 错误日志
* @param {string} prefix - 日志前缀
* @param {string} message - 日志消息
* @param {...any} args - 附加参数
*/
const errorLog = (prefix, message, ...args) => {
console.error(`[${prefix}] [${new Date().toISOString()}] ${message}`, ...args);
};
/**
* 异步重试工具函数
* @param {Function} fn - 返回 Promise 的函数
* @param {Object} opts - retry 库选项
* @returns {Promise<any>}
*/
const withRetry = async (fn, opts = RETRY_OPTIONS) => {
const { retries = 3, factor = 2, minTimeout = 1000 } = opts;
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < retries) {
const delay = minTimeout * Math.pow(factor, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
};
/**
* 校验 Hexo 部署配置
* @param {Object} hexo - Hexo 实例
* @returns {Object|null} 配置对象,校验失败返回 null
*/
const validateConfig = (hexo) => {
const config = hexo.config.deploy;
if (!config) {
errorLog('配置', '未找到 deploy 配置,请在 _config.yml 中配置');
return null;
}
const domainRegex = /^https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const required = ['secret_id', 'secret_key', 'bucket', 'region', 'upload_dir'];
const errors = [];
required.forEach(item => {
if (!config[item]) errors.push(`缺少配置项: ${item}`);
else if (config[item] === `your_${item}`) errors.push(`未正确配置: ${item}`);
});
const cache_type = config.cache_type || 'cdn';
if (!['edgeone', 'cdn'].includes(cache_type)) {
errors.push(`不支持的缓存类型 "${cache_type}",请使用 "edgeone" 或 "cdn"`);
}
const cdnDomains = config.cdn_domains || [];
cdnDomains.forEach((item, index) => {
if (!item.domain) {
errors.push(`cdn_domains[${index}] 缺少 domain 字段`);
} else if (!domainRegex.test(item.domain)) {
errors.push(`cdn_domains[${index}] 的 domain 格式不合法: ${item.domain}`);
}
if (item.ignore_paths && !Array.isArray(item.ignore_paths)) {
errors.push(`cdn_domains[${index}] 的 ignore_paths 必须为数组`);
}
if (item.ignore_extensions) {
if (!Array.isArray(item.ignore_extensions)) {
errors.push(`cdn_domains[${index}] 的 ignore_extensions 必须为数组`);
} else {
item.ignore_extensions.forEach(ext => {
if (typeof ext !== 'string') {
errors.push(`cdn_domains[${index}] 的 ignore_extensions 中 "${ext}" 必须为字符串`);
} else if (!ext.startsWith('.')) {
errors.push(`cdn_domains[${index}] 的 ignore_extensions 中 "${ext}" 必须以 "." 开头`);
}
});
}
}
});
if (errors.length) {
errorLog('配置', '校验失败:');
errors.forEach(error => console.error(` - ${error}`));
return null;
}
return {
secretId: config.secret_id,
secretKey: config.secret_key,
bucket: config.bucket,
region: config.region,
uploadDir: path.join(hexo.base_dir, config.upload_dir),
cache_type: cache_type,
cdnDomains: cdnDomains.map(item => ({
domain: item.domain,
ignorePaths: (item.ignore_paths || []).filter(p => p).map(segment => {
let normalized = segment.startsWith('/') ? segment.slice(1) : segment;
return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized;
}),
ignoreExtensions: (item.ignore_extensions || []).map(ext => ext.toLowerCase())
})),
removeRemoteFiles: config.remove_remote_files || false,
refreshIndexPage: config.refresh_index_page || false,
concurrency: Math.max(MIN_CONCURRENCY, Math.min(config.concurrency ?? 10, MAX_CONCURRENCY)),
enableLog: config.enable_log || false
};
};
/**
* 初始化 COS、CDN 和 EdgeOne 客户端
* @param {Object} config - 配置对象
* @returns {Object} 客户端对象
*/
const initClients = (config) => {
const clientConfig = { credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '' };
return {
cos: new COS({ SecretId: config.secretId, SecretKey: config.secretKey }),
cdn: new CommonClient('cdn.tencentcloudapi.com', '2018-06-06', clientConfig),
edgeone: new CommonClient('teo.tencentcloudapi.com', '2022-09-01', clientConfig)
};
};
/**
* 流式计算文件 MD5 值
* @param {string} filePath - 本地文件路径
* @returns {Promise<string>} MD5 值
*/
const calculateMD5 = (filePath) => {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
};
/**
* 递归获取目录下所有文件
* @param {string} dir - 目录路径
* @returns {Promise<string[]>} 文件路径数组
*/
const getFiles = async (dir) => {
const subdirs = await fsPromises.readdir(dir);
const files = await Promise.all(subdirs.map(async (subdir) => {
const res = path.resolve(dir, subdir);
return (await fsPromises.stat(res)).isDirectory() ? getFiles(res) : res;
}));
return files.flat();
};
/**
* 获取 COS 存储桶中的文件列表
* @param {COS} cos - COS 客户端
* @param {Object} config - 配置对象
* @returns {Promise<string[]>} 文件 Key 数组
*/
const listCosFiles = async (cos, config) => {
let contents = [];
let continuationToken;
do {
const data = await new Promise((resolve, reject) => {
cos.getBucket({ Bucket: config.bucket, Region: config.region, ContinuationToken: continuationToken }, (err, data) => {
err ? reject(err) : resolve(data);
});
});
if (data.Contents) contents = contents.concat(data.Contents);
continuationToken = data.NextContinuationToken;
} while (continuationToken);
return contents.filter(item => !item.Key.endsWith('/')).map(item => item.Key);
};
/**
* 上传文件到 COS,支持重试
* @param {COS} cos - COS 客户端
* @param {Object} config - 配置对象
* @param {string} key - 文件 Key
* @param {string} filePath - 本地文件路径
* @returns {Promise<void>}
*/
const uploadFile = async (cos, config, key, filePath) => {
await withRetry(async () => {
await cos.putObject({
Bucket: config.bucket,
Region: config.region,
Key: key,
Body: fs.createReadStream(filePath)
});
log(config, '上传', `成功: ${key}`);
});
};
/**
* 删除 COS 中的文件(分批 + 重试)
* @param {COS} cos - COS 客户端
* @param {Object} config - 配置对象
* @param {string[]} keys - 要删除的文件 Key 数组
* @returns {Promise<void>}
*/
const deleteCosFiles = async (cos, config, keys) => {
if (!keys.length) return;
for (let i = 0; i < keys.length; i += COS_DELETE_BATCH_SIZE) {
const batch = keys.slice(i, i + COS_DELETE_BATCH_SIZE);
await withRetry(async () => {
await cos.deleteMultipleObject({
Bucket: config.bucket,
Region: config.region,
Objects: batch.map(key => ({ Key: key }))
});
log(config, '删除', `完成 ${batch.length} 个远程文件`);
});
}
};
/**
* 刷新 CDN 缓存(分批 + 重试)
* @param {CommonClient} cdn - CDN 客户端
* @param {Object} config - 配置对象
* @param {string[]} urls - URL 数组
* @returns {Promise<void>}
*/
const purgeCdnCache = async (cdn, config, urls) => {
for (let i = 0; i < urls.length; i += CDN_PURGE_BATCH_SIZE) {
const batchUrls = urls.slice(i, i + CDN_PURGE_BATCH_SIZE);
await withRetry(async () => {
await cdn.request('PurgeUrlsCache', { Urls: batchUrls });
log(config, 'CDN刷新', `提交 ${batchUrls.length} 个 URL`);
});
}
};
/**
* 刷新 EdgeOne 缓存(支持多域名独立处理)
* @param {CommonClient} edgeone - EdgeOne 客户端
* @param {Object} config - 配置对象
* @param {string[]} urls - URL 数组
* @returns {Promise<number>} 成功刷新的 URL 数量
*/
const purgeEdgeOneCache = async (edgeone, config, urls) => {
const invalidUrls = urls.filter(u => {
try { new URL(u); return false; } catch { return true; }
});
if (invalidUrls.length) {
errorLog('EdgeOne', `无效的 URL: ${invalidUrls.join(', ')}`);
return 0;
}
// 提取唯一主域名
const mainDomains = new Set(urls.map(u => {
const hostname = new URL(u).hostname;
return hostname.split('.').slice(-2).join('.');
}));
// 只调用一次 DescribeZones,缓存结果供所有域名使用
let allZones;
try {
const response = await edgeone.request("DescribeZones", {});
allZones = response?.Zones || [];
} catch (error) {
errorLog('EdgeOne', '获取 Zone 列表失败:', error);
return 0;
}
let refreshedCount = 0;
for (const mainDomain of mainDomains) {
// 查找 ZoneId:优先精确匹配,其次后缀匹配
const zone = allZones.find(z => z.ZoneName === mainDomain)
|| allZones.find(z => z.ZoneName.endsWith(`.${mainDomain}`));
const zoneId = zone?.ZoneId;
if (!zoneId) {
errorLog('EdgeOne', `未找到 ${mainDomain} 对应的 ZoneId,跳过该域名`);
continue;
}
// 获取配额
let quota;
try {
const response = await edgeone.request("DescribeContentQuota", { ZoneId: zoneId });
const purgeUrlQuota = (response?.PurgeQuota || []).find(q => q.Type === 'purge_url');
quota = {
batchLimit: purgeUrlQuota?.Batch || EDGEONE_PURGE_BATCH_SIZE,
dailyLimit: purgeUrlQuota?.Daily || 1000,
dailyAvailable: purgeUrlQuota?.DailyAvailable || 0
};
} catch (error) {
errorLog('EdgeOne', `ZoneId ${zoneId} 获取配额失败,跳过:`, error);
continue;
}
log(config, 'EdgeOne', `Zone ${zoneId} 配额: 单次 ${quota.batchLimit} / 每日 ${quota.dailyLimit} / 剩余 ${quota.dailyAvailable}`);
const isFreePlan = quota.batchLimit === EDGEONE_PURGE_BATCH_SIZE;
const batchSize = isFreePlan ? EDGEONE_PURGE_BATCH_SIZE : CDN_PURGE_BATCH_SIZE;
const domainUrls = urls.filter(u => new URL(u).hostname.endsWith(mainDomain));
// 创建清除任务
let tasks;
if (isFreePlan && domainUrls.length > quota.dailyAvailable) {
// 免费版配额不足,回退到主机名级刷新
const hostnames = new Set(domainUrls.map(u => new URL(u).hostname));
tasks = Array.from(hostnames).map(hostname => ({
type: 'purge_host',
targets: [hostname],
method: 'invalidate'
}));
log(config, 'EdgeOne', `Zone ${zoneId} 策略: 主机名级刷新(免费版配额不足,${domainUrls.length} URL > ${quota.dailyAvailable} 剩余)`);
} else {
tasks = Array.from({ length: Math.ceil(domainUrls.length / batchSize) }, (_, i) => ({
type: 'purge_url',
targets: domainUrls.slice(i * batchSize, (i + 1) * batchSize),
method: 'delete'
}));
log(config, 'EdgeOne', `Zone ${zoneId} 策略: URL级刷新,${tasks.length} 个任务`);
}
// 执行清除任务
for (const task of tasks) {
try {
await withRetry(async () => {
await edgeone.request("CreatePurgeTask", {
ZoneId: zoneId,
Type: task.type,
Targets: task.targets,
Method: task.method || 'delete'
});
log(config, 'EdgeOne', `缓存清除成功: Type=${task.type}, 数量=${task.targets.length}`);
});
refreshedCount += task.targets.length;
} catch (error) {
errorLog('EdgeOne', `缓存清除失败(ZoneId: ${zoneId}):`, error);
}
}
}
return refreshedCount;
};
/**
* 构建缓存刷新 URL 列表
* @param {string[]} changedFiles - 变更的文件 Key 数组
* @param {Object} config - 配置对象
* @returns {string[]} URL 数组
*/
const buildPurgeUrls = (changedFiles, config) => {
const urls = [];
config.cdnDomains.forEach(({ domain, ignorePaths, ignoreExtensions }) => {
changedFiles.forEach(file => {
if (ignorePaths.some(p => p && (file.startsWith(p + '/') || file === p))) return;
if (ignoreExtensions.includes(path.extname(file).toLowerCase())) return;
let urlFile = file === 'index.html' ? '/index.html' : file;
const urlPath = config.refreshIndexPage && urlFile.endsWith('/index.html')
? urlFile.replace(/\/index\.html$/, '/')
: urlFile;
urls.push(new URL(urlPath, domain).toString());
});
});
return urls;
};
/**
* 主逻辑:上传文件、同步远程文件、刷新缓存
* @param {Object} config - 配置对象
* @param {Object} clients - 客户端对象
* @returns {Promise<void>}
*/
const main = async (config, clients) => {
const { cos, cdn, edgeone } = clients;
const pLimit = await getPLimit();
const limit = pLimit(config.concurrency);
const localFiles = await getFiles(config.uploadDir);
const localFileKeys = new Set(localFiles.map(filePath => path.relative(config.uploadDir, filePath).replace(/\\/g, '/')));
// 获取远程文件
const remoteFiles = config.removeRemoteFiles ? await listCosFiles(cos, config) : [];
// 上传变更文件(收集结果而非 push)
const results = await Promise.all(localFiles.map(filePath => limit(async () => {
const key = path.relative(config.uploadDir, filePath).replace(/\\/g, '/');
if (key.startsWith('..') || path.isAbsolute(key)) {
errorLog('上传', `跳过越界路径: ${filePath}`);
return null;
}
const localMD5 = await calculateMD5(filePath);
if (!localMD5) return null;
try {
const data = await cos.headObject({ Bucket: config.bucket, Region: config.region, Key: key });
if (data.ETag?.replace(/"/g, '') === localMD5) return null;
} catch (error) {
if (error.statusCode !== 404) throw error;
}
await uploadFile(cos, config, key, filePath);
return key;
})));
const changedFiles = results.filter(Boolean);
// 删除多余远程文件
let deletedCount = 0;
if (config.removeRemoteFiles) {
const filesToDelete = remoteFiles.filter(key => !localFileKeys.has(key));
deletedCount = filesToDelete.length;
log(config, '同步', `远程 ${remoteFiles.length} 个文件,本地 ${localFileKeys.size} 个文件,待删除 ${deletedCount} 个`);
if (config.enableLog && deletedCount > 0) {
filesToDelete.forEach(key => log(config, '删除', key));
}
await deleteCosFiles(cos, config, filesToDelete);
}
// 刷新缓存
let refreshedCount = 0;
if (config.cdnDomains.length && changedFiles.length) {
const urls = buildPurgeUrls(changedFiles, config);
if (config.cache_type === 'cdn') {
await purgeCdnCache(cdn, config, urls);
refreshedCount = urls.length;
} else if (config.cache_type === 'edgeone') {
refreshedCount = await purgeEdgeOneCache(edgeone, config, urls);
}
}
log(config, '部署', `上传: ${changedFiles.length} 个文件 | 删除: ${deletedCount} 个文件 | 刷新: ${refreshedCount} 个 URL`);
log(config, '部署', '完成!');
};
/**
* Hexo 部署入口
* @param {Object} hexo - Hexo 实例
*/
module.exports = async function () {
const hexo = this;
const config = validateConfig(hexo);
if (!config) return;
const clients = initClients(config);
try {
await main(config, clients);
} catch (error) {
errorLog('部署', '失败:', error);
}
};
// 导出内部函数供测试使用
module.exports._validateConfig = validateConfig;
module.exports._calculateMD5 = calculateMD5;
module.exports._getFiles = getFiles;
module.exports._withRetry = withRetry;
module.exports._buildPurgeUrls = buildPurgeUrls;