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

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
PORT=3000
DUCKDB_PATH=./data/awesome.duckdb
SESSION_SECRET=change-me-in-production
ADMIN_INIT_PASSWORD=admin
AI_INGEST_KEY=
BASE_URL=http://localhost:3000

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
data/
.env
*.log

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
COPY public ./public
VOLUME ["/app/data"]
EXPOSE 3000
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD node -e "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "src/index.js"]

68
README.md Normal file
View File

@ -0,0 +1,68 @@
# Awesome Index
把网上牛逼的东西收进一个库:给人用,也给 AI 用。
精选开源软件 / 微服务 / SaaS / 网站 / 工具 / 脚本 / 插件的结构化数据库,内置 MCP 接口。
- 设计方案:[design/DESIGN.md](design/DESIGN.md)Riso 印刷风格 + 静态原型)
- 框架设计:[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)(模块划分 / 数据模型 / 端点)
## 快速开始
### 本地开发
```bash
npm install
npm run dev # http://localhost:3000--watch 热重载)
npm test # node:test10 个用例
```
首次启动自动完成建表与种子数据。默认管理员 `admin`,密码取 `ADMIN_INIT_PASSWORD`(默认 `admin`)。
### Docker 部署
```bash
cp .env.example .env # 修改 SESSION_SECRET / ADMIN_INIT_PASSWORD / AI_INGEST_KEY
docker compose up -d --build
```
数据持久化在宿主机 `./data/awesome.duckdb`,升级镜像不丢数据。
## 给人的入口
| 页面 | 地址 |
|---|---|
| 首页(搜索 / MCP 说明 / 最近收录) | `/` |
| 列表页(类型 + 嵌套标签过滤) | `/entries?q=关键字&type=工具&tags=1,2&sort=stars` |
| 详情页(元数据 / 评论 / 机读 JSON | `/entries/:slug` |
| 随机游览 | `/random` |
| 登录 | `/login` |
| 管理台(内容/标签/评论/内容源/账号) | `/admin` |
## 给 AI 的入口MCP
Streamable HTTP · 只读 · 无需密钥:
```
POST {BASE_URL}/mcp
tools: search_entries / get_entry / random_entry / list_tags
```
一句话让 Agent 自己接入:
> 请把 URL 为 `{BASE_URL}/mcp` 的 MCP 服务器添加到你的客户端配置中,名称用 awesome-index完成后告诉我现在可以调用哪些工具。
## 内容源(自动化收录)
三类来源汇聚到同一条目表:
| kind | 说明 | 新条目状态 |
|---|---|---|
| `human` | 管理台人工录入(常开) | active |
| `ai` | `POST /api/ingest/entry` + `X-Ingest-Key` 头 | pending |
| `feed` | 定时拉取外部站点rss / html / eryajf-weekly 适配器),管理台可手动触发 | pending |
首个站点适配器二丫讲梵学习周刊wiki.eryajf.net。运行记录见管理台「内容源 → 运行记录」。
## 技术栈
Node.js 22 (ESM) · Express 5 · EJS · DuckDB (`@duckdb/node-api`) · zod · express-session · @modelcontextprotocol/sdk · node-cron · cheerio

16
docker-compose.yml Normal file
View File

