Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/mirror.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ jobs:
path: .cache
# 稳定 key:每次成功运行覆盖同一个缓存条目,避免堆积 370MB 级快照,
# 并保证增量状态(.cache/state)在下次运行一定能恢复
key: mirror-${{ hashFiles('src/data/channels.json') }}
# 媒体改为直链后缓存只有 HTML/CSS/JS,换前缀作废旧的大缓存
key: mirror-hotlink-${{ hashFiles('src/data/channels.json') }}
restore-keys: |
mirror-
mirror-hotlink-

- name: Mirror and sanitize
run: node scripts/mirror.mjs
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ npm run dev # 本地预览

本地快速验证可加环境变量:`MIRROR_QUICK=1 npm run mirror`(只抓两层页面)。

镜像默认不下载 Halo 的 `?width=` 缩略图变体(静态托管下无收益),页面 `srcset` 会自动收敛到原图;多频道并发抓取(最多 3 路)。
镜像只下载 HTML/CSS/JS 及少量非媒体附件(zip/pdf/xml),图片、视频、音频、字体一律不落盘,
清洗时把引用改写为原站绝对 URL(直链),因此镜像体积小(总量约 28MB 量级)、CI 拉取快;
代价是访客浏览时由浏览器直接向友站请求媒体流量,友站若有防盗链(Referer 校验)可能破图。
多频道并发抓取(最多 3 路)。

增量同步:有状态后按 `sitemap.xml` 的 `lastmod` 对比,只拉新增/更新的文章页,源站删除的页面会同步删除;首页与归档列表始终刷新;首次运行或 sitemap 不可用时自动回退全量抓取。状态文件在 `.cache/state/`,随 Actions 缓存保留。

Expand All @@ -35,7 +38,6 @@ npm run dev # 本地预览

- `extraOrigins`:图片放在独立图床时,把图床域名加进 `img-src` 白名单,如 `["https://image.example.com:443"]`。
- `alwaysFetch`:sitemap 未收录但希望始终刷新的页面,如 `["/links"]`。
- `extraAssets`:希望镜像到本地的外站资源(如动态壁纸视频),`url` 为原地址、`path` 为镜像内相对路径;已存在时跳过,随 CI 缓存保留。

提交 PR 即视为同意本站对博客做静态镜像(保留署名、功能引导回原站)。CI 会自动校验格式。

Expand Down
71 changes: 38 additions & 33 deletions scripts/mirror.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import astroConfig from '../astro.config.mjs';
import { sanitizeChannel } from './sanitize.mjs';
import { sanitizeChannel, MEDIA_EXT_RE, SAFE_FILE_RE, looksLikeMedia } from './sanitize.mjs';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const base = (astroConfig.base ?? '').replace(/\/$/, '');
Expand All @@ -13,6 +13,10 @@ const outRoot = path.join(ROOT, 'public/channels');
const stateRoot = path.join(ROOT, '.cache/state');
// 首页与归档列表不在 sitemap 中,但新文章会出现在这里,始终刷新
const ALWAYS_FETCH = ['/', '/archives'];
// 媒体/字体不再下载:wget 直接拒绝(含 ?width= 缩略图变体),
// 清洗阶段再把引用直链回原站。zip/pdf/xml 等非媒体附件仍下载。
const REJECT_MEDIA_RE =
'(\\?width=)|\\.(png|jpe?g|webp|gif|svg|ico|avif|bmp|apng|mp4|webm|ogv|mov|mp3|wav|ogg|oga|flac|aac|m4a|woff2?|ttf|otf|eot)([?#]|$)';

