0520发布文章

This commit is contained in:
zqlit committed 2026-05-21 00:27:35 +08:00
1 parent 347d52d4b8
commit c8cdf9a7bc
90 files changed
+11923 -54

No files matched your search

+69
View File
@@ -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}",
};
+165
View File
@@ -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 });
}
+16
View File
@@ -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 || "";
+259
View File
@@ -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;
}
+146
View File
@@ -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;
}
+190
View File
@@ -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;
}