Files
blog/blog-admin/src/routes/rss/tools.ts
T
zqlit 43908e8125 chore(备份): 异地备份定案为 OpenList 离线 bundle,撤销全部 git 辅仓
用户定案:「辅助仓就用 openlist,其他不再考虑」。
据此把前一轮为 Gitee 铺的路全部收回,git 远端只剩 CNB 一个。

一、git 配置收口
- `pushall` 别名 `origin + gitee` → `!git push origin main`(只推唯一远端)
- 移除 `gitea` remote(自建 23.254.236.47:3001)—— 远端仓库本身没删,
  需要时可 `git remote add` 恢复;改动前配置存
  `.workbuddy-backup/git-remotes.20261006-211516.txt`
- 确认无 gitee 相关 credential 残留

二、发布链路去 Gitee 化(6 处)
- `deploy/editor-api/bootstrap.sh`
    · 删掉 GITEE_URL / GITEE_SSH_KEY 两个变量与「没给私钥就降级」的分支
    · PUSH_REMOTES 默认 → origin
    · 原「配 gitee 辅仓远端」一节改为「清理退役远端」循环(gh/gitee/gitea),
      让从旧部署续用的工作区自动恢复干净
- `docker-compose.editor.yml`、`editor-api/server.mjs` → 默认值 origin
- `editor-api/README.md` → 变量表同步
- `editor-api/Dockerfile` → 注释里的「CNB / GitHub」改「CNB」
- `blog-admin/src/routes/rss/tools.ts` → deploy-notify 的注释里
  「与 Gitea Actions 的构建通知配套」改为中性描述
  (该轮询链路 2026-10-04 起已被 CNB 国内节点直传取代)

三、`scripts/setup-cnb-remotes.sh` 重写为单远端模式
- 去掉 GITEE_URL / GITEE_TOKEN 参数、校验、凭据写入与自检提示
- 新增「清理退役远端」步骤
- 凭据处理改为「已存在空的 credential.helper 就不再添加」,
  不再用 --replace-all —— 本仓另有一个从 `$HOME/.workbuddy/secrets/cnb-token`
  读令牌的自定义 helper,那是有效的,不能被脚本抹掉

四、备份升级为「唯一辅仓」的配置
- 保留份数 3 → 7(一周窗口;每份 605.5 MB ≈ 4.2 GB,F50 有 256 GB)
    · `scripts/backup-task.cmd` 默认参数 --keep 7
    · `scripts/backup-bundle.mjs` 的 KEEP 默认值同步为 7
      (原先写的是 2,一直被命令行参数掩盖着)
- 远端目录 `/本地/备份` → `/本地/备份/blog-bundle`:
  根目录是用户自己在用的(放着 github-zqlit-*、local-repos-* 等手工备份),
  实测发现直接放根下的 bundle 已被清掉 —— 改子目录隔离,避免混放与误删

五、新增 `scripts/backup-run.mjs`:备份的推荐入口 + 失败告警
- 读 `.workbuddy-backup/openlist-backup.env`(只补空缺,环境变量优先)
- 跑 backup-bundle.mjs 并实时透传输出,同时留一份日志尾部
- 退出码非 0 → 经 `scripts/send_mail.js` 发告警邮件(附日志尾部与常见原因);
  成功默认不发,`--notify-success` 才发
- 退出用 `process.exitCode` 而非 `process.exit()`,避免截断未排干的 stdout
- 发信失败不改判备份退出码 —— 通知不该掩盖真正的故障
- 理由:这是当前**唯一**的异地备份,而「每天自动跑」的任务最典型的失败模式
  恰恰是静默的(F50 被带出门、换了网段、OpenList 没起来、口令改过……),
  没有告警就要等到真要用备份那天才发现
- `scripts/backup-task.cmd` 改调它