const channelsData = JSON.parse(await readFile(path.join(ROOT, 'src/data/channels.json'), 'utf8'));
const channels = (channelsData.channels ?? []).filter((c) => c.enabled !== false);
Expand All @@ -39,8 +43,8 @@ async function processChannel(channel) {
// 缓存目录为空(首次 / 缓存丢失)时,先全量抓取并播种状态
if (!hostDir) {
const args = [
'-m', '-p', '-np', '-E', '-k', '-nv', '--no-remove-listing',
'--timeout=30', '--tries=3', '--reject-regex', '\\?width=',
'-m', '-p', '-np', '-E', '-nv', '--no-remove-listing',
'--timeout=30', '--tries=3', '--reject-regex', REJECT_MEDIA_RE,
'-P', cacheDir, channel.url,
];
if (process.env.MIRROR_QUICK) args.push('--level=2');
Expand Down Expand Up @@ -78,8 +82,6 @@ async function processChannel(channel) {
}
}

await fetchExtraAssets(channel, hostDir);

try {
await sanitizeChannel({
hostDir,
Expand All @@ -89,8 +91,9 @@ async function processChannel(channel) {
prefix,
base: base || '',
extraOrigins: channel.extraOrigins || [],
extraAssets: channel.extraAssets || [],
});
// 镜像产物已无媒体;顺手清掉缓存里的旧媒体文件,避免 Actions 缓存滞留
await purgeMediaFiles(hostDir);
const { sizeBytes, fileCount } = await dirStats(outDir);
entry.sizeBytes = sizeBytes;
entry.fileCount = fileCount;
Expand All @@ -106,31 +109,6 @@ async function processChannel(channel) {
return entry;
}

// 把配置里声明的外站资源(如动态壁纸视频)镜像到本地,避免访客每次访问都请求友站图床。
// 只下载一次:文件已存在则跳过,随 Actions 缓存保留。
async function fetchExtraAssets(channel, hostDir) {
if (!hostDir) return;
for (const asset of channel.extraAssets || []) {
if (!asset?.url || !asset?.path || asset.path.includes('..')) continue;
const target = path.join(hostDir, asset.path);
if (existsSync(target)) continue;
await mkdir(path.dirname(target), { recursive: true });
console.log(`[${channel.id}] 下载额外资源 ${asset.url}`);
try {
const res = await fetch(asset.url, { signal: AbortSignal.timeout(120000) });
if (!res.ok) {
console.warn(`[${channel.id}] 额外资源下载失败 HTTP ${res.status}:${asset.url}`);
continue;
}
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(target, buf);
console.log(`[${channel.id}] 额外资源已保存 ${asset.path}(${(buf.length / 1048576).toFixed(1)} MB)`);
} catch (e) {
console.warn(`[${channel.id}] 额外资源下载失败:${asset.url} (${e.message})`);
}
}
}

// 增量更新:对照 sitemap + 状态文件,只拉新增/更新的页面,并删除源站已移除的页面
async function tryIncremental(channel, cacheDir) {
const stateFile = path.join(stateRoot, `${channel.id}.json`);
Expand Down Expand Up @@ -186,8 +164,8 @@ async function tryIncremental(channel, cacheDir) {
if (urls.size) {
fetched = urls.size;
const args = [
'-E', '-k', '-p', '-np', '-N', '-nv', '--timeout=30', '--tries=3',
'--reject-regex', '\\?width=', '-P', cacheDir, ...urls,
'-E', '-p', '-np', '-N', '-nv', '--timeout=30', '--tries=3',
'--reject-regex', REJECT_MEDIA_RE, '-P', cacheDir, ...urls,
];
const res = spawnSync('wget', args, { stdio: 'inherit' });
if (res.status !== 0) {
Expand Down Expand Up @@ -389,3 +367,30 @@ async function looksLikeHtml(file) {
return false;
}
}

async function listAllFiles(dir) {
const out = [];
async function walk(d) {
for (const entry of await readdir(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isDirectory()) await walk(p);
else if (entry.isFile()) out.push(p);
}
}
await walk(dir);
return out;
}

// 删除缓存中遗留的媒体/字体文件(wget 新拉取已按 REJECT_MEDIA_RE 拒绝)
async function purgeMediaFiles(dir) {
if (!existsSync(dir)) return;
let removed = 0;
for (const file of await listAllFiles(dir)) {
const rel = path.relative(dir, file);
if (MEDIA_EXT_RE.test(rel) || (!SAFE_FILE_RE.test(rel) && (await looksLikeMedia(file)))) {
await rm(file, { force: true });
removed += 1;
}
}
if (removed) console.log(`[cache] 清理 ${removed} 个媒体/字体缓存文件`);
}
Loading
Loading