224 lines
6.5 KiB
TypeScript
224 lines
6.5 KiB
TypeScript
import { RSS_API_BASE, RSS_API_TOKEN, WECHAT_APP_ID, WECHAT_APP_SECRET } from "./config";
|
|
|
|
async function rssFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const url = `${RSS_API_BASE}${path}`;
|
|
const res = await fetch(url, options);
|
|
if (!res.ok) throw new Error(`RSS API error: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
function withToken(params?: URLSearchParams): string {
|
|
const p = params || new URLSearchParams();
|
|
if (RSS_API_TOKEN) p.set("token", RSS_API_TOKEN);
|
|
return p.toString();
|
|
}
|
|
|
|
// ---- Types ----
|
|
|
|
export interface Feed {
|
|
url: string;
|
|
feedTitle: string;
|
|
siteUrl?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface FeedHealth {
|
|
url: string;
|
|
status: number;
|
|
ok: boolean;
|
|
latency?: number;
|
|
error?: string;
|
|
}
|
|
|
|
export interface Link {
|
|
name: string;
|
|
url: string;
|
|
description?: string;
|
|
image?: string;
|
|
hidden?: boolean;
|
|
rss?: string;
|
|
addedAt?: string;
|
|
}
|
|
|
|
export interface LinkHealth {
|
|
url: string;
|
|
ok: boolean;
|
|
status?: number;
|
|
latency?: number;
|
|
error?: string;
|
|
}
|
|
|
|
export interface Article {
|
|
title: string;
|
|
link: string;
|
|
pubDate: string;
|
|
feedName: string;
|
|
siteUrl: string;
|
|
favicon: string;
|
|
author: string;
|
|
}
|
|
|
|
export interface HealthResult {
|
|
total: number;
|
|
alive: number;
|
|
dead: number;
|
|
results: FeedHealth[];
|
|
}
|
|
|
|
// ---- Feeds API ----
|
|
|
|
export async function getFeeds(): Promise<Feed[]> {
|
|
const data = await rssFetch<{ feeds: Feed[] }>("/api/feeds");
|
|
return data.feeds || [];
|
|
}
|
|
|
|
export async function addFeed(url: string, feedTitle: string): Promise<void> {
|
|
const qs = withToken();
|
|
await rssFetch(`/api/feeds?${qs}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url, feedTitle }),
|
|
});
|
|
}
|
|
|
|
export async function updateFeed(oldUrl: string, newUrl: string, feedTitle: string): Promise<void> {
|
|
const qs = withToken();
|
|
const body: Record<string, string> = { url: newUrl, feedTitle };
|
|
if (oldUrl && oldUrl !== newUrl) body.oldUrl = oldUrl;
|
|
await rssFetch(`/api/feeds?${qs}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
export async function deleteFeed(url: string): Promise<void> {
|
|
const qs = withToken(new URLSearchParams({ url, token: RSS_API_TOKEN }));
|
|
await rssFetch(`/api/feeds?${qs}`, { method: "DELETE" });
|
|
}
|
|
|
|
export async function getFeedsHealth(): Promise<HealthResult> {
|
|
return rssFetch("/api/health");
|
|
}
|
|
|
|
// ---- Articles API ----
|
|
|
|
export async function getArticles(limit = 10): Promise<Article[]> {
|
|
const data = await rssFetch<{ articles: Article[] }>(`/api/articles?limit=${limit}`);
|
|
return data.articles || [];
|
|
}
|
|
|
|
// ---- Links API ----
|
|
|
|
export async function getLinks(all = false): Promise<Link[]> {
|
|
const p = new URLSearchParams();
|
|
if (all) p.set("all", "1");
|
|
const data = await rssFetch<{ links: Link[] }>(`/api/links?${p.toString()}`);
|
|
return data.links || [];
|
|
}
|
|
|
|
export async function addLink(link: { name: string; url: string; description?: string; hidden?: boolean }): Promise<void> {
|
|
const qs = withToken();
|
|
await rssFetch(`/api/links?${qs}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(link),
|
|
});
|
|
}
|
|
|
|
export async function updateLink(oldUrl: string, data: { name?: string; url?: string; description?: string; hidden?: boolean }): Promise<void> {
|
|
const qs = withToken();
|
|
const body: Record<string, unknown> = { ...data };
|
|
if (data.url && data.url !== oldUrl) body.oldUrl = oldUrl;
|
|
await rssFetch(`/api/links?${qs}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
export async function deleteLink(url: string): Promise<void> {
|
|
const qs = withToken(new URLSearchParams({ url, token: RSS_API_TOKEN }));
|
|
await rssFetch(`/api/links?${qs}`, { method: "DELETE" });
|
|
}
|
|
|
|
// ---- SCF Proxy ----
|
|
|
|
const SCF_PROXY = process.env.SCF_PROXY || "https://scfapi.usj.cc";
|
|
|
|
export async function checkLinkHealth(targetUrl: string): Promise<LinkHealth> {
|
|
const start = Date.now();
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 12000);
|
|
const res = await fetch(targetUrl, {
|
|
method: "HEAD",
|
|
signal: controller.signal,
|
|
headers: { "User-Agent": "Mozilla/5.0 (compatible; BlogAdmin/1.0)" },
|
|
});
|
|
clearTimeout(timer);
|
|
return { url: targetUrl, ok: res.ok || res.status < 500, status: res.status, latency: Date.now() - start };
|
|
} catch (err: unknown) {
|
|
return { url: targetUrl, ok: false, error: err instanceof Error ? err.message : "timeout", latency: Date.now() - start };
|
|
}
|
|
}
|
|
|
|
export async function checkLinkViaProxy(targetUrl: string): Promise<LinkHealth> {
|
|
const start = Date.now();
|
|
try {
|
|
const res = await fetch(SCF_PROXY, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url: targetUrl, timeout: 12000 }),
|
|
});
|
|
const data = await res.json();
|
|
return {
|
|
url: targetUrl,
|
|
ok: data.ok && (data.status < 500),
|
|
status: data.status,
|
|
latency: Date.now() - start,
|
|
error: data.ok ? undefined : data.error,
|
|
};
|
|
} catch (err: unknown) {
|
|
return { url: targetUrl, ok: false, error: err instanceof Error ? err.message : "proxy error", latency: Date.now() - start };
|
|
}
|
|
}
|
|
|
|
// ---- WeChat Material API ----
|
|
|
|
export interface WechatArticle {
|
|
title: string;
|
|
thumb_media_url?: string;
|
|
thumb_media_id?: string;
|
|
author?: string;
|
|
digest?: string;
|
|
show_cover_pic?: number;
|
|
content: string;
|
|
content_source_url?: string;
|
|
content_image_urls?: { original_url: string }[];
|
|
need_open_comment?: number;
|
|
only_fans_can_comment?: number;
|
|
}
|
|
|
|
export async function getWechatStatus(): Promise<{ configured: boolean; appId?: string }> {
|
|
const data = await rssFetch<{ configured: boolean; appId?: string }>("/api/wechat-material");
|
|
return data;
|
|
}
|
|
|
|
export async function syncWechat(articles: Record<string, unknown>[]): Promise<{ ok: boolean; media_id?: string; message?: string }> {
|
|
const qs = withToken();
|
|
const body: Record<string, unknown> = { articles };
|
|
if (WECHAT_APP_ID) body.appId = WECHAT_APP_ID;
|
|
if (WECHAT_APP_SECRET) body.appSecret = WECHAT_APP_SECRET;
|
|
const res = await fetch(`${RSS_API_BASE}/api/wechat-material?${qs}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok || !data.ok) {
|
|
throw new Error(data.error || data.message || `WeChat API error: ${res.status}`);
|
|
}
|
|
return data;
|
|
}
|