feat: 初始化随机图片服务项目

添加核心功能模块包括:
- 服务入口文件 app.js
- 图片服务、缓存管理、页数检测等核心服务
- 日志工具和定时任务调度器
- 项目配置文件和文档
This commit is contained in:
2025-08-08 03:18:21 +08:00
commit 7d30c12712
11 changed files with 6770 additions and 0 deletions

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# 服务端口
PORT=3000
# 缓存配置
CACHE_DIR=./cache
MAX_CACHE_SIZE_MB=1024
# API 配置
SOMEACG_API_BASE=https://someacg.vercel.app/api
SOMEACG_CDN_BASE=https://cdn.someacg.top/graph/origin
# 页数检测配置
MAX_PAGE_DETECTION_TIMEOUT=30000
MAX_RETRY_ATTEMPTS=3
# 日志配置
LOG_LEVEL=info
LOG_FILE=./logs/app.log

144
.gitignore vendored Normal file
View File

@@ -0,0 +1,144 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Cache directories
cache/
*.cache
# Logs
logs/
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

176
README.md Normal file
View File

@@ -0,0 +1,176 @@
# SomeACG 随机图片服务
基于 someacg 提供的图片列表 API开发的高性能随机图片服务。支持自动页数检测、智能缓存管理和定时更新功能。
## 功能特性
- 🚀 **高性能缓存**: 预加载全量图片数据100% 缓存命中率
- 🔄 **自动更新**: 每日凌晨自动检测最大页数并更新缓存
- 📱 **多端支持**: 支持桌面端和移动端不同尺寸的图片
- 🛡️ **错误处理**: 完善的重试机制和降级策略
- 📊 **状态监控**: 提供详细的服务状态和缓存统计信息
- 🗂️ **智能管理**: 自动清理过期缓存,支持配置缓存大小限制
## 快速开始
### 安装依赖
```bash
npm install
```
### 配置环境变量
复制 `.env.example``.env` 并根据需要修改配置:
```bash
cp .env.example .env
```
主要配置项:
```env
# 服务端口
PORT=3000
# 缓存配置
CACHE_DIR=./cache
MAX_CACHE_SIZE_MB=1024
# API 配置
SOMEACG_API_BASE=https://someacg.vercel.app/api
SOMEACG_CDN_BASE=https://cdn.someacg.top/graph/origin
```
### 启动服务
```bash
# 生产环境
npm start
# 开发环境
npm run dev
```
## API 接口
### 获取随机图片
```http
GET /api/random
```
**查询参数:**
| 参数 | 类型 | 说明 | 可选值 |
|------|------|------|--------|
| size | string/number | 图片尺寸类型 | `1`/`mobile`(移动端), `2`/`desktop`(桌面端),不传则随机 |
| type | string | 返回类型 | `url`(默认,返回图片信息), `image`(直接返回图片数据) |
**响应示例:**
```json
{
"_id": "68889a7645c10a92ad0e8e94",
"quality": true,
"title": "「綺麗。」",
"desc": "",
"file_name": "132641568_p0.jpg",
"size": {
"width": 3024,
"height": 4536,
"_id": "6894f7e091dd1a5cfedcd845"
},
"url": "https://cdn.someacg.top/graph/origin/132641568_p0.jpg",
"cached": true
}
```
### 服务状态
```http
GET /api/status
```
返回服务运行状态、内存使用情况、缓存统计等信息。
### 首页
```http
GET /
```
返回服务基本信息。
## 核心功能
### 页数检测
- 启动时自动检测 someacg API 的最大页数
- 使用二分法高效探测页数范围
- 每日凌晨 2:00 自动更新页数范围
- 支持桌面端和移动端分别检测
### 缓存策略
1. **预加载**: 启动时和每日更新后,全量拉取所有页面的图片列表
2. **随机排序**: 将获取的图片列表随机打乱,确保随机性
3. **本地缓存**: 按顺序下载并缓存图片到本地存储
4. **智能清理**: 达到缓存大小限制时,自动清理最早访问的图片
5. **100% 命中**: 所有用户请求都从本地缓存获取,无需访问外部 CDN
### 错误处理
- **网络重试**: API 请求失败时自动重试 3 次
- **降级策略**: 页数检测失败时使用前一天的缓存数据
- **跳过机制**: 缓存中图片丢失时自动跳到下一张
- **限流处理**: 遇到 429 错误时自动延长等待时间
### 定时任务
- **每日更新**: 凌晨 2:00 自动检测页数并重新预加载缓存
- **健康检查**: 每小时检查服务状态和内存使用情况
- **日志清理**: 每 6 小时执行一次日志清理和垃圾回收
## 项目结构
```
src/
├── app.js # 主应用入口
├── services/
│ ├── CacheManager.js # 缓存管理服务
│ ├── PageDetector.js # 页数检测服务
│ ├── ImageService.js # 图片服务(核心业务逻辑)
│ └── scheduler.js # 定时任务调度器
└── utils/
└── logger.js # 日志工具
```
## 配置说明
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| PORT | 3000 | 服务端口 |
| CACHE_DIR | ./cache | 缓存目录 |
| MAX_CACHE_SIZE_MB | 1024 | 最大缓存大小MB |
| MAX_PAGE_DETECTION_TIMEOUT | 30000 | 页数检测超时时间(毫秒) |
| MAX_RETRY_ATTEMPTS | 3 | 最大重试次数 |
| LOG_LEVEL | info | 日志级别 |
## 性能优化
- 使用本地缓存避免频繁访问外部 CDN
- 批量并发下载图片,提高预加载效率
- 智能缓存清理策略,避免磁盘空间不足
- 内存使用监控和垃圾回收机制
## 注意事项
1. 首次启动需要较长时间进行预加载,请耐心等待
2. 确保有足够的磁盘空间用于图片缓存
3. someacg CDN 对单 IP 有并发限制,预加载过程中可能遇到 429 错误
4. 建议在服务器环境中运行,以获得更好的网络稳定性
## 许可证
MIT License