六、文档
- `架构总览.md`
    · §1.2 地址地图:备份行改指 F50/OpenList;通知行补「兼做备份失败告警」
    · §2 旅程图:双推改单推,并说明备份换了介质
    · §5.1 / §5.2 推送与远端:只剩 origin;补「已移除远端」表与恢复命令;
      GitHub 退役记录保留并补上「CI 定义也已删除」
    · §5.3 由「辅仓选型」改为「异地备份的定案」—— 明确不走 git 远端;
      平台对比数据保留备查,并注明 `bin/linux/hugo` 出库不必再做了
    · §5.6 补「唯一备份」定位、专属子目录、失败告警、keep 7、SMTP 配置键,
      实测数据更新为本次复测值
    · §6 待办:#2 定案、#3 不必做、#4 已移除、#8 已更新、#10 定位升级,
      新增 #11(F50 目录使用约定)
- `CNB构建落地方案.md` §4.0:双远端改单远端,脚本示例去掉 Gitee 参数
- `README.md`:推送说明改单推;脚本表补 backup-run.mjs

实测(2026-10-06,本轮复测):
  bundle 7.4s / AES-256-GCM 加密 1.3s / 上传 19.2s(31.6 MB/s)
  / 读回 sha256 一致 → 端到端 60.6s,退出码 0
  告警邮件链路已实测(发出一封「备份成功」验证信)

★ 一处过程记录,供以后避免重复踩坑:
  中途我把「本机沙箱里 `env -u ... cmd > file` 会让输出整个消失」
  误判成 process.exit 截断 stdout,并据此改了日志实现;
  随后用 `env -u FOO echo hi > file`(同样零输出)证伪 ——
  那是沙箱文件重定向的伪影,与脚本无关。相关改动已回滚,
  只留下本身无害的 process.exitCode 写法。
2026-10-06 21:27:28 +08:00