@ -0,0 +1,16 @@
services:
web:
build: .
container_name: awesome-index
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
PORT: "3000"
DUCKDB_PATH: /app/data/awesome.duckdb
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-admin}
AI_INGEST_KEY: ${AI_INGEST_KEY:-}
BASE_URL: ${BASE_URL:-http://localhost:3000}
restart: unless-stopped

270
docs/ARCHITECTURE.md Normal file
View File

@ -0,0 +1,270 @@
# Awesome Index · 框架设计
> 目标:把 `design/prototype/` 的静态原型落地为一个由 DuckDB 驱动、全 JavaScript 编写、
> 对人与对 AIMCP同时提供服务、可 Docker 部署的小型数据库网站。
> 本文档回答三个问题:**模块怎么分、每个模块负责什么、模块对应哪个目录。**
---
## 1. 总体架构
经典三横层,外加一个并列的机器读者入口:
```
┌──────────────────────────────────────────┐
人类读者 ────▶ │ 视图层 views/ (EJS SSR) + public/ 静态 │
│ 页面内 <script> fetch('/api/')
└───────────────┬──────────────────────────┘
AI 读者 ─────▶ ┌──────────────┴──────────────────────────┐
(MCP) │ 接口层 routes/HTTP + MCP
│ 参数校验(zod) → 调用 service → 统一响应 │
└───────────────┬──────────────────────────┘
外部站点 ────▶ ┌──────────────┴──────────────────────────┐
周刊/RSS │ 采集层 ingest/(定时拉取与整理) │
│ scheduler → adapter 解析 → 去重 → 入库 │
└───────────────┬──────────────────────────┘
┌───────────────┴──────────────────────────┐
│ 业务服务层 services/(唯一持有 SQL 处) │
│ 条目 / 标签树 / 评论 / 账号 / 内容源 / 搜索 │
└───────────────┬──────────────────────────┘
┌───────────────┴──────────────────────────┐
│ 数据层 db/DuckDB 单文件库) │
│ data/awesome.duckdb │
└──────────────────────────────────────────┘
```
### 关键技术决策
| 决策点 | 选择 | 理由 |
|---|---|---|
| 运行时 | Node.js 22 LTS + ESM | 全站 JavaScript 要求22 为当前 LTS |
| Web 框架 | Express 5 | 成熟、薄、够用;不引入大而全框架 |
| 数据库 | DuckDB`@duckdb/node-api` 官方驱动) | 选型既定;单文件库,随 volume 持久化 |
| ORM | **不引入**services 内手写参数化 SQL | 规模小避免多一层抽象SQL 可审查 |
| 模板 | EJS 服务端渲染骨架 | 页面可直接输出后端数据(「混入后台调用」的另一半),渐进增强 |
| 校验 | zod | 路由层统一输入校验 |
| 会话 | express-session默认 MemoryStore | 单实例部署,无需外部存储 |
| MCP | `@modelcontextprotocol/sdk`Streamable HTTP | 挂在 `/mcp`,只读,复用 service 层 |
| 测试 | node:test + 临时 DuckDB 文件 | 零额外测试框架依赖 |
---
## 2. 目录结构
```
awesome/
├── package.json # 依赖与 scriptsdev / start / test / migrate
├── Dockerfile # 多阶段构建node:22-alpine
├── docker-compose.yml # 单服务编排 + data 卷 + healthcheck
├── .env.example # 配置样例
├── .gitignore # node_modules / data / .env
├── src/
│ ├── index.js # 【入口】读取配置 → 连库 → 迁移 → 起 HTTP 服务
│ ├── app.js # 【装配】Express 实例:中间件顺序 + 挂载全部路由
│ ├── config.js # 【配置】环境变量集中读取与默认值
│ │
│ ├── db/ # ── 数据层 ──
│ │ ├── connection.js # DuckDB 连接单例(含初始化与查询助手)
│ │ ├── schema.sql # 全部建表 DDL唯一 schema 事实来源)
│ │ └── migrate.js # 启动时执行 DDL + 幂等种子数据(初始管理员等)
│ │
│ ├── routes/ # ── 接口层(薄:校验 → service → 响应)──
│ │ ├── pages.routes.js # 页面路由GET 首页/列表/详情/登录/管理台壳
│ │ ├── entries.routes.js # /api/entries…搜索、随机、详情、增改灰删
│ │ ├── tags.routes.js # /api/tags…树读取、新增、移动、删除
│ │ ├── comments.routes.js # /api/comments…发布、列表、删除
│ │ ├── admin.routes.js # /api/admin…账号 CRUD、内容源开关、AI 录入入口)
│ │ └── mcp.routes.js # /mcp 与 /healthz 的挂载转发
│ │
│ ├── services/ # ── 业务服务层(核心规则所在,唯一写 SQL 处)──
│ │ ├── entry.service.js # 条目 CRUD、状态机、核验时间
│ │ ├── tag.service.js # 标签树:增删、移动(防环)、子级上移继承
│ │ ├── comment.service.js # 评论发布/删除
│ │ ├── user.service.js # 账号与角色admin / editor
│ │ ├── source.service.js # 内容源注册表human / ai / feed 三类源的配置与策略
│ │ └── search.service.js # 搜索DuckDB FTS、过滤组合、random_entry
│ │
│ ├── ingest/ # ── 采集层:自动化内容的来源(拉取与整理)──
│ │ ├── scheduler.js # 定时器:按每条 feed 源的 cron 计划触发 pipeline
│ │ ├── pipeline.js # 单次采集编排fetch → 解析 → 去重 → 入库 → 运行记录
│ │ └── adapters/ # 解析适配器(可插拔,一个外部站点一个文件)
│ │ ├── rss.adapter.js # 通用 RSS/Atom多数博客周刊直接可用
│ │ ├── html.adapter.js # 通用网页CSS 选择器规则配置化抽取
│ │ └── eryajf-weekly.adapter.js # 站点专用wiki.eryajf.net 学习周刊逐期解析
│ │
│ ├── mcp/ # ── 机器读者入口 ──
│ │ ├── server.js # SDK Server + StreamableHTTPServerTransport 装配
│ │ └── tools.js # search_entries / get_entry / random_entry / list_tags
│ │ # (工具实现 = 直接调用 services只读
│ │
│ ├── middleware/
│ │ ├── auth.js # requireLogin / requireRole('admin')AI Key 校验
│ │ └── errors.js # 404 兜底 + 统一错误处理器 + 请求日志
│ │
│ └── views/ # ── 视图(由原型平移改造)──
│ ├── layouts/base.ejs # 公共 <head>、顶栏、页脚骨架
│ ├── index.ejs # 首页 ← design/prototype/index.html
│ ├── list.ejs # 列表页 ← list.html
│ ├── detail.ejs # 详情页 ← detail.html
│ ├── login.ejs # 登录页 ← login.html
│ └── admin/ # 管理台各分区 ← admin.html 拆分
│ ├── accounts.ejs tags.ejs content.ejs comments.ejs sources.ejs
├── public/ # ── 静态资源Express.static 直出)──
│ ├── css/main.css # 设计系统 ← design/prototype/assets/css/main.css
│ ├── js/main.js # 通用交互 ← assets/js/main.js去演示逻辑
│ ├── js/admin.js # 管理台交互(分区切换、表格操作)
│ └── favicon.svg
├── data/ # DuckDB 库文件目录docker volume 挂载点,不入库)
└── test/
├── helpers.js # 构建临时库 + 生成 app 实例的工具
├── tags.test.js # 标签树移动/删除规则
├── entries.test.js # 状态机与搜索
└── mcp.test.js # 四个工具冒烟测试
```
---
## 3. 模块职责
### 3.1 入口与装配 —— `src/index.js`、`src/app.js`
- **负责**启动顺序编排config → db.connect → migrate → app → listen中间件装配静态目录、session、json 解析、路由挂载、错误处理最后注册)。
- **不做**:任何业务逻辑。
### 3.2 配置 —— `src/config.js`
- **负责**:集中读取环境变量并给默认值:`PORT`(3000)、`DUCKDB_PATH`(./data/awesome.duckdb)、`SESSION_SECRET`、`ADMIN_INIT_PASSWORD`、`AI_INGEST_KEY`、`MCP_READONLY`(true)。
- **约束**:任何模块不得直接读 `process.env`,一律 import config。
### 3.3 数据层 —— `src/db/`
- **负责**连接单例与查询助手prepared statement 封装);`schema.sql` 为全库唯一 DDL 来源;`migrate.js` 幂等CREATE TABLE IF NOT EXISTS + 种子:初始 admin、内置「人工录入」源
- **不做**:不含业务规则;不了解 HTTP。
### 3.4 接口层 —— `src/routes/`
- **负责**URL → handler 映射zod 解析 query/body调用对应 service包装统一响应 `{ ok: true, data }` / `{ ok: false, error: { code, message } }`
- **不做**SQL、业务判定如「能否灰掉」属于 service
### 3.5 业务服务层 —— `src/services/`(核心)
| service | 核心规则 |
|---|---|
| `entry.service` | 状态机:`pending → active / greyed``active ⇄ greyed` 可逆;删除仅物理删除且需 admin。写入时维护 `updated_at`、人工改动刷新 `verified_at` |
| `tag.service` | 树操作:新增子标签;**移动**时校验目标不是自身后代(防环);**删除**父标签时子标签自动上移一级sort 权重排序 |
| `comment.service` | 登录或匿名昵称均可发布删除为物理删除admin 或本人) |
| `user.service` | 角色 `admin / editor`editor 不能管理账号与内容源 |
| `source.service` | 内容源注册:三类来源——`human`(人工录入,常开)/ `ai`AI 接口,可停用)/ `feed`**外部站点自动拉取**URL、适配器名、cron 计划策略字段AI/Feed 新条目默认落 `pending`,「直发」开关关闭时才直接 `active`;记录每源的最近运行状态 |
| `search.service` | 组合过滤(关键字 + 类型 + 标签集合,标签含后代);基于 DuckDB FTS 的全文索引;`random_entry()` 从 active 集合随机 |
### 3.6 采集层 —— `src/ingest/`(自动化内容的来源)
内容源不只是「人工 / AI 接口」两种手动通道,还包含**从外部站点定时拉取并整理**的
自动通道。首个落地来源:二丫讲梵学习周刊 `https://wiki.eryajf.net/weekly/`
VuePress 站点,自带 `/rss.xml`;每期页面内含若干开源项目条目)。
- **负责**
- `scheduler.js`:进程内定时器,扫描 enabled 的 feed 源,到达各自 cron 计划即触发一次采集(单实例内互斥锁防重入);
- `pipeline.js`:单次采集编排——`adapter.fetch(url)` 产出原始条目数组
`{ title, url, description, extra }` → 规范化 → **按规范化 URL 去重**(已存在即 skip
`entry.service.create()` 入库(状态按源策略:默认 `pending` 待人工核验)→ 写一条运行记录;
- `adapters/`:解析器可插拔。优先写通用 `rss.adapter.js`(覆盖大多数周刊/博客);
RSS 拿不到结构化项目列表时用站点专用适配器,如 `eryajf-weekly.adapter.js`
(拉取最新一期 HTML抽取期号、每期内的项目名/仓库链接/推荐语);
- 失败不抛出中断进程:错误记入运行记录并在管理台可见。
- **不做**:不做 UI 判断、不直接写 SQL经 services不改已存在条目更新由人工在管理台完成
### 3.7 MCP 模块 —— `src/mcp/`
- **负责**:把四个只读工具暴露为 MCP Streamable HTTP`POST/GET/DELETE /mcp``search_entries`、`get_entry`、`random_entry`、`list_tags`;实现即一行 service 调用,保证**人看的 API 与 AI 用的 MCP 永远同源同权**。
- **不做**:任何写操作(登录页已声明:此门不对机器开放)。
### 3.8 中间件 —— `src/middleware/`
- `auth.js`session 登录态;`requireRole('admin')` 保护 `/api/admin/*``X-Ingest-Key` 校验 AI 录入来源并标记 `source='ai'`
- `errors.js`:业务错误 → HTTP 状态码映射;兜底 500简量请求日志。
### 3.9 视图 —— `src/views/`(「混入后台调用」模式)
- EJS 渲染**骨架与首屏必需数据**(标题、统计、首屏卡片),保证无 JS 也完整可读;
- 交互性部分(搜索联想、筛选联动、管理台表格、评论提交)由页面内 `<script>` 直接 `fetch('/api/…')` 完成——即需求所述「直接在页面中混入后台调用」;
- 原型中的演示数据与演示 toast 全部替换为真实接口调用。
### 3.10 静态资源 —— `public/`
- 设计系统 CSS 与通用 JS 从原型**原样平移**(这是本次设计阶段的资产);仅删除 demo 提交拦截,改为真实提交。
---
## 4. 数据模型概要(支撑模块边界)
```
users(id, username UNIQUE, password_hash, role, status, last_login_at)
sources(id, kind 'human'|'ai'|'feed', name, enabled,
url, adapter, cron_expr, -- feed 源专用:拉取地址 / 适配器 / 计划
api_key_hash, direct_publish, -- ai 源专用
last_run_at, last_run_status)
source_runs(id, source_id → sources.id, started_at, finished_at,
status, found, created, skipped, error) -- feed 每次采集一条运行记录
entries(id, title, slug UNIQUE, type, description_md, url, license,
stars, status 'active'|'greyed'|'pending', source_id → sources.id,
verified_at, created_at, updated_at)
tags(id, name, parent_id → tags.id, sort) -- 嵌套树
entry_tags(entry_id, tag_id) -- 多对多
comments(id, entry_id, author_name, user_id NULL, body, created_at)
audit_logs(id, actor, action, target, created_at) -- 人工改动审计
```
外键方向决定依赖routes → services → dbservices 之间允许单向调用
(如 entry.service 调 tag.service 校验标签存在),禁止循环。
---
## 5. 主要 HTTP 端点(与 MCP 的对应)
| 方法与路径 | 说明 | MCP 工具 |
|---|---|---|
| `GET /api/entries?q&type&tags&sort&page` | 搜索/浏览tags 含后代展开) | `search_entries` |
| `GET /api/entries/random` | 随机一条 | `random_entry` |
| `GET /api/entries/:slug` | 详情(含机读 JSON 所需全字段) | `get_entry` |
| `POST/PATCH/DELETE /api/entries…` | 增改灰删登录AI 走 ingest key → pending | — |
| `GET /api/tags/tree` · `POST/PATCH/DELETE /api/tags…` | 标签树读写 | `list_tags`(读) |
| `POST /api/comments` · `DELETE /api/comments/:id` | 评论 | — |
| `POST /api/admin/login` · `/api/admin/users…` · `/api/admin/sources…` | 登录、账号、内容源feed 源含 URL/适配器/计划配置) | — |
| `POST /api/admin/sources/:id/run` · `GET /api/admin/sources/:id/runs` | 手动触发一次拉取 · 运行历史found/created/skipped | — |
| `GET /healthz` | 存活检查compose healthcheck 用) | — |
---
## 6. Docker 部署形态
```yaml
services:
web:
build: .
ports: ["3000:3000"]
volumes: ["./data:/app/data"] # DuckDB 单文件持久化
environment:
SESSION_SECRET: ${SESSION_SECRET}
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD}
AI_INGEST_KEY: ${AI_INGEST_KEY}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
```
- 镜像:两阶段构建,产物仅 `node_modules(prune --omit=dev)` + `src` + `public`
- 单进程单实例MemoryStore session 与 DuckDB 文件写入都以此为前提);
- 升级 = 换镜像重启,数据留在宿主机 `./data`
---
## 7. 实现里程碑(建议顺序)
1. **M1 地基**config / db / schema / migrate + 种子 → `GET /healthz` 通;
2. **M2 核心 API**entries / tags含树规则/ comments 的 service 与路由 + 单测;
3. **M3 页面接管**views 平移原型,页面 fetch 替换演示数据(首页、列表、详情);
4. **M4 管理侧与内容源**auth、admin 各分区接真数据、AI ingest key 通道;
5. **M5 采集层**scheduler + rss.adapter 通用链路 → `eryajf-weekly.adapter.js`
首个站点适配器跑通「拉取 → pending → 管理台核验上架」闭环 + 运行记录;
6. **M6 MCP**:四工具挂载 + 冒烟测试;
7. **M7 交付**Dockerfile / compose / README。

