0520发布文章
This commit is contained in:
1 parent
993ac5581d
commit
013dce4f9f
89 files changed
+11923
-54
No files matched your search
@@ -0,0 +1,69 @@
|
||||
const DEEPSEEK_BASE = process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com";
|
||||
const DEEPSEEK_KEY = process.env.DEEPSEEK_API_KEY || "";
|
||||
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
|
||||
const DEEPSEEK_ANTHROPIC = process.env.DEEPSEEK_ANTHROPIC === "true";
|
||||
|
||||
export async function aiChat(
|
||||
systemPrompt: string,
|
||||
userMessage: string
|
||||
): Promise<string> {
|
||||
if (!DEEPSEEK_KEY) throw new Error("DeepSeek API key not configured");
|
||||
|
||||
let url: string;
|
||||
let headers: Record<string, string>;
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
if (DEEPSEEK_ANTHROPIC) {
|
||||
// Anthropic Messages API
|
||||
url = `${DEEPSEEK_BASE}/v1/messages`;
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": DEEPSEEK_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
};
|
||||
body = {
|
||||
model: DEEPSEEK_MODEL,
|
||||
max_tokens: 2048,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: "user", content: userMessage }],
|
||||
};
|
||||
} else {
|
||||
// OpenAI-compatible API (default)
|
||||
url = `${DEEPSEEK_BASE}/v1/chat/completions`;
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${DEEPSEEK_KEY}`,
|
||||
};
|
||||
body = {
|
||||
model: DEEPSEEK_MODEL,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: { message?: string } }).error?.message || `API error: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// Anthropic format or OpenAI format
|
||||
const text = data?.content?.[0]?.text || data?.choices?.[0]?.message?.content || "";
|
||||
if (!text) throw new Error("AI returned empty response");
|
||||
return text;
|
||||
}
|
||||
|
||||
export const AI_PROMPTS = {
|
||||
polish: "你是一个文字润色助手。请润色以下文字,使其更流畅、更有文采,但保持原意和语气不变。直接返回润色后的文字,不要加任何解释。",
|
||||
expand: "你是一个写作助手。请扩写以下文字,增加更多细节和论述,使其更充实。保持原风格和语气。直接返回扩写后的文字,不要加任何解释。",
|
||||
shorten: "你是一个写作助手。请缩写以下文字,保留核心信息,删除冗余。直接返回缩写后的文字,不要加任何解释。",
|
||||
fix: "你是一个文字校对助手。请修正以下文字中的错别字、语法错误和标点问题。直接返回修正后的文字,不要加任何解释。",
|
||||
summary: "根据以下文章标题和内容,生成一个简短的摘要(100字以内)。直接返回摘要,不要加任何解释。\n\n标题:{title}\n\n内容片段:{content}",
|
||||
tags: "根据以下文章标题和内容,提取3-5个中文标签(用顿号分隔)。只返回标签,不要加解释。\n\n标题:{title}\n\n内容片段:{content}",
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ARTALK_SERVER, ARTALK_SITE } from "./config";
|
||||
|
||||
// Artalk API v2 types
|
||||
export interface ArtalkComment {
|
||||
id: number;
|
||||
content: string;
|
||||
nick: string;
|
||||
email: string;
|
||||
page_key: string;
|
||||
page_title: string;
|
||||
user_id: number;
|
||||
ua: string;
|
||||
ip: string;
|
||||
is_collapsed: boolean;
|
||||
is_pending: boolean;
|
||||
is_pinned: boolean;
|
||||
vote_up: number;
|
||||
vote_down: number;
|
||||
rid: number;
|
||||
date: string;
|
||||
updated_at: string;
|
||||
badge_word?: string;
|
||||
is_allow_reply?: boolean;
|
||||
children?: ArtalkComment[];
|
||||
}
|
||||
|
||||
export interface ArtalkPage {
|
||||
id: number;
|
||||
admin_only: boolean;
|
||||
key: string;
|
||||
title: string;
|
||||
url: string;
|
||||
view: number;
|
||||
comment_count: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let authToken: string | null = null;
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
authToken = token;
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ token: string; user: { name: string; email: string } }> {
|
||||
const url = `${ARTALK_SERVER}/api/v2/auth/email/login`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!json || !json.token) {
|
||||
throw new Error(json.msg || "登录失败");
|
||||
}
|
||||
|
||||
authToken = json.token as string;
|
||||
return { token: authToken, user: json.user };
|
||||
}
|
||||
|
||||
export async function getPages(options?: {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
type?: "all" | "admin_only";
|
||||
}): Promise<{ pages: ArtalkPage[]; total: number }> {
|
||||
const { offset = 0, limit = 50, type = "all" } = options || {};
|
||||
|
||||
const params = new URLSearchParams({
|
||||
site_name: ARTALK_SITE,
|
||||
offset: offset.toString(),
|
||||
limit: limit.toString(),
|
||||
type,
|
||||
});
|
||||
|
||||
const url = `${ARTALK_SERVER}/api/v2/pages?${params.toString()}`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(url, { headers });
|
||||
const json = await res.json();
|
||||
|
||||
if (json.msg && !json.pages && !json.count) {
|
||||
throw new Error(json.msg);
|
||||
}
|
||||
|
||||
return { pages: json.pages || json.data || [], total: json.count || json.total || 0 };
|
||||
}
|
||||
|
||||
export async function getComments(
|
||||
pageKey: string,
|
||||
options?: { offset?: number; limit?: number }
|
||||
): Promise<{ comments: ArtalkComment[]; total: number }> {
|
||||
const { offset = 0, limit = 100 } = options || {};
|
||||
|
||||
const params = new URLSearchParams({
|
||||
site_name: ARTALK_SITE,
|
||||
page_key: pageKey,
|
||||
offset: offset.toString(),
|
||||
limit: limit.toString(),
|
||||
flat_mode: "true",
|
||||
sort_by: "date_desc",
|
||||
});
|
||||
|
||||
const url = `${ARTALK_SERVER}/api/v2/comments?${params.toString()}`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(url, { headers });
|
||||
const json = await res.json();
|
||||
|
||||
if (json.msg && !json.comments && !json.count) {
|
||||
throw new Error(json.msg);
|
||||
}
|
||||
|
||||
return { comments: json.comments || json.data || [], total: json.count || json.total || 0 };
|
||||
}
|
||||
|
||||
async function artalkMutate<T>(endpoint: string, method: string, body: Record<string, unknown>): Promise<T> {
|
||||
const url = `${ARTALK_SERVER}${endpoint}`;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(url, { method, headers, body: JSON.stringify(body) });
|
||||
const json = await res.json();
|
||||
|
||||
if (json.msg) {
|
||||
throw new Error(json.msg);
|
||||
}
|
||||
return (json.data || json) as T;
|
||||
}
|
||||
|
||||
export async function updateComment(
|
||||
id: number,
|
||||
data: {
|
||||
content?: string;
|
||||
is_collapsed?: boolean;
|
||||
is_pending?: boolean;
|
||||
is_pinned?: boolean;
|
||||
rid?: number;
|
||||
}
|
||||
): Promise<ArtalkComment> {
|
||||
return artalkMutate<ArtalkComment>(`/api/v2/comments/${id}`, "PUT", { ...data, site_name: ARTALK_SITE });
|
||||
}
|
||||
|
||||
export async function createComment(data: {
|
||||
page_key: string;
|
||||
content: string;
|
||||
name: string;
|
||||
email: string;
|
||||
rid?: number;
|
||||
}): Promise<ArtalkComment> {
|
||||
return artalkMutate<ArtalkComment>("/api/v2/comments", "POST", { ...data, site_name: ARTALK_SITE });
|
||||
}
|
||||
|
||||
export async function deleteComment(id: number): Promise<void> {
|
||||
await artalkMutate<void>(`/api/v2/comments/${id}`, "DELETE", { site_name: ARTALK_SITE });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from "path";
|
||||
|
||||
export const BLOG_ROOT = path.resolve(process.cwd(), "..");
|
||||
export const CONTENT_DIR = path.join(BLOG_ROOT, "content", "post");
|
||||
export const RECYCLE_DIR = path.join(process.cwd(), ".recycle");
|
||||
export const RECYCLE_POSTS_DIR = path.join(RECYCLE_DIR, "posts");
|
||||
export const RECYCLE_META_DIR = path.join(RECYCLE_DIR, "meta");
|
||||
export const ARTALK_SERVER = "https://artalk.usj.cc";
|
||||
export const ARTALK_SITE = "优世界";
|
||||
export const ITEMS_PER_PAGE = 20;
|
||||
export const RECYCLE_DAYS = 30;
|
||||
export const HUGO_PREVIEW = "http://localhost:1313";
|
||||
export const RSS_API_BASE = process.env.RSS_API_BASE || "https://rssapi.usj.cc";
|
||||
export const RSS_API_TOKEN = process.env.RSS_API_TOKEN || "";
|
||||
export const WECHAT_APP_ID = process.env.WECHAT_APP_ID || "";
|
||||
export const WECHAT_APP_SECRET = process.env.WECHAT_APP_SECRET || "";
|
||||
@@ -0,0 +1,259 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
import { CONTENT_DIR, ITEMS_PER_PAGE } from "./config";
|
||||
|
||||
export interface PostFrontMatter {
|
||||
pid?: number;
|
||||
title: string;
|
||||
date: string;
|
||||
slug: string;
|
||||
draft: boolean;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
author?: string;
|
||||
layout?: string;
|
||||
ai_comment?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
slug: string;
|
||||
frontMatter: PostFrontMatter;
|
||||
content: string;
|
||||
filePath: string;
|
||||
dirPath: string;
|
||||
}
|
||||
|
||||
export interface PostListItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
draft: boolean;
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
author: string;
|
||||
}
|
||||
|
||||
function parseDate(dateStr: string): Date {
|
||||
const d = new Date(dateStr);
|
||||
return isNaN(d.getTime()) ? new Date(0) : d;
|
||||
}
|
||||
|
||||
function findPostDirectories(): string[] {
|
||||
const result: string[] = [];
|
||||
if (!fs.existsSync(CONTENT_DIR)) return result;
|
||||
|
||||
const years = fs.readdirSync(CONTENT_DIR).filter((f) => {
|
||||
const full = path.join(CONTENT_DIR, f);
|
||||
return fs.statSync(full).isDirectory() && /^\d{4}$/.test(f);
|
||||
});
|
||||
|
||||
for (const year of years) {
|
||||
const yearDir = path.join(CONTENT_DIR, year);
|
||||
const posts = fs.readdirSync(yearDir).filter((f) => {
|
||||
return fs.statSync(path.join(yearDir, f)).isDirectory();
|
||||
});
|
||||
for (const post of posts) {
|
||||
result.push(path.join(yearDir, post));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function slugFromDirName(dirName: string): string {
|
||||
// e.g. "2026-01-21-how-to-implement-friend-circle-in-hugo" -> "how-to-implement-friend-circle-in-hugo"
|
||||
const match = dirName.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
|
||||
return match ? match[1] : dirName;
|
||||
}
|
||||
|
||||
function yearFromDirName(dirName: string): string {
|
||||
const match = dirName.match(/^(\d{4})/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
export function listPosts(options?: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
includeDrafts?: boolean;
|
||||
}): { posts: PostListItem[]; total: number; totalPages: number } {
|
||||
const { search = "", page = 1, perPage = ITEMS_PER_PAGE, includeDrafts = true } = options || {};
|
||||
const dirs = findPostDirectories();
|
||||
|
||||
const allPosts: PostListItem[] = [];
|
||||
|
||||
for (const dirPath of dirs) {
|
||||
const indexPath = path.join(dirPath, "index.md");
|
||||
if (!fs.existsSync(indexPath)) continue;
|
||||
|
||||
const raw = fs.readFileSync(indexPath, "utf-8");
|
||||
const { data } = matter(raw);
|
||||
const dirName = path.basename(dirPath);
|
||||
|
||||
const post: PostListItem = {
|
||||
slug: String(data.slug || slugFromDirName(dirName)),
|
||||
title: data.title || "Untitled",
|
||||
date: data.date ? new Date(data.date).toISOString() : "",
|
||||
draft: data.draft === true,
|
||||
categories: Array.isArray(data.categories) ? data.categories : [],
|
||||
tags: Array.isArray(data.tags) ? data.tags : [],
|
||||
author: data.author || "",
|
||||
};
|
||||
|
||||
if (!includeDrafts && post.draft) continue;
|
||||
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const match =
|
||||
post.title.toLowerCase().includes(q) ||
|
||||
post.slug.toLowerCase().includes(q) ||
|
||||
post.categories.some((c) => c.toLowerCase().includes(q)) ||
|
||||
post.tags.some((t) => t.toLowerCase().includes(q));
|
||||
if (!match) continue;
|
||||
}
|
||||
|
||||
allPosts.push(post);
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
allPosts.sort((a, b) => parseDate(b.date).getTime() - parseDate(a.date).getTime());
|
||||
|
||||
const total = allPosts.length;
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
const start = (page - 1) * perPage;
|
||||
const posts = allPosts.slice(start, start + perPage);
|
||||
|
||||
return { posts, total, totalPages };
|
||||
}
|
||||
|
||||
export function getPost(slug: string): Post | null {
|
||||
const dirs = findPostDirectories();
|
||||
|
||||
for (const dirPath of dirs) {
|
||||
const indexPath = path.join(dirPath, "index.md");
|
||||
if (!fs.existsSync(indexPath)) continue;
|
||||
|
||||
const raw = fs.readFileSync(indexPath, "utf-8");
|
||||
const { data, content } = matter(raw);
|
||||
const existingSlug = String(data.slug || slugFromDirName(path.basename(dirPath)));
|
||||
|
||||
if (existingSlug === slug) {
|
||||
return {
|
||||
slug: existingSlug,
|
||||
frontMatter: data as PostFrontMatter,
|
||||
content,
|
||||
filePath: indexPath,
|
||||
dirPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createPost(frontMatter: PostFrontMatter, content: string): string {
|
||||
const date = new Date(frontMatter.date || new Date());
|
||||
const year = date.getFullYear().toString();
|
||||
const dateStr = date.toISOString().slice(0, 10);
|
||||
// Folder name: date + sanitized title (Chinese-friendly)
|
||||
const titlePart = (frontMatter.title || "untitled")
|
||||
.replace(/[^\w一-鿿㐀-䶿]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase() || "post";
|
||||
const dirName = `${dateStr}-${titlePart}`;
|
||||
const dirPath = path.join(CONTENT_DIR, year, dirName);
|
||||
|
||||
if (fs.existsSync(dirPath)) {
|
||||
throw new Error(`Post already exists at ${dirPath}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
|
||||
const fm: Record<string, unknown> = {
|
||||
title: frontMatter.title,
|
||||
date: frontMatter.date,
|
||||
slug: frontMatter.slug,
|
||||
draft: frontMatter.draft ?? false,
|
||||
categories: frontMatter.categories || [],
|
||||
tags: frontMatter.tags || [],
|
||||
author: frontMatter.author || "",
|
||||
layout: frontMatter.layout || "post",
|
||||
};
|
||||
|
||||
if (frontMatter.ai_comment) {
|
||||
fm.ai_comment = frontMatter.ai_comment;
|
||||
}
|
||||
|
||||
const fileContent = matter.stringify(content, fm);
|
||||
const indexPath = path.join(dirPath, "index.md");
|
||||
fs.writeFileSync(indexPath, fileContent, "utf-8");
|
||||
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
export function updatePost(
|
||||
slug: string,
|
||||
frontMatter: Partial<PostFrontMatter>,
|
||||
content?: string
|
||||
): { success: true; dirPath: string } | { success: false } {
|
||||
const post = getPost(slug);
|
||||
if (!post) return { success: false };
|
||||
|
||||
const merged = { ...post.frontMatter, ...frontMatter };
|
||||
|
||||
// If slug changed, we need to rename the directory
|
||||
if (frontMatter.slug && frontMatter.slug !== post.frontMatter.slug) {
|
||||
const newDate = new Date(merged.date || post.frontMatter.date);
|
||||
const newYear = newDate.getFullYear().toString();
|
||||
const newDateStr = newDate.toISOString().slice(0, 10);
|
||||
const newDirName = `${newDateStr}-${frontMatter.slug}`;
|
||||
const newDirPath = path.join(CONTENT_DIR, newYear, newDirName);
|
||||
|
||||
if (fs.existsSync(newDirPath) && newDirPath !== post.dirPath) {
|
||||
throw new Error(`Target path already exists: ${newDirPath}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(newDirPath), { recursive: true });
|
||||
fs.renameSync(post.dirPath, newDirPath);
|
||||
|
||||
const fm: Record<string, unknown> = {
|
||||
title: merged.title,
|
||||
date: merged.date,
|
||||
slug: frontMatter.slug,
|
||||
draft: merged.draft ?? false,
|
||||
categories: merged.categories || [],
|
||||
tags: merged.tags || [],
|
||||
author: merged.author || "",
|
||||
layout: merged.layout || "post",
|
||||
};
|
||||
if (merged.ai_comment) fm.ai_comment = merged.ai_comment;
|
||||
|
||||
const fileContent = matter.stringify(content ?? post.content, fm);
|
||||
fs.writeFileSync(path.join(newDirPath, "index.md"), fileContent, "utf-8");
|
||||
return { success: true, dirPath: newDirPath };
|
||||
}
|
||||
|
||||
// Update in place
|
||||
const fm: Record<string, unknown> = {
|
||||
title: merged.title,
|
||||
date: merged.date,
|
||||
slug: merged.slug,
|
||||
draft: merged.draft ?? false,
|
||||
categories: merged.categories || [],
|
||||
tags: merged.tags || [],
|
||||
author: merged.author || "",
|
||||
layout: merged.layout || "post",
|
||||
};
|
||||
if (merged.ai_comment) fm.ai_comment = merged.ai_comment;
|
||||
|
||||
const fileContent = matter.stringify(content ?? post.content, fm);
|
||||
fs.writeFileSync(post.filePath, fileContent, "utf-8");
|
||||
return { success: true, dirPath: post.dirPath };
|
||||
}
|
||||
|
||||
export function getPostCount(): number {
|
||||
return findPostDirectories().filter((dir) =>
|
||||
fs.existsSync(path.join(dir, "index.md"))
|
||||
).length;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
RECYCLE_DIR,
|
||||
RECYCLE_POSTS_DIR,
|
||||
RECYCLE_META_DIR,
|
||||
RECYCLE_DAYS,
|
||||
} from "./config";
|
||||
|
||||
export interface RecycleItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
deletedAt: string; // ISO date
|
||||
originalPath: string;
|
||||
expiresAt: string; // ISO date
|
||||
daysRemaining: number;
|
||||
}
|
||||
|
||||
export function initRecycleBin(): void {
|
||||
if (!fs.existsSync(RECYCLE_DIR)) {
|
||||
fs.mkdirSync(RECYCLE_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(RECYCLE_POSTS_DIR)) {
|
||||
fs.mkdirSync(RECYCLE_POSTS_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(RECYCLE_META_DIR)) {
|
||||
fs.mkdirSync(RECYCLE_META_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function moveToRecycle(
|
||||
slug: string,
|
||||
sourceDir: string,
|
||||
title: string
|
||||
): void {
|
||||
initRecycleBin();
|
||||
|
||||
const now = new Date();
|
||||
const expires = new Date(now);
|
||||
expires.setDate(expires.getDate() + RECYCLE_DAYS);
|
||||
|
||||
const meta: Omit<RecycleItem, "daysRemaining"> = {
|
||||
slug,
|
||||
title,
|
||||
deletedAt: now.toISOString(),
|
||||
originalPath: sourceDir,
|
||||
expiresAt: expires.toISOString(),
|
||||
};
|
||||
|
||||
// Move post directory to recycle
|
||||
const targetDir = path.join(RECYCLE_POSTS_DIR, slug);
|
||||
if (fs.existsSync(targetDir)) {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(sourceDir, targetDir);
|
||||
|
||||
// Save metadata
|
||||
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
|
||||
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function listRecycle(): RecycleItem[] {
|
||||
initRecycleBin();
|
||||
|
||||
if (!fs.existsSync(RECYCLE_META_DIR)) return [];
|
||||
|
||||
const items: RecycleItem[] = [];
|
||||
const files = fs.readdirSync(RECYCLE_META_DIR).filter((f) => f.endsWith(".json"));
|
||||
|
||||
for (const file of files) {
|
||||
const metaPath = path.join(RECYCLE_META_DIR, file);
|
||||
const raw = fs.readFileSync(metaPath, "utf-8");
|
||||
const meta = JSON.parse(raw);
|
||||
const now = new Date();
|
||||
const expires = new Date(meta.expiresAt);
|
||||
const daysRemaining = Math.max(
|
||||
0,
|
||||
Math.ceil((expires.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
);
|
||||
|
||||
items.push({ ...meta, daysRemaining });
|
||||
}
|
||||
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime()
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function restoreFromRecycle(slug: string): boolean {
|
||||
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
|
||||
if (!fs.existsSync(metaPath)) return false;
|
||||
|
||||
const raw = fs.readFileSync(metaPath, "utf-8");
|
||||
const meta = JSON.parse(raw);
|
||||
const recyclePostPath = path.join(RECYCLE_POSTS_DIR, slug);
|
||||
|
||||
if (!fs.existsSync(recyclePostPath)) return false;
|
||||
|
||||
// Ensure target directory exists
|
||||
const targetDir = path.dirname(meta.originalPath);
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// If target already exists, remove it first
|
||||
if (fs.existsSync(meta.originalPath)) {
|
||||
fs.rmSync(meta.originalPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
fs.renameSync(recyclePostPath, meta.originalPath);
|
||||
fs.unlinkSync(metaPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function permanentDelete(slug: string): boolean {
|
||||
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
|
||||
const recyclePostPath = path.join(RECYCLE_POSTS_DIR, slug);
|
||||
|
||||
let deleted = false;
|
||||
if (fs.existsSync(recyclePostPath)) {
|
||||
fs.rmSync(recyclePostPath, { recursive: true, force: true });
|
||||
deleted = true;
|
||||
}
|
||||
if (fs.existsSync(metaPath)) {
|
||||
fs.unlinkSync(metaPath);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export function cleanExpired(): number {
|
||||
const items = listRecycle();
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
if (item.daysRemaining <= 0) {
|
||||
permanentDelete(item.slug);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- 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 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<{
|
||||
total: number; alive: number; dead: number;
|
||||
results: FeedHealth[];
|
||||
}> {
|
||||
return rssFetch("/api/health");
|
||||
}
|
||||
|
||||
// ---- 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(url: string, data: { name?: string; description?: string; hidden?: boolean }): Promise<void> {
|
||||
// RSS API uses POST as upsert
|
||||
const qs = withToken();
|
||||
await rssFetch(`/api/links?${qs}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, ...data }),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user