411 lines
17 KiB
TypeScript
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.
// /api/favicon —— 自动发现并返回站点 favicon
// /api/update —— 接收抓取结果写入 KV(scheduled 已内置,此端点保留兼容外部上报)
// /api/proxy —— RSS 抓取代理(EdgeOne 版迁过来;注意本 Worker 在境外,等价于原 EdgeOne)
// 迁自 edgeone/functions/api/{favicon,update,proxy}.js
import type { Env } from '../../types';
import { respond, requireAuth } from '../../lib/rss/util';
import { notifyAdmin } from '../../lib/admin-notify';
import { checkProxyHealth, notifyProxyDown } from '../../lib/rss/proxy-health';
import { kvGetJson, kvPutJson } from '../../lib/rss/util';
const CACHE_TTL = 604800;
function resolveUrl(href: string, baseUrl: string): string | null {
if (href.startsWith('http://') || href.startsWith('https://')) return href;
try {
return new URL(href, baseUrl).href;
} catch {
return null;
}
}
async function discoverFaviconUrl(siteUrl: string): Promise<string | null> {
if (!/^https?:\/\//i.test(siteUrl)) siteUrl = 'https://' + siteUrl;
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 12000);
const res = await fetch(siteUrl, {
signal: ctrl.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
Accept: 'text/html,application/xhtml+xml,*/*',
},
});
clearTimeout(t);
const html = await res.text();
const iconPatterns = [
/<link[^>]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i,
/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']apple-touch-icon["']/i,
];
for (const pattern of iconPatterns) {
const match = html.match(pattern);
if (match) return resolveUrl(match[1].trim(), siteUrl);
}
const urlObj = new URL(siteUrl);
const defaultFavicon = `${urlObj.protocol}//${urlObj.host}/favicon.ico`;
try {
const ctrl2 = new AbortController();
const t2 = setTimeout(() => ctrl2.abort(), 8000);
const check = await fetch(defaultFavicon, { method: 'HEAD', signal: ctrl2.signal, redirect: 'follow' });
clearTimeout(t2);
const ct = (check.headers.get('content-type') || '').toLowerCase();
// 必须确认返回的真是图片:Typecho/WordPress 等会把 404 页面以 200 + text/html 返回
if (check.ok && (ct.startsWith('image/') || ct.includes('octet-stream'))) return defaultFavicon;
} catch {
/* 继续走兜底 */
}
// favicon.ico 不通或返回的不是图片 → 兜底第三方 favicon 服务(国内可达),
// 避免友圈卡片大面积落回灰色默认头像
return `https://api.iowen.cn/favicon/${urlObj.host}.png`;
} catch {
try {
const urlObj = new URL(siteUrl);
// 同上:抓取失败兜底第三方服务
return `https://api.iowen.cn/favicon/${urlObj.host}.png`;
} catch {
return null;
}
}
}
/**
* favicon 图片响应的统一缓存头。
*
* ★ 为什么要显式加 s-maxage / CDN-Cache-Control:
* `Cache-Control: max-age=86400` 只让**浏览器**缓存,Cloudflare 边缘默认
* **不缓存 Worker 的响应**(实测响应里没有 cf-cache-status)。于是每个访客、
* 每次清缓存、每换设备,都会真正进 Worker → 第一条语句就是 KV.get → KV 读量被放大。
*
* 加上 s-maxage(共享缓存)后,CF 边缘可缓存 24h:边缘命中就完全不进 Worker,
* KV 读取 ≈ 0,同时首包更快。浏览器侧仍保留 max-age。
* 只对**成功拿到图片**的响应使用;失败/兜底响应不缓存(避免把错误固化一天)。
*/
function imageCacheHeaders(contentType: string): Record<string, string> {
return {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
'CDN-Cache-Control': 'max-age=86400',
'Cloudflare-CDN-Cache-Control': 'max-age=86400',
'Access-Control-Allow-Origin': '*',
};
}
export async function favicon(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const siteUrl = url.searchParams.get('url');
if (!siteUrl) return respond({ error: 'url parameter required' }, 400);
const bodyKey = 'favicon:body:v2:' + siteUrl;
// 一级缓存:图片本体直接存 KV(base64)——命中后零外站回源,响应最快
const cached = await env.RSS_KV.get(bodyKey);
if (cached) {
const [meta, b64] = cached.split('|');
const body = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
return new Response(body, { status: 200, headers: imageCacheHeaders(meta) });
}
// 失败短缓存:抓取失败的站点 1 小时内不再反复打外站
const missKey = 'favicon:miss:' + siteUrl;
if (await env.RSS_KV.get(missKey)) {
return letterSvg(siteUrl);
}
if (url.searchParams.get('meta')) {
const u = await discoverFaviconUrl(siteUrl);
return respond({ url: u });
}
// 发现 + 下载图片本体(兼容读旧版 URL 字符串缓存:存量友链的发现结果都在旧 key 里)
let faviconUrl: string | null = await env.RSS_KV.get('favicon:' + siteUrl).catch(() => null);
if (!faviconUrl) {
try {
faviconUrl = await discoverFaviconUrl(siteUrl);
if (faviconUrl) await env.RSS_KV.put('favicon:' + siteUrl, faviconUrl, { expirationTtl: CACHE_TTL });
} catch {
faviconUrl = null;
}
}
if (faviconUrl) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10000);
const imgRes = await fetch(faviconUrl, {
signal: ctrl.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
},
});
clearTimeout(t);
if (imgRes.ok) {
const rawCt = (imgRes.headers.get('content-type') || '').toLowerCase();
// 只接受真正的图片(含 octet-stream 的 .ico);HTML/文本一律视为抓取失败
if (!rawCt.startsWith('image/') && !rawCt.includes('octet-stream')) {
throw new Error('favicon not an image: ' + rawCt);
}
const contentType = rawCt || 'image/x-icon';
const buf = await imgRes.arrayBuffer();
// 只缓存小于 300KB 的图(KV 单值上限 25MB,异常大图不占空间)
if (buf.byteLength <= 307200) {
let bin = '';
const bytes = new Uint8Array(buf);
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
}
await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL });
}
return new Response(buf, { status: 200, headers: imageCacheHeaders(contentType) });
}
} catch {
/* 落到第三方兜底 */
}
}
// 兜底链:第三方 favicon 服务代理返回(仍尝试写缓存,下次直接命中)
let host = siteUrl.replace(/^https?:\/\//, '').split('/')[0];
const fallback = `https://api.iowen.cn/favicon/${host}.png`;
try {
const ctrl3 = new AbortController();
const t3 = setTimeout(() => ctrl3.abort(), 10000);
const fRes = await fetch(fallback, { signal: ctrl3.signal, redirect: 'follow' });
clearTimeout(t3);
if (fRes.ok) {
const contentType = fRes.headers.get('content-type') || 'image/png';
const buf = await fRes.arrayBuffer();
let bin = '';
const bytes = new Uint8Array(buf);
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
}
await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL });
return new Response(buf, { status: 200, headers: imageCacheHeaders(contentType) });
}
} catch {
/* 彻底失败 */
}
// 记一次失败,1 小时内不再打外站;返回自绘字母 SVG 兜底
// (零依赖永远可用,比第三方服务和灰色默认头像都体面)
await env.RSS_KV.put(missKey, '1', { expirationTtl: 3600 });
return letterSvg(siteUrl);
}
/** 站点首字母占位图(永远 200 的最后兜底) */
function letterSvg(siteUrl: string): Response {
let host = siteUrl.replace(/^https?:\/\//, '').split('/')[0].replace(/^www\./, '');
const letter = (host[0] || '?').toUpperCase();
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">` +
`<rect width="64" height="64" rx="12" fill="#2f9e63"/>` +
`<text x="32" y="43" font-family="Arial,sans-serif" font-size="32" font-weight="600" fill="#ffffff" text-anchor="middle">${letter}</text></svg>`;
return new Response(svg, {
status: 200,
headers: {
// 兜底图也缓存(否则每个访客都要重跑一遍抓取失败流程 = 3 次 KV 读 + 1 次写)
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
'CDN-Cache-Control': 'max-age=86400',
'Cloudflare-CDN-Cache-Control': 'max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
export async function update(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
try {
const data = await request.json();
await env.RSS_KV.put('latest', JSON.stringify(data), { expirationTtl: 604800 });
return respond({ ok: true });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
/**
* POST /api/notify-mail —— 通用提醒邮件(给自己发一封)
* body: { subject, text, dedupeKey?, minGapHours? }
* 鉴权:requireAuth;收件人固定 MAIL_ADMIN,不能指定别人。
*/
export async function notifyMail(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const result = await notifyAdmin(env, {
subject: String(body?.subject || '提醒'),
text: String(body?.text || ''),
dedupeKey: body?.dedupeKey,
minGapHours: Number(body?.minGapHours) || 0,
});
if (result.error) return respond({ error: result.error }, 400);
return respond(result);
}
/**
* GET /api/proxy-health —— 国内代理(scfapi.usj.cc)体检
* ?notify=1 时若不健康还会给站长发一封提醒邮件(48h 节流)。
*/
export async function proxyHealth(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const health = await checkProxyHealth(env);
let mailed: unknown = null;
if (!health.ok && new URL(request.url).searchParams.get('notify') === '1') {
mailed = await notifyProxyDown(env, health);
}
return respond({ ...health, mailed });
}
export async function proxy(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' } });
}
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { url, timeout = 15000 } = body;
if (!url) return respond({ error: 'url required' }, 400);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const targetRes = await fetch(url, {
signal: controller.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
Accept: 'text/html,application/xhtml+xml,application/xml,application/json;q=0.9,*/*;q=0.8',
},
});
clearTimeout(timer);
const text = await targetRes.text();
const ct = targetRes.headers.get('content-type') || '';
return respond({ ok: true, status: targetRes.status, contentType: ct, body: text });
} catch (err) {
return respond({ ok: false, error: (err as Error).message }, 502);
}
}
/**
* POST /api/cron/run?offset=0&limit=35 —— 手动触发一批抓取(token 鉴权)。
* 免费版 subrequest 限制下用于补跑/首灌数据。
*/
export async function cronRun(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const url = new URL(request.url);
const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10) || 0, 0);
const limit = Math.max(parseInt(url.searchParams.get('limit') || '35', 10) || 0, 0);
// dry=1:只跑抓取、不写 KV、不发通知 —— 用于安全地测耗时/成功率(不会污染线上数据)
const dry = url.searchParams.get('dry') === '1';
// rotate=1:从 KV 游标继续抓(模拟定时任务的行为)
const rotate = url.searchParams.get('rotate') === '1';
// 抓取主要是 IO 等待(不计 CPU),HTTP 触发也没有时长上限,35 个源串行能跑完
const { runCron } = await import('../../lib/rss/cron');
const stats = await runCron(env, { offset, limit, dry, rotate });
return respond({ ok: true, offset, limit, stats });
}
// ============================================================================
// 部署状态(国内中转机同步监控)
// CI 的 build job 在 COS 上传后上报 phase=built;中转机 sync.sh 同步完成后
// 上报 phase=synced。GET 公开可查,对比两者就知道国内线路部署完了没。
// ============================================================================
interface DeployPhase {
hash: string;
phase: string;
at: string;
}
export async function deployStatusGet(_request: Request, env: Env): Promise<Response> {
const st = await kvGetJson<{ builds: DeployPhase[] }>(env, 'deploy_status', { builds: [] });
const builds = (st.builds || []).slice(0, 5);
const built = builds.find((b) => b.phase === 'built');
const synced = builds.find((b) => b.phase === 'synced');
let state = 'unknown';
if (built && synced && synced.hash === built.hash) state = 'synced';
else if (built && !synced) state = 'syncing';
else if (synced && !built) state = 'synced'; // 未配置 CI 上报时,以中转机记录为准
else if (built && synced && synced.hash !== built.hash) state = 'syncing';
return respond({
state, // synced = 国内已生效;syncing = 构建完成等待中转机同步
latest_build: built || null,
latest_sync: synced || null,
builds,
});
}
export async function deployStatusPost(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: { hash?: string; phase?: string } = {};
try {
body = (await request.json()) as { hash?: string; phase?: string };
} catch {
return respond({ error: 'invalid json' }, 400);
}
const hash = String(body.hash || '').slice(0, 64);
const phase = String(body.phase || '').slice(0, 20);
if (!hash || !phase) return respond({ error: 'hash and phase required' }, 400);
const st = await kvGetJson<{ builds: DeployPhase[] }>(env, 'deploy_status', { builds: [] });
st.builds = (st.builds || []).filter((b) => !(b.phase === phase && b.hash === hash));
st.builds.unshift({ hash, phase, at: new Date().toISOString() });
st.builds = st.builds.slice(0, 20);
await kvPutJson(env, 'deploy_status', st);
return respond({ ok: true });
}
/**
* POST /api/deploy-notify —— 部署完成邮件(token 鉴权)。
* 历史用途:与广州中转机 `sync.sh` 配套 —— 构建通知告诉你"构建好了",
* 这封邮件告诉你"国内线路真正生效了"。
* (2026-10-04 起国内线路改由 CNB 国内节点直传又拍云,该轮询链路已停用;
* 端点保留以兼容旧调用方。)
*/
export async function deployNotify(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: { hash?: string } = {};
try {
body = (await request.json()) as { hash?: string };
} catch {
return respond({ error: 'invalid json' }, 400);
}
const hash = String(body.hash || '').slice(0, 12) || '未知';
const { sendMail } = await import('../../lib/mail');
const okMail = await sendMail(env, {
to: 'imql@qq.com',
subject: `✅ 博客国内线路部署完成 ${hash}`,
text: `构建产物 ${hash} 已同步到又拍云并刷新多吉云 CDN,国内线路已生效。\n时间:${new Date().toISOString()}`,
html:
`<p>构建产物 <code>${hash}</code> 已同步到又拍云并刷新多吉云 CDN,<b>国内线路已生效</b>。</p>` +
`<p style="color:#8a8e98">时间:${new Date().toISOString()} · artalk-cf deploy monitor</p>`,
});
return respond({ ok: okMail });
}