404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
// /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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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: {
|
|||
|
|
'Content-Type': meta,
|
|||
|
|
'Cache-Control': 'public, max-age=86400',
|
|||
|
|
'Access-Control-Allow-Origin': '*',
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 失败短缓存:抓取失败的站点 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: {
|
|||
|
|
'Content-Type': contentType,
|
|||
|
|
'Cache-Control': 'public, max-age=86400',
|
|||
|
|
'Access-Control-Allow-Origin': '*',
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} 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: {
|
|||
|
|
'Content-Type': contentType,
|
|||
|
|
'Cache-Control': 'public, max-age=86400',
|
|||
|
|
'Access-Control-Allow-Origin': '*',
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} 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: {
|
|||
|
|
'Content-Type': 'image/svg+xml',
|
|||
|
|
'Cache-Control': 'public, 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 鉴权)。
|
|||
|
|
* 与 Gitea Actions 的构建通知配套:构建通知告诉你"构建好了",
|
|||
|
|
* 这封邮件告诉你"国内线路真正生效了"。
|
|||
|
|
*/
|
|||
|
|
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 });
|
|||
|
|
}
|