1839
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "awesome-index",
"version": "0.1.0",
"description": "把网上牛逼的东西收进一个库:给人用,也给 AI 用",
"type": "module",
"private": true,
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"migrate": "node src/db/migrate.js",
"test": "node --test --test-force-exit \"test/**/*.test.js\""
},
"dependencies": {
"@duckdb/node-api": "1.5.5-r.4",
"@modelcontextprotocol/sdk": "^1.30.0",
"cheerio": "^1.0.0",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-session": "^1.18.1",
"node-cron": "^4.6.0",
"zod": "^3.25.76"
}
}

1839
public/css/main.css Normal file

File diff suppressed because it is too large Load Diff

1
public/favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="4" y="4" width="20" height="20" rx="5" fill="#2436E8"/><rect x="10" y="10" width="18" height="18" rx="5" fill="none" stroke="#FF4D2E" stroke-width="2.5"/></svg>

After

Width:  |  Height:  |  Size: 230 B

206
public/js/admin.js Normal file
View File

@ -0,0 +1,206 @@
(function () {
"use strict";
async function api(url, body, method) {
var res = await fetch(url, {
method: method || "POST",
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
var json = null;
try { json = await res.json(); } catch (e) {}
if (!res.ok || (json && json.ok === false)) {
if (res.status === 401) { location.href = "/login"; return new Promise(function () {}); }
throw new Error((json && json.error && json.error.message) || "请求失败(" + res.status + "");
}
return json;
}
function toast(msg) {
var el = document.querySelector(".toast");
if (!el) return;
el.textContent = msg;
el.classList.add("show");
clearTimeout(el._t);
el._t = setTimeout(function () { el.classList.remove("show"); }, 2600);
}
function refresh() {
setTimeout(function () { location.reload(); }, 500);
}
document.querySelectorAll(".admin-nav button[data-section]").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".admin-nav button").forEach(function (b) {
b.setAttribute("aria-current", "false");
});
btn.setAttribute("aria-current", "true");
var id = btn.getAttribute("data-section");
document.querySelectorAll(".admin-main > section").forEach(function (s) {
s.classList.toggle("active", s.id === id);
});
});
});
document.querySelectorAll(".tree-row[data-tree]").forEach(function (row) {
row.addEventListener("click", function (e) {
if (e.target.closest("button[data-tag-action]")) return;
var target = document.getElementById(row.getAttribute("data-tree"));
if (!target) return;
var open = target.classList.toggle("open");
row.setAttribute("aria-expanded", open ? "true" : "false");
});
});
document.addEventListener("click", async function (e) {
var btn = e.target.closest("[data-action]");
if (!btn) return;
var action = btn.getAttribute("data-action");
try {
if (action === "tag-add") {
var parentId = btn.getAttribute("data-parent-id");
var name = prompt(parentId ? "新增子标签名称:" : "新增顶层标签组名称:");
if (!name) return;
await api("/api/tags", { name: name, parentId: parentId ? Number(parentId) : null });
toast("标签已创建"); refresh();
}
if (action === "tag-rename") {
var name2 = prompt("重命名为:", btn.getAttribute("data-name"));
if (!name2 || name2 === btn.getAttribute("data-name")) return;
await api("/api/tags/" + btn.getAttribute("data-id"), { name: name2 }, "PATCH");
toast("已重命名"); refresh();
}
if (action === "tag-move") {
var target3 = prompt("移动到哪个标签之下?填其 ID留空移到顶层\n" + btn.getAttribute("data-ids"));
if (target3 === null) return;
await api("/api/tags/" + btn.getAttribute("data-id"),
{ parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH");
toast("已移动"); refresh();
}
if (action === "tag-delete") {
if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return;
await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
}
if (action === "entry-status") {
await api("/api/entries/" + btn.getAttribute("data-id") + "/status",
{ status: btn.getAttribute("data-status") }, "PATCH");
toast("状态已更新"); refresh();
}
if (action === "entry-delete") {
if (!confirm("物理删除该条目?此操作不可恢复。")) return;
await api("/api/entries/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
}
if (action === "comment-delete") {
if (!confirm("删除该评论?")) return;
await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
}
if (action === "user-disable") {
var status = btn.getAttribute("data-status") === "active" ? "disabled" : "active";
await api("/api/admin/users/" + btn.getAttribute("data-id") + "/status", { status: status }, "PATCH");
toast("账号状态已更新"); refresh();
}
} catch (err) {
toast(err.message);
}
});
var userAddForm = document.getElementById("user-add-form");
if (userAddForm) {
userAddForm.addEventListener("submit", function (e) {
e.preventDefault();
api("/api/admin/users", {
username: userAddForm.username.value.trim(),
password: userAddForm.password.value,
role: userAddForm.role.value,
})
.then(function () { toast("账号已创建"); refresh(); })
.catch(function (err) { toast(err.message); });
});
}
var aiKeyForm = document.getElementById("ai-key-form");
if (aiKeyForm) {
aiKeyForm.addEventListener("submit", function (e) {
e.preventDefault();
api("/api/admin/ai-key", { key: aiKeyForm.key.value.trim() }, "PUT")
.then(function () { toast("AI 录入密钥已更新"); refresh(); })
.catch(function (err) { toast(err.message); });
});
}
document.querySelectorAll("form.source-config").forEach(function (form) {
form.addEventListener("submit", function (e) {
e.preventDefault();
var id = form.getAttribute("data-id");
api("/api/admin/sources/" + id, {
name: form.name.value,
url: form.url.value,
adapter: form.adapter.value,
cron_expr: form.cron_expr.value,
enabled: form.enabled.checked,
}, "PATCH")
.then(function () { toast("内容源已保存"); refresh(); })
.catch(function (err) { toast(err.message); });
});
});
document.querySelectorAll("[data-source-run]").forEach(function (btn) {
btn.addEventListener("click", async function () {
var id = btn.getAttribute("data-source-run");
btn.disabled = true;
try {
var json = await api("/api/admin/sources/" + id + "/run", {});
var s = json.data;
toast(s.error
? "拉取失败:" + s.error
: "完成:发现 " + s.found + " · 新增 " + s.created + " · 跳过 " + s.skipped);
if (!s.error) refresh();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
}
});
});
document.querySelectorAll("[data-source-runs]").forEach(function (btn) {
btn.addEventListener("click", async function () {
var box = document.getElementById("runs-" + btn.getAttribute("data-source-runs"));
if (!box) return;
if (!box.hidden) { box.hidden = true; return; }
try {
var json = await api("/api/admin/sources/" + btn.getAttribute("data-source-runs") + "/runs", undefined, "GET");
box.textContent = JSON.stringify(json.data, null, 2).slice(0, 3000);
box.hidden = false;
} catch (err) {
toast(err.message);
}
});
});
var sourceAddForm = document.getElementById("source-add-form");
if (sourceAddForm) {
sourceAddForm.addEventListener("submit", function (e) {
e.preventDefault();
api("/api/admin/sources", {
name: sourceAddForm.name.value.trim(),
url: sourceAddForm.url.value.trim(),
adapter: sourceAddForm.adapter.value,
cron_expr: sourceAddForm.cron_expr.value.trim(),
})
.then(function () { toast("拉取源已创建"); refresh(); })
.catch(function (err) { toast(err.message); });
});
}
})();

169
public/js/main.js Normal file
View File

@ -0,0 +1,169 @@
(function () {
"use strict";
var toastEl = document.querySelector(".toast");
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
window.awToast = toast;
async function postJson(url, body, method) {
var res = await fetch(url, {
method: method || "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body || {}),
});
var json = null;
try {
json = await res.json();
} catch (e) {}
if (!res.ok || (json && json.ok === false)) {
throw new Error((json && json.error && json.error.message) || "请求失败(" + res.status + "");
}
return json;
}
window.awApi = postJson;
document.querySelectorAll("[data-copy]").forEach(function (btn) {
btn.addEventListener("click", function () {
var text = btn.getAttribute("data-copy");
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { toast("已复制"); },
function () { toast("复制失败,请手动选择文本"); }
);
} else {
toast("当前环境不支持自动复制");
}
});
});
var searchInput = document.getElementById("q");
var echo = document.getElementById("query-echo");
if (searchInput && echo) {
var paint = function () {
var q = searchInput.value.trim();
echo.textContent = q
? '→ mcp: search_entries({ "q": "' + q + '", "limit": 20 })'
: '→ mcp: search_entries({ "q": "…" })';
};
searchInput.addEventListener("input", paint);
paint();
}
var searchForm = document.getElementById("search-form");
if (searchForm) {
searchForm.addEventListener("submit", function (e) {
e.preventDefault();
var q = searchForm.querySelector("#q") ? searchForm.querySelector("#q").value.trim() : "";
location.href = "/entries" + (q ? "?q=" + encodeURIComponent(q) : "");
});
}
var randomBtn = document.getElementById("random-btn");
if (randomBtn) {
randomBtn.addEventListener("click", function () {
location.href = "/random";
});
}
var navToggle = document.querySelector(".nav-toggle");
if (navToggle) {
navToggle.addEventListener("click", function () {
var bar = document.querySelector(".topbar");
var open = bar.classList.toggle("menu-open");
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
});
}
var railToggle = document.getElementById("rail-toggle");
if (railToggle) {
railToggle.addEventListener("click", function () {
document.getElementById("filter-rail").classList.toggle("open");
});
}
document.querySelectorAll(".tree-row[data-tree]").forEach(function (row) {
row.addEventListener("click", function () {
var target = document.getElementById(row.getAttribute("data-tree"));
if (!target) return;
var open = target.classList.toggle("open");
row.setAttribute("aria-expanded", open ? "true" : "false");
});
});
document.querySelectorAll(".view-tabs button[data-view]").forEach(function (tab) {
tab.addEventListener("click", function () {
document
.querySelectorAll(".view-tabs button[data-view]")
.forEach(function (t) { t.setAttribute("aria-selected", "false"); });
tab.setAttribute("aria-selected", "true");
var view = tab.getAttribute("data-view");
document.querySelectorAll("[data-view-panel]").forEach(function (p) {
p.hidden = p.getAttribute("data-view-panel") !== view;
});
});
});
document.querySelectorAll(".filter-auto input[type=checkbox], .filter-auto select").forEach(function (el) {
el.addEventListener("change", function () {
el.form && el.form.submit();
});
});
var loginForm = document.getElementById("login-form");
if (loginForm) {
loginForm.addEventListener("submit", function (e) {
e.preventDefault();
if (!loginForm.reportValidity()) return;
postJson("/api/admin/login", {
username: loginForm.username.value,
password: loginForm.password.value,
})
.then(function () {
toast("登录成功,正在进入管理台…");
setTimeout(function () { location.href = "/admin"; }, 400);
})
.catch(function (err) { toast(err.message); });
});
}
var commentForm = document.querySelector(".comment-form[data-entry-id]");
if (commentForm) {
commentForm.addEventListener("submit", function (e) {
e.preventDefault();
if (!commentForm.reportValidity()) return;
postJson("/api/comments", {
entryId: Number(commentForm.getAttribute("data-entry-id")),
authorName: commentForm.querySelector("[name=authorName]").value,
body: commentForm.querySelector("[name=body]").value,
})
.then(function () {
toast("评论已发布");
setTimeout(function () { location.reload(); }, 500);
})
.catch(function (err) { toast(err.message); });
});
}
var feedbackForm = document.querySelector(".feedback[data-feedback]");
if (feedbackForm) {
feedbackForm.addEventListener("submit", function (e) {
e.preventDefault();
if (!feedbackForm.reportValidity()) return;
postJson("/api/feedback", { body: feedbackForm.querySelector("textarea").value })
.then(function () {
toast("已收到,感谢反馈");
feedbackForm.reset();
})
.catch(function (err) { toast(err.message); });
});
}
})();