5155
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "someacg-randompic",
"version": "1.0.0",
"description": "基于 someacg 提供的图片列表 API 的高性能随机图片服务",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest"
},
"keywords": ["someacg", "random", "image", "api"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"axios": "^1.6.0",
"node-cron": "^3.0.3",
"fs-extra": "^11.1.1",
"winston": "^3.11.0",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.7.0"
}
}

119
src/app.js Normal file
View File

@@ -0,0 +1,119 @@
const express = require('express');
const path = require('path');
require('dotenv').config();
const logger = require('./utils/logger');
const CacheManager = require('./services/CacheManager');
const PageDetector = require('./services/PageDetector');
const ImageService = require('./services/ImageService');
const scheduler = require('./services/scheduler');
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件
app.use(express.json());
app.use(express.static('public'));
// 全局服务实例
let cacheManager;
let pageDetector;
let imageService;
// 初始化服务
async function initializeServices() {
try {
logger.info('正在初始化服务...');
cacheManager = new CacheManager();
pageDetector = new PageDetector();
imageService = new ImageService(cacheManager, pageDetector);
// 初始化缓存目录
await cacheManager.init();
// 检测页数范围
await pageDetector.detectMaxPages();
// 预加载缓存
await imageService.preloadCache();
// 启动定时任务
scheduler.start(pageDetector, imageService);
logger.info('服务初始化完成');
} catch (error) {
logger.error('服务初始化失败:', error);
process.exit(1);
}
}
// 路由
app.get('/', (req, res) => {
res.json({
message: 'SomeACG 随机图片服务',
version: '1.0.0',
status: 'running'
});
});
// 随机图片接口
app.get('/api/random', async (req, res) => {
try {
const { size, type = 'url' } = req.query;
const result = await imageService.getRandomImage(size, type);
if (type === 'image') {
// 直接返回图片
res.set('Content-Type', 'image/jpeg');
res.send(result.buffer);
} else {
// 返回图片信息
res.json(result);
}
} catch (error) {
logger.error('获取随机图片失败:', error);
res.status(500).json({ error: '服务暂时不可用' });
}
});
// 状态接口
app.get('/api/status', (req, res) => {
const status = {
uptime: process.uptime(),
memory: process.memoryUsage(),
maxPages: pageDetector ? pageDetector.getMaxPages() : null,
cacheStats: cacheManager ? cacheManager.getStats() : null
};
res.json(status);
});
// 错误处理中间件
app.use((err, req, res, next) => {
logger.error('未处理的错误:', err);
res.status(500).json({ error: '内部服务器错误' });
});
// 404 处理
app.use((req, res) => {
res.status(404).json({ error: '接口不存在' });
});
// 启动服务器
app.listen(PORT, async () => {
logger.info(`服务器启动在端口 ${PORT}`);
await initializeServices();
});
// 优雅关闭
process.on('SIGTERM', () => {
logger.info('收到 SIGTERM 信号,正在关闭服务器...');
process.exit(0);
});
process.on('SIGINT', () => {
logger.info('收到 SIGINT 信号,正在关闭服务器...');
process.exit(0);
});
module.exports = app;

View File

