feat: awesome index 全功能实现

- M1-M2: 数据层与核心API(DuckDB, entries/tags/comments/search服务)
- M3: SSR页面(EJS模板,接真实数据)
- M4: 管理后台(登录/用户/内容源/AI ingest)
- M5: 采集层(scheduler+rss/html/eryajf适配器)
- M6: MCP只读接口(Streamable HTTP, 4工具)
- M7: Docker部署(Dockerfile+docker-compose)
- 测试: 10个用例全部通过
- 修复: DuckDB连接参数绑定、状态机迁移、标签树操作
This commit is contained in:
cheney
2026-08-28 09:32:48 +08:00
parent c47bb15d8c
commit dec103546e
51 changed files with 7298 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
import express from "express";
import session from "express-session";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadUser } from "./middleware/auth.js";
import { notFoundHandler, errorHandler } from "./middleware/errors.js";
import { pagesRouter } from "./routes/pages.routes.js";
import { entriesRouter } from "./routes/entries.routes.js";
import { tagsRouter } from "./routes/tags.routes.js";
import { commentsRouter } from "./routes/comments.routes.js";
import { adminRouter } from "./routes/admin.routes.js";
import { mcpRouter } from "./mcp/server.js";
const here = path.dirname(fileURLToPath(import.meta.url));
export function buildApp(config) {
const app = express();
app.disable("x-powered-by");
app.set("view engine", "ejs");
app.set("views", path.join(here, "views"));
app.locals.config = config;
app.locals.fmtStars = (n) => {
const v = Number(n) || 0;
return v >= 1000 ? (v / 1000).toFixed(1) + "k" : String(v);
};
app.locals.fmtDate = (d) =>
d ? new Date(d).toISOString().slice(0, 10) : "—";
app.locals.qs = (obj) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v)) {
if (v.length) p.set(k, v.join(","));
} else if (v !== undefined && v !== null && String(v) !== "") {
p.set(k, v);
}
}
const s = p.toString();
return s;
};
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true }));
app.use(
session({
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", maxAge: 1000 * 60 * 60 * 24 * 14 },
})
);
app.use(loadUser);
app.use(express.static(path.join(here, "..", "public")));
app.use(pagesRouter);
app.use(entriesRouter);
app.use(tagsRouter);
app.use(commentsRouter);
app.use(adminRouter);
app.use(mcpRouter);
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}
+29
View File
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";
function num(v, d) {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : d;
}
function str(v, d) {
return v && v.trim() ? v.trim() : d;
}
const env = process.env;
export const config = {
port: num(env.PORT, 3000),
duckdbPath: str(env.DUCKDB_PATH, "./data/awesome.duckdb"),
sessionSecret: str(env.SESSION_SECRET, "dev-secret-change-me"),
adminInitPassword: str(env.ADMIN_INIT_PASSWORD, "admin"),
aiIngestKey: str(env.AI_INGEST_KEY, ""),
baseUrl: str(env.BASE_URL, `http://localhost:${num(env.PORT, 3000)}`),
schedulerEnabled: env.SCHEDULER_DISABLED !== "1",
};
export function ensureDataDir() {
fs.mkdirSync(path.dirname(path.resolve(config.duckdbPath)), {
recursive: true,
});
}
+41
View File
@@ -0,0 +1,41 @@
import { DuckDBInstance } from "@duckdb/node-api";
let conn = null;
export async function connect(dbPath) {
const instance = await DuckDBInstance.create(dbPath);
conn = await instance.connect();
return conn;
}
export async function close() {
if (conn) {
conn.closeSync();
conn = null;
}
}
export function getDb() {
if (!conn) throw new Error("database not connected");
return conn;
}
export async function all(sql, params = []) {
const reader = await getDb().runAndReadAll(sql, params);
const rows = reader.getRowObjectsJS();
for (const row of rows) {
for (const key of Object.keys(row)) {
if (typeof row[key] === "bigint") row[key] = Number(row[key]);
}
}
return rows;
}
export async function get(sql, params = []) {
const rows = await all(sql, params);
return rows[0] ?? null;
}
export async function run(sql, params = []) {
await getDb().run(sql, params);
}
+99
View File
@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { all, get, run } from "./connection.js";
import { ensureBuiltins } from "../services/source.service.js";
import { createUser, findByUsername } from "../services/user.service.js";
const here = path.dirname(fileURLToPath(import.meta.url));
async function runSchema() {
const ddl = fs.readFileSync(path.join(here, "schema.sql"), "utf8");
for (const stmt of ddl.split(/;\s*\n/)) {
const sql = stmt.trim();
if (sql) await run(sql);
}
}
export async function seedTagsAndEntries() {
const tagCount = await get(`SELECT COUNT(*) AS c FROM tags`);
if (Number(tagCount.c) > 0) return;
async function addTag(name, parentId = null, sort = 0) {
await run(`INSERT INTO tags (name, parent_id, sort) VALUES (?, ?, ?)`, [
name,
parentId,
sort,
]);
return get(`SELECT id FROM tags WHERE name = ? AND parent_id IS NOT DISTINCT FROM ?`, [
name,
parentId,
]);
}
const lang = await addTag("编程语言", null, 0);
for (const [i, n] of ["Rust", "JavaScript / TS", "Go", "Python", "Shell", "C"].entries()) {
await addTag(n, lang.id, i);
}
const form = await addTag("形态", null, 1);
for (const [i, n] of ["CLI", "自托管", "Docker 就绪", "跨平台"].entries()) {
await addTag(n, form.id, i);
}
const misc = [];
for (const [i, n] of ["终端工具", "存储", "监控", "在线服务"].entries()) {
misc.push(await addTag(n, null, 2 + i));
}
async function tagIds(names) {
const ids = [];
for (const n of names) {
const t = await get(`SELECT id FROM tags WHERE name = ?`, [n]);
if (t) ids.push(t.id);
}
return ids;
}
const humanSource = await get(`SELECT id FROM sources WHERE kind = 'human'`);
const samples = [
["ripgrep", "ripgrep", "工具", "以正则递归搜索目录,默认尊重 .gitignore,是代码库里最快的找东西方式。", "https://github.com/BurntSushi/ripgrep", "MIT", 52300, ["Rust", "CLI", "跨平台", "终端工具"]],
["fzf", "fzf", "工具", "通用模糊查找器,管道进任何列表都能交互式筛选,终端工作流的粘合剂。", "https://github.com/junegunn/fzf", "MIT", 68100, ["Go", "CLI", "跨平台", "终端工具"]],
["fd", "fd", "工具", "find 的现代替代品,语法直觉、彩色输出、快得离谱,和 fzf 是天生一对。", "https://github.com/sharkdp/fd", "Apache-2.0", 35700, ["Rust", "CLI", "终端工具"]],
["zoxide", "zoxide", "脚本", "记录你的 cd 习惯,之后打 z proj 就能跳到最常去的目录。", "https://github.com/ajeetdsouza/zoxide", "MIT", 24900, ["Rust", "CLI", "终端工具"]],
["Uptime Kuma", "uptime-kuma", "应用", "自托管的服务可用性监控,漂亮的状态页、通知渠道齐全,五分钟部署完事。", "https://github.com/louislam/uptime-kuma", "MIT", 71900, ["JavaScript / TS", "自托管", "Docker 就绪", "监控"]],
["Excalidraw", "excalidraw", "网站", "手绘风格的白板画图工具,架构图和示意草图的好搭档,支持实时协作。", "https://excalidraw.com", "MIT", 96100, ["TypeScript", "在线服务"]],
["minio", "minio", "微服务", "兼容 S3 API 的对象存储,单二进制即可跑起来,自建存储事实上的标准答案。", "https://github.com/minio/minio", "AGPL-3.0", 54600, ["Go", "自托管", "Docker 就绪", "存储"]],
["ttyd", "ttyd", "微服务", "把任意终端程序共享到浏览器里,一条命令起一个 Web 终端。", "https://github.com/tsl0922/ttyd", "MIT", 10200, ["C", "自托管", "Docker 就绪"]],
];
for (const [title, slug, type, desc, url, license, stars, tags] of samples) {
await run(
`INSERT INTO entries (title, slug, type, description_md, url, license, stars, status, source_id, verified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, now())`,
[title, slug, type, desc, url, license, stars, humanSource.id]
);
const e = await get(`SELECT id FROM entries WHERE slug = ?`, [slug]);
for (const tid of await tagIds(tags)) {
await run(`INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?, ?)`, [e.id, tid]);
}
}
}
export async function runMigrate(config) {
await runSchema();
const admin = await findByUsername("admin");
if (!admin) {
await createUser({ username: "admin", password: config.adminInitPassword, role: "admin" });
}
await ensureBuiltins({ aiIngestKey: config.aiIngestKey });
const feed = await get(`SELECT id FROM sources WHERE adapter = 'eryajf-weekly'`);
if (!feed) {
await run(
`INSERT INTO sources (kind, name, enabled, url, adapter, cron_expr)
VALUES ('feed', '二丫讲梵 · 学习周刊', FALSE, 'https://wiki.eryajf.net/learning-weekly/', 'eryajf-weekly', '0 9 * * 1')`
);
}
await seedTagsAndEntries();
}
+89
View File
@@ -0,0 +1,89 @@
CREATE SEQUENCE IF NOT EXISTS users_id_seq;
CREATE SEQUENCE IF NOT EXISTS sources_id_seq;
CREATE SEQUENCE IF NOT EXISTS source_runs_id_seq;
CREATE SEQUENCE IF NOT EXISTS entries_id_seq;
CREATE SEQUENCE IF NOT EXISTS tags_id_seq;
CREATE SEQUENCE IF NOT EXISTS comments_id_seq;
CREATE SEQUENCE IF NOT EXISTS audit_logs_id_seq;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY DEFAULT nextval('users_id_seq'),
username VARCHAR UNIQUE NOT NULL,
password_hash VARCHAR NOT NULL,
role VARCHAR NOT NULL DEFAULT 'editor',
status VARCHAR NOT NULL DEFAULT 'active',
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS sources (
id INTEGER PRIMARY KEY DEFAULT nextval('sources_id_seq'),
kind VARCHAR NOT NULL,
name VARCHAR UNIQUE NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
url VARCHAR,
adapter VARCHAR,
cron_expr VARCHAR DEFAULT '0 9 * * 1',
api_key_hash VARCHAR,
direct_publish BOOLEAN NOT NULL DEFAULT FALSE,
last_run_at TIMESTAMPTZ,
last_run_status VARCHAR
);
CREATE TABLE IF NOT EXISTS source_runs (
id INTEGER PRIMARY KEY DEFAULT nextval('source_runs_id_seq'),
source_id INTEGER NOT NULL REFERENCES sources(id),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
status VARCHAR,
found INTEGER DEFAULT 0,
created INTEGER DEFAULT 0,
skipped INTEGER DEFAULT 0,
error TEXT
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY DEFAULT nextval('tags_id_seq'),
name VARCHAR NOT NULL,
parent_id INTEGER REFERENCES tags(id),
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY DEFAULT nextval('entries_id_seq'),
title VARCHAR NOT NULL,
slug VARCHAR UNIQUE NOT NULL,
type VARCHAR NOT NULL,
description_md TEXT DEFAULT '',
url VARCHAR DEFAULT '',
license VARCHAR DEFAULT '',
stars BIGINT DEFAULT 0,
status VARCHAR NOT NULL DEFAULT 'pending',
source_id INTEGER REFERENCES sources(id),
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS entry_tags (
entry_id INTEGER NOT NULL REFERENCES entries(id),
tag_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (entry_id, tag_id)
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY DEFAULT nextval('comments_id_seq'),
entry_id INTEGER NOT NULL REFERENCES entries(id),
user_id INTEGER REFERENCES users(id),
author_name VARCHAR NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY DEFAULT nextval('audit_logs_id_seq'),
actor VARCHAR NOT NULL,
action VARCHAR NOT NULL,
target VARCHAR DEFAULT '',
detail TEXT DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+32
View File
@@ -0,0 +1,32 @@
import { config, ensureDataDir } from "./config.js";
import { connect } from "./db/connection.js";
import { runMigrate } from "./db/migrate.js";
import { buildApp } from "./app.js";
import { startScheduler } from "./ingest/scheduler.js";
async function main() {
ensureDataDir();
await connect(config.duckdbPath);
await runMigrate(config);
const app = buildApp(config);
const server = app.listen(config.port, () => {
console.log(`[awesome-index] listening on http://localhost:${config.port}`);
});
if (config.schedulerEnabled) {
await startScheduler();
}
const shutdown = async () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 2000).unref();
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
main().catch((err) => {
console.error("startup failed:", err);
process.exit(1);
});
+145
View File
@@ -0,0 +1,145 @@
import * as cheerio from "cheerio";
export function normalizeUrl(raw) {
try {
const u = new URL(String(raw).trim());
u.hash = "";
for (const key of [...u.searchParams.keys()]) {
if (key.startsWith("utm_") || key === "ref" || key === "source") {
u.searchParams.delete(key);
}
}
let s = u.toString();
if (s.endsWith("/")) s = s.slice(0, -1);
return s;
} catch {
return String(raw).trim();
}
}
async function getText(url, timeoutMs = 15000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { "user-agent": "AwesomeIndexBot/0.1 (+https://honor3.com)" },
redirect: "follow",
});
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
return await res.text();
} finally {
clearTimeout(timer);
}
}
function githubRepoFromLink(href) {
const m = String(href).match(/github\.com\/([\w.-]+)\/([\w.-]+)/i);
return m ? { owner: m[1], repo: m[2].replace(/\.git$/, "") } : null;
}
const rss = {
async fetch(url) {
const xml = await getText(url);
const $ = cheerio.load(xml, { xmlMode: true });
const items = [];
$("item, entry").each((_, el) => {
const node = $(el);
const link = node.find("link").first().text().trim() || node.find("link").attr("href") || "";
const title = node.find("title").first().text().trim();
const description =
node.find("description").first().text().trim() ||
node.find("summary").first().text().trim();
if (title && link) items.push({ title, url, link, description });
});
return items.slice(0, 50);
},
};
const html = {
async fetch(url, options = {}) {
const itemSel = options.selectors?.item || "article a";
const body = await getText(url);
const $ = cheerio.load(body);
const items = [];
$(itemSel).each((_, el) => {
const node = $(el);
const href = node.attr("href");
if (!href) return;
const abs = new URL(href, url).toString();
items.push({
title: node.text().replace(/\s+/g, " ").trim().slice(0, 120),
url: abs,
description: options.selectors?.description
? node.closest(options.selectors.description).text().replace(/\s+/g, " ").trim()
: "",
});
});
return items.slice(0, 50);
},
};
const eryajfWeekly = {
async fetch(listUrl) {
const indexHtml = await getText(listUrl);
const $ = cheerio.load(indexHtml);
const issues = [];
$("a[href*='/pages/']").each((_, el) => {
const node = $(el);
const text = node.text().trim();
const href = node.attr("href");
if (/学习周刊-总第\d+期/.test(text) && href) {
issues.push({ text, url: new URL(href, listUrl).toString() });
}
});
issues.reverse();
const results = [];
for (const issue of issues.slice(0, 3)) {
const issueHtml = await getText(issue.url);
const $$ = cheerio.load(issueHtml);
const seen = new Set();
$$('a[href*="github.com"]').each((_, el) => {
const anchor = $$(el);
const repo = githubRepoFromLink(anchor.attr("href"));
if (!repo) return;
const repoUrl = `https://github.com/${repo.owner}/${repo.repo}`;
if (seen.has(repoUrl)) return;
seen.add(repoUrl);
const containerText = anchor
.closest("li, p, td")
.text()
.replace(/\s+/g, " ")
.trim();
const name =
anchor.text().replace(/[*`\[\]]/g, "").trim() || `${repo.owner}/${repo.repo}`;
const description = (containerText.replace(anchor.text(), "").replace(/[::]\s*$/, "").trim() || "").slice(0, 300);
results.push({
title: name,
url: repoUrl,
description,
extra: { from: issue.text },
});
});
}
return results;
},
};
const registry = new Map(
Object.entries({
rss,
html,
"eryajf-weekly": eryajfWeekly,
})
);
export function registerAdapter(name, adapter) {
registry.set(name, adapter);
}
export function getAdapter(name) {
const adapter = registry.get(name);
if (!adapter) throw new Error(`未知适配器:${name}`);
return adapter;
}
+63
View File
@@ -0,0 +1,63 @@
import * as sourceService from "../services/source.service.js";
import * as entryService from "../services/entry.service.js";
import { getAdapter, normalizeUrl } from "./adapters/index.js";
import { get } from "../db/connection.js";
async function isKnownUrl(url) {
const row = await get(`SELECT id FROM entries WHERE url = ?`, [url]);
return Boolean(row);
}
function inferType(url) {
if (/github\.com|gitlab\.com|gitee\.com/i.test(url)) return "工具";
return "SaaS";
}
export async function runSource(sourceId) {
const source = await sourceService.getById(sourceId);
if (!source) throw new Error(`内容源不存在:${sourceId}`);
if (source.kind !== "feed") throw new Error("该源不是拉取型内容源");
await sourceService.markRunStart(source.id);
const stats = { found: 0, created: 0, skipped: 0, error: "" };
try {
const adapter = getAdapter(source.adapter);
const rawItems = await adapter.fetch(source.url, {});
stats.found = rawItems.length;
for (const item of rawItems) {
if (!item.title || !item.url) {
stats.skipped += 1;
continue;
}
const normalizedUrl = normalizeUrl(item.url);
if (await isKnownUrl(normalizedUrl)) {
stats.skipped += 1;
continue;
}
try {
const result = await entryService.createEntry(
{
title: String(item.title).slice(0, 120),
url: normalizedUrl,
type: inferType(normalizedUrl),
description_md: String(item.description || "").slice(0, 500),
tags: ["自动收录"],
status: "pending",
},
{ source }
);
if (result.created) stats.created += 1;
else stats.skipped += 1;
} catch (err) {
stats.error = stats.error || err.message;
}
}
} catch (err) {
stats.error = err.message;
}
await sourceService.recordRun(source.id, stats);
return stats;
}
+42
View File
@@ -0,0 +1,42 @@
import cron from "node-cron";
import * as sourceService from "../services/source.service.js";
import { runSource } from "./pipeline.js";
import { config } from "../config.js";
const jobs = new Map();
function startJob(source) {
if (!cron.validate(source.cron_expr || "")) return;
const task = cron.schedule(source.cron_expr, () => {
runSource(source.id).catch((err) =>
console.error(`[ingest] ${source.name} 运行失败:`, err.message)
);
});
jobs.set(source.id, task);
}
export async function startScheduler() {
if (!config.schedulerEnabled) return;
const sources = await sourceService.listSources();
for (const src of sources) {
if (src.kind === "feed" && src.enabled) startJob(src);
}
}
export function rescheduleSource(source) {
stopJob(source.id);
if (source.kind === "feed" && source.enabled) startJob(source);
}
export function stopJob(id) {
const task = jobs.get(Number(id));
if (task) {
task.stop();
jobs.delete(Number(id));
}
}
export function stopAll() {
for (const task of jobs.values()) task.stop();
jobs.clear();
}
+42
View File
@@ -0,0 +1,42 @@
import { Router } from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerTools } from "./tools.js";
export const mcpRouter = Router();
mcpRouter.post("/mcp", async (req, res) => {
try {
const server = new McpServer({
name: "awesome-index",
version: "0.1.0",
});
registerTools(server);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", async () => {
await transport.close();
await server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error("[mcp] request failed:", err.message);
if (!res.headersSent) {
res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null });
}
}
});
mcpRouter.get("/mcp", (_req, res) => {
res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "GET not supported (stateless mode)" }, id: null });
});
mcpRouter.delete("/mcp", (_req, res) => {
res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "DELETE not supported (stateless mode)" }, id: null });
});
+80
View File
@@ -0,0 +1,80 @@
import { z } from "zod";
import * as searchService from "../services/search.service.js";
import * as tagService from "../services/tag.service.js";
function text(data) {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
export function registerTools(server) {
server.registerTool(
"search_entries",
{
description:
"按关键字、类型、标签搜索本库收录的开源软件、服务与网站(Awesome Index)",
inputSchema: {
q: z.string().describe("需求关键字,如:自托管相册 / 终端文件管理器"),
limit: z.number().int().min(1).max(50).optional().describe("返回条数,默认 20"),
},
},
async ({ q, limit }) => {
const result = await searchService.searchEntries({
q,
perPage: Math.min(50, limit ?? 20),
});
return text({
total: result.total,
items: result.items.map((e) => ({
slug: e.slug,
title: e.title,
type: e.type,
url: e.url,
stars: Number(e.stars),
license: e.license,
tags: e.tags,
description: e.description_md,
})),
});
}
);
server.registerTool(
"get_entry",
{
description: "获取单条收录内容的完整详情与元数据",
inputSchema: {
slug: z.string().describe("条目 slug,也可传标题关键字自动匹配"),
},
},
async ({ slug }) => {
const direct = await searchService.searchEntries({ q: slug, perPage: 1 });
if (!direct.items.length) return text({ error: "not_found", query: slug });
return text(direct.items[0]);
}
);
server.registerTool(
"random_entry",
{
description: "随机返回一条收录内容,用于探索发现",
inputSchema: {},
},
async () => {
const entry = await searchService.randomActive();
if (!entry) return text({ error: "empty" });
return text(entry);
}
);
server.registerTool(
"list_tags",
{
description: "列出全部标签组及其嵌套结构与收录数量",
inputSchema: {},
},
async () => {
const tree = await tagService.attachCounts(await tagService.buildTree());
return text(tree);
}
);
}
+35
View File
@@ -0,0 +1,35 @@
import * as sourceService from "../services/source.service.js";
import { HttpError } from "./errors.js";
export function requireLogin(req, res, next) {
if (!req.session.userId) {
throw new HttpError(401, "unauthorized", "请先登录");
}
next();
}
export function requireAdmin(req, res, next) {
if (!req.session.userId) {
throw new HttpError(401, "unauthorized", "请先登录");
}
if (req.session.role !== "admin") {
throw new HttpError(403, "forbidden", "需要管理员权限");
}
next();
}
export function loadUser(req, res, next) {
res.locals.user = req.session.userId
? { id: req.session.userId, username: req.session.username, role: req.session.role }
: null;
next();
}
export async function aiSourceFromRequest(req) {
const key = req.get("x-ingest-key") || "";
const source = await sourceService.findByApiKey(key);
if (!source) {
throw new HttpError(403, "invalid_key", "AI 录入密钥无效或未启用");
}
return source;
}
+24
View File
@@ -0,0 +1,24 @@
export class HttpError extends Error {
constructor(status, code, message) {
super(message);
this.status = status;
this.code = code;
}
}
export function notFoundHandler(req, res) {
if (req.path.startsWith("/api/")) {
return res.status(404).json({ ok: false, error: { code: "not_found", message: "接口不存在" } });
}
res.status(404).send("404 Not Found");
}
export function errorHandler(err, req, res, _next) {
const status = err.status || 500;
if (status >= 500) console.error(err);
const message = err.message || "服务器内部错误";
if (req.path.startsWith("/api/") || req.path === "/mcp") {
return res.status(status).json({ ok: false, error: { code: err.code || "error", message } });
}
res.status(status).send(message);
}
+131
View File
@@ -0,0 +1,131 @@
import { Router } from "express";
import * as userService from "../services/user.service.js";
import * as sourceService from "../services/source.service.js";
import * as commentService from "../services/comment.service.js";
import * as searchService from "../services/search.service.js";
import * as audit from "../services/audit.service.js";
import { requireAdmin } from "../middleware/auth.js";
import { HttpError } from "../middleware/errors.js";
import { runSource } from "../ingest/pipeline.js";
import { rescheduleSource } from "../ingest/scheduler.js";
export const adminRouter = Router();
function actor(req) {
return req.session.username || `user:${req.session.userId}`;
}
adminRouter.post("/api/admin/login", async (req, res) => {
const user = await userService.verifyLogin(
String(req.body.username || ""),
String(req.body.password || "")
);
if (!user) throw new HttpError(401, "bad_credentials", "用户名或密码不正确");
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
res.json({ ok: true, data: { username: user.username, role: user.role } });
});
adminRouter.post("/api/admin/logout", (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
adminRouter.get("/api/admin/session", (req, res) => {
if (!req.session.userId) throw new HttpError(401, "unauthorized", "未登录");
res.json({
ok: true,
data: { username: req.session.username, role: req.session.role },
});
});
adminRouter.get("/api/admin/users", requireAdmin, async (_req, res) => {
res.json({ ok: true, data: await userService.listUsers() });
});
adminRouter.post("/api/admin/users", requireAdmin, async (req, res) => {
const user = await userService.createUser({
username: String(req.body.username || "").trim(),
password: String(req.body.password || ""),
role: req.body.role,
});
await audit.log({ actor: actor(req), action: "user.create", target: user.username });
res.status(201).json({ ok: true });
});
adminRouter.patch("/api/admin/users/:id/status", requireAdmin, async (req, res) => {
const id = Number(req.params.id);
if (id === req.session.userId) throw new HttpError(400, "bad_request", "不能停用自己");
await userService.setUserStatus(id, req.body.status);
res.json({ ok: true });
});
adminRouter.get("/api/admin/sources", requireAdmin, async (_req, res) => {
res.json({ ok: true, data: await sourceService.listSources() });
});
adminRouter.post("/api/admin/sources", requireAdmin, async (req, res) => {
const src = await sourceService.createSource({
kind: "feed",
name: String(req.body.name || "").trim(),
enabled: req.body.enabled !== false,
url: String(req.body.url || "").trim(),
adapter: req.body.adapter,
cron_expr: req.body.cron_expr,
});
rescheduleSource(src);
await audit.log({ actor: actor(req), action: "source.create", target: src.name });
res.status(201).json({ ok: true, data: src });
});
adminRouter.patch("/api/admin/sources/:id", requireAdmin, async (req, res) => {
const id = Number(req.params.id);
const patch = {};
for (const key of ["name", "enabled", "url", "adapter", "cron_expr"]) {
if (req.body[key] !== undefined) patch[key] = req.body[key];
}
if (req.body.directPublish !== undefined) {
await sourceService.setDirectPublish(id, req.body.directPublish);
}
const src = await sourceService.updateSource(id, patch);
if (src.kind === "feed") rescheduleSource(src);
res.json({ ok: true, data: src });
});
adminRouter.post("/api/admin/sources/:id/run", requireAdmin, async (req, res) => {
const stats = await runSource(Number(req.params.id));
res.json({ ok: !stats.error, data: stats });
});
adminRouter.get("/api/admin/sources/:id/runs", requireAdmin, async (req, res) => {
res.json({ ok: true, data: await sourceService.listRuns(Number(req.params.id)) });
});
adminRouter.put("/api/admin/ai-key", requireAdmin, async (req, res) => {
const key = String(req.body.key || "").trim();
await sourceService.setAiKey(key);
await audit.log({
actor: actor(req),
action: "aikey.update",
target: "",
detail: key ? "已设置" : "已清除",
});
res.json({ ok: true });
});
adminRouter.get("/api/admin/comments", requireAdmin, async (req, res) => {
res.json({ ok: true, data: await commentService.listAll({ q: req.query.q ?? "" }) });
});
adminRouter.get("/api/admin/entries", requireAdmin, async (req, res) => {
const result = await searchService.searchEntries({
q: req.query.q ?? "",
types: String(req.query.type || "")
.split(",")
.filter(Boolean),
sort: "updated",
page: Number(req.query.page) || 1,
perPage: 20,
status: req.query.status ?? "all",
});
res.json({ ok: true, data: result });
});
+45
View File
@@ -0,0 +1,45 @@
import { Router } from "express";
import * as commentService from "../services/comment.service.js";
import * as audit from "../services/audit.service.js";
import { requireAdmin } from "../middleware/auth.js";
import { HttpError } from "../middleware/errors.js";
export const commentsRouter = Router();
commentsRouter.get("/api/comments", async (req, res) => {
if (req.query.q !== undefined || req.query.all === "1") {
return res.json({ ok: true, data: await commentService.listAll({ q: req.query.q }) });
}
const entryId = Number(req.query.entryId);
if (!Number.isFinite(entryId)) throw new HttpError(400, "bad_request", "缺少 entryId");
res.json({ ok: true, data: await commentService.listForEntry(entryId) });
});
commentsRouter.post("/api/comments", async (req, res) => {
const entryId = Number(req.body.entryId);
if (!Number.isFinite(entryId)) throw new HttpError(400, "bad_request", "缺少条目 ID");
await commentService.create({
entryId,
userId: req.session.userId ?? null,
authorName: req.session.username ?? req.body.authorName,
body: req.body.body,
});
res.status(201).json({ ok: true });
});
commentsRouter.post("/api/feedback", async (req, res) => {
const body = String(req.body.body || "").trim();
if (!body) throw new HttpError(400, "bad_request", "反馈内容不能为空");
await audit.log({
actor: String(req.body.email || "").trim() || "匿名访客",
action: "feedback",
target: "",
detail: body.slice(0, 2000),
});
res.status(201).json({ ok: true });
});
commentsRouter.delete("/api/comments/:id", requireAdmin, async (req, res) => {
await commentService.remove(Number(req.params.id));
res.json({ ok: true });
});
+106
View File
@@ -0,0 +1,106 @@
import { Router } from "express";
import { z } from "zod";
import * as entryService from "../services/entry.service.js";
import * as searchService from "../services/search.service.js";
import * as sourceService from "../services/source.service.js";
import { HttpError } from "../middleware/errors.js";
import { aiSourceFromRequest } from "../middleware/auth.js";
export const entriesRouter = Router();
function csv(v) {
return String(v || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
entriesRouter.get("/api/entries", async (req, res) => {
const result = await searchService.searchEntries({
q: req.query.q ?? "",
types: csv(req.query.type),
tagIds: csv(req.query.tags).map(Number).filter(Number.isFinite),
sort: req.query.sort ?? "updated",
page: Number(req.query.page) || 1,
perPage: Math.min(50, Number(req.query.perPage) || 10),
status: req.query.status ?? "active",
});
res.json({ ok: true, data: result });
});
entriesRouter.get("/api/entries/random", async (_req, res) => {
const entry = await searchService.randomActive();
if (!entry) throw new HttpError(404, "empty", "还没有可游览的条目");
res.json({ ok: true, data: entry });
});
entriesRouter.get("/api/entries/:slug", async (req, res) => {
const entry = await entryService.getBySlug(req.params.slug);
if (!entry) throw new HttpError(404, "not_found", "条目不存在");
res.json({ ok: true, data: entry });
});
const entryInput = z.object({
title: z.string().min(1),
url: z.string().url(),
type: z.enum(entryService.ENTRY_TYPES),
description_md: z.string().max(5000).optional(),
license: z.string().optional(),
stars: z.number().nonnegative().optional(),
tags: z.array(z.string()).optional(),
status: z.string().optional(),
});
async function resolveCreator(req) {
if (req.session.userId && ["admin", "editor"].includes(req.session.role)) {
const sources = await sourceService.listSources();
return sources.find((s) => s.kind === "human");
}
return aiSourceFromRequest(req);
}
entriesRouter.post("/api/entries", async (req, res) => {
const input = entryInput.parse(req.body);
const source = await resolveCreator(req);
const result = await entryService.createEntry(input, { source });
res.status(result.created ? 201 : 200).json({ ok: true, data: result });
});
entriesRouter.post("/api/ingest/entry", async (req, res) => {
const input = entryInput.parse(req.body);
const source = await aiSourceFromRequest(req);
const result = await entryService.createEntry(input, { source });
res.status(result.created ? 201 : 200).json({ ok: true, data: result });
});
const patchInput = z.object({
title: z.string().min(1).optional(),
url: z.string().url().optional(),
type: z.enum(entryService.ENTRY_TYPES).optional(),
description_md: z.string().max(5000).optional(),
license: z.string().optional(),
stars: z.number().nonnegative().optional(),
tags: z.array(z.string()).optional(),
});
entriesRouter.patch("/api/entries/:id", async (req, res) => {
const id = Number(req.params.id);
const patch = patchInput.parse(req.body);
const isHumanEditor = ["admin", "editor"].includes(req.session.role);
const entry = await entryService.updateEntry(id, patch, { isHumanEditor });
res.json({ ok: true, data: entry });
});
const statusInput = z.object({ status: z.enum(["active", "greyed", "pending"]) });
entriesRouter.patch("/api/entries/:id/status", async (req, res) => {
const id = Number(req.params.id);
const { status } = statusInput.parse(req.body);
const entry = await entryService.setStatus(id, status);
res.json({ ok: true, data: entry });
});
entriesRouter.delete("/api/entries/:id", async (req, res) => {
await entryService.deleteEntry(Number(req.params.id));
res.json({ ok: true });
});
+133
View File
@@ -0,0 +1,133 @@
import { Router } from "express";
import * as searchService from "../services/search.service.js";
import * as entryService from "../services/entry.service.js";
import * as tagService from "../services/tag.service.js";
import * as commentService from "../services/comment.service.js";
import * as sourceService from "../services/source.service.js";
import * as userService from "../services/user.service.js";
import { ENTRY_TYPES } from "../services/entry.service.js";
export const pagesRouter = Router();
function csv(v) {
return String(v || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
pagesRouter.get("/healthz", (_req, res) => {
res.json({ ok: true, uptime: process.uptime() });
});
pagesRouter.get("/", async (_req, res, next) => {
try {
res.render("index", {
title: "Awesome Index · 把网上牛逼的东西收进一个库",
stats: await searchService.stats(),
recent: await entryService.listRecent(4),
});
} catch (e) {
next(e);
}
});
pagesRouter.get("/entries", async (req, res, next) => {
try {
const filters = {
q: req.query.q ?? "",
types: csv(req.query.type),
tagIds: csv(req.query.tags).map(Number).filter(Number.isFinite),
sort: req.query.sort ?? "updated",
page: Math.max(1, Number(req.query.page) || 1),
status: "active",
};
const [result, tagTree, typeCounts] = await Promise.all([
searchService.searchEntries({ ...filters, perPage: 10 }),
tagService.attachCounts(await tagService.buildTree()),
searchService.typeCounts(),
]);
const totalPages = Math.max(1, Math.ceil(result.total / result.perPage));
res.render("list", {
title: `搜索${filters.q ? `:${filters.q}` : "全部"} · Awesome Index`,
result,
filters,
totalPages,
types: ENTRY_TYPES,
tagTree,
typeCounts,
});
} catch (e) {
next(e);
}
});
pagesRouter.get("/entries/:slug", async (req, res, next) => {
try {
const entry = await entryService.getBySlug(req.params.slug);
if (!entry || entry.status === "greyed") {
return res.status(404).send("条目不存在或已下架");
}
const comments = await commentService.listForEntry(entry.id);
const machine = {
id: entry.slug,
type: entry.type,
name: entry.title,
description: entry.description_md,
url: entry.url,
tags: entry.tags,
license: entry.license,
stars: Number(entry.stars),
updated_at: entry.updated_at,
status: entry.status,
};
res.render("detail", {
title: `${entry.title} · Awesome Index`,
entry,
comments,
machineJson: JSON.stringify(machine, null, 2),
});
} catch (e) {
next(e);
}
});
pagesRouter.get("/random", async (_req, res, next) => {
try {
const entry = await searchService.randomActive();
if (!entry) return res.redirect("/entries");
res.redirect(`/entries/${entry.slug}`);
} catch (e) {
next(e);
}
});
pagesRouter.get("/login", (_req, res) => {
res.render("login", { title: "管理台登录 · Awesome Index" });
});
pagesRouter.get("/admin", async (req, res, next) => {
try {
if (!req.session.userId) return res.redirect("/login");
const isAdmin = req.session.role === "admin";
const [sources, users, comments, entries, tagTreeAdmin] = await Promise.all([
sourceService.listSources(),
isAdmin ? userService.listUsers() : Promise.resolve([]),
commentService.listAll({}),
searchService.searchEntries({ status: "all", perPage: 50 }),
tagService.attachCounts(await tagService.buildTree()),
]);
res.render("admin", {
title: "管理台 · Awesome Index",
user: { username: req.session.username, role: req.session.role },
sources,
users,
comments,
entries: entries.items,
isAdmin,
tagTreeAdmin,
});
} catch (e) {
next(e);
}
});
+34
View File
@@ -0,0 +1,34 @@
import { Router } from "express";
import * as tagService from "../services/tag.service.js";
import { requireAdmin, requireLogin } from "../middleware/auth.js";
export const tagsRouter = Router();
tagsRouter.get("/api/tags/tree", async (_req, res) => {
const tree = await tagService.attachCounts(await tagService.buildTree());
res.json({ ok: true, data: tree });
});
tagsRouter.get("/api/tags", async (_req, res) => {
res.json({ ok: true, data: await tagService.listFlat() });
});
tagsRouter.post("/api/tags", requireLogin, async (req, res) => {
const parentId = req.body.parentId ? Number(req.body.parentId) : null;
const tag = await tagService.createTag({ name: req.body.name, parentId });
res.status(201).json({ ok: true, data: tag });
});
tagsRouter.patch("/api/tags/:id", requireLogin, async (req, res) => {
const id = Number(req.params.id);
if (req.body.name !== undefined) await tagService.renameTag(id, req.body.name);
if (req.body.parentId !== undefined) {
await tagService.moveTag(id, req.body.parentId ? Number(req.body.parentId) : null);
}
res.json({ ok: true });
});
tagsRouter.delete("/api/tags/:id", requireAdmin, async (req, res) => {
await tagService.deleteTag(Number(req.params.id));
res.json({ ok: true });
});
+19
View File
@@ -0,0 +1,19 @@
import { all, get, run } from "../db/connection.js";
export async function log({ actor, action, target = "", detail = "" }) {
await run(`INSERT INTO audit_logs (actor, action, target, detail) VALUES (?, ?, ?, ?)`, [
actor,
action,
target,
detail,
]);
}
export async function recent(limit = 50) {
return all(`SELECT * FROM audit_logs ORDER BY id DESC LIMIT ?`, [limit]);
}
export async function count() {
const row = await get(`SELECT COUNT(*) AS c FROM audit_logs`);
return Number(row?.c ?? 0);
}
+41
View File
@@ -0,0 +1,41 @@
import { all, get, run } from "../db/connection.js";
export async function listForEntry(entryId) {
return all(
`SELECT c.*, u.username
FROM comments c LEFT JOIN users u ON u.id = c.user_id
WHERE c.entry_id = ?
ORDER BY c.created_at DESC`,
[entryId]
);
}
export async function create({ entryId, userId = null, authorName, body }) {
const text = String(body || "").trim();
if (!text) throw Object.assign(new Error("评论内容不能为空"), { status: 400 });
if (text.length > 2000) throw Object.assign(new Error("评论太长了(≤2000 字)"), { status: 400 });
const entry = await get(`SELECT id FROM entries WHERE id = ?`, [entryId]);
if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 });
const name =
String(authorName || "").trim() || "匿名";
await run(
`INSERT INTO comments (entry_id, user_id, author_name, body) VALUES (?, ?, ?, ?)`,
[entryId, userId, name.slice(0, 40), text]
);
}
export async function remove(id) {
await run(`DELETE FROM comments WHERE id = ?`, [id]);
}
export async function listAll({ q = "" } = {}) {
const like = `%${q}%`;
return all(
`SELECT c.*, e.title AS entry_title, e.slug AS entry_slug
FROM comments c JOIN entries e ON e.id = c.entry_id
LEFT JOIN users u ON u.id = c.user_id
WHERE (? = '' OR c.body LIKE ? OR c.author_name LIKE ?)
ORDER BY c.created_at DESC LIMIT 200`,
[q, like, like]
);
}
+182
View File
@@ -0,0 +1,182 @@
import { all, get, run } from "../db/connection.js";
export const ENTRY_TYPES = ["微服务", "SaaS", "网站", "工具", "应用", "脚本", "插件"];
export const STATUSES = ["active", "greyed", "pending"];
const ALLOWED_TRANSITIONS = new Set([
"pending>active",
"pending>greyed",
"active>greyed",
"greyed>active",
"greyed>pending",
]);
function httpError(status, message) {
return Object.assign(new Error(message), { status });
}
function baseSlug(title) {
const ascii = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (ascii.length >= 2) return ascii.slice(0, 60);
return `e-${Date.now().toString(36)}`;
}
export async function slugify(title) {
let slug = baseSlug(title);
while (await get(`SELECT id FROM entries WHERE slug = ?`, [slug])) {
slug = `${baseSlug(title).slice(0, 50)}-${Math.random().toString(36).slice(2, 6)}`;
}
return slug;
}
async function linkTags(entryId, names = []) {
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [entryId]);
for (const raw of names) {
const name = String(raw).trim();
if (!name) continue;
let tag = await get(`SELECT id FROM tags WHERE name = ?`, [name]);
if (!tag) {
await run(`INSERT INTO tags (name, parent_id, sort) VALUES (?, NULL, 99)`, [name]);
tag = await get(`SELECT id FROM tags WHERE name = ?`, [name]);
}
await run(`INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?, ?)`, [
entryId,
tag.id,
]);
}
}
async function setEntryTagsParam(entryId) {
const tags = await all(
`SELECT t.name FROM tags t JOIN entry_tags et ON et.tag_id = t.id
WHERE et.entry_id = ? ORDER BY t.sort, t.id`,
[entryId]
);
return tags.map((t) => t.name);
}
function decideStatus(source, requested) {
const wanted = requestStatus(requested);
if (!source || source.kind === "human") return wanted ?? "active";
return source.direct_publish && wanted === "active" ? "active" : "pending";
}
function requestStatus(status) {
if (!status) return null;
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
return status;
}
export async function createEntry(data, { source } = {}) {
if (!ENTRY_TYPES.includes(data.type)) {
throw httpError(400, `类型必须是 ${ENTRY_TYPES.join(" / ")}`);
}
if (!String(data.title || "").trim()) throw httpError(400, "标题不能为空");
if (!String(data.url || "").trim()) throw httpError(400, "链接不能为空");
const existing = await get(`SELECT id FROM entries WHERE url = ?`, [data.url.trim()]);
if (existing) return { created: false, id: existing.id };
const status = decideStatus(source, data.status);
const slug = data.slug || (await slugify(data.title));
await run(
`INSERT INTO entries (title, slug, type, description_md, url, license, stars, status, source_id, verified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.title.trim(),
slug,
data.type,
data.description_md || "",
data.url.trim(),
data.license || "",
Number(data.stars) || 0,
status,
source?.id ?? null,
status === "active" && source?.kind === "human" ? new Date() : null,
]
);
const row = await get(`SELECT * FROM entries WHERE slug = ?`, [slug]);
await linkTags(row.id, data.tags);
return { created: true, id: row.id, status };
}
const EDITABLE = ["title", "type", "description_md", "url", "license", "stars"];
export async function updateEntry(id, patch, { isHumanEditor = false } = {}) {
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
if (!entry) throw httpError(404, "条目不存在");
if (patch.type !== undefined && !ENTRY_TYPES.includes(patch.type)) {
throw httpError(400, `类型必须是 ${ENTRY_TYPES.join(" / ")}`);
}
const next = { ...entry };
for (const key of EDITABLE) {
if (patch[key] !== undefined) next[key] = patch[key];
}
await run(
`UPDATE entries SET title = ?, type = ?, description_md = ?, url = ?, license = ?, stars = ?, updated_at = now()
WHERE id = ?`,
[next.title, next.type, next.description_md, next.url, next.license, Number(next.stars) || 0, id]
);
if (patch.tags !== undefined) await linkTags(id, patch.tags);
if (isHumanEditor) {
await run(`UPDATE entries SET verified_at = now() WHERE id = ?`, [id]);
}
return getById(id);
}
export async function setStatus(id, status) {
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
if (!entry) throw httpError(404, "条目不存在");
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
const transition = `${entry.status}>${status}`;
if (!ALLOWED_TRANSITIONS.has(transition)) {
throw httpError(400, `不允许的状态变更:${entry.status} → ${status}`);
}
await run(
`UPDATE entries SET status = ?, updated_at = now(),
verified_at = CASE WHEN ? = 'active' THEN now() ELSE verified_at END
WHERE id = ?`,
[status, status, id]
);
return getById(id);
}
export async function deleteEntry(id) {
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]);
await run(`DELETE FROM comments WHERE entry_id = ?`, [id]);
await run(`DELETE FROM entries WHERE id = ?`, [id]);
}
export function assertType(type) {
if (type !== undefined && !ENTRY_TYPES.includes(type)) {
throw httpError(400, `类型必须是 ${ENTRY_TYPES.join(" / ")}`);
}
}
export async function getById(id) {
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
if (!entry) return null;
entry.tags = await setEntryTagsParam(entry.id);
return entry;
}
export async function getBySlug(slug) {
const entry = await get(`SELECT * FROM entries WHERE slug = ?`, [slug]);
if (!entry) return null;
entry.tags = await setEntryTagsParam(entry.id);
return entry;
}
export async function listRecent(limit = 4) {
const rows = await all(
`SELECT e.id, e.title, e.slug, e.type, e.description_md, e.url, e.stars, e.updated_at
FROM entries e WHERE e.status = 'active'
ORDER BY e.created_at DESC LIMIT ?`,
[limit]
);
for (const row of rows) row.tags = await setEntryTagsParam(row.id);
return rows;
}
+112
View File
@@ -0,0 +1,112 @@
import { all, get } from "../db/connection.js";
import * as tags from "./tag.service.js";
const SORTS = {
updated: "e.updated_at DESC",
stars: "e.stars DESC",
created: "e.created_at DESC",
title: "e.title ASC",
};
export async function searchEntries({
q = "",
types = [],
tagIds = [],
sort = "updated",
page = 1,
perPage = 10,
status = "active",
} = {}) {
const where = [];
const params = [];
if (status && status !== "all") {
where.push(`e.status = ?`);
params.push(status);
}
if (q.trim()) {
const like = `%${q.trim()}%`;
where.push(`(e.title LIKE ? OR e.description_md LIKE ? OR e.url LIKE ?)`);
params.push(like, like, like);
}
if (types.length) {
where.push(`e.type IN (${types.map(() => "?").join(",")})`);
params.push(...types);
}
let cte = "";
const cteParams = [];
if (tagIds.length) {
const expanded = new Set();
for (const id of tagIds) for (const d of await tags.descendantIds(id)) expanded.add(d);
const ids = [...expanded];
cte = `WITH sel(tag_id) AS (SELECT unnest([${ids.map(() => "?").join(",")}]))`;
cteParams.push(...ids);
where.push(
`EXISTS (SELECT 1 FROM entry_tags et JOIN sel ON sel.tag_id = et.tag_id WHERE et.entry_id = e.id)`
);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const orderBy = SORTS[sort] || SORTS.updated;
const offset = (Math.max(1, page) - 1) * perPage;
const totalRow = await get(
`${cte} SELECT COUNT(*) AS c FROM entries e ${whereSql}`,
[...cteParams, ...params]
);
const items = await all(
`${cte} SELECT e.id, e.title, e.slug, e.type, e.description_md, e.url, e.license, e.stars, e.status, e.updated_at
FROM entries e ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
[...cteParams, ...params, perPage, offset]
);
const tagRows =
items.length > 0
? await all(
`SELECT et.entry_id AS entry_id, t.name AS name
FROM entry_tags et JOIN tags t ON t.id = et.tag_id
WHERE et.entry_id IN (${items.map(() => "?").join(",")})`,
items.map((i) => i.id)
)
: [];
const byEntry = new Map();
for (const r of tagRows) {
const key = Number(r.entry_id);
if (!byEntry.has(key)) byEntry.set(key, []);
byEntry.get(key).push(r.name);
}
for (const item of items) item.tags = byEntry.get(Number(item.id)) ?? [];
return { total: Number(totalRow?.c ?? 0), page: Math.max(1, page), perPage, items };
}
export async function typeCounts() {
const rows = await all(
`SELECT type, COUNT(*) AS c FROM entries WHERE status = 'active' GROUP BY type`
);
const map = {};
for (const r of rows) map[r.type] = Number(r.c);
return map;
}
export async function randomActive() {
return get(`SELECT * FROM entries WHERE status = 'active' ORDER BY random() LIMIT 1`);
}
export async function stats() {
const row = await get(
`SELECT
COUNT(*) FILTER (WHERE status <> 'pending') AS total,
COUNT(*) FILTER (WHERE status = 'active') AS active,
COUNT(*) FILTER (WHERE created_at >= now() - INTERVAL 7 DAY AND status = 'active') AS week_new
FROM entries`
);
const tagRow = await get(`SELECT COUNT(*) AS c FROM tags`);
return {
total: Number(row?.total ?? 0),
active: Number(row?.active ?? 0),
weekNew: Number(row?.week_new ?? 0),
tags: Number(tagRow?.c ?? 0),
};
}
+110
View File
@@ -0,0 +1,110 @@
import crypto from "node:crypto";
import { all, get, run } from "../db/connection.js";
function keyHash(key) {
return key ? crypto.createHash("sha256").update(key).digest("hex") : null;
}
export async function ensureBuiltins({ aiIngestKey }) {
const human = await get(`SELECT id FROM sources WHERE name = '人工录入'`);
if (!human) {
await run(`INSERT INTO sources (kind, name, enabled) VALUES ('human', '人工录入', TRUE)`);
}
const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`);
if (!ai) {
await run(
`INSERT INTO sources (kind, name, enabled, api_key_hash, direct_publish)
VALUES ('ai', 'AI 接口', ?, ?, FALSE)`,
[Boolean(aiIngestKey), keyHash(aiIngestKey)]
);
}
}
export async function listSources() {
return all(
`SELECT s.*,
(SELECT COUNT(*) FROM entries e WHERE e.source_id = s.id) AS entry_count
FROM sources s ORDER BY s.id`
);
}
export async function getById(id) {
return get(`SELECT * FROM sources WHERE id = ?`, [id]);
}
export async function findByApiKey(key) {
if (!key) return null;
return get(
`SELECT * FROM sources WHERE kind = 'ai' AND enabled AND api_key_hash = ?`,
[keyHash(key)]
);
}
export async function createSource(data) {
if (data.kind !== "feed") throw Object.assign(new Error("仅支持创建 feed 源"), { status: 400 });
if (!data.adapter) {
throw Object.assign(new Error("缺少适配器名称"), { status: 400 });
}
await run(
`INSERT INTO sources (kind, name, enabled, url, adapter, cron_expr)
VALUES ('feed', ?, ?, ?, ?, ?)`,
[data.name, data.enabled !== false, data.url, data.adapter, data.cron_expr || "0 9 * * 1"]
);
return get(`SELECT * FROM sources WHERE name = ?`, [data.name]);
}
export async function updateSource(id, patch) {
const src = await getById(id);
if (!src) throw Object.assign(new Error("内容源不存在"), { status: 404 });
const next = { ...src, ...patch };
await run(
`UPDATE sources SET name = ?, enabled = ?, url = ?, adapter = ?, cron_expr = ?
WHERE id = ?`,
[next.name, Boolean(next.enabled), next.url ?? "", next.adapter ?? "", next.cron_expr ?? "0 9 * * 1", id]
);
return getById(id);
}
export async function setAiKey(key) {
const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`);
if (!ai) return;
await run(`UPDATE sources SET enabled = ?, api_key_hash = ? WHERE id = ?`, [
Boolean(key),
keyHash(key),
ai.id,
]);
}
export async function setDirectPublish(id, value) {
await run(`UPDATE sources SET direct_publish = ? WHERE id = ?`, [Boolean(value), id]);
}
export async function markRunStart(id) {
await run(`UPDATE sources SET last_run_at = now(), last_run_status = 'running' WHERE id = ?`, [id]);
}
export async function recordRun(sourceId, stats) {
await run(
`INSERT INTO source_runs (source_id, finished_at, status, found, created, skipped, error)
VALUES (?, now(), ?, ?, ?, ?, ?)`,
[
sourceId,
stats.error ? "failed" : "ok",
stats.found ?? 0,
stats.created ?? 0,
stats.skipped ?? 0,
stats.error ? String(stats.error).slice(0, 1000) : "",
]
);
await run(`UPDATE sources SET last_run_status = ? WHERE id = ?`, [
stats.error ? "failed" : "ok",
sourceId,
]);
}
export async function listRuns(sourceId, limit = 10) {
return all(
`SELECT * FROM source_runs WHERE source_id = ? ORDER BY id DESC LIMIT ?`,
[sourceId, limit]
);
}
+99
View File
@@ -0,0 +1,99 @@
import { all, get, run } from "../db/connection.js";
export async function listFlat() {
return all(`SELECT * FROM tags ORDER BY parent_id NULLS FIRST, sort, id`);
}
export async function buildTree() {
const rows = await listFlat();
const nodes = new Map();
for (const r of rows) {
nodes.set(Number(r.id), { ...r, id: Number(r.id), parent_id: r.parent_id ? Number(r.parent_id) : null, children: [] });
}
const roots = [];
for (const node of nodes.values()) {
if (node.parent_id && nodes.has(node.parent_id)) {
nodes.get(node.parent_id).children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
export async function descendantIds(id) {
const rows = await all(
`WITH RECURSIVE tree(id) AS (
SELECT id FROM tags WHERE id = ?
UNION ALL
SELECT t.id FROM tags t JOIN tree ON t.parent_id = tree.id
)
SELECT id FROM tree`,
[id]
);
return rows.map((r) => Number(r.id));
}
export async function isDescendant(candidateId, ancestorId) {
const ids = await descendantIds(ancestorId);
return ids.includes(Number(candidateId));
}
export async function createTag({ name, parentId = null }) {
const trimmed = String(name || "").trim();
if (!trimmed) throw Object.assign(new Error("标签名不能为空"), { status: 400 });
if (parentId) {
const parent = await get(`SELECT id FROM tags WHERE id = ?`, [parentId]);
if (!parent) throw Object.assign(new Error("上级标签不存在"), { status: 400 });
}
await run(`INSERT INTO tags (name, parent_id, sort) VALUES (?, ?, 0)`, [trimmed, parentId]);
return get(`SELECT * FROM tags WHERE name = ? AND parent_id IS NOT DISTINCT FROM ? ORDER BY id DESC LIMIT 1`, [trimmed, parentId]);
}
export async function renameTag(id, name) {
const trimmed = String(name || "").trim();
if (!trimmed) throw Object.assign(new Error("标签名不能为空"), { status: 400 });
const tag = await get(`SELECT * FROM tags WHERE id = ?`, [id]);
if (!tag) throw Object.assign(new Error("标签不存在"), { status: 404 });
await run(`UPDATE tags SET name = ? WHERE id = ?`, [trimmed, id]);
}
export async function moveTag(id, newParentId) {
const tag = await get(`SELECT * FROM tags WHERE id = ?`, [id]);
if (!tag) throw Object.assign(new Error("标签不存在"), { status: 404 });
let parent = null;
if (newParentId) {
parent = await get(`SELECT * FROM tags WHERE id = ?`, [newParentId]);
if (!parent) throw Object.assign(new Error("目标上级不存在"), { status: 400 });
if (await isDescendant(newParentId, id)) {
throw Object.assign(new Error("不能把标签移动到自己的子级下"), { status: 400 });
}
}
await run(`UPDATE tags SET parent_id = ? WHERE id = ?`, [parent ? parent.id : null, id]);
}
export async function deleteTag(id) {
const tag = await get(`SELECT * FROM tags WHERE id = ?`, [id]);
if (!tag) throw Object.assign(new Error("标签不存在"), { status: 404 });
await run(`UPDATE tags SET parent_id = ? WHERE parent_id = ?`, [tag.parent_id ?? null, id]);
await run(`DELETE FROM entry_tags WHERE tag_id = ?`, [id]);
await run(`DELETE FROM tags WHERE id = ?`, [id]);
}
export async function attachCounts(tree) {
const counts = await all(
`SELECT et.tag_id AS tag_id, COUNT(*) AS c
FROM entry_tags et JOIN entries e ON e.id = et.entry_id
WHERE e.status = 'active'
GROUP BY et.tag_id`
);
const byId = new Map(counts.map((r) => [Number(r.tag_id), Number(r.c)]));
const walk = (nodes) => {
for (const n of nodes) {
n.count = byId.get(n.id) ?? 0;
walk(n.children);
}
};
walk(tree);
return tree;
}
+49
View File
@@ -0,0 +1,49 @@
import crypto from "node:crypto";
import { all, get, run } from "../db/connection.js";
export function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(password, salt, 32).toString("hex");
return `scrypt:${salt}:${hash}`;
}
export function verifyPassword(password, stored) {
const [, salt, hash] = String(stored).split(":");
if (!salt || !hash) return false;
const candidate = crypto.scryptSync(password, salt, 32).toString("hex");
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(hash));
}
export async function findByUsername(username) {
return get(`SELECT * FROM users WHERE username = ?`, [username]);
}
export async function verifyLogin(username, password) {
const user = await findByUsername(username);
if (!user || user.status !== "active") return null;
if (!verifyPassword(password, user.password_hash)) return null;
await run(`UPDATE users SET last_login_at = now() WHERE id = ?`, [user.id]);
return user;
}
export async function listUsers() {
return all(`SELECT id, username, role, status, last_login_at
FROM users ORDER BY id`);
}
export async function createUser({ username, password, role }) {
const exists = await findByUsername(username);
if (exists) throw Object.assign(new Error("用户名已存在"), { status: 409 });
await run(
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`,
[username, hashPassword(password), role === "admin" ? "admin" : "editor"]
);
return findByUsername(username);
}
export async function setUserStatus(id, status) {
await run(`UPDATE users SET status = ? WHERE id = ?`, [
status === "active" ? "active" : "disabled",
id,
]);
}
+227
View File
@@ -0,0 +1,227 @@
<%- include('partials/head') %>
<%- include('partials/topbar', { active: 'admin' }) %>
<div class="admin-shell">
<nav class="admin-nav" aria-label="管理分区">
<h4>管理台 · <%= user.username %></h4>
<button type="button" data-section="sec-content" aria-current="true">内容管理</button>
<button type="button" data-section="sec-tags" aria-current="false">标签管理</button>
<button type="button" data-section="sec-comments" aria-current="false">评论管理</button>
<button type="button" data-section="sec-sources" aria-current="false">内容源</button>
<% if (isAdmin) { %>
<button type="button" data-section="sec-accounts" aria-current="false">账号管理</button>
<% } %>
</nav>
<main class="admin-main">
<!-- 内容管理 -->
<section id="sec-content" class="active">
<p class="anno">// table: entries · 状态 active | greyed | pending</p>
<h1>内容管理</h1>
<p class="admin-desc">全部收录条目。灰掉 = 暂不可见但保留数据;删除为物理删除,需确认。</p>
<table class="data-table" style="margin-top:22px">
<thead><tr><th>标题</th><th>类型</th><th>状态</th><th>更新时间</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
<% for (const e of entries) { %>
<tr>
<td>
<b><%= e.title %></b><br />
<span class="mono" style="font-size:11px;color:var(--ink-dim)"><%= (e.tags || []).join(' · ') || '—' %></span>
</td>
<td><%= e.type %></td>
<td>
<% if (e.status === 'active') { %><span class="status-pill status-ok">active</span>
<% } else if (e.status === 'pending') { %><span class="status-pill status-warn">pending</span>
<% } else { %><span class="status-pill status-dim"><%= e.status %></span><% } %>
</td>
<td class="mono"><%= fmtDate(e.updated_at) %></td>
<td>
<div class="row-actions">
<% if (e.status !== 'active') { %>
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button>
<% } else { %>
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="greyed">灰掉</button>
<% } %>
<% if (isAdmin) { %>
<button class="btn btn-sm btn-danger" type="button" data-action="entry-delete" data-id="<%= e.id %>">删除</button>
<% } %>
</div>
</td>
</tr>
<% } %>
</tbody>
</table>
</section>
<!-- 标签管理 -->
<section id="sec-tags">
<p class="anno">// tree: tags · 删除父级时子级自动上移;移动到自己的子级会被拒绝</p>
<h1>标签管理</h1>
<div style="display:flex;gap:10px;margin-top:18px">
<button class="btn btn-primary btn-sm" type="button" data-action="tag-add">新增顶层标签组</button>
<a class="btn btn-sm" href="/entries">前台预览</a>
</div>
<div class="tagmgr">
<div class="panel-box">
<h3>标签树 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(#ID 用于「移动」时填写目标)</span></h3>
<ul class="tag-tree">
<% for (const root of tagTreeAdmin) { %><%- include('partials/tag-node', { node: root }) %><% } %>
</ul>
</div>
<div class="panel-box">
<h3>规则说明</h3>
<p style="font-size:13.5px;color:var(--ink-dim)">
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。
「移动」填写目标标签 ID;移动到自身子级会被拒绝以防止成环。
删除父标签时其子标签自动上移一级。
</p>
</div>
</div>
</section>
<!-- 评论管理 -->
<section id="sec-comments">
<p class="anno">// table: comments</p>
<h1>评论管理</h1>
<table class="data-table" style="margin-top:22px">
<thead><tr><th>评论者</th><th>内容</th><th>所属条目</th><th>时间</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
<% for (const c of comments) { %>
<tr>
<td><b><%= c.author_name %></b><% if (c.username) { %> <span class="mono" style="font-size:11px;color:var(--ink-dim)">@<%= c.username %></span><% } %></td>
<td style="max-width:280px"><%= c.body.slice(0, 80) %><%= c.body.length > 80 ? '…' : '' %></td>
<td><a href="/entries/<%= c.entry_slug %>" target="_blank"><%= c.entry_title %></a></td>
<td class="mono"><%= fmtDate(c.created_at) %></td>
<td><div class="row-actions">
<% if (isAdmin) { %><button class="btn btn-sm btn-danger" type="button" data-action="comment-delete" data-id="<%= c.id %>">删除</button><% } %>
</div></td>
</tr>
<% } %>
</tbody>
</table>
</section>
<!-- 内容源 -->
<section id="sec-sources">
<p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p>
<h1>内容源管理</h1>
<% for (const s of sources) { %>
<div class="panel-box" style="margin-top:20px">
<h3 style="display:flex;align-items:center;gap:10px">
<span class="dot <%= s.enabled ? '' : 'dot-off' %>"></span> <%= s.name %>
<span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">kind: <%= s.kind %> · 已收录 <%= Number(s.entry_count) %> 条</span>
</h3>
<% if (s.kind === 'human') { %>
<p style="font-size:13.5px;color:var(--ink-dim)">管理员与编辑在条目接口直接写入,始终可用,改动写入审计日志。</p>
<% } else if (s.kind === 'ai') { %>
<div class="switchline">
AI 录入接口启用
<span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span>
</div>
<form id="ai-key-form">
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key(留空 = 关闭通道)</p>
<div style="display:flex;gap:10px;max-width:420px">
<input class="input" name="key" type="text" placeholder="sk-…" />
<button class="btn btn-sm" type="submit">保存密钥</button>
</div>
</form>
<p class="mono" style="margin-top:12px;font-size:12px;color:var(--ink-dim)">
调用方式:POST /api/ingest/entry · header X-Ingest-Key · 新条目默认 pending
</p>
<% } else { %>
<form class="source-config" data-id="<%= s.id %>">
<div class="switchline">
启用定时拉取
<input type="checkbox" name="enabled" <%= s.enabled ? 'checked' : '' %> style="accent-color:var(--riso-blue)" />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px">
<div>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">名称</p>
<input class="input" name="name" value="<%= s.name %>" />
</div>
<div>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">拉取地址 / 计划(cron)</p>
<div style="display:flex;gap:8px">
<input class="input" name="url" value="<%= s.url %>" />
<input class="input mono" name="cron_expr" value="<%= s.cron_expr %>" style="max-width:130px" />
</div>
</div>
<div>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">适配器</p>
<select class="select" name="adapter">
<% for (const a of ['rss', 'html', 'eryajf-weekly']) { %>
<option value="<%= a %>" <%= s.adapter === a ? 'selected' : '' %>><%= a %></option>
<% } %>
</select>
</div>
<div style="display:flex;align-items:flex-end;gap:8px">
<button class="btn btn-primary btn-sm" type="submit">保存配置</button>
<button class="btn btn-sm" type="button" data-source-run="<%= s.id %>">立即拉取</button>
<button class="btn btn-sm" type="button" data-source-runs="<%= s.id %>">运行记录</button>
</div>
</div>
</form>
<pre class="code-block mono" id="runs-<%= s.id %>" hidden style="margin-top:12px"></pre>
<% } %>
</div>
<% } %>
<div class="panel-box" style="margin-top:20px">
<h3>新增拉取源</h3>
<form id="source-add-form" style="display:grid;grid-template-columns:1fr 1fr auto auto;gap:10px;align-items:end">
<div><input class="input" name="name" placeholder="源名称,如:阮一峰周刊" required /></div>
<div><input class="input" name="url" placeholder="https://…" required /></div>
<select class="select" name="adapter">
<option value="rss">rss</option>
<option value="html">html</option>
<option value="eryajf-weekly">eryajf-weekly</option>
</select>
<button class="btn btn-primary btn-sm" type="submit">创建</button>
</form>
</div>
</section>
<!-- 账号管理 -->
<% if (isAdmin) { %>
<section id="sec-accounts">
<p class="anno">// table: accounts · role: admin | editor</p>
<h1>账号管理</h1>
<table class="data-table" style="margin-top:22px">
<thead><tr><th>ID</th><th>用户名</th><th>角色</th><th>状态</th><th>最后登录</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
<% for (const u of users) { %>
<tr>
<td class="mono">#<%= u.id %></td>
<td><b><%= u.username %></b></td>
<td><%= u.role %></td>
<td><span class="status-pill <%= u.status === 'active' ? 'status-ok' : 'status-warn' %>"><%= u.status %></span></td>
<td class="mono"><%= u.last_login_at ? fmtDate(u.last_login_at) : '从未登录' %></td>
<td><div class="row-actions">
<% if (u.id !== user.id) { %>
<button class="btn btn-sm" type="button" data-action="user-disable" data-id="<%= u.id %>" data-status="<%= u.status %>">
<%= u.status === 'active' ? '停用' : '启用' %>
</button>
<% } %>
</div></td>
</tr>
<% } %>
</tbody>
</table>
<form id="user-add-form" style="display:flex;gap:10px;align-items:end;margin-top:18px;flex-wrap:wrap">
<div><p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">用户名</p><input class="input" name="username" required /></div>
<div><p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">初始密码</p><input class="input" name="password" type="password" required minlength="6" /></div>
<div>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">角色</p>
<select class="select" name="role"><option value="editor">editor</option><option value="admin">admin</option></select>
</div>
<button class="btn btn-primary btn-sm" type="submit">新增账号</button>
</form>
</section>
<% } %>
</main>
</div>
<script>window.__ADMIN_TAG_TREE__ = <%- JSON.stringify(tagTreeAdmin) %>;</script>
<%- include('partials/footer-bar') %>
+96
View File
@@ -0,0 +1,96 @@
<%- include('partials/head') %>
<%- include('partials/topbar', { active: 'browse' }) %>
<main class="container">
<p class="crumbs"><a href="/">~/awesome-index</a> / <a href="/entries"><%= entry.type %></a> / <%= entry.title %></p>
<div class="detail-head">
<div class="detail-title">
<h1><%= entry.title %></h1>
<span class="chip chip-type"><%= entry.type %></span>
<% if (entry.status === 'pending') { %><span class="status-pill status-warn">pending 待核验</span><% } %>
<div class="detail-actions">
<a class="btn btn-sm" href="<%= entry.url %>" target="_blank" rel="noopener">访问仓库 ↗</a>
<button class="btn btn-sm" type="button" data-copy='请通过 awesome-index 的 MCP 接口调用 get_entry,slug 为 "<%= entry.slug %>"'>复制给 AI</button>
</div>
</div>
<p style="margin-top:12px;color:var(--ink-dim);max-width:56ch"><%= entry.description_md %></p>
</div>
<div class="detail-layout">
<article class="detail-body">
<section>
<p class="anno">// entry.body · markdown source</p>
<h2>这是什么</h2>
<p><%= entry.description_md || '暂无介绍。' %></p>
<p style="margin-top:10px">仓库地址:<a class="mono" style="color:var(--riso-blue)" href="<%= entry.url %>" target="_blank" rel="noopener"><%= entry.url %></a></p>
</section>
<section>
<p class="anno">// dual reader view · 人读 ⇄ 机读</p>
<div class="view-tabs" role="tablist" aria-label="视图切换">
<button type="button" data-view="human" aria-selected="true">人读</button>
<button type="button" data-view="machine" aria-selected="false">机读 JSON</button>
</div>
<div data-view-panel="human">
<p style="color:var(--ink-dim)">这一页就是人读视图。切换到「机读 JSON」可以看到 AI 通过 MCP 的 get_entry 拿到的同一条数据——两种读者,同一份事实。</p>
</div>
<div data-view-panel="machine" hidden>
<div class="code-block"><%= machineJson %></div>
</div>
</section>
<section>
<p class="anno">// comments · <%= comments.length %> 条</p>
<h2>评论</h2>
<% for (const c of comments) { %>
<div class="comment">
<span class="avatar"><%= (c.author_name || '?').slice(0, 1).toUpperCase() %></span>
<div>
<div class="comment-head">
<b><%= c.author_name %></b>
<% if (c.username) { %><span class="mono" style="font-size:11px;color:var(--ink-dim)">@<%= c.username %></span><% } %>
<time><%= fmtDate(c.created_at) %></time>
</div>
<p><%= c.body %></p>
</div>
</div>
<% } %>
<% if (!comments.length) { %><p style="color:var(--ink-dim)">还没有评论,说点有用的。</p><% } %>
<form class="comment-form" data-entry-id="<%= entry.id %>">
<div class="form-row">
<input class="input" name="authorName" type="text" placeholder="昵称"
value="<%= typeof user !== 'undefined' && user ? user.username : '' %>" required />
</div>
<textarea class="textarea" name="body" placeholder="使用场景、坑、搭配推荐…" required></textarea>
<div><button class="btn btn-primary" type="submit">发布评论</button></div>
</form>
</section>
</article>
<aside>
<div class="side-card">
<h3>元数据</h3>
<table class="meta-table">
<tr><td>类型</td><td><%= entry.type %></td></tr>
<tr><td>License</td><td><%= entry.license || '—' %></td></tr>
<tr><td>Stars</td><td><%= Number(entry.stars).toLocaleString() %></td></tr>
<tr><td>收录时间</td><td><%= fmtDate(entry.created_at) %></td></tr>
<tr><td>最近核验</td><td><%= entry.verified_at ? fmtDate(entry.verified_at) + ' ✓' : '待核验' %></td></tr>
<tr><td>状态</td><td><%= entry.status %></td></tr>
</table>
</div>
<div class="side-card">
<h3>标签</h3>
<div class="card-tags" style="margin-top:0">
<% for (const t of entry.tags) { %><span class="chip"><%= t %></span><% } %>
<% if (!entry.tags.length) { %><span class="mono" style="font-size:12px;color:var(--ink-dim)">无标签</span><% } %>
</div>
</div>
</aside>
</div>
</main>
<%- include('partials/footer-bar') %>
+146
View File
@@ -0,0 +1,146 @@
<%- include('partials/head') %>
<%- include('partials/topbar', { active: 'home' }) %>
<main>
<section class="hero container">
<div class="hero-grid">
<div class="hero-copy">
<p class="anno rise">~/awesome-index — 给人用,也给 AI 用</p>
<h1 class="rise d1">把网上<span class="hl">牛逼的东西</span>,<br />收进一个好用的库。</h1>
<p class="hero-sub rise d2">人工精选的开源软件、微服务、SaaS、网站、工具、脚本与插件。每一条都是结构化数据:人在页面上搜,AI 通过 MCP 直接调用。</p>
<form class="search-instrument rise d3" id="search-form" role="search">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.8-3.8"/></svg>
<input id="q" type="search" placeholder="输入需求关键字,如:自托管相册 / 终端文件管理器…" aria-label="搜索收录内容" />
<button class="btn btn-primary" type="submit">搜索</button>
</form>
<p class="query-echo rise d3" id="query-echo" aria-live="polite"></p>
<a class="mcp-jump rise d4" href="#mcp">
<span class="dot"></span>
给 AI 用?本站提供 MCP 接口
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 4v16m0 0 6-6m-6 6-6-6"/></svg>
</a>
<p class="hero-meta rise d4">
<span>收录 <b><%= stats.total %></b> 条</span>
<span>在架 <b><%= stats.active %></b> 条</span>
<span>标签组 <b><%= stats.tags %></b> 个</span>
<span>本周新增 <b>+<%= stats.weekNew %></b></span>
<span>存储 <b>DuckDB</b></span>
<span>MCP <b>就绪</b></span>
</p>
</div>
<div class="hero-visual rise d3" role="presentation">
<p class="spec-label mono">// get_entry("ripgrep") · 同一条数据,两种读者</p>
<div class="spec-machine mono" aria-hidden="true">{
"id": "ripgrep",
"type": "tool",
"name": "ripgrep",
"tags": ["Rust", "CLI"],
"stars": <%= typeof recent[0] !== 'undefined' ? Number(recent[0].stars) : 52300 %>,
"status": "active"
}</div>
<div class="spec-human" aria-hidden="true">
<span class="chip chip-type">工具</span>
<h3>ripgrep</h3>
<p>以正则递归搜索目录,默认尊重 .gitignore,是代码库里最快的找东西方式。</p>
<div class="card-tags"><span class="chip">Rust</span><span class="chip">CLI</span></div>
<p class="card-meta">★ <%= fmtStars(52300) %> · 终端找东西的事实标准</p>
</div>
<button class="btn btn-random" id="random-btn" type="button" title="随机打开一条收录,没有头绪时让库替你挑一个">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M3 7h4l10 10h4M17 3l4 4-4 4M3 17h4l2.5-2.5M21 17l-4 4"/></svg>
随机游览
</button>
</div>
</div>
</section>
<section class="mcp on-panel" id="mcp">
<div class="container">
<p class="anno">// mcp.transport: streamable-http · auth: none</p>
<h2 class="rise">同一个库,两种读者。<br />AI 走这边。</h2>
<p class="mcp-sub rise d1">本站所有条目同时暴露为 MCP(Model Context Protocol)接口。把下面的地址加进你的 AI 客户端,Claude、Cursor 或任何支持 MCP 的 Agent 就能直接搜索与读取这个库。</p>
<div class="agent-prompt rise d1">
<div>
<h3>不想手动改配置?</h3>
<p class="mono">复制下面这句话发给你的 Agent,它会自己完成接入:</p>
<blockquote class="mono">「请把 URL 为 <%- config.baseUrl %>/mcp 的 MCP 服务器添加到你的客户端配置中,名称用 awesome-index,完成后告诉我现在可以调用哪些工具。」</blockquote>
</div>
<button class="copy-btn" type="button" data-copy="请把 URL 为 <%- config.baseUrl %>/mcp 的 MCP 服务器添加到你的客户端配置中,名称用 awesome-index,完成后告诉我现在可以调用哪些工具。">复制给 Agent</button>
</div>
<div class="mcp-grid">
<aside class="endpoint-card rise d2">
<h3>MCP Server URL</h3>
<div class="endpoint-url">
<code id="mcp-url"><%- config.baseUrl %>/mcp</code>
<button class="copy-btn" type="button" data-copy="<%- config.baseUrl %>/mcp">复制</button>
</div>
<p class="endpoint-status"><span class="dot"></span>在线 · Streamable HTTP · 只读</p>
<p class="endpoint-note">只读访问,无需密钥。写入类操作仅限管理台与白名单来源。</p>
</aside>
<div class="steps">
<div class="step rise d2">
<div class="step-head"><span class="step-no">01</span><h3>把服务器加进客户端配置</h3></div>
<pre>{
<span class="k">"mcpServers"</span>: {
<span class="k">"awesome-index"</span>: {
<span class="k">"url"</span>: <span class="s">"<%- config.baseUrl %>/mcp"</span>
}
}
}</pre>
</div>
<div class="step rise d3">
<div class="step-head"><span class="step-no">02</span><h3>可用的工具</h3></div>
<table class="tools-table">
<thead><tr><th>tool</th><th>说明</th></tr></thead>
<tbody>
<tr><td>search_entries</td><td>按关键字搜索收录内容</td></tr>
<tr><td>get_entry</td><td>获取单条的完整详情与元数据</td></tr>
<tr><td>random_entry</td><td>随机返回一条,用于探索发现</td></tr>
<tr><td>list_tags</td><td>列出全部标签组及其嵌套结构</td></tr>
</tbody>
</table>
</div>
<div class="step rise d4">
<div class="step-head"><span class="step-no">03</span><h3>直接问你的 AI</h3></div>
<pre>「用 awesome-index 找三个能自托管的相册方案,
按 star 数排序,给我 repo 链接。」</pre>
</div>
</div>
</div>
</div>
</section>
<section class="recent container">
<div class="section-head">
<div>
<p class="anno">// resource: recent_entries · limit 4</p>
<h2>最近收录</h2>
</div>
<a href="/entries">查看全部 →</a>
</div>
<div class="card-grid">
<% for (const e of recent) { %>
<a class="card" href="/entries/<%= e.slug %>">
<span class="chip chip-type"><%= e.type %></span>
<h3><%= e.title %></h3>
<p><%= e.description_md %></p>
<div class="card-tags">
<% for (const t of (e.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %>
</div>
<p class="card-meta">★ <%= fmtStars(e.stars) %> · 更新于 <%= fmtDate(e.updated_at) %></p>
</a>
<% } %>
<% if (!recent.length) { %>
<p style="color:var(--ink-dim)">还没有收录条目——去管理台新增,或等采集源跑一趟。</p>
<% } %>
</div>
</section>
</main>
<%- include('partials/footer-full') %>
+115
View File
@@ -0,0 +1,115 @@
<%- include('partials/head') %>
<%- include('partials/topbar', { active: 'browse' }) %>
<main class="container">
<div class="page-head">
<p class="anno">// tool: search_entries · filters applied server-side</p>
<h1><%= filters.q ? '「' + filters.q + '」的搜索结果' : '浏览全部' %></h1>
</div>
<form class="search-instrument" id="search-form" role="search" style="max-width:720px;margin-top:-6px;box-shadow:none">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.8-3.8"/></svg>
<input id="q" type="search" value="<%= filters.q %>" aria-label="搜索收录内容" />
<button class="btn btn-primary" type="submit">搜索</button>
</form>
<div class="list-toolbar">
<span class="result-count">共 <b><%= result.total %></b> 条结果</span>
<div class="toolbar-right">
<label class="mono" for="sort" style="font-size:12px;color:var(--ink-dim)">排序</label>
<select class="select filter-auto-submit" id="sort">
<% for (const [key, label] of [['updated','最近更新'],['stars','Star 最多'],['created','最近收录'],['title','标题']]) { %>
<option value="<%= key %>" <%= filters.sort === key ? 'selected' : '' %>><%= label %></option>
<% } %>
</select>
<button class="btn btn-sm rail-toggle" id="rail-toggle" type="button">筛选标签</button>
</div>
</div>
<div class="list-layout">
<aside class="filter-rail" id="filter-rail" aria-label="标签过滤">
<form method="get" action="/entries" class="filter-auto">
<% if (filters.q) { %><input type="hidden" name="q" value="<%= filters.q %>" /><% } %>
<input type="hidden" name="sort" value="<%= filters.sort %>" />
<div class="filter-group">
<h3>类型</h3>
<% for (const t of types) { %>
<label class="checkline">
<input type="checkbox" name="type" value="<%= t %>" <%= filters.types.includes(t) ? 'checked' : '' %> />
<%= t %> <span class="cnt"><%= typeCounts[t] || 0 %></span>
</label>
<% } %>
</div>
<div class="filter-group">
<h3>编程语言 / 标签</h3>
<ul class="tag-tree">
<% function renderNode(node) { %>
<li>
<% if (node.children.length) { %>
<button class="tree-row" type="button" data-tree="tag-<%= node.id %>" aria-expanded="false">
<span class="tw">▶</span> <%= node.name %> <span class="cnt"><%= node.count %></span>
</button>
<ul class="tag-children" id="tag-<%= node.id %>">
<% for (const child of node.children) { %><%- renderNode(child) %><% } %>
</ul>
<% } else { %>
<label class="checkline">
<input type="checkbox" name="tags" value="<%= node.id %>" <%= filters.tagIds.includes(node.id) ? 'checked' : '' %> />
<%= node.name %> <span class="cnt"><%= node.count %></span>
</label>
<% } %>
</li>
<% } for (const root of tagTree) { %><%- renderNode(root) %><% } %>
</ul>
</div>
<div style="display:flex;gap:14px;margin-top:8px">
<button class="btn btn-sm" type="submit">应用筛选</button>
<a class="clear-filters" href="/entries">清空全部筛选</a>
</div>
</form>
</aside>
<section class="results" aria-label="搜索结果">
<% for (const e of result.items) { %>
<a class="result-row" href="/entries/<%= e.slug %>">
<span class="chip chip-type"><%= e.type %></span>
<div class="result-main">
<h3><%= e.title %></h3>
<p><%= e.description_md %></p>
<div class="card-tags">
<% for (const t of e.tags.slice(0, 4)) { %><span class="chip"><%= t %></span><% } %>
</div>
</div>
<div class="result-side">
<span>★ <%= fmtStars(e.stars) %></span>
<span><%= e.license || '—' %> · 更新于 <%= fmtDate(e.updated_at) %></span>
</div>
</a>
<% } %>
<% if (!result.items.length) { %>
<p style="color:var(--ink-dim);padding:30px 0">没有匹配的条目。换个关键字,或者清空筛选再试。</p>
<% } %>
<nav class="pager" aria-label="分页">
<% const mk = (p) => '/entries?' + qs({ q: filters.q, type: filters.types, tags: filters.tagIds, sort: filters.sort, page: p }); %>
<% if (result.page > 1) { %><a class="btn btn-sm" href="<%= mk(result.page - 1) %>">← 上一页</a><% } else { %><button class="btn btn-sm" disabled style="opacity:.45">← 上一页</button><% } %>
<span>第 <%= result.page %> / <%= totalPages %> 页</span>
<% if (result.page < totalPages) { %><a class="btn btn-sm" href="<%= mk(result.page + 1) %>">下一页 →</a><% } else { %><button class="btn btn-sm" disabled style="opacity:.45">下一页 →</button><% } %>
</nav>
</section>
</div>
</main>
<script>
document.querySelectorAll(".filter-auto-submit").forEach(function (sel) {
sel.addEventListener("change", function () {
var u = new URL(location.href);
u.searchParams.set("sort", sel.value);
location.href = u.toString();
});
});
</script>
<%- include('partials/footer-bar') %>
+42
View File
@@ -0,0 +1,42 @@
<%- include('partials/head') %>
<%- include('partials/topbar', { active: 'admin' }) %>
<main>
<div class="auth-wrap container">
<section class="auth-card rise" aria-labelledby="auth-title">
<p class="anno">// route: /admin/login · auth: session cookie</p>
<h1 id="auth-title">管理台登录</h1>
<p class="auth-sub">人工通道。AI Agent 请走 <a href="/#mcp">MCP 接口</a>——这扇门不对机器开放。</p>
<form id="login-form" novalidate>
<div class="field">
<label for="login-user">用户名</label>
<input class="input" id="login-user" name="username" type="text" placeholder="admin" autocomplete="username" required />
</div>
<div class="field">
<label for="login-pass">密码</label>
<input class="input" id="login-pass" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required />
</div>
<div class="auth-row">
<label class="checkline" style="padding:0"><input type="checkbox" checked /> 记住这台设备</label>
<a href="mailto:admin@honor3.com">忘记密码?</a>
</div>
<button class="btn btn-primary btn-block" type="submit">登 录</button>
</form>
<div class="auth-note mono">
<span>// 初始账号:admin(密码为 ADMIN_INIT_PASSWORD)</span>
<span>// agents: POST /mcp · 此处返回 403</span>
</div>
</section>
</div>
</main>
<div class="footer-bar" style="border-top:2px solid var(--ink)">
<div class="container" style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:20px 0 26px;font-family:var(--font-mono);font-size:12px">
<span>© 2026 honor3.com · Awesome Index Admin</span>
<span><a href="/" style="color:inherit;text-decoration:none">← 返回首页</a></span>
</div>
</div>
<%- include('partials/footer-bar') %>
+11
View File
@@ -0,0 +1,11 @@
<footer class="footer-bar" style="border-top:0;padding-top:26px;background:transparent;color:var(--ink-dim)">
<div class="container" style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:20px 0 26px;font-family:var(--font-mono);font-size:12px">
<span>© 2026 honor3.com · Awesome Index</span>
<span><a href="mailto:admin@honor3.com" style="color:inherit;text-decoration:none">admin@honor3.com</a></span>
</div>
</footer>
<div class="toast" role="status"></div>
<script src="/js/main.js"></script>
<script src="/js/admin.js"></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<footer class="footer on-panel">
<div class="container">
<div class="footer-grid">
<div class="footer-brand">
<a class="brand" href="/" style="color:var(--paper-on-panel)">
<span class="brand-mark"><i style="border-color:#5b6cff"></i><i></i></span>
AWESOME·INDEX
</a>
<p>一个人工维护的「牛逼东西」数据库。条目由人工录入、AI 接口与外部周刊自动拉取汇聚;坏数据会被灰掉,好东西值得被检索。</p>
</div>
<div>
<h4>站内</h4>
<ul>
<li><a href="/">首页</a></li>
<li><a href="/entries">浏览全部</a></li>
<li><a href="/random">随机一条</a></li>
<li><a href="/admin">管理入口 →</a></li>
</ul>
</div>
<div>
<h4>给 AI</h4>
<ul>
<li><a href="#mcp-url" class="mono">POST /mcp</a></li>
<li><a href="#mcp">MCP 接入说明</a></li>
</ul>
</div>
<form class="feedback" data-feedback>
<h4><label for="fb-text">快速反馈</label></h4>
<textarea id="fb-text" required placeholder="坏链接 / 推荐条目 / 吐槽都行"></textarea>
<button type="submit">发送</button>
<span class="alt">或写信到 <a href="mailto:admin@honor3.com">admin@honor3.com</a></span>
</form>
</div>
<div class="footer-bar" style="border-top:1px solid rgba(233,235,228,.14)">
<span>© 2026 honor3.com · Awesome Index</span>
<span>由 DuckDB 驱动 · MCP 就绪 · 数据每日快照</span>
</div>
</div>
</footer>
<div class="toast" role="status"></div>
<script src="/js/main.js"></script>
<script src="/js/admin.js"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= typeof title !== 'undefined' ? title : 'Awesome Index' %></title>
<meta name="description" content="精选开源软件、服务与网站的数据库。给人用,也给 AI 用——直接提供 MCP 接口。" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;700;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/main.css" />
</head>
<body>
+23
View File
@@ -0,0 +1,23 @@
<li>
<div class="tree-row" style="cursor:default">
<% if (node.children.length) { %>
<button class="tree-row" type="button" style="flex:1" data-tree="atag-<%= node.id %>" aria-expanded="false">
<span class="tw">▶</span> <%= node.name %>
</button>
<% } else { %>
<span style="flex:1;padding-left:18px"><%= node.name %></span>
<% } %>
<span class="mono cnt"><%= node.count %> · #<%= node.id %></span>
<span class="row-actions" style="margin-left:8px">
<button class="btn btn-sm" type="button" data-action="tag-add" data-parent-id="<%= node.id %>">+子级</button>
<button class="btn btn-sm" type="button" data-action="tag-rename" data-id="<%= node.id %>" data-name="<%= node.name %>">改名</button>
<button class="btn btn-sm" type="button" data-action="tag-move" data-id="<%= node.id %>">移动</button>
<button class="btn btn-sm btn-danger" type="button" data-action="tag-delete" data-id="<%= node.id %>">删</button>
</span>
</div>
<% if (node.children.length) { %>
<ul class="tag-children" id="atag-<%= node.id %>">
<% for (const child of node.children) { %><%- include('tag-node', { node: child }) %><% } %>
</ul>
<% } %>
</li>
+14
View File
@@ -0,0 +1,14 @@
<header class="topbar">
<div class="container topbar-inner">
<a class="brand" href="/">
<span class="brand-mark"><i></i><i></i></span>
AWESOME·INDEX
</a>
<nav class="topnav" aria-label="主导航">
<a href="/" <%= typeof active !== 'undefined' && active==='home' ? 'aria-current="page"' : '' %>>首页</a>
<a href="/entries" <%= typeof active !== 'undefined' && active==='browse' ? 'aria-current="page"' : '' %>>浏览全部</a>
<a href="/admin" <%= typeof active !== 'undefined' && active==='admin' ? 'aria-current="page"' : '' %>>管理入口</a>
</nav>
<button class="btn btn-sm nav-toggle" aria-expanded="false">菜单</button>
</div>
</header>