65
src/app.js Normal file
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
src/config.js Normal file
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
src/db/connection.js Normal file
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
src/db/migrate.js Normal file
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
src/db/schema.sql Normal file
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
src/index.js Normal file
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);
});

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
src/ingest/pipeline.js Normal file
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
src/ingest/scheduler.js Normal file
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
src/mcp/server.js Normal file
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
src/mcp/tools.js Normal file
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
src/middleware/auth.js Normal file
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
src/middleware/errors.js Normal file
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
src/routes/admin.routes.js Normal file
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 });
});

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 });
});

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
src/routes/pages.routes.js Normal file
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
src/routes/tags.routes.js Normal file
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 });
});

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);
}

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]
);
}

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;
}

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),
};
}

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]
);
}

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;
}

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
src/views/admin.ejs Normal file
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
src/views/detail.ejs Normal file
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_entryslug 为 "<%= 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
src/views/index.ejs Normal file
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">本站所有条目同时暴露为 MCPModel 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
src/views/list.ejs Normal file
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
src/views/login.ejs Normal file
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') %>

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>

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>

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>

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>

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>

74
test/entries.test.js Normal file
View File

@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
test("seeded entries are searchable", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/entries?q=ripgrep");
assert.equal(res.status, 200);
assert.ok(res.json.data.total >= 1);
assert.equal(res.json.data.items[0].title, "ripgrep");
});
});
test("random endpoint returns an active entry", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/entries/random");
assert.equal(res.status, 200);
assert.equal(res.json.data.status, "active");
});
});
test("invalid status transition is rejected", async () => {
await withApp(async ({ api, loginAdmin }) => {
const cookie = await loginAdmin();
const found = await api("/api/entries?q=ripgrep");
const id = found.json.data.items[0].id;
const res = await api(`/api/entries/${id}/status`, {
method: "PATCH",
body: { status: "active" },
cookie,
});
assert.equal(res.status, 400);
});
});
test("greyed -> active is allowed", async () => {
await withApp(async ({ api, loginAdmin }) => {
const cookie = await loginAdmin();
const found = await api("/api/entries?q=ripgrep");
const id = found.json.data.items[0].id;
const grey = await api(`/api/entries/${id}/status`, {
method: "PATCH",
body: { status: "greyed" },
cookie,
});
assert.equal(grey.json.data.status, "greyed");
const back = await api(`/api/entries/${id}/status`, {
method: "PATCH",
body: { status: "active" },
cookie,
});
assert.equal(back.json.data.status, "active");
});
});
test("AI ingest without a valid key is forbidden", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "x", url: "https://github.com/a/b", type: "工具" },
});
assert.equal(res.status, 403);
});
});
test("unauthenticated writes to entries are forbidden", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/entries", {
method: "POST",
body: { title: "x", url: "https://github.com/a/c", type: "工具" },
});
assert.equal(res.status, 403);
});
});