@@ -0,0 +1,261 @@
const fs = require('fs-extra');
const path = require('path');
const crypto = require('crypto');
const logger = require('../utils/logger');
class CacheManager {
constructor() {
this.cacheDir = process.env.CACHE_DIR || './cache';
this.maxCacheSizeMB = parseInt(process.env.MAX_CACHE_SIZE_MB) || 1024;
this.maxCacheSize = this.maxCacheSizeMB * 1024 * 1024; // 转换为字节
// 缓存统计
this.stats = {
totalFiles: 0,
totalSize: 0,
hitCount: 0,
missCount: 0,
lastCleanup: null
};
// 文件访问时间记录
this.accessTimes = new Map();
}
/**
* 初始化缓存目录
*/
async init() {
try {
// 确保缓存目录存在
await fs.ensureDir(this.cacheDir);
// 扫描现有缓存文件
await this.scanCacheDir();
logger.info(`缓存管理器初始化完成,缓存目录: ${this.cacheDir}`);
logger.info(`当前缓存统计:`, this.stats);
} catch (error) {
logger.error('缓存管理器初始化失败:', error);
throw error;
}
}
/**
* 扫描缓存目录,统计现有文件
*/
async scanCacheDir() {
try {
const files = await fs.readdir(this.cacheDir);
let totalSize = 0;
for (const file of files) {
const filePath = path.join(this.cacheDir, file);
const stat = await fs.stat(filePath);
if (stat.isFile()) {
totalSize += stat.size;
this.accessTimes.set(file, stat.mtime);
}
}
this.stats.totalFiles = files.length;
this.stats.totalSize = totalSize;
} catch (error) {
logger.warn('扫描缓存目录失败:', error);
}
}
/**
* 生成缓存文件名
* @param {string} fileName - 原始文件名
* @returns {string} 缓存文件名
*/
generateCacheFileName(fileName) {
const hash = crypto.createHash('md5').update(fileName).digest('hex');
const ext = path.extname(fileName);
return `${hash}${ext}`;
}
/**
* 获取缓存文件路径
* @param {string} fileName - 原始文件名
* @returns {string} 缓存文件完整路径
*/
getCacheFilePath(fileName) {
const cacheFileName = this.generateCacheFileName(fileName);
return path.join(this.cacheDir, cacheFileName);
}
/**
* 检查文件是否已缓存
* @param {string} fileName - 原始文件名
* @returns {Promise<boolean>} 是否已缓存
*/
async isCached(fileName) {
const cacheFilePath = this.getCacheFilePath(fileName);
return await fs.pathExists(cacheFilePath);
}
/**
* 获取缓存的图片
* @param {string} fileName - 原始文件名
* @returns {Promise<Buffer|null>} 图片数据或 null
*/
async getCachedImage(fileName) {
try {
const cacheFilePath = this.getCacheFilePath(fileName);
if (await fs.pathExists(cacheFilePath)) {
// 更新访问时间
const cacheFileName = this.generateCacheFileName(fileName);
this.accessTimes.set(cacheFileName, new Date());
this.stats.hitCount++;
return await fs.readFile(cacheFilePath);
}
this.stats.missCount++;
return null;
} catch (error) {
logger.error(`获取缓存图片失败 ${fileName}:`, error);
this.stats.missCount++;
return null;
}
}
/**
* 缓存图片
* @param {string} fileName - 原始文件名
* @param {Buffer} imageBuffer - 图片数据
* @returns {Promise<boolean>} 是否缓存成功
*/
async cacheImage(fileName, imageBuffer) {
try {
const cacheFilePath = this.getCacheFilePath(fileName);
const cacheFileName = this.generateCacheFileName(fileName);
// 检查缓存空间
await this.ensureCacheSpace(imageBuffer.length);
// 写入缓存文件
await fs.writeFile(cacheFilePath, imageBuffer);
// 更新统计信息
this.stats.totalFiles++;
this.stats.totalSize += imageBuffer.length;
this.accessTimes.set(cacheFileName, new Date());
logger.debug(`图片已缓存: ${fileName} (${imageBuffer.length} bytes)`);
return true;
} catch (error) {
logger.error(`缓存图片失败 ${fileName}:`, error);
return false;
}
}
/**
* 确保有足够的缓存空间
* @param {number} requiredSize - 需要的空间大小(字节)
*/
async ensureCacheSpace(requiredSize) {
if (this.stats.totalSize + requiredSize <= this.maxCacheSize) {
return; // 空间足够
}
logger.info('缓存空间不足,开始清理...');
// 获取所有缓存文件,按访问时间排序
const files = Array.from(this.accessTimes.entries())
.sort((a, b) => a[1] - b[1]); // 按访问时间升序排序
let freedSpace = 0;
const targetFreeSpace = requiredSize + (this.maxCacheSize * 0.1); // 额外释放10%空间
for (const [fileName, accessTime] of files) {
if (freedSpace >= targetFreeSpace) break;
try {
const filePath = path.join(this.cacheDir, fileName);
const stat = await fs.stat(filePath);
await fs.remove(filePath);
freedSpace += stat.size;
this.stats.totalSize -= stat.size;
this.stats.totalFiles--;
this.accessTimes.delete(fileName);
logger.debug(`清理缓存文件: ${fileName} (${stat.size} bytes)`);
} catch (error) {
logger.warn(`清理缓存文件失败 ${fileName}:`, error);
}
}
this.stats.lastCleanup = new Date();
logger.info(`缓存清理完成,释放空间: ${freedSpace} bytes`);
}
/**
* 清空所有缓存
*/
async clearCache() {
try {
await fs.emptyDir(this.cacheDir);
this.stats.totalFiles = 0;
this.stats.totalSize = 0;
this.accessTimes.clear();
logger.info('缓存已清空');
} catch (error) {
logger.error('清空缓存失败:', error);
throw error;
}
}
/**
* 获取缓存统计信息
* @returns {Object} 统计信息
*/
getStats() {
return {
...this.stats,
maxCacheSizeMB: this.maxCacheSizeMB,
currentSizeMB: Math.round(this.stats.totalSize / 1024 / 1024 * 100) / 100,
hitRate: this.stats.hitCount + this.stats.missCount > 0
? Math.round(this.stats.hitCount / (this.stats.hitCount + this.stats.missCount) * 100) / 100
: 0
};
}
/**
* 删除指定的缓存文件
* @param {string} fileName - 原始文件名
* @returns {Promise<boolean>} 是否删除成功
*/
async removeCachedImage(fileName) {
try {
const cacheFilePath = this.getCacheFilePath(fileName);
const cacheFileName = this.generateCacheFileName(fileName);
if (await fs.pathExists(cacheFilePath)) {
const stat = await fs.stat(cacheFilePath);
await fs.remove(cacheFilePath);
this.stats.totalFiles--;
this.stats.totalSize -= stat.size;
this.accessTimes.delete(cacheFileName);
return true;
}
return false;
} catch (error) {
logger.error(`删除缓存文件失败 ${fileName}:`, error);
return false;
}
}
}
module.exports = CacheManager;

