修复若干问题
This commit is contained in:
1 parent
013dce4f9f
commit
d9b7026d21
12 files changed
+581
-40
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ARTALK_SERVER, ARTALK_SITE } from "@/lib/config";
|
||||
|
||||
async function getArtalkData(path: string, token: string) {
|
||||
const url = `${ARTALK_SERVER}${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Artalk API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function deleteArtalkPage(id: number, token: string) {
|
||||
const url = `${ARTALK_SERVER}/api/v2/pages/${id}`;
|
||||
const res = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ site_name: ARTALK_SITE }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const token = (body as Record<string, string>).token;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "token required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Login to get a valid token
|
||||
const allPages: { id: number; key: string; title: string; comment_count?: number }[] = [];
|
||||
let offset = 0;
|
||||
const limit = 200;
|
||||
|
||||
// Fetch all pages
|
||||
while (true) {
|
||||
const data = await getArtalkData(
|
||||
`/api/v2/pages?site_name=${encodeURIComponent(ARTALK_SITE)}&limit=${limit}&offset=${offset}`,
|
||||
token
|
||||
);
|
||||
const pages = data.pages || data.data || [];
|
||||
if (pages.length === 0) break;
|
||||
allPages.push(...pages);
|
||||
offset += limit;
|
||||
if (pages.length < limit) break;
|
||||
}
|
||||
|
||||
// 2. Group by title
|
||||
const byTitle = new Map<string, typeof allPages>();
|
||||
for (const p of allPages) {
|
||||
const t = (p.title || "").trim();
|
||||
if (!byTitle.has(t)) byTitle.set(t, []);
|
||||
byTitle.get(t)!.push(p);
|
||||
}
|
||||
|
||||
// 3. For each duplicate group, keep the one with most comments, delete the rest (especially 0-comment ones)
|
||||
let deleted = 0;
|
||||
const deletedIds: number[] = [];
|
||||
|
||||
for (const [title, pages] of byTitle) {
|
||||
if (pages.length <= 1) continue;
|
||||
|
||||
// Sort: most comments first, then by id desc (newest first)
|
||||
pages.sort((a, b) => {
|
||||
const ca = a.comment_count ?? 0;
|
||||
const cb = b.comment_count ?? 0;
|
||||
if (ca !== cb) return cb - ca;
|
||||
return b.id - a.id;
|
||||
});
|
||||
|
||||
// Keep the first one, delete the rest
|
||||
const [keep, ...toDelete] = pages;
|
||||
for (const p of toDelete) {
|
||||
try {
|
||||
const ok = await deleteArtalkPage(p.id, token);
|
||||
if (ok) {
|
||||
deleted++;
|
||||
deletedIds.push(p.id);
|
||||
}
|
||||
} catch { /* skip failed deletions */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Also clean up pages with 0 comments that are NOT part of duplicate groups
|
||||
// (only if they look like test/404 pages — very short titles or empty keys)
|
||||
// Skip this step to be safe — only deduplicate.
|
||||
|
||||
return NextResponse.json({
|
||||
total: allPages.length,
|
||||
duplicateGroups: [...byTitle.values()].filter((ps) => ps.length > 1).length,
|
||||
deleted,
|
||||
deletedIds,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { updatePage } from "@/lib/artalk";
|
||||
import { ARTALK_SITE } from "@/lib/config";
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { key, title, admin_only } = body;
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "title is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await updatePage(parseInt(id, 10), {
|
||||
key: key || "",
|
||||
title,
|
||||
admin_only: admin_only ?? false,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ARTALK_SERVER, ARTALK_SITE } from "@/lib/config";
|
||||
|
||||
async function artalkFetch(path: string, token: string) {
|
||||
const res = await fetch(`${ARTALK_SERVER}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Artalk error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface ArtalkPage {
|
||||
id: number;
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ArtalkComment {
|
||||
nick: string;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "token required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Step 1: Fetch all pages
|
||||
const allPages: ArtalkPage[] = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const data = await artalkFetch(
|
||||
`/api/v2/pages?site_name=${encodeURIComponent(ARTALK_SITE)}&limit=200&offset=${offset}`,
|
||||
token
|
||||
);
|
||||
const pages = data.pages || data.data || [];
|
||||
if (pages.length === 0) break;
|
||||
allPages.push(...pages);
|
||||
offset += 200;
|
||||
if (pages.length < 200) break;
|
||||
}
|
||||
|
||||
// Step 2: Fetch comments per page with concurrency limit
|
||||
const pageCommentCounts: { title: string; count: number }[] = [];
|
||||
const userMap = new Map<string, { nick: string; count: number }>();
|
||||
|
||||
// Batch pages with concurrency of 10
|
||||
for (let i = 0; i < allPages.length; i += 10) {
|
||||
const batch = allPages.slice(i, i + 10);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map((p) =>
|
||||
artalkFetch(
|
||||
`/api/v2/comments?site_name=${encodeURIComponent(ARTALK_SITE)}&page_key=${encodeURIComponent(p.key)}&limit=200&flat_mode=true`,
|
||||
token
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const page = batch[j];
|
||||
const result = results[j];
|
||||
if (result.status !== "fulfilled") continue;
|
||||
|
||||
const comments: ArtalkComment[] = result.value.comments || result.value.data || [];
|
||||
const count = comments.length;
|
||||
if (count > 0) {
|
||||
pageCommentCounts.push({ title: page.title || "(无标题)", count });
|
||||
}
|
||||
|
||||
for (const c of comments) {
|
||||
const key = (c.nick || "匿名").trim();
|
||||
const existing = userMap.get(key);
|
||||
if (existing) existing.count++;
|
||||
else userMap.set(key, { nick: c.nick || "匿名", count: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort
|
||||
const topArticles = pageCommentCounts
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20);
|
||||
|
||||
const topUsers = [...userMap.values()]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 30);
|
||||
|
||||
const totalComments = pageCommentCounts.reduce((s, a) => s + a.count, 0);
|
||||
|
||||
return NextResponse.json({
|
||||
topArticles,
|
||||
topUsers,
|
||||
totalArticles: allPages.length,
|
||||
totalComments,
|
||||
uniqueUsers: userMap.size,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,39 @@ interface ImageFile {
|
||||
size: number;
|
||||
url: string;
|
||||
slug: string;
|
||||
localUrl: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const serveFile = searchParams.get("file");
|
||||
|
||||
// Mode 2: Serve a specific image file (relative path from CONTENT_DIR)
|
||||
if (serveFile) {
|
||||
const fullPath = path.join(CONTENT_DIR, serveFile);
|
||||
if (!fullPath.startsWith(CONTENT_DIR)) {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 403 });
|
||||
}
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp",
|
||||
};
|
||||
const buffer = fs.readFileSync(fullPath);
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Content-Type": mimeMap[ext] || "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Content-Length": String(buffer.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Mode 1: List all images
|
||||
const images: ImageFile[] = [];
|
||||
const exts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"]);
|
||||
|
||||
@@ -38,9 +67,9 @@ export async function GET() {
|
||||
scan(full, folder ? `${folder}/${entry}` : entry, childSlug);
|
||||
} else if (exts.has(path.extname(entry).toLowerCase())) {
|
||||
const rel = path.relative(CONTENT_DIR, full).replace(/\\/g, "/");
|
||||
// Hugo serves page resources at /<slug>/<filename>
|
||||
const url = slug ? `/${slug}/${entry}` : `/${rel}`;
|
||||
images.push({ name: entry, path: full, folder, size: stat.size, url, slug });
|
||||
const localUrl = `/api/images?file=${encodeURIComponent(rel)}`;
|
||||
images.push({ name: entry, path: rel, folder, size: stat.size, url, slug, localUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,15 +87,16 @@ export async function GET() {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const filePath = searchParams.get("path");
|
||||
if (!filePath) return NextResponse.json({ error: "path required" }, { status: 400 });
|
||||
const relPath = searchParams.get("path");
|
||||
if (!relPath) return NextResponse.json({ error: "path required" }, { status: 400 });
|
||||
|
||||
if (!filePath.startsWith(CONTENT_DIR)) {
|
||||
const fullPath = path.join(CONTENT_DIR, relPath);
|
||||
if (!fullPath.startsWith(CONTENT_DIR)) {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getFeeds, addFeed, deleteFeed, checkLinkViaProxy } from "@/lib/rssapi";
|
||||
import { getFeeds, addFeed, updateFeed, deleteFeed, checkLinkViaProxy } from "@/lib/rssapi";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -43,9 +43,7 @@ export async function PUT(request: NextRequest) {
|
||||
if (!oldUrl) {
|
||||
return NextResponse.json({ error: "oldUrl is required" }, { status: 400 });
|
||||
}
|
||||
// Delete old, add new (RSS API has no update endpoint for feeds)
|
||||
await deleteFeed(oldUrl);
|
||||
await addFeed(newUrl || oldUrl, feedTitle || "");
|
||||
await updateFeed(oldUrl, newUrl || oldUrl, feedTitle || "");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -44,17 +44,12 @@ export async function PUT(request: NextRequest) {
|
||||
if (!oldUrl) {
|
||||
return NextResponse.json({ error: "oldUrl is required" }, { status: 400 });
|
||||
}
|
||||
// If URL changed, delete old and create new
|
||||
if (newUrl && newUrl !== oldUrl) {
|
||||
await deleteLink(oldUrl);
|
||||
await addLink({ name: name || "", url: newUrl, description, hidden });
|
||||
} else {
|
||||
const data: Record<string, unknown> = {};
|
||||
if (name !== undefined) data.name = name;
|
||||
if (description !== undefined) data.description = description;
|
||||
if (hidden !== undefined) data.hidden = hidden;
|
||||
await updateLink(oldUrl, data);
|
||||
}
|
||||
const data: Record<string, unknown> = {};
|
||||
if (name !== undefined) data.name = name;
|
||||
if (description !== undefined) data.description = description;
|
||||
if (hidden !== undefined) data.hidden = hidden;
|
||||
if (newUrl !== undefined) data.url = newUrl;
|
||||
await updateLink(oldUrl, data);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
return NextResponse.json(
|
||||
|
||||
Reference in new issue
Block a user