55
test/helpers.js Normal file
View File

@ -0,0 +1,55 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { connect, close } from "../src/db/connection.js";
import { runMigrate } from "../src/db/migrate.js";
import { buildApp } from "../src/app.js";
export async function withApp(fn) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "awesome-test-"));
await connect(path.join(dir, "test.duckdb"));
await runMigrate({ adminInitPassword: "test123", aiIngestKey: "" });
const app = buildApp({
sessionSecret: "test-secret",
duckdbPath: path.join(dir, "test.duckdb"),
baseUrl: "http://test.local",
});
const server = app.listen(0);
await new Promise((r) => server.once("listening", r));
const base = `http://127.0.0.1:${server.address().port}`;
async function api(pathname, { method = "GET", body, cookie } = {}) {
const res = await fetch(base + pathname, {
method,
headers: {
...(body ? { "content-type": "application/json" } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let json = null;
try {
json = await res.json();
} catch {}
return { status: res.status, json, headers: res.headers };
}
async function loginAdmin() {
const res = await fetch(base + "/api/admin/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username: "admin", password: "test123" }),
});
const cookies = res.headers.getSetCookie().map((c) => c.split(";")[0]);
return cookies.join("; ");
}
try {
await fn({ base, api, loginAdmin });
} finally {
server.closeAllConnections?.();
await new Promise((r) => server.close(r));
await close();
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
}
}

57
test/pipeline.test.js Normal file
View File

@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
import { registerAdapter } from "../src/ingest/adapters/index.js";
import { runSource } from "../src/ingest/pipeline.js";
test("feed pipeline dedupes by url and records runs", async () => {
await withApp(async ({ api, loginAdmin }) => {
registerAdapter("test-fake", {
async fetch() {
return [
{
title: "ripgrep",
url: "https://github.com/BurntSushi/ripgrep?utm_source=x",
description: "dup",
},
{
title: "brand-new-tool",
url: "https://github.com/fake/brand-new-tool",
description: "new item from fake source",
},
{ title: "", url: "https://example.com/invalid", description: "" },
];
},
});
const cookie = await loginAdmin();
const created = await api("/api/admin/sources", {
method: "POST",
body: {
name: "fake 源",
url: "mem://fake",
adapter: "test-fake",
cron_expr: "0 9 * * 1",
},
cookie,
});
assert.equal(created.status, 201);
const sourceId = created.json.data.id;
const stats = await runSource(sourceId);
assert.equal(stats.found, 3);
assert.equal(stats.created, 1);
assert.equal(stats.skipped, 2);
const runs = await api(`/api/admin/sources/${sourceId}/runs`, {}, cookie ? {} : {});
void runs;
const pending = await api("/api/entries?q=brand-new-tool&status=all");
assert.equal(pending.json.data.total, 1);
assert.equal(pending.json.data.items[0].status, "pending");
const manual = await api(`/api/admin/sources/${sourceId}/run`, { method: "POST", cookie });
assert.equal(manual.json.data.created, 0);
assert.equal(manual.json.data.skipped, 3);
});
});

67
test/tags.test.js Normal file
View File

@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
test("seeded tag tree exposes nested groups with counts", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/tags/tree");
assert.equal(res.status, 200);
const tree = res.json.data;
const lang = tree.find((n) => n.name === "编程语言");
assert.ok(lang);
assert.ok(lang.children.some((c) => c.name === "Rust"));
assert.ok(lang.children.every((c) => c.count >= 0));
});
});
test("tag create / rename / move / delete rules", async () => {
await withApp(async ({ api, loginAdmin }) => {
const cookie = await loginAdmin();
const root = (await api("/api/tags/tree")).json.data.find(
(n) => n.name === "编程语言"
);
const created = await api("/api/tags", {
method: "POST",
body: { name: "测试语言", parentId: root.id },
cookie,
});
assert.equal(created.status, 201);
const childId = created.json.data.id;
const grandchild = await api("/api/tags", {
method: "POST",
body: { name: "测试子语言", parentId: childId },
cookie,
});
const grandId = grandchild.json.data.id;
const renamed = await api(`/api/tags/${grandId}`, {
method: "PATCH",
body: { name: "测试子语言2" },
cookie,
});
assert.ok(renamed.ok !== false);
const cyclic = await api(`/api/tags/${root.id}`, {
method: "PATCH",
body: { parentId: childId },
cookie,
});
assert.equal(cyclic.status, 400);
await api(`/api/tags/${childId}`, { method: "DELETE", cookie });
const flat = (await api("/api/tags")).json.data;
const moved = flat.find((t) => t.id === grandId);
assert.equal(moved.parent_id ?? null, root.id);
});
});
test("tag deletion requires admin", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/tags/1", { method: "DELETE" });
assert.equal(res.status, 401);
});
});