View File

@@ -0,0 +1,406 @@
const axios = require('axios');
const logger = require('../utils/logger');
class ImageService {
constructor(cacheManager, pageDetector) {
this.cacheManager = cacheManager;
this.pageDetector = pageDetector;
this.apiBase = process.env.SOMEACG_API_BASE || 'https://someacg.vercel.app/api';
this.cdnBase = process.env.SOMEACG_CDN_BASE || 'https://cdn.someacg.top/graph/origin';
this.maxRetries = parseInt(process.env.MAX_RETRY_ATTEMPTS) || 3;
// 预加载的图片队列
this.imageQueue = {
desktop: [], // size=2
mobile: [] // size=1
};
// 当前队列索引
this.queueIndex = {
desktop: 0,
mobile: 0
};
// 预加载状态
this.preloadStatus = {
desktop: { total: 0, loaded: 0, inProgress: false },
mobile: { total: 0, loaded: 0, inProgress: false }
};
}
/**
* 获取随机图片
* @param {string|number} size - 尺寸类型,可以是 '1', '2', 'mobile', 'desktop' 或 undefined
* @param {string} type - 返回类型,'url' 或 'image'
* @returns {Promise<Object>} 图片信息或图片数据
*/
async getRandomImage(size, type = 'url') {
try {
// 标准化 size 参数
const normalizedSize = this.normalizeSize(size);
const sizeType = normalizedSize === 1 ? 'mobile' : 'desktop';
// 从预加载队列中获取图片
const imageInfo = this.getNextImageFromQueue(sizeType);
if (!imageInfo) {
// 队列为空,尝试实时获取
logger.warn(`${sizeType} 队列为空,尝试实时获取图片`);
return await this.getRealTimeRandomImage(normalizedSize, type);
}
if (type === 'image') {
// 返回图片数据
const imageBuffer = await this.getImageBuffer(imageInfo.file_name);
return {
...imageInfo,
buffer: imageBuffer
};
} else {
// 返回图片URL和信息
return {
...imageInfo,
url: `${this.cdnBase}/${imageInfo.file_name}`,
cached: await this.cacheManager.isCached(imageInfo.file_name)
};
}
} catch (error) {
logger.error('获取随机图片失败:', error);
throw new Error('获取图片失败');
}
}
/**
* 标准化尺寸参数
* @param {string|number} size - 输入的尺寸参数
* @returns {number} 标准化后的尺寸1 或 2
*/
normalizeSize(size) {
if (size === 'mobile' || size === '1' || size === 1) {
return 1;
} else if (size === 'desktop' || size === '2' || size === 2) {
return 2;
} else {
// 随机选择
return Math.random() < 0.5 ? 1 : 2;
}
}
/**
* 从队列中获取下一张图片
* @param {string} sizeType - 'mobile' 或 'desktop'
* @returns {Object|null} 图片信息或 null
*/
getNextImageFromQueue(sizeType) {
const queue = this.imageQueue[sizeType];
const currentIndex = this.queueIndex[sizeType];
if (queue.length === 0 || currentIndex >= queue.length) {
return null;
}
const imageInfo = queue[currentIndex];
this.queueIndex[sizeType] = (currentIndex + 1) % queue.length;
return imageInfo;
}
/**
* 实时获取随机图片(当队列为空时使用)
* @param {number} size - 尺寸类型
* @param {string} type - 返回类型
* @returns {Promise<Object>} 图片信息
*/
async getRealTimeRandomImage(size, type) {
const randomPage = this.pageDetector.getRandomPage(size);
const imageList = await this.fetchImageList(randomPage, size);
if (!imageList || imageList.length === 0) {
throw new Error('未找到可用图片');
}
const randomIndex = Math.floor(Math.random() * imageList.length);
const imageInfo = imageList[randomIndex];
if (type === 'image') {
const imageBuffer = await this.downloadImage(imageInfo.file_name);
return {
...imageInfo,
buffer: imageBuffer
};
} else {
return {
...imageInfo,
url: `${this.cdnBase}/${imageInfo.file_name}`,
cached: false
};
}
}
/**
* 预加载缓存
*/
async preloadCache() {
logger.info('开始预加载图片缓存...');
try {
// 并行预加载两种尺寸的图片
await Promise.all([
this.preloadImagesForSize(2, 'desktop'),
this.preloadImagesForSize(1, 'mobile')
]);
logger.info('图片缓存预加载完成');
} catch (error) {
logger.error('预加载缓存失败:', error);
}
}
/**
* 为指定尺寸预加载图片
* @param {number} size - 尺寸类型
* @param {string} sizeType - 'mobile' 或 'desktop'
*/
async preloadImagesForSize(size, sizeType) {
if (this.preloadStatus[sizeType].inProgress) {
logger.warn(`${sizeType} 预加载已在进行中`);
return;
}
this.preloadStatus[sizeType].inProgress = true;
try {
logger.info(`开始预加载 ${sizeType} 图片 (size=${size})`);
// 获取所有页面的图片列表
const allImages = await this.fetchAllImages(size);
// 随机打乱顺序
this.shuffleArray(allImages);
// 更新队列
this.imageQueue[sizeType] = allImages;
this.queueIndex[sizeType] = 0;
// 更新预加载状态
this.preloadStatus[sizeType].total = allImages.length;
this.preloadStatus[sizeType].loaded = 0;
logger.info(`${sizeType} 图片列表获取完成,共 ${allImages.length} 张图片`);
// 开始下载和缓存图片
await this.downloadAndCacheImages(allImages, sizeType);
} catch (error) {
logger.error(`预加载 ${sizeType} 图片失败:`, error);
} finally {
this.preloadStatus[sizeType].inProgress = false;
}
}
/**
* 获取所有页面的图片
* @param {number} size - 尺寸类型
* @returns {Promise<Array>} 所有图片信息
*/
async fetchAllImages(size) {
const maxPage = this.pageDetector.getMaxPageForSize(size);
const allImages = [];
logger.info(`开始获取 size=${size} 的所有图片,共 ${maxPage}`);
// 分批获取,避免过多并发请求
const batchSize = 10;
for (let i = 1; i <= maxPage; i += batchSize) {
const batch = [];
for (let page = i; page < i + batchSize && page <= maxPage; page++) {
batch.push(this.fetchImageList(page, size));
}
try {
const results = await Promise.all(batch);
for (const imageList of results) {
if (imageList && imageList.length > 0) {
allImages.push(...imageList);
}
}
logger.debug(`已获取第 ${i}-${Math.min(i + batchSize - 1, maxPage)} 页,当前总数: ${allImages.length}`);
} catch (error) {
logger.warn(`获取第 ${i}-${Math.min(i + batchSize - 1, maxPage)} 页时出错:`, error.message);
}
}
return allImages;
}
/**
* 获取指定页面的图片列表
* @param {number} page - 页码
* @param {number} size - 尺寸类型
* @returns {Promise<Array>} 图片列表
*/
async fetchImageList(page, size) {
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const response = await axios.get(`${this.apiBase}/list`, {
params: { page, size },
timeout: 10000
});
if (response.status === 200 && Array.isArray(response.data)) {
return response.data;
}
return [];
} catch (error) {
if (attempt === this.maxRetries) {
logger.warn(`获取图片列表失败 page=${page}, size=${size}:`, error.message);
return [];
}
await this.sleep(1000 * attempt);
}
}
return [];
}
/**
* 下载并缓存图片
* @param {Array} images - 图片信息数组
* @param {string} sizeType - 尺寸类型
*/
async downloadAndCacheImages(images, sizeType) {
logger.info(`开始下载和缓存 ${sizeType} 图片,共 ${images.length}`);
const concurrency = 5; // 并发下载数量
let completed = 0;
for (let i = 0; i < images.length; i += concurrency) {
const batch = images.slice(i, i + concurrency);
await Promise.all(batch.map(async (image) => {
try {
// 检查是否已缓存
if (await this.cacheManager.isCached(image.file_name)) {
completed++;
this.preloadStatus[sizeType].loaded = completed;
return;
}
// 下载图片
const imageBuffer = await this.downloadImage(image.file_name);
// 缓存图片
await this.cacheManager.cacheImage(image.file_name, imageBuffer);
completed++;
this.preloadStatus[sizeType].loaded = completed;
if (completed % 100 === 0) {
logger.info(`${sizeType} 已缓存 ${completed}/${images.length} 张图片`);
}
} catch (error) {
logger.warn(`下载图片失败 ${image.file_name}:`, error.message);
completed++;
this.preloadStatus[sizeType].loaded = completed;
}
}));
}
logger.info(`${sizeType} 图片缓存完成,共处理 ${completed} 张图片`);
}
/**
* 下载图片
* @param {string} fileName - 文件名
* @returns {Promise<Buffer>} 图片数据
*/
async downloadImage(fileName) {
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const response = await axios.get(`${this.cdnBase}/${fileName}`, {
responseType: 'arraybuffer',
timeout: 30000
});
return Buffer.from(response.data);
} catch (error) {
if (error.response?.status === 429) {
// 遇到限流,等待更长时间
await this.sleep(5000 * attempt);
} else if (attempt === this.maxRetries) {
throw error;
} else {
await this.sleep(1000 * attempt);
}
}
}
}
/**
* 获取图片数据(优先从缓存)
* @param {string} fileName - 文件名
* @returns {Promise<Buffer>} 图片数据
*/
async getImageBuffer(fileName) {
// 先尝试从缓存获取
let imageBuffer = await this.cacheManager.getCachedImage(fileName);
if (!imageBuffer) {
// 缓存未命中,下载图片
imageBuffer = await this.downloadImage(fileName);
// 异步缓存图片
this.cacheManager.cacheImage(fileName, imageBuffer).catch(error => {
logger.warn(`异步缓存图片失败 ${fileName}:`, error);
});
}
return imageBuffer;
}
/**
* 获取预加载状态
* @returns {Object} 预加载状态
*/
getPreloadStatus() {
return {
...this.preloadStatus,
queueStatus: {
desktop: {
total: this.imageQueue.desktop.length,
current: this.queueIndex.desktop
},
mobile: {
total: this.imageQueue.mobile.length,
current: this.queueIndex.mobile
}
}
};
}
/**
* 随机打乱数组
* @param {Array} array - 要打乱的数组
*/
shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
/**
* 睡眠函数
* @param {number} ms - 毫秒数
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = ImageService;

View File

@@ -0,0 +1,172 @@
const axios = require('axios');
const logger = require('../utils/logger');
class PageDetector {
constructor() {
this.apiBase = process.env.SOMEACG_API_BASE || 'https://someacg.vercel.app/api';
this.maxRetries = parseInt(process.env.MAX_RETRY_ATTEMPTS) || 3;
this.timeout = parseInt(process.env.MAX_PAGE_DETECTION_TIMEOUT) || 30000;
// 缓存的最大页数
this.maxPages = {
desktop: 0, // size=2
mobile: 0 // size=1
};
// 上次检测时间
this.lastDetection = null;
}
/**
* 检测指定 size 的最大页数
* @param {number} size - 1 为移动端2 为桌面端
* @returns {Promise<number>} 最大页数
*/
async detectMaxPageForSize(size) {
logger.info(`开始检测 size=${size} 的最大页数`);
let left = 1;
let right = 10000; // 初始上限
let maxPage = 1;
// 先找到一个有效的上限
while (await this.isPageValid(right, size)) {
left = right;
right *= 2;
if (right > 100000) break; // 防止无限循环
}
// 二分查找最大页数
while (left <= right) {
const mid = Math.floor((left + right) / 2);
try {
if (await this.isPageValid(mid, size)) {
maxPage = mid;
left = mid + 1;
} else {
right = mid - 1;
}
} catch (error) {
logger.warn(`检测页数 ${mid} 时出错:`, error.message);
right = mid - 1;
}
}
logger.info(`size=${size} 的最大页数为: ${maxPage}`);
return maxPage;
}
/**
* 检查指定页数是否有效
* @param {number} page - 页码
* @param {number} size - 尺寸类型
* @returns {Promise<boolean>} 是否有效
*/
async isPageValid(page, size) {
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const response = await axios.get(`${this.apiBase}/list`, {
params: { page, size },
timeout: this.timeout
});
// 检查响应是否有效
if (response.status === 200 && Array.isArray(response.data) && response.data.length > 0) {
return true;
}
return false;
} catch (error) {
if (error.response?.status === 404 || error.response?.status === 400) {
return false;
}
if (attempt === this.maxRetries) {
logger.warn(`检测页数 ${page} (size=${size}) 失败,已重试 ${this.maxRetries} 次:`, error.message);
throw error;
}
// 等待后重试
await this.sleep(1000 * attempt);
}
}
return false;
}
/**
* 检测所有类型的最大页数
*/
async detectMaxPages() {
try {
logger.info('开始检测最大页数...');
// 并行检测两种尺寸的最大页数
const [desktopMax, mobileMax] = await Promise.all([
this.detectMaxPageForSize(2), // 桌面端
this.detectMaxPageForSize(1) // 移动端
]);
this.maxPages.desktop = desktopMax;
this.maxPages.mobile = mobileMax;
this.lastDetection = new Date();
logger.info('页数检测完成:', this.maxPages);
} catch (error) {
logger.error('页数检测失败:', error);
// 如果检测失败且没有缓存的页数,使用默认值
if (this.maxPages.desktop === 0) {
this.maxPages.desktop = 100;
this.maxPages.mobile = 100;
logger.warn('使用默认页数范围:', this.maxPages);
}
}
}
/**
* 获取最大页数
* @returns {Object} 包含桌面端和移动端最大页数的对象
*/
getMaxPages() {
return { ...this.maxPages };
}
/**
* 获取指定尺寸的最大页数
* @param {number} size - 1 为移动端2 为桌面端
* @returns {number} 最大页数
*/
getMaxPageForSize(size) {
return size === 1 ? this.maxPages.mobile : this.maxPages.desktop;
}
/**
* 获取随机页码
* @param {number} size - 1 为移动端2 为桌面端
* @returns {number} 随机页码
*/
getRandomPage(size) {
const maxPage = this.getMaxPageForSize(size);
return Math.floor(Math.random() * maxPage) + 1;
}
/**
* 获取上次检测时间
* @returns {Date|null} 上次检测时间
*/
getLastDetectionTime() {
return this.lastDetection;
}
/**
* 睡眠函数
* @param {number} ms - 毫秒数
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = PageDetector;

243
src/services/scheduler.js Normal file
View File

@@ -0,0 +1,243 @@
const cron = require('node-cron');
const logger = require('../utils/logger');
class Scheduler {
constructor() {
this.tasks = new Map();
this.isRunning = false;
}
/**
* 启动定时任务
* @param {PageDetector} pageDetector - 页数检测器实例
* @param {ImageService} imageService - 图片服务实例
*/
start(pageDetector, imageService) {
if (this.isRunning) {
logger.warn('定时任务已在运行中');
return;
}
logger.info('启动定时任务调度器...');
// 每日凌晨 2:00 执行页数检测和缓存更新
const dailyTask = cron.schedule('0 2 * * *', async () => {
logger.info('开始执行每日定时任务...');
try {
// 1. 检测最大页数
logger.info('执行页数检测...');
await pageDetector.detectMaxPages();
// 2. 重新预加载缓存
logger.info('重新预加载图片缓存...');
await imageService.preloadCache();
logger.info('每日定时任务执行完成');
} catch (error) {
logger.error('每日定时任务执行失败:', error);
}
}, {
scheduled: false,
timezone: 'Asia/Shanghai'
});
// 每小时检查一次服务状态
const healthCheckTask = cron.schedule('0 * * * *', () => {
this.performHealthCheck(pageDetector, imageService);
}, {
scheduled: false,
timezone: 'Asia/Shanghai'
});
// 每6小时清理一次日志可选
const logCleanupTask = cron.schedule('0 */6 * * *', () => {
this.performLogCleanup();
}, {
scheduled: false,
timezone: 'Asia/Shanghai'
});
// 启动所有任务
dailyTask.start();
healthCheckTask.start();
logCleanupTask.start();
// 保存任务引用
this.tasks.set('daily', dailyTask);
this.tasks.set('healthCheck', healthCheckTask);
this.tasks.set('logCleanup', logCleanupTask);
this.isRunning = true;
logger.info('定时任务调度器启动完成');
// 记录下次执行时间
this.logNextExecutionTimes();
}
/**
* 停止所有定时任务
*/
stop() {
if (!this.isRunning) {
logger.warn('定时任务未在运行');
return;
}
logger.info('停止定时任务调度器...');
// 停止所有任务
for (const [name, task] of this.tasks) {
try {
task.stop();
logger.info(`定时任务 ${name} 已停止`);
} catch (error) {
logger.error(`停止定时任务 ${name} 失败:`, error);
}
}
this.tasks.clear();
this.isRunning = false;
logger.info('定时任务调度器已停止');
}
/**
* 执行健康检查
* @param {PageDetector} pageDetector - 页数检测器实例
* @param {ImageService} imageService - 图片服务实例
*/
performHealthCheck(pageDetector, imageService) {
try {
logger.info('执行服务健康检查...');
// 检查页数检测器状态
const maxPages = pageDetector.getMaxPages();
const lastDetection = pageDetector.getLastDetectionTime();
if (!lastDetection || Date.now() - lastDetection.getTime() > 25 * 60 * 60 * 1000) {
logger.warn('页数检测时间过久,可能需要手动触发检测');
}
if (maxPages.desktop === 0 || maxPages.mobile === 0) {
logger.error('页数检测结果异常:', maxPages);
}
// 检查图片服务状态
const preloadStatus = imageService.getPreloadStatus();
for (const [sizeType, status] of Object.entries(preloadStatus)) {
if (sizeType === 'queueStatus') continue;
if (status.inProgress) {
logger.info(`${sizeType} 预加载正在进行中: ${status.loaded}/${status.total}`);
} else if (status.total === 0) {
logger.warn(`${sizeType} 预加载队列为空`);
}
}
// 记录内存使用情况
const memUsage = process.memoryUsage();
const memUsageMB = {
rss: Math.round(memUsage.rss / 1024 / 1024),
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
external: Math.round(memUsage.external / 1024 / 1024)
};
logger.info('内存使用情况 (MB):', memUsageMB);
// 如果内存使用过高,记录警告
if (memUsageMB.heapUsed > 512) {
logger.warn('内存使用量较高,当前堆内存使用:', memUsageMB.heapUsed, 'MB');
}
logger.info('服务健康检查完成');
} catch (error) {
logger.error('健康检查执行失败:', error);
}
}
/**
* 执行日志清理
*/
performLogCleanup() {
try {
logger.info('执行日志清理...');
// 这里可以添加日志文件清理逻辑
// 例如删除超过一定天数的日志文件
// 强制垃圾回收(如果可用)
if (global.gc) {
global.gc();
logger.info('执行了垃圾回收');
}
logger.info('日志清理完成');
} catch (error) {
logger.error('日志清理失败:', error);
}
}
/**
* 手动触发每日任务
* @param {PageDetector} pageDetector - 页数检测器实例
* @param {ImageService} imageService - 图片服务实例
*/
async triggerDailyTask(pageDetector, imageService) {
logger.info('手动触发每日任务...');
try {
await pageDetector.detectMaxPages();
await imageService.preloadCache();
logger.info('手动每日任务执行完成');
} catch (error) {
logger.error('手动每日任务执行失败:', error);
throw error;
}
}
/**
* 获取任务状态
* @returns {Object} 任务状态信息
*/
getStatus() {
const status = {
isRunning: this.isRunning,
tasks: {}
};
for (const [name, task] of this.tasks) {
status.tasks[name] = {
running: task.running || false
};
}
return status;
}
/**
* 记录下次执行时间
*/
logNextExecutionTimes() {
const now = new Date();
// 计算下次每日任务执行时间
const nextDaily = new Date(now);
nextDaily.setHours(2, 0, 0, 0);
if (nextDaily <= now) {
nextDaily.setDate(nextDaily.getDate() + 1);
}
// 计算下次健康检查执行时间
const nextHealthCheck = new Date(now);
nextHealthCheck.setMinutes(0, 0, 0);
nextHealthCheck.setHours(nextHealthCheck.getHours() + 1);
logger.info(`下次每日任务执行时间: ${nextDaily.toLocaleString('zh-CN')}`);
logger.info(`下次健康检查执行时间: ${nextHealthCheck.toLocaleString('zh-CN')}`);
}
}
// 导出单例实例
module.exports = new Scheduler();

50
src/utils/logger.js Normal file
View File

@@ -0,0 +1,50 @@
const winston = require('winston');
const path = require('path');
const fs = require('fs-extra');
// 确保日志目录存在
const logDir = path.join(process.cwd(), 'logs');
fs.ensureDirSync(logDir);
// 创建 winston logger
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss'
}),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'someacg-randompic' },
transports: [
// 错误日志文件
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// 所有日志文件
new winston.transports.File({
filename: path.join(logDir, 'combined.log'),
maxsize: 5242880, // 5MB
maxFiles: 5
})
]
});
// 开发环境下同时输出到控制台
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
return `${timestamp} [${level}]: ${message} ${Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''}`;
})
)
}));
}
module.exports = logger;