2026-01-18 23:35:56 +08:00
|
|
|
const fs = require('fs');
|
|
|
|
|
const path = require('path');
|
2026-05-20 15:33:10 +08:00
|
|
|
const axios = require('axios');
|
|
|
|
|
|
2026-05-21 13:45:17 +08:00
|
|
|
const API_BASE = 'https://api.usj.cc';
|
2026-05-20 15:33:10 +08:00
|
|
|
const HEALTH_URL = `${API_BASE}/api/health`;
|
|
|
|
|
const LINKS_URL = `${API_BASE}/api/links`;
|
2026-01-18 23:35:56 +08:00
|
|
|
|
2026-01-30 23:07:58 +08:00
|
|
|
const outputPath = path.join(__dirname, '../static/link_status.json');
|
2026-05-20 15:33:10 +08:00
|
|
|
const summaryPath = path.join(__dirname, '../failed_links_summary.txt');
|
|
|
|
|
|
|
|
|
|
const BATCH_TIMEOUT = 120000; // /api/health probes all sites server-side
|
|
|
|
|
const SINGLE_TIMEOUT = 20000;
|
|
|
|
|
const PER_SITE_CONCURRENCY = 8;
|
2026-01-18 23:35:56 +08:00
|
|
|
|
2026-01-30 23:07:58 +08:00
|
|
|
if (!fs.existsSync(path.dirname(outputPath))) {
|
|
|
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 15:33:10 +08:00
|
|
|
const client = axios.create({
|
|
|
|
|
timeout: BATCH_TIMEOUT,
|
|
|
|
|
validateStatus: (s) => s >= 200 && s < 500,
|
2026-01-31 15:14:57 +08:00
|
|
|
headers: {
|
2026-05-20 15:33:10 +08:00
|
|
|
'User-Agent': 'blog-link-health/1.0 (+github-actions)'
|
|
|
|
|
}
|
2026-01-31 15:14:57 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-20 15:33:10 +08:00
|
|
|
function ensureArray(value) {
|
|
|
|
|
return Array.isArray(value) ? value : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickName(map, url) {
|
|
|
|
|
if (!url) return '';
|
|
|
|
|
return map.get(url) || map.get(url.replace(/\/+$/, '')) || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildNameMap(links) {
|
|
|
|
|
const map = new Map();
|
|
|
|
|
links.forEach((l) => {
|
|
|
|
|
if (!l || !l.url) return;
|
|
|
|
|
map.set(l.url, l.name || l.url);
|
|
|
|
|
map.set(l.url.replace(/\/+$/, ''), l.name || l.url);
|
|
|
|
|
});
|
|
|
|
|
return map;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchLinks() {
|
2026-01-31 15:14:57 +08:00
|
|
|
try {
|
2026-05-20 15:33:10 +08:00
|
|
|
const resp = await client.get(LINKS_URL, { timeout: SINGLE_TIMEOUT });
|
|
|
|
|
return ensureArray(resp.data && resp.data.links);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('[link-check] /api/links failed:', e.message);
|
|
|
|
|
return [];
|
2026-01-31 15:14:57 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 15:33:10 +08:00
|
|
|
async function fetchBatchHealth() {
|
2026-01-18 23:35:56 +08:00
|
|
|
try {
|
2026-05-20 15:33:10 +08:00
|
|
|
const resp = await client.get(HEALTH_URL, { timeout: BATCH_TIMEOUT });
|
|
|
|
|
const data = resp.data;
|
|
|
|
|
if (!data || !Array.isArray(data.results)) {
|
|
|
|
|
console.warn('[link-check] /api/health returned unexpected payload, falling back to per-site probes');
|
|
|
|
|
return null;
|
2026-01-30 23:07:58 +08:00
|
|
|
}
|
2026-05-20 15:33:10 +08:00
|
|
|
return data;
|
2026-01-18 23:35:56 +08:00
|
|
|
} catch (e) {
|
2026-05-20 15:33:10 +08:00
|
|
|
console.warn('[link-check] /api/health batch failed:', e.message, '- falling back to per-site probes');
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchSingleHealth(url) {
|
|
|
|
|
try {
|
|
|
|
|
const resp = await client.get(HEALTH_URL, {
|
|
|
|
|
params: { url },
|
|
|
|
|
timeout: SINGLE_TIMEOUT
|
|
|
|
|
});
|
|
|
|
|
const data = resp.data || {};
|
|
|
|
|
return {
|
|
|
|
|
url,
|
|
|
|
|
status: typeof data.status === 'number' ? data.status : 0,
|
|
|
|
|
ok: !!data.ok,
|
|
|
|
|
latency: typeof data.latency === 'number' ? data.latency : null,
|
|
|
|
|
error: typeof data.error === 'string' ? data.error : ''
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return { url, status: 0, ok: false, latency: null, error: e.message || 'request_failed' };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchPerSiteHealth(links) {
|
|
|
|
|
const queue = links.filter((l) => l && l.url).map((l) => l.url);
|
|
|
|
|
const out = [];
|
|
|
|
|
let cursor = 0;
|
|
|
|
|
|
|
|
|
|
async function worker() {
|
|
|
|
|
while (cursor < queue.length) {
|
|
|
|
|
const idx = cursor++;
|
|
|
|
|
const url = queue[idx];
|
|
|
|
|
const result = await fetchSingleHealth(url);
|
|
|
|
|
out.push(result);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workers = Array(Math.min(queue.length, PER_SITE_CONCURRENCY))
|
|
|
|
|
.fill(null)
|
|
|
|
|
.map(() => worker());
|
|
|
|
|
await Promise.all(workers);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeOutputs(results, nameMap) {
|
|
|
|
|
const map = {};
|
|
|
|
|
const failed = [];
|
|
|
|
|
|
|
|
|
|
results.forEach((r) => {
|
|
|
|
|
if (!r || !r.url) return;
|
|
|
|
|
map[r.url] = !!r.ok;
|
|
|
|
|
if (!r.ok) {
|
|
|
|
|
const name = pickName(nameMap, r.url) || r.url;
|
|
|
|
|
const reason = r.error ? r.error : (r.status ? `HTTP ${r.status}` : 'unknown');
|
|
|
|
|
failed.push(`- ${name}: ${r.url} (${reason})`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fs.writeFileSync(outputPath, JSON.stringify(map, null, 2));
|
|
|
|
|
|
|
|
|
|
if (failed.length > 0) {
|
|
|
|
|
const summary = `\n🚫 监测到 ${failed.length} 个异常友链:\n${failed.join('\n')}\n`;
|
|
|
|
|
fs.writeFileSync(summaryPath, summary);
|
|
|
|
|
} else {
|
|
|
|
|
fs.writeFileSync(summaryPath, '\n✅ 所有友链状态正常\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return failed.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function main() {
|
|
|
|
|
const start = Date.now();
|
|
|
|
|
console.log('[link-check] starting via', HEALTH_URL);
|
|
|
|
|
|
|
|
|
|
const linksList = await fetchLinks();
|
|
|
|
|
const nameMap = buildNameMap(linksList);
|
|
|
|
|
|
|
|
|
|
let results = null;
|
|
|
|
|
const batch = await fetchBatchHealth();
|
|
|
|
|
if (batch) {
|
|
|
|
|
results = batch.results;
|
|
|
|
|
console.log(`[link-check] batch report: total=${batch.total} alive=${batch.alive} dead=${batch.dead}`);
|
|
|
|
|
} else if (linksList.length > 0) {
|
|
|
|
|
console.log(`[link-check] per-site probe of ${linksList.length} sites...`);
|
|
|
|
|
results = await fetchPerSiteHealth(linksList);
|
|
|
|
|
} else {
|
|
|
|
|
console.error('[link-check] no links to probe and batch endpoint failed');
|
2026-01-18 23:35:56 +08:00
|
|
|
process.exit(1);
|
|
|
|
|
}
|
2026-05-20 15:33:10 +08:00
|
|
|
|
|
|
|
|
const failedCount = writeOutputs(results, nameMap);
|
|
|
|
|
const duration = ((Date.now() - start) / 1000).toFixed(2);
|
|
|
|
|
console.log(`[link-check] done in ${duration}s, failed=${failedCount}, status=${outputPath}, summary=${summaryPath}`);
|
2026-01-18 23:35:56 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 15:33:10 +08:00
|
|
|
main().catch((e) => {
|
|
|
|
|
console.error('[link-check] fatal:', e);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|