2026-05-10 13:12:44 +08:00

273 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ═══════════════════════════════════════════════════════════
* ZY-MIRROR-AGENT · Step 1 · 照镜子 — 快照引擎
* ═══════════════════════════════════════════════════════════
*
* 定时拉取第三方书库的公开 API 元数据(只读·不写·不改·不爬内容页)
* 生成本地快照用于后续 diff 比较
*
* 守护: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
* ═══════════════════════════════════════════════════════════
*/
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const { MIRROR_CONFIG, getEnabledSources } = require('./config');
/**
* 简易 HTTP GET不引入额外依赖
*/
function httpGet(url, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(url, { timeout: timeoutMs }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return httpGet(res.headers.location, timeoutMs).then(resolve).catch(reject);
}
let data = '';
res.setEncoding('utf8');
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body: data, headers: res.headers });
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
});
}
/**
* 生成快照文件名
*/
function snapshotFilename(sourceId) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
return `${sourceId}_${ts}.json`;
}
/**
* 获取最新的快照文件路径
*/
function getLatestSnapshot(sourceId) {
const dir = MIRROR_CONFIG.snapshot_dir;
try {
const files = fs.readdirSync(dir)
.filter(f => f.startsWith(sourceId + '_') && f.endsWith('.json'))
.sort()
.reverse();
return files.length > 0 ? path.join(dir, files[0]) : null;
} catch {
return null;
}
}
/**
* 探测数据源的可用性和版本信息
*/
async function probeSource(source) {
const result = {
source_id: source.id,
source_name: source.name,
probed_at: new Date().toISOString(),
reachable: false,
version: null,
error: null
};
try {
// 尝试获取版本信息
if (source.version_url) {
const versionUrl = source.version_url.replace('{base_url}', source.base_url);
const resp = await httpGet(versionUrl);
result.reachable = true;
try {
result.version = JSON.parse(resp.body);
} catch {
result.version = resp.body.slice(0, 500);
}
} else {
// 没有版本接口,尝试搜索一个空串做连通性测试
const testUrl = source.search_url
.replace('{base_url}', source.base_url)
.replace('{query}', 'test')
.replace('{page}', '1')
.replace('{keyword}', 'test');
await httpGet(testUrl);
result.reachable = true;
}
} catch (err) {
result.error = err.message;
}
return result;
}
/**
* 检查 GitHub 仓库的最新 release/commit 信息
* 用于判断上游项目是否有更新
*/
async function checkGitHubUpdate(githubRepoUrl) {
if (!githubRepoUrl) return null;
// 从 URL 提取 owner/repo
const match = githubRepoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) return null;
const [, owner, repo] = match;
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
try {
const resp = await httpGet(apiUrl);
const release = JSON.parse(resp.body);
return {
tag: release.tag_name,
name: release.name,
published_at: release.published_at,
html_url: release.html_url
};
} catch {
// 没有 release尝试获取最新 commit
try {
const commitUrl = `https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`;
const resp = await httpGet(commitUrl);
const commits = JSON.parse(resp.body);
if (commits.length > 0) {
return {
type: 'commit',
sha: commits[0].sha.slice(0, 7),
message: commits[0].commit.message.slice(0, 100),
date: commits[0].commit.committer.date,
html_url: commits[0].html_url
};
}
} catch {
return null;
}
}
return null;
}
/**
* 对单个数据源执行完整快照
*
* 快照内容:
* - 数据源可用性探测
* - GitHub 上游版本检查
* - 热门/推荐书目元数据样本(如有 API
*/
async function takeSnapshot(source) {
const snapshot = {
source_id: source.id,
source_name: source.name,
snapshot_at: new Date().toISOString(),
probe: null,
upstream: null,
sample_books: [],
errors: []
};
// 1. 探测可用性
try {
snapshot.probe = await probeSource(source);
} catch (err) {
snapshot.errors.push({ step: 'probe', error: err.message });
}
// 2. 检查 GitHub 上游更新
try {
snapshot.upstream = await checkGitHubUpdate(source.github_repo);
} catch (err) {
snapshot.errors.push({ step: 'github_check', error: err.message });
}
// 3. 尝试搜索几个常见关键词获取书目样本(仅元数据)
if (snapshot.probe && snapshot.probe.reachable) {
const sampleKeywords = ['玄幻', '都市', '言情'];
for (const keyword of sampleKeywords) {
try {
const searchUrl = source.search_url
.replace('{base_url}', source.base_url)
.replace('{query}', encodeURIComponent(keyword))
.replace('{keyword}', encodeURIComponent(keyword))
.replace('{page}', '1');
const resp = await httpGet(searchUrl);
const data = JSON.parse(resp.body);
// 只保留元数据标题、作者、ID不保存内容
const books = Array.isArray(data) ? data : (data.books || data.data || data.list || []);
const metaOnly = books.slice(0, 5).map(b => ({
id: b.book_id || b.id || b.bookId,
title: b.title || b.book_name || b.name,
author: b.author || b.author_name,
category: b.category || b.genre || b.tag
}));
snapshot.sample_books.push({
keyword,
count: books.length,
sample: metaOnly
});
} catch (err) {
snapshot.errors.push({ step: `search_${keyword}`, error: err.message });
}
}
}
// 4. 保存快照文件
const filename = snapshotFilename(source.id);
const filepath = path.join(MIRROR_CONFIG.snapshot_dir, filename);
try {
fs.mkdirSync(MIRROR_CONFIG.snapshot_dir, { recursive: true });
fs.writeFileSync(filepath, JSON.stringify(snapshot, null, 2), 'utf8');
} catch (err) {
snapshot.errors.push({ step: 'save', error: err.message });
}
return { filepath, snapshot };
}
/**
* 对所有启用的数据源执行快照
*/
async function takeAllSnapshots() {
const sources = getEnabledSources();
const results = [];
for (const source of sources) {
try {
const result = await takeSnapshot(source);
results.push(result);
} catch (err) {
results.push({
filepath: null,
snapshot: {
source_id: source.id,
source_name: source.name,
snapshot_at: new Date().toISOString(),
errors: [{ step: 'fatal', error: err.message }]
}
});
}
}
return results;
}
module.exports = {
httpGet,
probeSource,
checkGitHubUpdate,
takeSnapshot,
takeAllSnapshots,
getLatestSnapshot,
snapshotFilename
};