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:
parent
c47bb15d8c
commit
dec103546e
6
.env.example
Normal file
6
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal 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
68
README.md
Normal 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:test,10 个用例
|
||||
```
|
||||
|
||||
首次启动自动完成建表与种子数据。默认管理员 `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
16
docker-compose.yml
Normal 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
270
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,270 @@
|
||||
# Awesome Index · 框架设计
|
||||
|
||||
> 目标:把 `design/prototype/` 的静态原型落地为一个由 DuckDB 驱动、全 JavaScript 编写、
|
||||
> 对人与对 AI(MCP)同时提供服务、可 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 # 依赖与 scripts(dev / 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 → db;services 之间允许单向调用
|
||||
(如 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
1839
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
1839
public/css/main.css
Normal file
File diff suppressed because it is too large
Load Diff
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal 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
206
public/js/admin.js
Normal 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
169
public/js/main.js
Normal 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
65
src/app.js
Normal 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
29
src/config.js
Normal 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
41
src/db/connection.js
Normal 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
99
src/db/migrate.js
Normal 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
89
src/db/schema.sql
Normal 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
32
src/index.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { config, ensureDataDir } from "./config.js";
|
||||
import { connect } from "./db/connection.js";
|
||||
import { runMigrate } from "./db/migrate.js";
|
||||
import { buildApp } from "./app.js";
|
||||
import { startScheduler } from "./ingest/scheduler.js";
|
||||
|
||||
async function main() {
|
||||
ensureDataDir();
|
||||
await connect(config.duckdbPath);
|
||||
await runMigrate(config);
|
||||
|
||||
const app = buildApp(config);
|
||||
const server = app.listen(config.port, () => {
|
||||
console.log(`[awesome-index] listening on http://localhost:${config.port}`);
|
||||
});
|
||||
|
||||
if (config.schedulerEnabled) {
|
||||
await startScheduler();
|
||||
}
|
||||
|
||||
const shutdown = async () => {
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(0), 2000).unref();
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("startup failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
145
src/ingest/adapters/index.js
Normal file
145
src/ingest/adapters/index.js
Normal 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
63
src/ingest/pipeline.js
Normal 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
42
src/ingest/scheduler.js
Normal 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
42
src/mcp/server.js
Normal 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
80
src/mcp/tools.js
Normal 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
35
src/middleware/auth.js
Normal 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
24
src/middleware/errors.js
Normal 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
131
src/routes/admin.routes.js
Normal file
@ -0,0 +1,131 @@
|
||||
import { Router } from "express";
|
||||
import * as userService from "../services/user.service.js";
|
||||
import * as sourceService from "../services/source.service.js";
|
||||
import * as commentService from "../services/comment.service.js";
|
||||
import * as searchService from "../services/search.service.js";
|
||||
import * as audit from "../services/audit.service.js";
|
||||
import { requireAdmin } from "../middleware/auth.js";
|
||||
import { HttpError } from "../middleware/errors.js";
|
||||
import { runSource } from "../ingest/pipeline.js";
|
||||
import { rescheduleSource } from "../ingest/scheduler.js";
|
||||
export const adminRouter = Router();
|
||||
|
||||
function actor(req) {
|
||||
return req.session.username || `user:${req.session.userId}`;
|
||||
}
|
||||
|
||||
adminRouter.post("/api/admin/login", async (req, res) => {
|
||||
const user = await userService.verifyLogin(
|
||||
String(req.body.username || ""),
|
||||
String(req.body.password || "")
|
||||
);
|
||||
if (!user) throw new HttpError(401, "bad_credentials", "用户名或密码不正确");
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.role = user.role;
|
||||
res.json({ ok: true, data: { username: user.username, role: user.role } });
|
||||
});
|
||||
|
||||
adminRouter.post("/api/admin/logout", (req, res) => {
|
||||
req.session.destroy(() => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
adminRouter.get("/api/admin/session", (req, res) => {
|
||||
if (!req.session.userId) throw new HttpError(401, "unauthorized", "未登录");
|
||||
res.json({
|
||||
ok: true,
|
||||
data: { username: req.session.username, role: req.session.role },
|
||||
});
|
||||
});
|
||||
|
||||
adminRouter.get("/api/admin/users", requireAdmin, async (_req, res) => {
|
||||
res.json({ ok: true, data: await userService.listUsers() });
|
||||
});
|
||||
|
||||
adminRouter.post("/api/admin/users", requireAdmin, async (req, res) => {
|
||||
const user = await userService.createUser({
|
||||
username: String(req.body.username || "").trim(),
|
||||
password: String(req.body.password || ""),
|
||||
role: req.body.role,
|
||||
});
|
||||
await audit.log({ actor: actor(req), action: "user.create", target: user.username });
|
||||
res.status(201).json({ ok: true });
|
||||
});
|
||||
|
||||
adminRouter.patch("/api/admin/users/:id/status", requireAdmin, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (id === req.session.userId) throw new HttpError(400, "bad_request", "不能停用自己");
|
||||
await userService.setUserStatus(id, req.body.status);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
adminRouter.get("/api/admin/sources", requireAdmin, async (_req, res) => {
|
||||
res.json({ ok: true, data: await sourceService.listSources() });
|
||||
});
|
||||
|
||||
adminRouter.post("/api/admin/sources", requireAdmin, async (req, res) => {
|
||||
const src = await sourceService.createSource({
|
||||
kind: "feed",
|
||||
name: String(req.body.name || "").trim(),
|
||||
enabled: req.body.enabled !== false,
|
||||
url: String(req.body.url || "").trim(),
|
||||
adapter: req.body.adapter,
|
||||
cron_expr: req.body.cron_expr,
|
||||
});
|
||||
rescheduleSource(src);
|
||||
await audit.log({ actor: actor(req), action: "source.create", target: src.name });
|
||||
res.status(201).json({ ok: true, data: src });
|
||||
});
|
||||
|
||||
adminRouter.patch("/api/admin/sources/:id", requireAdmin, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const patch = {};
|
||||
for (const key of ["name", "enabled", "url", "adapter", "cron_expr"]) {
|
||||
if (req.body[key] !== undefined) patch[key] = req.body[key];
|
||||
}
|
||||
if (req.body.directPublish !== undefined) {
|
||||
await sourceService.setDirectPublish(id, req.body.directPublish);
|
||||
}
|
||||
const src = await sourceService.updateSource(id, patch);
|
||||
if (src.kind === "feed") rescheduleSource(src);
|
||||
res.json({ ok: true, data: src });
|
||||
});
|
||||
|
||||
adminRouter.post("/api/admin/sources/:id/run", requireAdmin, async (req, res) => {
|
||||
const stats = await runSource(Number(req.params.id));
|
||||
res.json({ ok: !stats.error, data: stats });
|
||||
});
|
||||
|
||||
adminRouter.get("/api/admin/sources/:id/runs", requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true, data: await sourceService.listRuns(Number(req.params.id)) });
|
||||
});
|
||||
|
||||
adminRouter.put("/api/admin/ai-key", requireAdmin, async (req, res) => {
|
||||
const key = String(req.body.key || "").trim();
|
||||
await sourceService.setAiKey(key);
|
||||
await audit.log({
|
||||
actor: actor(req),
|
||||
action: "aikey.update",
|
||||
target: "",
|
||||
detail: key ? "已设置" : "已清除",
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
adminRouter.get("/api/admin/comments", requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true, data: await commentService.listAll({ q: req.query.q ?? "" }) });
|
||||
});
|
||||
|
||||
adminRouter.get("/api/admin/entries", requireAdmin, async (req, res) => {
|
||||
const result = await searchService.searchEntries({
|
||||
q: req.query.q ?? "",
|
||||
types: String(req.query.type || "")
|
||||
.split(",")
|
||||
.filter(Boolean),
|
||||
sort: "updated",
|
||||
page: Number(req.query.page) || 1,
|
||||
perPage: 20,
|
||||
status: req.query.status ?? "all",
|
||||
});
|
||||
res.json({ ok: true, data: result });
|
||||
});
|
||||
45
src/routes/comments.routes.js
Normal file
45
src/routes/comments.routes.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { Router } from "express";
|
||||
import * as commentService from "../services/comment.service.js";
|
||||
import * as audit from "../services/audit.service.js";
|
||||
import { requireAdmin } from "../middleware/auth.js";
|
||||
import { HttpError } from "../middleware/errors.js";
|
||||
|
||||
export const commentsRouter = Router();
|
||||
|
||||
commentsRouter.get("/api/comments", async (req, res) => {
|
||||
if (req.query.q !== undefined || req.query.all === "1") {
|
||||
return res.json({ ok: true, data: await commentService.listAll({ q: req.query.q }) });
|
||||
}
|
||||
const entryId = Number(req.query.entryId);
|
||||
if (!Number.isFinite(entryId)) throw new HttpError(400, "bad_request", "缺少 entryId");
|
||||
res.json({ ok: true, data: await commentService.listForEntry(entryId) });
|
||||
});
|
||||
|
||||
commentsRouter.post("/api/comments", async (req, res) => {
|
||||
const entryId = Number(req.body.entryId);
|
||||
if (!Number.isFinite(entryId)) throw new HttpError(400, "bad_request", "缺少条目 ID");
|
||||
await commentService.create({
|
||||
entryId,
|
||||
userId: req.session.userId ?? null,
|
||||
authorName: req.session.username ?? req.body.authorName,
|
||||
body: req.body.body,
|
||||
});
|
||||
res.status(201).json({ ok: true });
|
||||
});
|
||||
|
||||
commentsRouter.post("/api/feedback", async (req, res) => {
|
||||
const body = String(req.body.body || "").trim();
|
||||
if (!body) throw new HttpError(400, "bad_request", "反馈内容不能为空");
|
||||
await audit.log({
|
||||
actor: String(req.body.email || "").trim() || "匿名访客",
|
||||
action: "feedback",
|
||||
target: "",
|
||||
detail: body.slice(0, 2000),
|
||||
});
|
||||
res.status(201).json({ ok: true });
|
||||
});
|
||||
|
||||
commentsRouter.delete("/api/comments/:id", requireAdmin, async (req, res) => {
|
||||
await commentService.remove(Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
106
src/routes/entries.routes.js
Normal file
106
src/routes/entries.routes.js
Normal 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
133
src/routes/pages.routes.js
Normal 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
34
src/routes/tags.routes.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { Router } from "express";
|
||||
import * as tagService from "../services/tag.service.js";
|
||||
import { requireAdmin, requireLogin } from "../middleware/auth.js";
|
||||
|
||||
export const tagsRouter = Router();
|
||||
|
||||
tagsRouter.get("/api/tags/tree", async (_req, res) => {
|
||||
const tree = await tagService.attachCounts(await tagService.buildTree());
|
||||
res.json({ ok: true, data: tree });
|
||||
});
|
||||
|
||||
tagsRouter.get("/api/tags", async (_req, res) => {
|
||||
res.json({ ok: true, data: await tagService.listFlat() });
|
||||
});
|
||||
|
||||
tagsRouter.post("/api/tags", requireLogin, async (req, res) => {
|
||||
const parentId = req.body.parentId ? Number(req.body.parentId) : null;
|
||||
const tag = await tagService.createTag({ name: req.body.name, parentId });
|
||||
res.status(201).json({ ok: true, data: tag });
|
||||
});
|
||||
|
||||
tagsRouter.patch("/api/tags/:id", requireLogin, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (req.body.name !== undefined) await tagService.renameTag(id, req.body.name);
|
||||
if (req.body.parentId !== undefined) {
|
||||
await tagService.moveTag(id, req.body.parentId ? Number(req.body.parentId) : null);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
tagsRouter.delete("/api/tags/:id", requireAdmin, async (req, res) => {
|
||||
await tagService.deleteTag(Number(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
19
src/services/audit.service.js
Normal file
19
src/services/audit.service.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { all, get, run } from "../db/connection.js";
|
||||
|
||||
export async function log({ actor, action, target = "", detail = "" }) {
|
||||
await run(`INSERT INTO audit_logs (actor, action, target, detail) VALUES (?, ?, ?, ?)`, [
|
||||
actor,
|
||||
action,
|
||||
target,
|
||||
detail,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function recent(limit = 50) {
|
||||
return all(`SELECT * FROM audit_logs ORDER BY id DESC LIMIT ?`, [limit]);
|
||||
}
|
||||
|
||||
export async function count() {
|
||||
const row = await get(`SELECT COUNT(*) AS c FROM audit_logs`);
|
||||
return Number(row?.c ?? 0);
|
||||
}
|
||||
41
src/services/comment.service.js
Normal file
41
src/services/comment.service.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { all, get, run } from "../db/connection.js";
|
||||
|
||||
export async function listForEntry(entryId) {
|
||||
return all(
|
||||
`SELECT c.*, u.username
|
||||
FROM comments c LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.entry_id = ?
|
||||
ORDER BY c.created_at DESC`,
|
||||
[entryId]
|
||||
);
|
||||
}
|
||||
|
||||
export async function create({ entryId, userId = null, authorName, body }) {
|
||||
const text = String(body || "").trim();
|
||||
if (!text) throw Object.assign(new Error("评论内容不能为空"), { status: 400 });
|
||||
if (text.length > 2000) throw Object.assign(new Error("评论太长了(≤2000 字)"), { status: 400 });
|
||||
const entry = await get(`SELECT id FROM entries WHERE id = ?`, [entryId]);
|
||||
if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 });
|
||||
const name =
|
||||
String(authorName || "").trim() || "匿名";
|
||||
await run(
|
||||
`INSERT INTO comments (entry_id, user_id, author_name, body) VALUES (?, ?, ?, ?)`,
|
||||
[entryId, userId, name.slice(0, 40), text]
|
||||
);
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
await run(`DELETE FROM comments WHERE id = ?`, [id]);
|
||||
}
|
||||
|
||||
export async function listAll({ q = "" } = {}) {
|
||||
const like = `%${q}%`;
|
||||
return all(
|
||||
`SELECT c.*, e.title AS entry_title, e.slug AS entry_slug
|
||||
FROM comments c JOIN entries e ON e.id = c.entry_id
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE (? = '' OR c.body LIKE ? OR c.author_name LIKE ?)
|
||||
ORDER BY c.created_at DESC LIMIT 200`,
|
||||
[q, like, like]
|
||||
);
|
||||
}
|
||||
182
src/services/entry.service.js
Normal file
182
src/services/entry.service.js
Normal file
@ -0,0 +1,182 @@
|
||||
import { all, get, run } from "../db/connection.js";
|
||||
|
||||
export const ENTRY_TYPES = ["微服务", "SaaS", "网站", "工具", "应用", "脚本", "插件"];
|
||||
export const STATUSES = ["active", "greyed", "pending"];
|
||||
|
||||
const ALLOWED_TRANSITIONS = new Set([
|
||||
"pending>active",
|
||||
"pending>greyed",
|
||||
"active>greyed",
|
||||
"greyed>active",
|
||||
"greyed>pending",
|
||||
]);
|
||||
|
||||
function httpError(status, message) {
|
||||
return Object.assign(new Error(message), { status });
|
||||
}
|
||||
|
||||
function baseSlug(title) {
|
||||
const ascii = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (ascii.length >= 2) return ascii.slice(0, 60);
|
||||
return `e-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
export async function slugify(title) {
|
||||
let slug = baseSlug(title);
|
||||
while (await get(`SELECT id FROM entries WHERE slug = ?`, [slug])) {
|
||||
slug = `${baseSlug(title).slice(0, 50)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
async function linkTags(entryId, names = []) {
|
||||
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [entryId]);
|
||||
for (const raw of names) {
|
||||
const name = String(raw).trim();
|
||||
if (!name) continue;
|
||||
let tag = await get(`SELECT id FROM tags WHERE name = ?`, [name]);
|
||||
if (!tag) {
|
||||
await run(`INSERT INTO tags (name, parent_id, sort) VALUES (?, NULL, 99)`, [name]);
|
||||
tag = await get(`SELECT id FROM tags WHERE name = ?`, [name]);
|
||||
}
|
||||
await run(`INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?, ?)`, [
|
||||
entryId,
|
||||
tag.id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function setEntryTagsParam(entryId) {
|
||||
const tags = await all(
|
||||
`SELECT t.name FROM tags t JOIN entry_tags et ON et.tag_id = t.id
|
||||
WHERE et.entry_id = ? ORDER BY t.sort, t.id`,
|
||||
[entryId]
|
||||
);
|
||||
return tags.map((t) => t.name);
|
||||
}
|
||||
|
||||
function decideStatus(source, requested) {
|
||||
const wanted = requestStatus(requested);
|
||||
if (!source || source.kind === "human") return wanted ?? "active";
|
||||
return source.direct_publish && wanted === "active" ? "active" : "pending";
|
||||
}
|
||||
|
||||
function requestStatus(status) {
|
||||
if (!status) return null;
|
||||
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function createEntry(data, { source } = {}) {
|
||||
if (!ENTRY_TYPES.includes(data.type)) {
|
||||
throw httpError(400, `类型必须是 ${ENTRY_TYPES.join(" / ")}`);
|
||||
}
|
||||
if (!String(data.title || "").trim()) throw httpError(400, "标题不能为空");
|
||||
if (!String(data.url || "").trim()) throw httpError(400, "链接不能为空");
|
||||
|
||||
const existing = await get(`SELECT id FROM entries WHERE url = ?`, [data.url.trim()]);
|
||||
if (existing) return { created: false, id: existing.id };
|
||||
|
||||
const status = decideStatus(source, data.status);
|
||||
const slug = data.slug || (await slugify(data.title));
|
||||
await run(
|
||||
`INSERT INTO entries (title, slug, type, description_md, url, license, stars, status, source_id, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
data.title.trim(),
|
||||
slug,
|
||||
data.type,
|
||||
data.description_md || "",
|
||||
data.url.trim(),
|
||||
data.license || "",
|
||||
Number(data.stars) || 0,
|
||||
status,
|
||||
source?.id ?? null,
|
||||
status === "active" && source?.kind === "human" ? new Date() : null,
|
||||
]
|
||||
);
|
||||
const row = await get(`SELECT * FROM entries WHERE slug = ?`, [slug]);
|
||||
await linkTags(row.id, data.tags);
|
||||
return { created: true, id: row.id, status };
|
||||
}
|
||||
|
||||
const EDITABLE = ["title", "type", "description_md", "url", "license", "stars"];
|
||||
|
||||
export async function updateEntry(id, patch, { isHumanEditor = false } = {}) {
|
||||
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
|
||||
if (!entry) throw httpError(404, "条目不存在");
|
||||
if (patch.type !== undefined && !ENTRY_TYPES.includes(patch.type)) {
|
||||
throw httpError(400, `类型必须是 ${ENTRY_TYPES.join(" / ")}`);
|
||||
}
|
||||
const next = { ...entry };
|
||||
for (const key of EDITABLE) {
|
||||
if (patch[key] !== undefined) next[key] = patch[key];
|
||||
}
|
||||
await run(
|
||||
`UPDATE entries SET title = ?, type = ?, description_md = ?, url = ?, license = ?, stars = ?, updated_at = now()
|
||||
WHERE id = ?`,
|
||||
[next.title, next.type, next.description_md, next.url, next.license, Number(next.stars) || 0, id]
|
||||
);
|
||||
if (patch.tags !== undefined) await linkTags(id, patch.tags);
|
||||
if (isHumanEditor) {
|
||||
await run(`UPDATE entries SET verified_at = now() WHERE id = ?`, [id]);
|
||||
}
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
export async function setStatus(id, status) {
|
||||
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
|
||||
if (!entry) throw httpError(404, "条目不存在");
|
||||
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
|
||||
const transition = `${entry.status}>${status}`;
|
||||
if (!ALLOWED_TRANSITIONS.has(transition)) {
|
||||
throw httpError(400, `不允许的状态变更:${entry.status} → ${status}`);
|
||||
}
|
||||
await run(
|
||||
`UPDATE entries SET status = ?, updated_at = now(),
|
||||
verified_at = CASE WHEN ? = 'active' THEN now() ELSE verified_at END
|
||||
WHERE id = ?`,
|
||||
[status, status, id]
|
||||
);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
export async function deleteEntry(id) {
|
||||
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]);
|
||||
await run(`DELETE FROM comments WHERE entry_id = ?`, [id]);
|
||||
await run(`DELETE FROM entries WHERE id = ?`, [id]);
|
||||
}
|
||||
|
||||
export function assertType(type) {
|
||||
if (type !== undefined && !ENTRY_TYPES.includes(type)) {
|
||||
throw httpError(400, `类型必须是 ${ENTRY_TYPES.join(" / ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getById(id) {
|
||||
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
|
||||
if (!entry) return null;
|
||||
entry.tags = await setEntryTagsParam(entry.id);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function getBySlug(slug) {
|
||||
const entry = await get(`SELECT * FROM entries WHERE slug = ?`, [slug]);
|
||||
if (!entry) return null;
|
||||
entry.tags = await setEntryTagsParam(entry.id);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function listRecent(limit = 4) {
|
||||
const rows = await all(
|
||||
`SELECT e.id, e.title, e.slug, e.type, e.description_md, e.url, e.stars, e.updated_at
|
||||
FROM entries e WHERE e.status = 'active'
|
||||
ORDER BY e.created_at DESC LIMIT ?`,
|
||||
[limit]
|
||||
);
|
||||
for (const row of rows) row.tags = await setEntryTagsParam(row.id);
|
||||
return rows;
|
||||
}
|
||||
112
src/services/search.service.js
Normal file
112
src/services/search.service.js
Normal file
@ -0,0 +1,112 @@
|
||||
import { all, get } from "../db/connection.js";
|
||||
import * as tags from "./tag.service.js";
|
||||
|
||||
const SORTS = {
|
||||
updated: "e.updated_at DESC",
|
||||
stars: "e.stars DESC",
|
||||
created: "e.created_at DESC",
|
||||
title: "e.title ASC",
|
||||
};
|
||||
|
||||
export async function searchEntries({
|
||||
q = "",
|
||||
types = [],
|
||||
tagIds = [],
|
||||
sort = "updated",
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
status = "active",
|
||||
} = {}) {
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (status && status !== "all") {
|
||||
where.push(`e.status = ?`);
|
||||
params.push(status);
|
||||
}
|
||||
if (q.trim()) {
|
||||
const like = `%${q.trim()}%`;
|
||||
where.push(`(e.title LIKE ? OR e.description_md LIKE ? OR e.url LIKE ?)`);
|
||||
params.push(like, like, like);
|
||||
}
|
||||
if (types.length) {
|
||||
where.push(`e.type IN (${types.map(() => "?").join(",")})`);
|
||||
params.push(...types);
|
||||
}
|
||||
|
||||
let cte = "";
|
||||
const cteParams = [];
|
||||
if (tagIds.length) {
|
||||
const expanded = new Set();
|
||||
for (const id of tagIds) for (const d of await tags.descendantIds(id)) expanded.add(d);
|
||||
const ids = [...expanded];
|
||||
cte = `WITH sel(tag_id) AS (SELECT unnest([${ids.map(() => "?").join(",")}]))`;
|
||||
cteParams.push(...ids);
|
||||
where.push(
|
||||
`EXISTS (SELECT 1 FROM entry_tags et JOIN sel ON sel.tag_id = et.tag_id WHERE et.entry_id = e.id)`
|
||||
);
|
||||
}
|
||||
|
||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
const orderBy = SORTS[sort] || SORTS.updated;
|
||||
const offset = (Math.max(1, page) - 1) * perPage;
|
||||
|
||||
const totalRow = await get(
|
||||
`${cte} SELECT COUNT(*) AS c FROM entries e ${whereSql}`,
|
||||
[...cteParams, ...params]
|
||||
);
|
||||
const items = await all(
|
||||
`${cte} SELECT e.id, e.title, e.slug, e.type, e.description_md, e.url, e.license, e.stars, e.status, e.updated_at
|
||||
FROM entries e ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
||||
[...cteParams, ...params, perPage, offset]
|
||||
);
|
||||
|
||||
const tagRows =
|
||||
items.length > 0
|
||||
? await all(
|
||||
`SELECT et.entry_id AS entry_id, t.name AS name
|
||||
FROM entry_tags et JOIN tags t ON t.id = et.tag_id
|
||||
WHERE et.entry_id IN (${items.map(() => "?").join(",")})`,
|
||||
items.map((i) => i.id)
|
||||
)
|
||||
: [];
|
||||
const byEntry = new Map();
|
||||
for (const r of tagRows) {
|
||||
const key = Number(r.entry_id);
|
||||
if (!byEntry.has(key)) byEntry.set(key, []);
|
||||
byEntry.get(key).push(r.name);
|
||||
}
|
||||
for (const item of items) item.tags = byEntry.get(Number(item.id)) ?? [];
|
||||
|
||||
return { total: Number(totalRow?.c ?? 0), page: Math.max(1, page), perPage, items };
|
||||
}
|
||||
|
||||
export async function typeCounts() {
|
||||
const rows = await all(
|
||||
`SELECT type, COUNT(*) AS c FROM entries WHERE status = 'active' GROUP BY type`
|
||||
);
|
||||
const map = {};
|
||||
for (const r of rows) map[r.type] = Number(r.c);
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function randomActive() {
|
||||
return get(`SELECT * FROM entries WHERE status = 'active' ORDER BY random() LIMIT 1`);
|
||||
}
|
||||
|
||||
export async function stats() {
|
||||
const row = await get(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE status <> 'pending') AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'active') AS active,
|
||||
COUNT(*) FILTER (WHERE created_at >= now() - INTERVAL 7 DAY AND status = 'active') AS week_new
|
||||
FROM entries`
|
||||
);
|
||||
const tagRow = await get(`SELECT COUNT(*) AS c FROM tags`);
|
||||
return {
|
||||
total: Number(row?.total ?? 0),
|
||||
active: Number(row?.active ?? 0),
|
||||
weekNew: Number(row?.week_new ?? 0),
|
||||
tags: Number(tagRow?.c ?? 0),
|
||||
};
|
||||
}
|
||||
110
src/services/source.service.js
Normal file
110
src/services/source.service.js
Normal file
@ -0,0 +1,110 @@
|
||||
import crypto from "node:crypto";
|
||||
import { all, get, run } from "../db/connection.js";
|
||||
|
||||
function keyHash(key) {
|
||||
return key ? crypto.createHash("sha256").update(key).digest("hex") : null;
|
||||
}
|
||||
|
||||
export async function ensureBuiltins({ aiIngestKey }) {
|
||||
const human = await get(`SELECT id FROM sources WHERE name = '人工录入'`);
|
||||
if (!human) {
|
||||
await run(`INSERT INTO sources (kind, name, enabled) VALUES ('human', '人工录入', TRUE)`);
|
||||
}
|
||||
const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`);
|
||||
if (!ai) {
|
||||
await run(
|
||||
`INSERT INTO sources (kind, name, enabled, api_key_hash, direct_publish)
|
||||
VALUES ('ai', 'AI 接口', ?, ?, FALSE)`,
|
||||
[Boolean(aiIngestKey), keyHash(aiIngestKey)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSources() {
|
||||
return all(
|
||||
`SELECT s.*,
|
||||
(SELECT COUNT(*) FROM entries e WHERE e.source_id = s.id) AS entry_count
|
||||
FROM sources s ORDER BY s.id`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getById(id) {
|
||||
return get(`SELECT * FROM sources WHERE id = ?`, [id]);
|
||||
}
|
||||
|
||||
export async function findByApiKey(key) {
|
||||
if (!key) return null;
|
||||
return get(
|
||||
`SELECT * FROM sources WHERE kind = 'ai' AND enabled AND api_key_hash = ?`,
|
||||
[keyHash(key)]
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSource(data) {
|
||||
if (data.kind !== "feed") throw Object.assign(new Error("仅支持创建 feed 源"), { status: 400 });
|
||||
if (!data.adapter) {
|
||||
throw Object.assign(new Error("缺少适配器名称"), { status: 400 });
|
||||
}
|
||||
await run(
|
||||
`INSERT INTO sources (kind, name, enabled, url, adapter, cron_expr)
|
||||
VALUES ('feed', ?, ?, ?, ?, ?)`,
|
||||
[data.name, data.enabled !== false, data.url, data.adapter, data.cron_expr || "0 9 * * 1"]
|
||||
);
|
||||
return get(`SELECT * FROM sources WHERE name = ?`, [data.name]);
|
||||
}
|
||||
|
||||
export async function updateSource(id, patch) {
|
||||
const src = await getById(id);
|
||||
if (!src) throw Object.assign(new Error("内容源不存在"), { status: 404 });
|
||||
const next = { ...src, ...patch };
|
||||
await run(
|
||||
`UPDATE sources SET name = ?, enabled = ?, url = ?, adapter = ?, cron_expr = ?
|
||||
WHERE id = ?`,
|
||||
[next.name, Boolean(next.enabled), next.url ?? "", next.adapter ?? "", next.cron_expr ?? "0 9 * * 1", id]
|
||||
);
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
export async function setAiKey(key) {
|
||||
const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`);
|
||||
if (!ai) return;
|
||||
await run(`UPDATE sources SET enabled = ?, api_key_hash = ? WHERE id = ?`, [
|
||||
Boolean(key),
|
||||
keyHash(key),
|
||||
ai.id,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function setDirectPublish(id, value) {
|
||||
await run(`UPDATE sources SET direct_publish = ? WHERE id = ?`, [Boolean(value), id]);
|
||||
}
|
||||
|
||||
export async function markRunStart(id) {
|
||||
await run(`UPDATE sources SET last_run_at = now(), last_run_status = 'running' WHERE id = ?`, [id]);
|
||||
}
|
||||
|
||||
export async function recordRun(sourceId, stats) {
|
||||
await run(
|
||||
`INSERT INTO source_runs (source_id, finished_at, status, found, created, skipped, error)
|
||||
VALUES (?, now(), ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
sourceId,
|
||||
stats.error ? "failed" : "ok",
|
||||
stats.found ?? 0,
|
||||
stats.created ?? 0,
|
||||
stats.skipped ?? 0,
|
||||
stats.error ? String(stats.error).slice(0, 1000) : "",
|
||||
]
|
||||
);
|
||||
await run(`UPDATE sources SET last_run_status = ? WHERE id = ?`, [
|
||||
stats.error ? "failed" : "ok",
|
||||
sourceId,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function listRuns(sourceId, limit = 10) {
|
||||
return all(
|
||||
`SELECT * FROM source_runs WHERE source_id = ? ORDER BY id DESC LIMIT ?`,
|
||||
[sourceId, limit]
|
||||
);
|
||||
}
|
||||
99
src/services/tag.service.js
Normal file
99
src/services/tag.service.js
Normal file
@ -0,0 +1,99 @@
|
||||
import { all, get, run } from "../db/connection.js";
|
||||
|
||||
export async function listFlat() {
|
||||
return all(`SELECT * FROM tags ORDER BY parent_id NULLS FIRST, sort, id`);
|
||||
}
|
||||
|
||||
export async function buildTree() {
|
||||
const rows = await listFlat();
|
||||
const nodes = new Map();
|
||||
for (const r of rows) {
|
||||
nodes.set(Number(r.id), { ...r, id: Number(r.id), parent_id: r.parent_id ? Number(r.parent_id) : null, children: [] });
|
||||
}
|
||||
const roots = [];
|
||||
for (const node of nodes.values()) {
|
||||
if (node.parent_id && nodes.has(node.parent_id)) {
|
||||
nodes.get(node.parent_id).children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function descendantIds(id) {
|
||||
const rows = await all(
|
||||
`WITH RECURSIVE tree(id) AS (
|
||||
SELECT id FROM tags WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT t.id FROM tags t JOIN tree ON t.parent_id = tree.id
|
||||
)
|
||||
SELECT id FROM tree`,
|
||||
[id]
|
||||
);
|
||||
return rows.map((r) => Number(r.id));
|
||||
}
|
||||
|
||||
export async function isDescendant(candidateId, ancestorId) {
|
||||
const ids = await descendantIds(ancestorId);
|
||||
return ids.includes(Number(candidateId));
|
||||
}
|
||||
|
||||
export async function createTag({ name, parentId = null }) {
|
||||
const trimmed = String(name || "").trim();
|
||||
if (!trimmed) throw Object.assign(new Error("标签名不能为空"), { status: 400 });
|
||||
if (parentId) {
|
||||
const parent = await get(`SELECT id FROM tags WHERE id = ?`, [parentId]);
|
||||
if (!parent) throw Object.assign(new Error("上级标签不存在"), { status: 400 });
|
||||
}
|
||||
await run(`INSERT INTO tags (name, parent_id, sort) VALUES (?, ?, 0)`, [trimmed, parentId]);
|
||||
return get(`SELECT * FROM tags WHERE name = ? AND parent_id IS NOT DISTINCT FROM ? ORDER BY id DESC LIMIT 1`, [trimmed, parentId]);
|
||||
}
|
||||
|
||||
export async function renameTag(id, name) {
|
||||
const trimmed = String(name || "").trim();
|
||||
if (!trimmed) throw Object.assign(new Error("标签名不能为空"), { status: 400 });
|
||||
const tag = await get(`SELECT * FROM tags WHERE id = ?`, [id]);
|
||||
if (!tag) throw Object.assign(new Error("标签不存在"), { status: 404 });
|
||||
await run(`UPDATE tags SET name = ? WHERE id = ?`, [trimmed, id]);
|
||||
}
|
||||
|
||||
export async function moveTag(id, newParentId) {
|
||||
const tag = await get(`SELECT * FROM tags WHERE id = ?`, [id]);
|
||||
if (!tag) throw Object.assign(new Error("标签不存在"), { status: 404 });
|
||||
let parent = null;
|
||||
if (newParentId) {
|
||||
parent = await get(`SELECT * FROM tags WHERE id = ?`, [newParentId]);
|
||||
if (!parent) throw Object.assign(new Error("目标上级不存在"), { status: 400 });
|
||||
if (await isDescendant(newParentId, id)) {
|
||||
throw Object.assign(new Error("不能把标签移动到自己的子级下"), { status: 400 });
|
||||
}
|
||||
}
|
||||
await run(`UPDATE tags SET parent_id = ? WHERE id = ?`, [parent ? parent.id : null, id]);
|
||||
}
|
||||
|
||||
export async function deleteTag(id) {
|
||||
const tag = await get(`SELECT * FROM tags WHERE id = ?`, [id]);
|
||||
if (!tag) throw Object.assign(new Error("标签不存在"), { status: 404 });
|
||||
await run(`UPDATE tags SET parent_id = ? WHERE parent_id = ?`, [tag.parent_id ?? null, id]);
|
||||
await run(`DELETE FROM entry_tags WHERE tag_id = ?`, [id]);
|
||||
await run(`DELETE FROM tags WHERE id = ?`, [id]);
|
||||
}
|
||||
|
||||
export async function attachCounts(tree) {
|
||||
const counts = await all(
|
||||
`SELECT et.tag_id AS tag_id, COUNT(*) AS c
|
||||
FROM entry_tags et JOIN entries e ON e.id = et.entry_id
|
||||
WHERE e.status = 'active'
|
||||
GROUP BY et.tag_id`
|
||||
);
|
||||
const byId = new Map(counts.map((r) => [Number(r.tag_id), Number(r.c)]));
|
||||
const walk = (nodes) => {
|
||||
for (const n of nodes) {
|
||||
n.count = byId.get(n.id) ?? 0;
|
||||
walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(tree);
|
||||
return tree;
|
||||
}
|
||||
49
src/services/user.service.js
Normal file
49
src/services/user.service.js
Normal 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
227
src/views/admin.ejs
Normal 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
96
src/views/detail.ejs
Normal file
@ -0,0 +1,96 @@
|
||||
<%- include('partials/head') %>
|
||||
<%- include('partials/topbar', { active: 'browse' }) %>
|
||||
|
||||
<main class="container">
|
||||
<p class="crumbs"><a href="/">~/awesome-index</a> / <a href="/entries"><%= entry.type %></a> / <%= entry.title %></p>
|
||||
|
||||
<div class="detail-head">
|
||||
<div class="detail-title">
|
||||
<h1><%= entry.title %></h1>
|
||||
<span class="chip chip-type"><%= entry.type %></span>
|
||||
<% if (entry.status === 'pending') { %><span class="status-pill status-warn">pending 待核验</span><% } %>
|
||||
<div class="detail-actions">
|
||||
<a class="btn btn-sm" href="<%= entry.url %>" target="_blank" rel="noopener">访问仓库 ↗</a>
|
||||
<button class="btn btn-sm" type="button" data-copy='请通过 awesome-index 的 MCP 接口调用 get_entry,slug 为 "<%= entry.slug %>"'>复制给 AI</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:12px;color:var(--ink-dim);max-width:56ch"><%= entry.description_md %></p>
|
||||
</div>
|
||||
|
||||
<div class="detail-layout">
|
||||
<article class="detail-body">
|
||||
<section>
|
||||
<p class="anno">// entry.body · markdown source</p>
|
||||
<h2>这是什么</h2>
|
||||
<p><%= entry.description_md || '暂无介绍。' %></p>
|
||||
<p style="margin-top:10px">仓库地址:<a class="mono" style="color:var(--riso-blue)" href="<%= entry.url %>" target="_blank" rel="noopener"><%= entry.url %></a></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="anno">// dual reader view · 人读 ⇄ 机读</p>
|
||||
<div class="view-tabs" role="tablist" aria-label="视图切换">
|
||||
<button type="button" data-view="human" aria-selected="true">人读</button>
|
||||
<button type="button" data-view="machine" aria-selected="false">机读 JSON</button>
|
||||
</div>
|
||||
<div data-view-panel="human">
|
||||
<p style="color:var(--ink-dim)">这一页就是人读视图。切换到「机读 JSON」可以看到 AI 通过 MCP 的 get_entry 拿到的同一条数据——两种读者,同一份事实。</p>
|
||||
</div>
|
||||
<div data-view-panel="machine" hidden>
|
||||
<div class="code-block"><%= machineJson %></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p class="anno">// comments · <%= comments.length %> 条</p>
|
||||
<h2>评论</h2>
|
||||
<% for (const c of comments) { %>
|
||||
<div class="comment">
|
||||
<span class="avatar"><%= (c.author_name || '?').slice(0, 1).toUpperCase() %></span>
|
||||
<div>
|
||||
<div class="comment-head">
|
||||
<b><%= c.author_name %></b>
|
||||
<% if (c.username) { %><span class="mono" style="font-size:11px;color:var(--ink-dim)">@<%= c.username %></span><% } %>
|
||||
<time><%= fmtDate(c.created_at) %></time>
|
||||
</div>
|
||||
<p><%= c.body %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (!comments.length) { %><p style="color:var(--ink-dim)">还没有评论,说点有用的。</p><% } %>
|
||||
|
||||
<form class="comment-form" data-entry-id="<%= entry.id %>">
|
||||
<div class="form-row">
|
||||
<input class="input" name="authorName" type="text" placeholder="昵称"
|
||||
value="<%= typeof user !== 'undefined' && user ? user.username : '' %>" required />
|
||||
</div>
|
||||
<textarea class="textarea" name="body" placeholder="使用场景、坑、搭配推荐…" required></textarea>
|
||||
<div><button class="btn btn-primary" type="submit">发布评论</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside>
|
||||
<div class="side-card">
|
||||
<h3>元数据</h3>
|
||||
<table class="meta-table">
|
||||
<tr><td>类型</td><td><%= entry.type %></td></tr>
|
||||
<tr><td>License</td><td><%= entry.license || '—' %></td></tr>
|
||||
<tr><td>Stars</td><td><%= Number(entry.stars).toLocaleString() %></td></tr>
|
||||
<tr><td>收录时间</td><td><%= fmtDate(entry.created_at) %></td></tr>
|
||||
<tr><td>最近核验</td><td><%= entry.verified_at ? fmtDate(entry.verified_at) + ' ✓' : '待核验' %></td></tr>
|
||||
<tr><td>状态</td><td><%= entry.status %></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="side-card">
|
||||
<h3>标签</h3>
|
||||
<div class="card-tags" style="margin-top:0">
|
||||
<% for (const t of entry.tags) { %><span class="chip"><%= t %></span><% } %>
|
||||
<% if (!entry.tags.length) { %><span class="mono" style="font-size:12px;color:var(--ink-dim)">无标签</span><% } %>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer-bar') %>
|
||||
146
src/views/index.ejs
Normal file
146
src/views/index.ejs
Normal file
@ -0,0 +1,146 @@
|
||||
<%- include('partials/head') %>
|
||||
<%- include('partials/topbar', { active: 'home' }) %>
|
||||
|
||||
<main>
|
||||
<section class="hero container">
|
||||
<div class="hero-grid">
|
||||
<div class="hero-copy">
|
||||
<p class="anno rise">~/awesome-index — 给人用,也给 AI 用</p>
|
||||
<h1 class="rise d1">把网上<span class="hl">牛逼的东西</span>,<br />收进一个好用的库。</h1>
|
||||
<p class="hero-sub rise d2">人工精选的开源软件、微服务、SaaS、网站、工具、脚本与插件。每一条都是结构化数据:人在页面上搜,AI 通过 MCP 直接调用。</p>
|
||||
|
||||
<form class="search-instrument rise d3" id="search-form" role="search">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.8-3.8"/></svg>
|
||||
<input id="q" type="search" placeholder="输入需求关键字,如:自托管相册 / 终端文件管理器…" aria-label="搜索收录内容" />
|
||||
<button class="btn btn-primary" type="submit">搜索</button>
|
||||
</form>
|
||||
<p class="query-echo rise d3" id="query-echo" aria-live="polite"></p>
|
||||
|
||||
<a class="mcp-jump rise d4" href="#mcp">
|
||||
<span class="dot"></span>
|
||||
给 AI 用?本站提供 MCP 接口
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M12 4v16m0 0 6-6m-6 6-6-6"/></svg>
|
||||
</a>
|
||||
|
||||
<p class="hero-meta rise d4">
|
||||
<span>收录 <b><%= stats.total %></b> 条</span>
|
||||
<span>在架 <b><%= stats.active %></b> 条</span>
|
||||
<span>标签组 <b><%= stats.tags %></b> 个</span>
|
||||
<span>本周新增 <b>+<%= stats.weekNew %></b></span>
|
||||
<span>存储 <b>DuckDB</b></span>
|
||||
<span>MCP <b>就绪</b></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-visual rise d3" role="presentation">
|
||||
<p class="spec-label mono">// get_entry("ripgrep") · 同一条数据,两种读者</p>
|
||||
<div class="spec-machine mono" aria-hidden="true">{
|
||||
"id": "ripgrep",
|
||||
"type": "tool",
|
||||
"name": "ripgrep",
|
||||
"tags": ["Rust", "CLI"],
|
||||
"stars": <%= typeof recent[0] !== 'undefined' ? Number(recent[0].stars) : 52300 %>,
|
||||
"status": "active"
|
||||
}</div>
|
||||
<div class="spec-human" aria-hidden="true">
|
||||
<span class="chip chip-type">工具</span>
|
||||
<h3>ripgrep</h3>
|
||||
<p>以正则递归搜索目录,默认尊重 .gitignore,是代码库里最快的找东西方式。</p>
|
||||
<div class="card-tags"><span class="chip">Rust</span><span class="chip">CLI</span></div>
|
||||
<p class="card-meta">★ <%= fmtStars(52300) %> · 终端找东西的事实标准</p>
|
||||
</div>
|
||||
<button class="btn btn-random" id="random-btn" type="button" title="随机打开一条收录,没有头绪时让库替你挑一个">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M3 7h4l10 10h4M17 3l4 4-4 4M3 17h4l2.5-2.5M21 17l-4 4"/></svg>
|
||||
随机游览
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mcp on-panel" id="mcp">
|
||||
<div class="container">
|
||||
<p class="anno">// mcp.transport: streamable-http · auth: none</p>
|
||||
<h2 class="rise">同一个库,两种读者。<br />AI 走这边。</h2>
|
||||
<p class="mcp-sub rise d1">本站所有条目同时暴露为 MCP(Model Context Protocol)接口。把下面的地址加进你的 AI 客户端,Claude、Cursor 或任何支持 MCP 的 Agent 就能直接搜索与读取这个库。</p>
|
||||
|
||||
<div class="agent-prompt rise d1">
|
||||
<div>
|
||||
<h3>不想手动改配置?</h3>
|
||||
<p class="mono">复制下面这句话发给你的 Agent,它会自己完成接入:</p>
|
||||
<blockquote class="mono">「请把 URL 为 <%- config.baseUrl %>/mcp 的 MCP 服务器添加到你的客户端配置中,名称用 awesome-index,完成后告诉我现在可以调用哪些工具。」</blockquote>
|
||||
</div>
|
||||
<button class="copy-btn" type="button" data-copy="请把 URL 为 <%- config.baseUrl %>/mcp 的 MCP 服务器添加到你的客户端配置中,名称用 awesome-index,完成后告诉我现在可以调用哪些工具。">复制给 Agent</button>
|
||||
</div>
|
||||
|
||||
<div class="mcp-grid">
|
||||
<aside class="endpoint-card rise d2">
|
||||
<h3>MCP Server URL</h3>
|
||||
<div class="endpoint-url">
|
||||
<code id="mcp-url"><%- config.baseUrl %>/mcp</code>
|
||||
<button class="copy-btn" type="button" data-copy="<%- config.baseUrl %>/mcp">复制</button>
|
||||
</div>
|
||||
<p class="endpoint-status"><span class="dot"></span>在线 · Streamable HTTP · 只读</p>
|
||||
<p class="endpoint-note">只读访问,无需密钥。写入类操作仅限管理台与白名单来源。</p>
|
||||
</aside>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step rise d2">
|
||||
<div class="step-head"><span class="step-no">01</span><h3>把服务器加进客户端配置</h3></div>
|
||||
<pre>{
|
||||
<span class="k">"mcpServers"</span>: {
|
||||
<span class="k">"awesome-index"</span>: {
|
||||
<span class="k">"url"</span>: <span class="s">"<%- config.baseUrl %>/mcp"</span>
|
||||
}
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
<div class="step rise d3">
|
||||
<div class="step-head"><span class="step-no">02</span><h3>可用的工具</h3></div>
|
||||
<table class="tools-table">
|
||||
<thead><tr><th>tool</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>search_entries</td><td>按关键字搜索收录内容</td></tr>
|
||||
<tr><td>get_entry</td><td>获取单条的完整详情与元数据</td></tr>
|
||||
<tr><td>random_entry</td><td>随机返回一条,用于探索发现</td></tr>
|
||||
<tr><td>list_tags</td><td>列出全部标签组及其嵌套结构</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="step rise d4">
|
||||
<div class="step-head"><span class="step-no">03</span><h3>直接问你的 AI</h3></div>
|
||||
<pre>「用 awesome-index 找三个能自托管的相册方案,
|
||||
按 star 数排序,给我 repo 链接。」</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="recent container">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="anno">// resource: recent_entries · limit 4</p>
|
||||
<h2>最近收录</h2>
|
||||
</div>
|
||||
<a href="/entries">查看全部 →</a>
|
||||
</div>
|
||||
<div class="card-grid">
|
||||
<% for (const e of recent) { %>
|
||||
<a class="card" href="/entries/<%= e.slug %>">
|
||||
<span class="chip chip-type"><%= e.type %></span>
|
||||
<h3><%= e.title %></h3>
|
||||
<p><%= e.description_md %></p>
|
||||
<div class="card-tags">
|
||||
<% for (const t of (e.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %>
|
||||
</div>
|
||||
<p class="card-meta">★ <%= fmtStars(e.stars) %> · 更新于 <%= fmtDate(e.updated_at) %></p>
|
||||
</a>
|
||||
<% } %>
|
||||
<% if (!recent.length) { %>
|
||||
<p style="color:var(--ink-dim)">还没有收录条目——去管理台新增,或等采集源跑一趟。</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer-full') %>
|
||||
115
src/views/list.ejs
Normal file
115
src/views/list.ejs
Normal 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
42
src/views/login.ejs
Normal file
@ -0,0 +1,42 @@
|
||||
<%- include('partials/head') %>
|
||||
<%- include('partials/topbar', { active: 'admin' }) %>
|
||||
|
||||
<main>
|
||||
<div class="auth-wrap container">
|
||||
<section class="auth-card rise" aria-labelledby="auth-title">
|
||||
<p class="anno">// route: /admin/login · auth: session cookie</p>
|
||||
|
||||
<h1 id="auth-title">管理台登录</h1>
|
||||
<p class="auth-sub">人工通道。AI Agent 请走 <a href="/#mcp">MCP 接口</a>——这扇门不对机器开放。</p>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="field">
|
||||
<label for="login-user">用户名</label>
|
||||
<input class="input" id="login-user" name="username" type="text" placeholder="admin" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="login-pass">密码</label>
|
||||
<input class="input" id="login-pass" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="auth-row">
|
||||
<label class="checkline" style="padding:0"><input type="checkbox" checked /> 记住这台设备</label>
|
||||
<a href="mailto:admin@honor3.com">忘记密码?</a>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" type="submit">登 录</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-note mono">
|
||||
<span>// 初始账号:admin(密码为 ADMIN_INIT_PASSWORD)</span>
|
||||
<span>// agents: POST /mcp · 此处返回 403</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="footer-bar" style="border-top:2px solid var(--ink)">
|
||||
<div class="container" style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:20px 0 26px;font-family:var(--font-mono);font-size:12px">
|
||||
<span>© 2026 honor3.com · Awesome Index Admin</span>
|
||||
<span><a href="/" style="color:inherit;text-decoration:none">← 返回首页</a></span>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer-bar') %>
|
||||
11
src/views/partials/footer-bar.ejs
Normal file
11
src/views/partials/footer-bar.ejs
Normal file
@ -0,0 +1,11 @@
|
||||
<footer class="footer-bar" style="border-top:0;padding-top:26px;background:transparent;color:var(--ink-dim)">
|
||||
<div class="container" style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:20px 0 26px;font-family:var(--font-mono);font-size:12px">
|
||||
<span>© 2026 honor3.com · Awesome Index</span>
|
||||
<span><a href="mailto:admin@honor3.com" style="color:inherit;text-decoration:none">admin@honor3.com</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="toast" role="status"></div>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
48
src/views/partials/footer-full.ejs
Normal file
48
src/views/partials/footer-full.ejs
Normal file
@ -0,0 +1,48 @@
|
||||
<footer class="footer on-panel">
|
||||
<div class="container">
|
||||
<div class="footer-grid">
|
||||
<div class="footer-brand">
|
||||
<a class="brand" href="/" style="color:var(--paper-on-panel)">
|
||||
<span class="brand-mark"><i style="border-color:#5b6cff"></i><i></i></span>
|
||||
AWESOME·INDEX
|
||||
</a>
|
||||
<p>一个人工维护的「牛逼东西」数据库。条目由人工录入、AI 接口与外部周刊自动拉取汇聚;坏数据会被灰掉,好东西值得被检索。</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>站内</h4>
|
||||
<ul>
|
||||
<li><a href="/">首页</a></li>
|
||||
<li><a href="/entries">浏览全部</a></li>
|
||||
<li><a href="/random">随机一条</a></li>
|
||||
<li><a href="/admin">管理入口 →</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>给 AI</h4>
|
||||
<ul>
|
||||
<li><a href="#mcp-url" class="mono">POST /mcp</a></li>
|
||||
<li><a href="#mcp">MCP 接入说明</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form class="feedback" data-feedback>
|
||||
<h4><label for="fb-text">快速反馈</label></h4>
|
||||
<textarea id="fb-text" required placeholder="坏链接 / 推荐条目 / 吐槽都行"></textarea>
|
||||
<button type="submit">发送</button>
|
||||
<span class="alt">或写信到 <a href="mailto:admin@honor3.com">admin@honor3.com</a></span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="footer-bar" style="border-top:1px solid rgba(233,235,228,.14)">
|
||||
<span>© 2026 honor3.com · Awesome Index</span>
|
||||
<span>由 DuckDB 驱动 · MCP 就绪 · 数据每日快照</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="toast" role="status"></div>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
src/views/partials/head.ejs
Normal file
14
src/views/partials/head.ejs
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><%= typeof title !== 'undefined' ? title : 'Awesome Index' %></title>
|
||||
<meta name="description" content="精选开源软件、服务与网站的数据库。给人用,也给 AI 用——直接提供 MCP 接口。" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;700;900&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
23
src/views/partials/tag-node.ejs
Normal file
23
src/views/partials/tag-node.ejs
Normal file
@ -0,0 +1,23 @@
|
||||
<li>
|
||||
<div class="tree-row" style="cursor:default">
|
||||
<% if (node.children.length) { %>
|
||||
<button class="tree-row" type="button" style="flex:1" data-tree="atag-<%= node.id %>" aria-expanded="false">
|
||||
<span class="tw">▶</span> <%= node.name %>
|
||||
</button>
|
||||
<% } else { %>
|
||||
<span style="flex:1;padding-left:18px"><%= node.name %></span>
|
||||
<% } %>
|
||||
<span class="mono cnt"><%= node.count %> · #<%= node.id %></span>
|
||||
<span class="row-actions" style="margin-left:8px">
|
||||
<button class="btn btn-sm" type="button" data-action="tag-add" data-parent-id="<%= node.id %>">+子级</button>
|
||||
<button class="btn btn-sm" type="button" data-action="tag-rename" data-id="<%= node.id %>" data-name="<%= node.name %>">改名</button>
|
||||
<button class="btn btn-sm" type="button" data-action="tag-move" data-id="<%= node.id %>">移动</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" data-action="tag-delete" data-id="<%= node.id %>">删</button>
|
||||
</span>
|
||||
</div>
|
||||
<% if (node.children.length) { %>
|
||||
<ul class="tag-children" id="atag-<%= node.id %>">
|
||||
<% for (const child of node.children) { %><%- include('tag-node', { node: child }) %><% } %>
|
||||
</ul>
|
||||
<% } %>
|
||||
</li>
|
||||
14
src/views/partials/topbar.ejs
Normal file
14
src/views/partials/topbar.ejs
Normal 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
74
test/entries.test.js
Normal 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
55
test/helpers.js
Normal 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
57
test/pipeline.test.js
Normal 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
67
test/tags.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user