Compare commits

...
7 Commits
Author SHA1 Message Date
cheney 806761103b feat: 管理台搜索/分区刷新、评论人员体系、相关项目,修复内容源自动获取,完善 MCP 自描述
Build and Deploy / build (push) Successful in 1m32s
Build and Deploy / deploy (push) Successful in 26s
管理台:
- 内容管理新增名称搜索(title/slug/url/description),状态与翻页链接保留搜索词
- 菜单改为真实链接,每次点击重新向后台取数渲染分区,不再依赖手动刷新
- 内容管理每页固定 20 条
- pending 条目按钮改为「预览」,active 为「查看」

评论体系:
- 评论提交新增必填联系方式(手机号/邮箱),前端不做展示
- 首次评论以「昵称+联系方式」建档,之后需匹配且未被禁用
- 后台评论管理新增联系方式列;新增评论人员管理页,可启用/禁用/改名/删除
- 评论昵称输入框不再预置 admin

相关项目:
- 新增 entry_relations 双向关联,详情页侧栏展示并写入机读 JSON
- 编辑页可添加/移除关联

内容源自动获取:
- 修复 rss 适配器把 feed url 当成条目 url 的根本问题,支持 Atom link/href 与相对链接
- eryajf-weekly 限定正文容器取链接,修正项目说明的提取与「(opens new window)」清理
- pipeline 增加轮内去重与单条失败隔离;scheduler 对非法 cron 明确告警

MCP:
- 重写 instructions 为八节说明,新增 usage 工具返回结构化接入文档
- 补全各工具的参数、用途与返回格式说明;baseUrl 取自当次请求
- 首页 MCP 区块与 README 同步补充写入示例与踩坑清单

测试: 新增 comments/relations/admin/ingest-adapters 用例,共 29 个全部通过
2026-10-27 11:22:26 +08:00
cheney 3f7b48325f feat: 管理台内容状态过滤+预览、批量上架/灰掉、标签拖拽调整层级
Build and Deploy / build (push) Successful in 1m37s
Build and Deploy / deploy (push) Successful in 31s
2026-09-21 11:15:26 +08:00
cheney 7e62db31b8 牛逼
Build and Deploy / build (push) Successful in 27m44s
Build and Deploy / deploy (push) Successful in 25s
2026-09-01 14:30:11 +08:00
cheney 381e6a780d feat: 版本时间+8区、首页随机卡片点击进详情;chore: 优雅关闭DuckDB、start.sh改用docker stop
Build and Deploy / build (push) Successful in 10s
Build and Deploy / deploy (push) Failing after 45s
2026-09-01 11:40:21 +08:00
cheney 64f3f6ea58 feat: 首页版本号、简介布局与反显、密钥眼睛融合、定时拉取开关
Build and Deploy / build (push) Successful in 11s
Build and Deploy / deploy (push) Failing after 49s
2026-09-01 11:15:35 +08:00
cheney c09857cc81 feat: 登录记住本机+密码可见、内容分简介与主体、Markdown 渲染、首页随机条目
Build and Deploy / build (push) Successful in 56s
Build and Deploy / deploy (push) Successful in 28s
2026-09-01 10:53:17 +08:00
cheney a5c5d24083 refactor: 彻底移除 docker compose,改用纯 docker 构建+重启部署
Build and Deploy / build (push) Successful in 12s
Build and Deploy / deploy (push) Successful in 26s
2026-09-01 10:27:11 +08:00
47 changed files with 2379 additions and 293 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+17 -10
View File
@@ -3,27 +3,39 @@ name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build \
--build-arg GIT_BRANCH="${GITHUB_REF_NAME:-unknown}" \
--build-arg BUILD_TIME="$(TZ=Asia/Shanghai date +%Y%m%d%H%M%S)" \
-t awesome-index:latest .
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Copy files to server
- name: Copy scripts to server
uses: appleboy/scp-action@master
with:
host: 114.134.187.170
username: root
password: ${{ secrets.SERVER_PASSWORD }}
source: "package.json,package-lock.json,src,public,.env.example,start.sh"
source: "start.sh,.env.example"
target: "/app/awesome/"
- name: Deploy and start service
- name: Restart container
uses: appleboy/ssh-action@master
with:
host: 114.134.187.170
@@ -31,10 +43,5 @@ jobs:
password: ${{ secrets.SERVER_PASSWORD }}
script: |
cd /app/awesome
# 如不存在 .env 则从 example 复制
[ ! -f .env ] && cp .env.example .env
# 执行启动脚本
chmod +x start.sh
bash start.sh
+4
View File
@@ -6,6 +6,10 @@ RUN npm ci --omit=dev --no-audit --no-fund
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
ARG GIT_BRANCH=unknown
ARG BUILD_TIME=unknown
ENV GIT_BRANCH=$GIT_BRANCH
ENV BUILD_TIME=$BUILD_TIME
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
Vendored
+6 -24
View File
@@ -3,7 +3,6 @@ pipeline {
environment {
IMAGE_NAME = 'awesome-index'
CONTAINER_NAME = 'awesome'
DEPLOY_PATH = '/app/awesome'
SSH_HOST = '114.134.187.170'
SSH_USER = 'root'
@@ -13,9 +12,8 @@ pipeline {
stage('Build') {
steps {
script {
echo "Building Docker image ${IMAGE_NAME}:${BUILD_NUMBER}"
sh "docker build -t ${IMAGE_NAME}:${BUILD_NUMBER} ."
sh "docker tag ${IMAGE_NAME}:${BUILD_NUMBER} ${IMAGE_NAME}:latest"
echo "Building Docker image ${IMAGE_NAME}:latest"
sh "docker build -t ${IMAGE_NAME}:latest ."
}
}
}
@@ -29,28 +27,12 @@ pipeline {
// 创建部署目录
sh "sshpass -p '${SSH_PASS}' ssh -o StrictHostKeyChecking=no ${remote} 'mkdir -p ${DEPLOY_PATH}/data ${DEPLOY_PATH}/logs'"
// 复制必要文件
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no docker-compose.yml ${remote}:${DEPLOY_PATH}/"
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no .env.example ${remote}:${DEPLOY_PATH}/.env"
// 上传脚本(构建机 = 部署机,镜像已在服务器上,无需传输镜像)
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no .env.example ${remote}:${DEPLOY_PATH}/.env.example"
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no start.sh ${remote}:${DEPLOY_PATH}/"
// 导入镜像
sh "docker save ${IMAGE_NAME}:latest | gzip > ${IMAGE_NAME}.tar.gz"
sh "sshpass -p '${SSH_PASS}' scp -o StrictHostKeyChecking=no ${IMAGE_NAME}.tar.gz ${remote}:${DEPLOY_PATH}/"
// 在服务器上加载并重启
sh """
sshpass -p '${SSH_PASS}' ssh -o StrictHostKeyChecking=no ${remote} '
cd ${DEPLOY_PATH} &&
gunzip -c ${IMAGE_NAME}.tar.gz | docker load &&
rm -f ${IMAGE_NAME}.tar.gz &&
docker compose down --remove-orphans &&
docker compose up -d
'
"""
// 清理本地
sh "rm -f ${IMAGE_NAME}.tar.gz"
// 在服务器上重启容器
sh "sshpass -p '${SSH_PASS}' ssh -o StrictHostKeyChecking=no ${remote} 'cd ${DEPLOY_PATH} && chmod +x start.sh && bash start.sh'"
}
}
}
+111 -9
View File
@@ -13,7 +13,7 @@
```bash
npm install
npm run dev # http://localhost:3000(--watch 热重载)
npm test # node:test,10 个用例
npm test # node:test,27 个用例
```
首次启动自动完成建表与种子数据。默认管理员 `admin`,密码取 `ADMIN_INIT_PASSWORD`(默认 `admin`)。
@@ -21,11 +21,11 @@ npm test # node:test,10 个用例
### Docker 部署
```bash
cp .env.example .env # 修改 SESSION_SECRET / ADMIN_INIT_PASSWORD / AI_INGEST_KEY
docker compose up -d --build
docker build -t awesome-index:latest .
bash start.sh # 用新镜像重启容器
```
数据持久化在宿主机 `./data/awesome.duckdb`,升级镜像不丢数据。
`start.sh` 会检查 `.env`(不存在则从 `.env.example` 复制),然后 `docker run` 启动容器。数据持久化在宿主机 `./data/awesome.duckdb`,重启不丢数据。
## 给人的入口
@@ -33,24 +33,126 @@ docker compose up -d --build
|---|---|
| 首页(搜索 / MCP 说明 / 最近收录) | `/` |
| 列表页(类型 + 嵌套标签过滤) | `/entries?q=关键字&type=工具&tags=1,2&sort=stars` |
| 详情页(元数据 / 评论 / 机读 JSON) | `/entries/:slug` |
| 详情页(元数据 / 相关项目 / 评论 / 机读 JSON) | `/entries/:slug` |
| 随机游览 | `/random` |
| 登录 | `/login` |
| 管理台(内容/标签/评论/内容源/账号) | `/admin` |
| 管理台 | `/admin` |
管理台分区(**点菜单每次都重新向后台取数**,不需要手动刷新浏览器):
| 分区 | 说明 |
|---|---|
| 内容管理 | 名称搜索(标题 / slug / URL / 描述)+ 状态筛选,**每页 20 条**;pending 条目按钮为「预览」,active 为「查看」 |
| 标签管理 | 无限嵌套标签树,拖拽调整层级 |
| 评论管理 | 评论列表 + **联系方式**列 |
| 评论人员 | 以联系方式为身份依据,可启用 / 禁用 / 改名 / 删除 |
| 内容源 | 人工 / AI 接口 / 定时拉取(rss / html / eryajf-weekly) |
| 账号管理 | 管理员可见 |
### 评论规则
访客评论需要提供**昵称 + 联系方式**(手机号或邮箱):
- 联系方式**只存后台,前台详情页不展示**。
- **首次评论**以「昵称 + 联系方式」建档入库。
- **之后评论**必须昵称与该联系方式登记的一致,且未被管理员禁用,否则分别返回 400 / 403。
- 管理员可在「评论人员」分区按联系方式禁用某人;禁用后其评论被拒,历史评论保留。
### 相关项目
每个条目都能关联若干「相关项目」,在详情页侧栏展示,同时进入机读 JSON 的 `related` 字段。
- 关联在管理台「编辑条目 → 相关项目」里维护。
- **关联是双向的**:A 关联 B 后,B 的详情页也会出现 A(底层只存一条记录)。
- 前台只展示已上架(`active`)的相关条目。
## 给 AI 的入口(MCP)
Streamable HTTP · 只读 · 无需密钥:
> **一句话:读用 MCP(免密钥),写用 REST(要 `X-Ingest-Key`)。**
### 只读部分:MCP
```
POST {BASE_URL}/mcp
tools: search_entries / get_entry / random_entry / list_tags
transport: Streamable HTTP
auth: none
Accept: application/json, text/event-stream # 正规客户端自动带上;curl 直连需手写
```
**本端点不校验任何密钥**,MCP 连接里无需配置 token,它只提供读取能力。
客户端配置:
```json
{
"mcpServers": {
"awesome-index": {
"url": "{BASE_URL}/mcp"
}
}
}
```
一句话让 Agent 自己接入:
> 请把 URL 为 `{BASE_URL}/mcp` 的 MCP 服务器添加到你的客户端配置中,名称用 awesome-index,完成后告诉我现在可以调用哪些工具。
可用工具:
| tool | 参数 | 说明 |
|---|---|---|
| `search_entries` | `q`(必填), `limit`(1–50,默认 20) | 按关键字 / 类型 / 标签搜索。**查重先走它** |
| `get_entry` | `slug`(必填,也可传标题关键字自动匹配) | 取单条完整详情与元数据 |
| `random_entry` | — | 随机返回一条已上架内容,用于探索发现 |
| `list_tags` | — | 列出全部标签组、嵌套结构与收录数量 |
| `usage` | — | 返回完整接入说明(含写入接口与踩坑清单) |
结果格式:返回体在 `result.content[0].text` 里,是一段 **JSON 字符串,需要再解析一次**。
连接 `initialize` 后返回的 `instructions` 字段同样带有完整的接入与写入说明(AI 客户端通常会自动注入)。
### 写入部分:AI 录入接口
MCP 只读,不能写入。要让 Agent 把新内容录进本库,请使用 HTTP 接口:
```
POST {BASE_URL}/api/ingest/entry # 等价端点:POST {BASE_URL}/api/entries
Content-Type: application/json
```
鉴权请求头(**不要**把 key 放进 URL query):
| 方式 | 示例 |
|---|---|
| `X-Ingest-Key`(推荐) | `X-Ingest-Key: <key>` |
| 标准 Bearer(等价) | `Authorization: Bearer <key>` |
请求体字段:`title`(必填) / `url`(必填,合法 URL,用于去重) / `type`(必填,枚举:微服务 / SaaS / 网站 / 工具 / 应用 / 脚本 / 插件) / `summary`(≤200) / `description_md`(≤5000,Markdown) / `license` / `stars`(整数) / `tags`(字符串数组,自动建标签) / `status`。
> `slug` 不是合法字段,传了会被静默丢弃(服务端按标题自动生成)。
```bash
curl -X POST "{BASE_URL}/api/ingest/entry" \
-H "X-Ingest-Key: <key>" \
-H "Content-Type: application/json" \
-d '{"title":"MinerU","url":"https://github.com/opendatalab/MinerU","type":"工具","tags":["Python","OCR"]}'
```
返回:创建成功 `201 {"ok":true,"data":{"created":true,"id":<n>,"status":"pending"}}`;URL 已存在时 `200 {"created":false,"id":<n>}`(自动去重);鉴权失败 `403 invalid_key`。
### 五个必记的坑
1. **推荐 `X-Ingest-Key` 请求头**(等价 `Authorization: Bearer`);自创的 `X-API-Key`、`?key=` 一律 403。
2. **MCP 免密钥 ≠ 密钥有效**。`/mcp` 根本不查 key,别拿探测 MCP 来验证 ingest key。
3. **提交后默认 `pending`**,前台搜不到是正常的,等管理员审核转 `active`。**不要因为搜不到就重复提交。**
4. **同一 URL 重复提交返回 200 `created:false`**,这不是报错,说明已存在并被去重。
5. `slug` 不是字段,传了会被丢弃。
排错:`curl` 直连 `/mcp` 报 `406` 是缺 `Accept: application/json, text/event-stream`,正规客户端不受影响。
录入条目默认 `pending`(待审核),除非对应 AI 源开启了 direct_publish。
## 内容源(自动化收录)
三类来源汇聚到同一条目表:
@@ -58,7 +160,7 @@ tools: search_entries / get_entry / random_entry / list_tags
| kind | 说明 | 新条目状态 |
|---|---|---|
| `human` | 管理台人工录入(常开) | active |
| `ai` | `POST /api/ingest/entry` + `X-Ingest-Key` 头 | pending |
| `ai` | `POST /api/ingest/entry` + `X-Ingest-Key`(或 `Authorization: Bearer`)头 | pending |
| `feed` | 定时拉取外部站点(rss / html / eryajf-weekly 适配器),管理台可手动触发 | pending |
首个站点适配器:二丫讲梵学习周刊(wiki.eryajf.net)。运行记录见管理台「内容源 → 运行记录」。
+7 -12
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# Awesome Index 部署脚本(直接部署,不使用 Docker)
# Awesome Index 部署脚本(构建机 = 部署机:本地构建镜像,SSH 重启容器)
# 服务器: 114.134.187.170
set -e
@@ -19,20 +19,15 @@ if ! command -v sshpass &> /dev/null; then
exit 1
fi
echo "1. 创建部署目录..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
"mkdir -p $DEPLOY_PATH/data $DEPLOY_PATH/logs"
echo "1. 构建 Docker 镜像..."
docker build -t awesome-index:latest .
echo "2. 上传源码..."
sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no -r \
package.json package-lock.json src public .env.example start.sh \
echo "2. 上传脚本到服务器..."
sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no \
start.sh .env.example \
"$REMOTE_USER@$REMOTE_HOST:$DEPLOY_PATH/"
echo "3. 重命名 .env.example 为 .env(如不存在)..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
"cd $DEPLOY_PATH && [ ! -f .env ] && cp .env.example .env"
echo "4. 在服务器上执行启动脚本..."
echo "3. 在服务器上重启容器..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
"cd $DEPLOY_PATH && chmod +x start.sh && bash start.sh"
-16
View File
@@ -1,16 +0,0 @@
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
+2 -2
View File
@@ -58,8 +58,8 @@
```
awesome/
├── package.json # 依赖与 scripts(dev / start / test / migrate)
├── Dockerfile # 多阶段构建(node:22-alpine)
├── docker-compose.yml # 单服务编排 + data 卷 + healthcheck
├── Dockerfile # 多阶段构建(node:22-bookworm-slim)
├── start.sh # 用已构建镜像重启容器(docker run,无 compose)
├── .env.example # 配置样例
├── .gitignore # node_modules / data / .env
│
+13
View File
@@ -14,6 +14,7 @@
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-session": "^1.18.1",
"marked": "^18.0.11",
"node-cron": "^4.6.0",
"zod": "^3.25.76"
},
@@ -1139,6 +1140,18 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -20,6 +20,7 @@
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-session": "^1.18.1",
"marked": "^18.0.11",
"node-cron": "^4.6.0",
"zod": "^3.25.76"
}
+309 -3
View File
@@ -465,6 +465,13 @@ button.chip:hover {
padding: 18px;
transform: rotate(-3deg);
box-shadow: 9px 9px 0 var(--riso-blue);
color: inherit;
text-decoration: none;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.spec-human:hover {
transform: rotate(0deg) translateY(-2px);
box-shadow: 9px 12px 0 var(--riso-blue);
}
.spec-human h3 {
font-size: 16.5px;
@@ -725,6 +732,20 @@ button.chip:hover {
.step pre .s {
color: #ffd9a0;
}
.step pre .c {
color: #7d86a8;
font-style: italic;
}
.tools-table td:nth-child(2) {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--dim-on-panel);
white-space: nowrap;
}
.tools-table td:last-child {
color: var(--dim-on-panel);
line-height: 1.6;
}
.tools-table {
width: 100%;
border-collapse: collapse;
@@ -1171,6 +1192,81 @@ button.chip:hover {
overflow-x: auto;
white-space: pre;
}
.md-body {
line-height: 1.7;
font-size: 14.5px;
}
.md-body h1,
.md-body h2,
.md-body h3,
.md-body h4 {
font-weight: 700;
margin: 22px 0 10px;
}
.md-body h2 {
font-size: 20px;
}
.md-body h3 {
font-size: 16.5px;
}
.md-body p {
margin: 10px 0;
}
.md-body a {
color: var(--riso-blue);
text-decoration: underline;
}
.md-body ul,
.md-body ol {
margin: 10px 0;
padding-left: 22px;
}
.md-body li {
margin: 4px 0;
}
.md-body code {
font-family: var(--font-mono);
font-size: 12.5px;
background: var(--riso-blue-soft);
border-radius: 5px;
padding: 2px 5px;
}
.md-body pre {
background: var(--panel);
color: #cdd3ea;
border-radius: var(--r-md);
padding: 16px 18px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.6;
margin: 14px 0;
}
.md-body pre code {
background: none;
padding: 0;
color: inherit;
}
.md-body blockquote {
border-left: 3px solid var(--ink-faint);
padding-left: 14px;
color: var(--ink-dim);
margin: 12px 0;
}
.md-body table {
border-collapse: collapse;
margin: 14px 0;
font-size: 13.5px;
}
.md-body th,
.md-body td {
border: 1.5px solid var(--ink-faint);
padding: 7px 12px;
text-align: left;
}
.md-body th {
background: var(--riso-blue-soft);
}
.side-card {
background: var(--paper-raised);
border: var(--border);
@@ -1450,6 +1546,90 @@ button.chip:hover {
font-weight: 700;
margin-bottom: 14px;
}
/* ---------- 管理台:内容筛选 / 批量 / 分页 ---------- */
.status-chips {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: 18px;
}
.status-chips .spacer {
flex: 1;
}
.status-chips .page-hint {
font-size: 12px;
color: var(--ink-dim);
}
.batch-bar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 18px;
}
.batch-bar .batch-count {
font-size: 12px;
color: var(--ink-dim);
}
.data-table .entry-check,
.data-table .entry-check-all {
accent-color: var(--riso-blue);
width: 15px;
height: 15px;
cursor: pointer;
}
.pager {
display: flex;
gap: 10px;
margin-top: 16px;
}
.btn-disabled {
opacity: 0.45;
pointer-events: none;
}
/* ---------- 管理台:标签树 拖拽 ---------- */
.tagmgr-tree {
border: 1.5px dashed var(--ink-faint);
border-radius: var(--r-sm);
padding: 12px;
transition:
background 0.15s ease,
box-shadow 0.15s ease;
}
.tagmgr-tree .tag-tree {
font-size: 13.5px;
}
.tagmgr-tree .tag-node {
cursor: grab;
}
.tagmgr-tree .tag-node:active {
cursor: grabbing;
}
.tagmgr-tree .tag-node.drag-src {
opacity: 0.45;
}
.tagmgr-tree .tag-node.drag-over {
background: var(--riso-blue-soft);
box-shadow: inset 0 0 0 1.5px var(--riso-blue);
}
.tagmgr-tree.drag-over {
background: var(--riso-blue-soft);
box-shadow: inset 0 0 0 1.5px var(--riso-blue);
}
.tagmgr-tree .nameleaf {
flex: 1;
padding-left: 17px;
color: var(--ink);
}
.tagmgr-tree .toggler {
flex: 1;
font: inherit;
}
.dnd-hint {
font-size: 12px;
margin-top: 12px;
}
.switchline {
display: flex;
align-items: center;
@@ -1601,25 +1781,44 @@ button.chip:hover {
}
.ai-key-field .input {
width: 100%;
padding-right: 42px;
padding-right: 40px;
}
.pwd-field {
position: relative;
}
.pwd-field .input {
width: 100%;
padding-right: 40px;
}
.eye-toggle {
position: absolute;
right: 8px;
right: 2px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
width: 34px;
height: 100%;
padding: 0;
border: 0;
background: none;
cursor: pointer;
color: var(--ink-dim);
border-radius: 0 8px 8px 0;
}
.eye-toggle:hover {
color: var(--ink);
}
.eye-toggle .eye-closed {
display: none;
}
.eye-toggle[data-show] .eye-open {
display: none;
}
.eye-toggle[data-show] .eye-closed {
display: inline;
}
.auth-row {
margin-top: 14px;
display: flex;
@@ -1850,6 +2049,113 @@ button.chip:hover {
}
}
.mcp-pitfalls {
margin-top: 30px;
padding: 20px 22px;
border: var(--border);
border-radius: 12px;
background: var(--paper);
}
.mcp-pitfalls ol {
margin: 0;
padding-left: 20px;
display: grid;
gap: 9px;
}
.mcp-pitfalls li {
font-size: 13.5px;
line-height: 1.7;
color: var(--ink-dim);
}
.mcp-pitfalls li b {
color: var(--ink);
}
.mcp-pitfalls .mono {
font-size: 12.5px;
}
/* ---------- 管理台:分区导航 / 搜索 / 评论人员 / 相关项目 ---------- */
.admin-nav a[data-section] {
display: flex;
align-items: center;
gap: 10px;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
font-weight: 600;
color: inherit;
text-decoration: none;
}
.admin-nav a[data-section][aria-current="true"] {
background: var(--ink);
color: var(--paper);
}
.admin-nav .admin-nav-refresh {
margin-top: auto;
padding: 9px 12px;
border-radius: 8px;
border: var(--border);
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-dim);
text-decoration: none;
text-align: center;
}
.admin-nav .admin-nav-refresh:hover {
color: var(--ink);
border-color: var(--ink);
}
.admin-search {
display: flex;
gap: 8px;
align-items: center;
margin-top: 14px;
flex-wrap: wrap;
}
.admin-search .input {
flex: 1;
min-width: 240px;
}
.related-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px;
}
.related-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 0;
border-bottom: 1px dashed var(--ink-faint);
flex-wrap: wrap;
}
.related-list li:last-child {
border-bottom: 0;
}
.related-list li > a {
display: flex;
align-items: center;
gap: 8px;
color: inherit;
text-decoration: none;
min-width: 0;
}
.related-list li > a:hover b {
color: var(--riso-blue);
}
.related-list .chip-type {
flex: none;
}
aside .related-list li {
display: block;
padding: 10px 0;
}
/* ---------- 降低动效 ---------- */
@media (prefers-reduced-motion: reduce) {
html {
+252 -40
View File
@@ -26,30 +26,192 @@
}
function refresh() {
setTimeout(function () { location.reload(); }, 500);
location.reload();
}
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);
function esc(v) {
return String(v == null ? "" : v).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
var SECTION_IDS = [
"sec-content",
"sec-tags",
"sec-comments",
"sec-contacts",
"sec-sources",
"sec-accounts",
];
function activateSection(id, { syncNav } = { syncNav: true }) {
if (!SECTION_IDS.includes(id)) return;
document.querySelectorAll(".admin-main > section").forEach(function (s) {
s.classList.toggle("active", s.id === id);
});
if (syncNav) {
document.querySelectorAll(".admin-nav [data-section]").forEach(function (b) {
b.setAttribute("aria-current", b.getAttribute("data-section") === id ? "true" : "false");
});
}
}
// 菜单项是真实链接:点击即向后台发起一次新的 /admin?section=… 请求,
// 服务端重新查库渲染,因此每次切分区拿到的都是最新数据(无需手动刷新)。
document.querySelectorAll(".admin-nav a[data-section]").forEach(function (link) {
link.addEventListener("click", function (e) {
// 普通左键点击交给浏览器完成整页跳转(= 重新拉数据);
// 中键 / Ctrl / Cmd 点击保留「新标签打开」的默认行为。
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
activateSection(link.getAttribute("data-section"));
document.querySelector(".admin-nav-refresh")?.setAttribute(
"href",
link.getAttribute("href")
);
});
});
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");
// 兼容旧链接 /admin#sec-xxx:按 hash 切分区但不重新拉取
(function restoreHashSection() {
var hash = (location.hash || "").replace("#", "");
if (hash && SECTION_IDS.includes(hash)) activateSection(hash);
})();
/* ============ 标签树:客户端渲染 + 拖拽调整层级 ============ */
var tagRoot = document.getElementById("admin-tag-tree");
var tagDraggingId = null;
function tagRowHtml(n) {
var kids = n.children && n.children.length;
var label = kids
? '<button type="button" class="tree-row toggler" aria-expanded="true"><span class="tw">▶</span> <span class="name">' + esc(n.name) + "</span></button>"
: '<span class="nameleaf"><span class="name">' + esc(n.name) + "</span></span>";
return (
"<li>" +
'<div class="tree-row tag-node" draggable="true" data-id="' + n.id + '" data-drop-id="' + n.id + '">' +
label +
'<span class="mono cnt">' + n.count + " · #" + n.id + "</span>" +
'<span class="row-actions" style="margin-left:8px">' +
'<button class="btn btn-sm" type="button" data-action="tag-add" data-parent-id="' + n.id + '">+子级</button>' +
'<button class="btn btn-sm" type="button" data-action="tag-rename" data-id="' + n.id + '" data-name="' + esc(n.name) + '">改名</button>' +
'<button class="btn btn-sm" type="button" data-action="tag-move" data-id="' + n.id + '">移动</button>' +
'<button class="btn btn-sm btn-danger" type="button" data-action="tag-delete" data-id="' + n.id + '">删</button>' +
"</span>" +
"</div>" +
(kids ? '<ul class="tag-children open">' + n.children.map(tagRowHtml).join("") + "</ul>" : "") +
"</li>"
);
}
function renderTagAdminTree(tree) {
if (!tagRoot) return;
tagRoot.innerHTML = tree && tree.length
? '<ul class="tag-tree">' + tree.map(tagRowHtml).join("") + "</ul>"
: '<p class="mono dim">还没有标签。</p>';
}
async function refreshTags() {
if (!tagRoot) return;
var json = await api("/api/tags/tree", undefined, "GET");
renderTagAdminTree(json.data);
}
function clearTagDnd() {
tagDraggingId = null;
if (!tagRoot) return;
tagRoot.classList.remove("drag-over");
tagRoot.querySelectorAll(".drag-src,.drag-over").forEach(function (el) {
el.classList.remove("drag-src", "drag-over");
});
}
if (tagRoot) {
if (Array.isArray(window.__ADMIN_TAG_TREE__)) renderTagAdminTree(window.__ADMIN_TAG_TREE__);
else refreshTags();
tagRoot.addEventListener("click", function (e) {
var tog = e.target.closest(".toggler");
if (!tog) return;
var li = tog.closest("li");
var ul = null;
if (li) {
for (var i = 0; i < li.children.length; i++) {
if (li.children[i].tagName === "UL") ul = li.children[i];
}
}
if (!ul) return;
var open = ul.classList.toggle("open");
tog.setAttribute("aria-expanded", open ? "true" : "false");
});
tagRoot.addEventListener("dragstart", function (e) {
if (e.target.closest("button")) return;
var row = e.target.closest(".tag-node");
if (!row) return;
tagDraggingId = row.getAttribute("data-id");
e.dataTransfer.setData("text/plain", tagDraggingId);
e.dataTransfer.effectAllowed = "move";
row.classList.add("drag-src");
});
tagRoot.addEventListener("dragend", clearTagDnd);
tagRoot.addEventListener("dragover", function (e) {
if (!tagDraggingId) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
var row = e.target.closest(".tag-node[data-drop-id]");
tagRoot.querySelectorAll(".drag-over").forEach(function (el) {
el.classList.remove("drag-over");
});
if (row) row.classList.add("drag-over");
else tagRoot.classList.add("drag-over");
});
tagRoot.addEventListener("dragleave", function (e) {
if (e.target === tagRoot && !e.relatedTarget) tagRoot.classList.remove("drag-over");
});
tagRoot.addEventListener("drop", async function (e) {
var id = tagDraggingId;
var row = e.target.closest(".tag-node[data-drop-id]");
var dropTarget = row ? row.getAttribute("data-drop-id") : null;
clearTagDnd();
if (!id || dropTarget === id) return;
try {
await api("/api/tags/" + id, { parentId: dropTarget ? Number(dropTarget) : null }, "PATCH");
toast(dropTarget ? "已移动到目标标签下" : "已移回顶层");
await refreshTags();
} catch (err) {
toast(err.message);
}
});
}
/* ============ 批量选择 / 批量状态 ============ */
function selectedEntryIds() {
var ids = [];
document.querySelectorAll(".entry-check:checked").forEach(function (c) {
ids.push(Number(c.getAttribute("data-id")));
});
return ids;
}
function updateBatchCount() {
var el = document.getElementById("batch-count");
if (el) el.textContent = "已选 " + selectedEntryIds().length + " 项";
}
document.addEventListener("change", function (e) {
var ck = e.target;
if (ck.classList && ck.classList.contains("entry-check-all")) {
document.querySelectorAll(".entry-check").forEach(function (c) {
c.checked = ck.checked;
});
updateBatchCount();
} else if (ck.classList && ck.classList.contains("entry-check")) {
updateBatchCount();
}
});
document.addEventListener("click", async function (e) {
@@ -63,28 +225,32 @@
var name = prompt(parentId ? "新增子标签名称:" : "新增顶层标签组名称:");
if (!name) return;
await api("/api/tags", { name: name, parentId: parentId ? Number(parentId) : null });
toast("标签已创建"); refresh();
await refreshTags();
toast("标签已创建");
}
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();
await refreshTags();
toast("已重命名");
}
if (action === "tag-move") {
var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):\n" + btn.getAttribute("data-ids"));
var target3 = prompt("移动到哪个标签之下?填其 ID(留空移到顶层):");
if (target3 === null) return;
await api("/api/tags/" + btn.getAttribute("data-id"),
{ parentId: target3.trim() ? Number(target3.trim()) : null }, "PATCH");
toast("已移动"); refresh();
await refreshTags();
toast("已移动");
}
if (action === "tag-delete") {
if (!confirm("删除该标签?其子标签将上移一级,关联关系解除。")) return;
await api("/api/tags/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
await refreshTags();
toast("已删除");
}
if (action === "entry-status") {
@@ -99,12 +265,69 @@
toast("已删除"); refresh();
}
if (action === "entry-select-all") {
var checks = Array.prototype.slice.call(document.querySelectorAll(".entry-check"));
var allOn = checks.length > 0 && checks.every(function (c) { return c.checked; });
checks.forEach(function (c) { c.checked = !allOn; });
var allEl = document.querySelector(".entry-check-all");
if (allEl) allEl.checked = !allOn;
updateBatchCount();
}
if (action === "entry-batch") {
var ids = selectedEntryIds();
if (!ids.length) { toast("请先勾选条目"); return; }
var status = btn.getAttribute("data-status");
await api("/api/admin/entries/status", { ids: ids, status: status }, "POST");
toast(status === "active" ? "已上架 " + ids.length + " 条" : "已灰掉 " + ids.length + " 条");
refresh();
}
if (action === "comment-delete") {
if (!confirm("删除该评论?")) return;
await api("/api/comments/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
}
if (action === "contact-status") {
var enabled = btn.getAttribute("data-enabled") === "1";
if (enabled && !confirm("禁用后该联系方式将无法继续评论,确认?")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id") + "/status",
{ enabled: !enabled }, "PATCH");
toast(enabled ? "已禁用" : "已启用"); refresh();
}
if (action === "contact-rename") {
var nick = prompt("修改昵称:", btn.getAttribute("data-name"));
if (!nick || nick === btn.getAttribute("data-name")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id"),
{ nickname: nick }, "PATCH");
toast("昵称已更新"); refresh();
}
if (action === "contact-delete") {
if (!confirm("删除该评论人员?其历史评论会保留。")) return;
await api("/api/admin/contacts/" + btn.getAttribute("data-id"), undefined, "DELETE");
toast("已删除"); refresh();
}
if (action === "relation-add") {
var sel = document.getElementById("f-related");
if (!sel || !sel.value) { toast("没有可关联的条目"); return; }
var opt = sel.options[sel.selectedIndex];
await api("/api/admin/entries/" + window.__ADMIN_ENTRY_ID__ + "/relations",
{ relatedId: Number(sel.value) }, "POST");
toast("已关联:" + opt.textContent.trim().split(" · ")[0]);
refresh();
}
if (action === "relation-remove") {
if (!confirm("移除该关联?")) return;
await api("/api/admin/entries/" + window.__ADMIN_ENTRY_ID__ +
"/relations/" + btn.getAttribute("data-related-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");
@@ -139,19 +362,6 @@
});
}
document.querySelectorAll("[data-eye-toggle]").forEach(function (btn) {
btn.addEventListener("click", function () {
var input = btn.closest("form").querySelector("input[name=key]");
var show = input.type === "password";
input.type = show ? "text" : "password";
btn.setAttribute("aria-label", show ? "隐藏密钥" : "显示密钥");
var open = btn.querySelector(".eye-open");
var closed = btn.querySelector(".eye-closed");
if (open) open.hidden = show;
if (closed) closed.hidden = !show;
});
});
document.querySelectorAll("form.source-config").forEach(function (form) {
form.addEventListener("submit", function (e) {
e.preventDefault();
@@ -175,10 +385,12 @@
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();
if (s.error) {
toast("拉取失败:" + s.error);
} else {
toast("完成:发现 " + s.found + " · 新增 " + s.created + " · 跳过 " + s.skipped);
refresh();
}
} catch (err) {
toast(err.message);
} finally {
@@ -216,4 +428,4 @@
.catch(function (err) { toast(err.message); });
});
}
})();
})();
+40
View File
@@ -118,6 +118,20 @@
});
});
document.querySelectorAll("[data-eye-toggle]").forEach(function (btn) {
btn.addEventListener("click", function () {
var input = btn.closest(".pwd-field, .ai-key-field")
? btn.closest(".pwd-field, .ai-key-field").querySelector("input")
: null;
if (!input) return;
var show = input.type === "password";
input.type = show ? "text" : "password";
btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码");
if (show) btn.setAttribute("data-show", "");
else btn.removeAttribute("data-show");
});
});
var loginForm = document.getElementById("login-form");
if (loginForm) {
loginForm.addEventListener("submit", function (e) {
@@ -126,6 +140,7 @@
postJson("/api/admin/login", {
username: loginForm.username.value,
password: loginForm.password.value,
remember: loginForm.remember ? loginForm.remember.checked : true,
})
.then(function () {
toast("登录成功,正在进入管理台…");
@@ -135,14 +150,39 @@
});
}
var CONTACT_EMAIL = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/;
var CONTACT_PHONE = /^1[3-9]\d{9}$/;
function validContact(v) {
var s = String(v == null ? "" : v).trim();
return CONTACT_EMAIL.test(s) || CONTACT_PHONE.test(s);
}
var commentForm = document.querySelector(".comment-form[data-entry-id]");
if (commentForm) {
var contactInput = commentForm.querySelector("[name=contact]");
if (contactInput) {
contactInput.addEventListener("input", function () {
if (contactInput.value.trim() && validContact(contactInput.value)) {
contactInput.setCustomValidity("");
} else {
contactInput.setCustomValidity("请填写正确的手机号或邮箱(不会公开显示)");
}
});
}
commentForm.addEventListener("submit", function (e) {
e.preventDefault();
var contact = contactInput ? contactInput.value.trim() : "";
if (!validContact(contact)) {
toast("请填写正确的联系方式:手机号或邮箱");
if (contactInput) contactInput.focus();
return;
}
if (!commentForm.reportValidity()) return;
postJson("/api/comments", {
entryId: Number(commentForm.getAttribute("data-entry-id")),
authorName: commentForm.querySelector("[name=authorName]").value,
contact: contact,
body: commentForm.querySelector("[name=body]").value,
})
.then(function () {
+9
View File
@@ -2,6 +2,7 @@ import express from "express";
import session from "express-session";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { marked } from "marked";
import { loadUser } from "./middleware/auth.js";
import { notFoundHandler, errorHandler } from "./middleware/errors.js";
import { pagesRouter } from "./routes/pages.routes.js";
@@ -26,6 +27,14 @@ export function buildApp(config) {
};
app.locals.fmtDate = (d) =>
d ? new Date(d).toISOString().slice(0, 10) : "—";
app.locals.md = (s) => marked.parse(String(s || ""));
app.locals.excerpt = (s, n = 200) => {
const plain = String(s || "")
.replace(/[#*`>_\[\]()!-]/g, " ")
.replace(/\s+/g, " ")
.trim();
return plain.length > n ? plain.slice(0, n).trim() + "…" : plain;
};
app.locals.qs = (obj) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(obj)) {
+2
View File
@@ -20,6 +20,8 @@ export const config = {
aiIngestKey: str(env.AI_INGEST_KEY, ""),
baseUrl: str(env.BASE_URL, `http://localhost:${num(env.PORT, 3000)}`),
schedulerEnabled: env.SCHEDULER_DISABLED !== "1",
gitBranch: str(env.GIT_BRANCH, "dev"),
buildTime: str(env.BUILD_TIME, new Date().toISOString().replace(/[-T:]/g, "").slice(0, 14)),
};
export function ensureDataDir() {
+40
View File
@@ -15,6 +15,42 @@ async function runSchema() {
}
}
/**
* 老数据里已经存在的评论没有联系方式。这里按昵称补建评论人,
* 联系方式留一个占位值(不会被任何格式校验通过),管理员后续可修正。
*/
async function backfillContacts() {
const rows = await all(
`SELECT id, author_name FROM comments WHERE contact IS NULL OR contact = ''`
);
for (const row of rows) {
const nickname = String(row.author_name || "").trim() || "匿名";
const contact = await ensureContact(`legacy-${row.id}`, nickname);
await run(`UPDATE comments SET contact = ?, contact_id = ? WHERE id = ?`, [
contact.contact,
contact.id,
row.id,
]);
}
if (rows.length) {
await run(
`UPDATE contacts SET comment_count = (
SELECT COUNT(*) FROM comments c WHERE c.contact_id = contacts.id
)`
);
}
}
async function ensureContact(contact, nickname) {
const existing = await get(`SELECT * FROM contacts WHERE contact = ?`, [contact]);
if (existing) return existing;
await run(
`INSERT INTO contacts (contact, nickname) VALUES (?, ?)`,
[contact, nickname]
);
return get(`SELECT * FROM contacts WHERE contact = ?`, [contact]);
}
export async function seedTagsAndEntries() {
const tagCount = await get(`SELECT COUNT(*) AS c FROM tags`);
if (Number(tagCount.c) > 0) return;
@@ -81,6 +117,10 @@ export async function seedTagsAndEntries() {
export async function runMigrate(config) {
await runSchema();
await run(`ALTER TABLE sources ADD COLUMN IF NOT EXISTS api_key VARCHAR`);
await run(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS summary VARCHAR DEFAULT ''`);
await run(`ALTER TABLE comments ADD COLUMN IF NOT EXISTS contact VARCHAR DEFAULT ''`);
await run(`ALTER TABLE comments ADD COLUMN IF NOT EXISTS contact_id INTEGER`);
await backfillContacts();
const admin = await findByUsername("admin");
if (!admin) {
+22
View File
@@ -4,6 +4,8 @@ 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 contacts_id_seq;
CREATE SEQUENCE IF NOT EXISTS entry_relations_id_seq;
CREATE SEQUENCE IF NOT EXISTS audit_logs_id_seq;
CREATE TABLE IF NOT EXISTS users (
@@ -54,6 +56,7 @@ CREATE TABLE IF NOT EXISTS entries (
title VARCHAR NOT NULL,
slug VARCHAR UNIQUE NOT NULL,
type VARCHAR NOT NULL,
summary VARCHAR DEFAULT '',
description_md TEXT DEFAULT '',
url VARCHAR DEFAULT '',
license VARCHAR DEFAULT '',
@@ -71,15 +74,34 @@ CREATE TABLE IF NOT EXISTS entry_tags (
PRIMARY KEY (entry_id, tag_id)
);
CREATE TABLE IF NOT EXISTS entry_relations (
id INTEGER PRIMARY KEY DEFAULT nextval('entry_relations_id_seq'),
entry_id INTEGER NOT NULL REFERENCES entries(id),
related_id INTEGER NOT NULL REFERENCES entries(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
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),
contact_id INTEGER,
author_name VARCHAR NOT NULL,
contact VARCHAR DEFAULT '',
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY DEFAULT nextval('contacts_id_seq'),
contact VARCHAR UNIQUE NOT NULL,
nickname VARCHAR NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
comment_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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,
+14 -3
View File
@@ -1,5 +1,5 @@
import { config, ensureDataDir } from "./config.js";
import { connect } from "./db/connection.js";
import { connect, close } from "./db/connection.js";
import { runMigrate } from "./db/migrate.js";
import { buildApp } from "./app.js";
import { startScheduler } from "./ingest/scheduler.js";
@@ -19,8 +19,19 @@ async function main() {
}
const shutdown = async () => {
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 2000).unref();
console.log("[awesome-index] shutting down...");
try {
server.close();
if (config.schedulerEnabled) {
const { stopAll } = await import("./ingest/scheduler.js");
stopAll();
}
await close();
console.log("[awesome-index] duckdb closed (wal checkpointed)");
} catch (err) {
console.error("[awesome-index] shutdown error:", err);
}
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
+164 -38
View File
@@ -23,7 +23,12 @@ async function getText(url, timeoutMs = 15000) {
try {
const res = await fetch(url, {
signal: controller.signal,
headers: { "user-agent": "AwesomeIndexBot/0.1 (+https://honor3.com)" },
headers: {
"user-agent":
"Mozilla/5.0 (compatible; AwesomeIndexBot/0.2; +https://honor3.com)",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
},
redirect: "follow",
});
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
@@ -33,91 +38,212 @@ async function getText(url, timeoutMs = 15000) {
}
}
/** 去掉 VuePress 外链渲染出来的 “ (opens new window)” 之类尾巴 */
function cleanText(text) {
return String(text || "")
.replace(/\(opens?\s+new\s+window\)/gi, "")
.replace(/\s+/g, " ")
.trim();
}
function githubRepoFromLink(href) {
const m = String(href).match(/github\.com\/([\w.-]+)\/([\w.-]+)/i);
return m ? { owner: m[1], repo: m[2].replace(/\.git$/, "") } : null;
if (!m) return null;
const owner = m[1];
const repo = m[2].replace(/\.git$/, "");
// 排除用户主页 / 组织页(只有 owner 的场景由正则保证有 repo),以及站内导航链接
if (!owner || !repo) return null;
if (["sponsors", "topics", "search", "about", "pricing", "login", "join"].includes(repo)) {
return null;
}
return { owner, repo };
}
/** RSS / Atom:link 可能是文本节点、href 属性,或者是 <link rel="alternate"> 之外的其它 link */
function pickRssLink(node) {
const linkNode = node.find("link").first();
let link = cleanText(linkNode.text());
if (!link) {
link = String(
linkNode.attr("href") ||
node
.find("link")
.filter((_, el) => !String(el.attribs?.rel || "").includes("self"))
.first()
.attr("href") ||
""
).trim();
}
if (!link && node.attr("href")) link = String(node.attr("href")).trim();
return link;
}
const rss = {
async fetch(url) {
async fetch(url, options = {}) {
const xml = await getText(url);
const $ = cheerio.load(xml, { xmlMode: true });
const $ = cheerio.load(xml, { xmlMode: true, decodeEntities: 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 });
const link = pickRssLink(node);
const title = cleanText(node.find("title").first().text());
const description = cleanText(
node.find("description").first().text() ||
node.find("summary").first().text() ||
node.find("content").first().text()
);
let canonical = link;
try {
canonical = normalizeUrl(new URL(link, url).toString());
} catch {
canonical = normalizeUrl(link);
}
if (title && canonical) {
items.push({
title,
url: canonical,
description: description.replace(/<[^>]*>/g, "").trim().slice(0, 500),
});
}
});
return items.slice(0, 50);
return items.slice(0, options.limit ?? 50);
},
};
const html = {
async fetch(url, options = {}) {
const itemSel = options.selectors?.item || "article a";
const itemSel = options.selectors?.item || "article a, .post a, main a";
const body = await getText(url);
const $ = cheerio.load(body);
const seen = new Set();
const items = [];
$(itemSel).each((_, el) => {
const node = $(el);
const href = node.attr("href");
if (!href) return;
const abs = new URL(href, url).toString();
if (/^(#|javascript:|mailto:)/i.test(href)) return;
let abs;
try {
abs = new URL(href, url).toString();
} catch {
return;
}
const title = cleanText(node.text()).slice(0, 120);
if (!title || seen.has(abs)) return;
seen.add(abs);
items.push({
title: node.text().replace(/\s+/g, " ").trim().slice(0, 120),
title,
url: abs,
description: options.selectors?.description
? node.closest(options.selectors.description).text().replace(/\s+/g, " ").trim()
: "",
description: cleanText(
options.selectors?.description
? node.closest(options.selectors.description).text()
: ""
).slice(0, 500),
});
});
return items.slice(0, 50);
return items.slice(0, options.limit ?? 50);
},
};
const eryajfWeekly = {
async fetch(listUrl) {
async fetch(listUrl, options = {}) {
const maxIssues = options.maxIssues ?? 3;
const indexHtml = await getText(listUrl);
const $ = cheerio.load(indexHtml);
const issues = [];
const seenIssue = new Set();
$("a[href*='/pages/']").each((_, el) => {
const node = $(el);
const text = node.text().trim();
const text = cleanText(node.text());
const href = node.attr("href");
if (/学习周刊-总第\d+期/.test(text) && href) {
issues.push({ text, url: new URL(href, listUrl).toString() });
if (!href || !/学习周刊-总第\d+期/.test(text)) return;
let abs;
try {
abs = new URL(href, listUrl).toString();
} catch {
return;
}
if (seenIssue.has(abs)) return;
seenIssue.add(abs);
issues.push({ text, url: abs });
});
// 目录页按时间正序排列,最新的在最后
issues.reverse();
const results = [];
for (const issue of issues.slice(0, 3)) {
const issueHtml = await getText(issue.url);
const seenRepo = new Set();
/**
* 详情也是 CSR 渲染,SSR 输出里每个项目长这样:
* <ul>
* <li>项目地址:<a href="https://github.com/o/r">Name</a></li>
* <li>项目说明:描述文字…<img></li>
* </ul>
* <hr>
* 所以描述不在 <a> 所在的 <li> 里,得往上找到整个 <ul>(或 <p>)再取。
*/
function surroundingBlock(node) {
for (const sel of ["ul", "ol", "p", "tr"]) {
const hit = node.closest(sel);
if (hit.length) return hit;
}
return node.parent();
}
for (const issue of issues.slice(0, maxIssues)) {
let issueHtml;
try {
issueHtml = await getText(issue.url);
} catch (err) {
console.error(`[ingest] 周刊页拉取失败 ${issue.url}:`, err.message);
continue;
}
const $$ = cheerio.load(issueHtml);
const seen = new Set();
$$('a[href*="github.com"]').each((_, el) => {
// 站内的导航栏、侧边栏、面包屑里也挂着一堆 github 链接,限定在正文容器内
const root = [
".theme-vdoing-content.content__default",
".theme-default-content",
".custom-page-content",
"article",
]
.map((sel) => $$(sel).first())
.find((node) => node.length);
const scope = root && root.length ? root : $$.root();
scope.find('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);
if (seenRepo.has(repoUrl)) return;
seenRepo.add(repoUrl);
// 标题:锚文本;锚文本是裸 URL 时用 owner/repo
let name = cleanText(anchor.text()).replace(/^项目地址[::]?\s*/, "").trim();
if (!name || /^https?:\/\//i.test(name)) name = `${repo.owner}/${repo.repo}`;
// 描述:在所属的 ul/p 块里找「项目说明:」那段
const block = surroundingBlock(anchor);
let description = "";
block.find("li, p, td").each((_, part) => {
if (description) return;
const txt = cleanText($$(part).text());
const hit = /^项目说明[::]?\s*(.*)$/s.exec(txt);
if (hit) description = hit[1].trim();
});
if (!description) {
// 退化:拿块内除了「项目地址:」行以外的文字
const whole = cleanText(block.text());
const stripped = whole
.replace(/^项目地址[::]?\s*/, "")
.replace(new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, ""), "")
.replace(/^[::\s]+/, "");
description = stripped === name ? "" : stripped;
}
results.push({
title: name,
title: name.slice(0, 120),
url: repoUrl,
description,
description: description.slice(0, 300),
extra: { from: issue.text },
});
});
+31 -4
View File
@@ -13,6 +13,20 @@ function inferType(url) {
return "SaaS";
}
/** 标题里如果塞了裸 URL,就用仓库 owner/repo 兜底 */
function cleanTitle(raw) {
let title = String(raw || "").replace(/\s+/g, " ").trim();
const repo = title.match(/github\.com\/([\w.-]+)\/([\w.-]+)/i);
if (repo) title = `${repo[1]}/${repo[2].replace(/\.git$/, "")}`;
return title.slice(0, 120);
}
function buildDescription(item) {
const desc = String(item.description || "").replace(/\s+/g, " ").trim();
const from = item.extra?.from ? `\n\n> 来源:${item.extra.from}` : "";
return (desc ? desc.slice(0, 500) : "(自动收录,暂无描述)") + from;
}
export async function runSource(sourceId) {
const source = await sourceService.getById(sourceId);
if (!source) throw new Error(`内容源不存在:${sourceId}`);
@@ -24,14 +38,23 @@ export async function runSource(sourceId) {
try {
const adapter = getAdapter(source.adapter);
const rawItems = await adapter.fetch(source.url, {});
stats.found = rawItems.length;
stats.found = Array.isArray(rawItems) ? rawItems.length : 0;
if (!stats.found) {
stats.error = stats.error || "没有解析到任何条目(检查地址或适配器是否匹配)";
}
for (const item of rawItems) {
const seen = new Set();
for (const item of rawItems || []) {
if (!item.title || !item.url) {
stats.skipped += 1;
continue;
}
const normalizedUrl = normalizeUrl(item.url);
if (seen.has(normalizedUrl)) {
stats.skipped += 1;
continue;
}
seen.add(normalizedUrl);
if (await isKnownUrl(normalizedUrl)) {
stats.skipped += 1;
continue;
@@ -39,10 +62,11 @@ export async function runSource(sourceId) {
try {
const result = await entryService.createEntry(
{
title: String(item.title).slice(0, 120),
title: cleanTitle(item.title),
url: normalizedUrl,
type: inferType(normalizedUrl),
description_md: String(item.description || "").slice(0, 500),
summary: String(item.summary || "").slice(0, 200),
description_md: buildDescription(item),
tags: ["自动收录"],
status: "pending",
},
@@ -51,7 +75,10 @@ export async function runSource(sourceId) {
if (result.created) stats.created += 1;
else stats.skipped += 1;
} catch (err) {
// 单条失败不应该让整轮拉取报废
stats.skipped += 1;
stats.error = stats.error || err.message;
console.error(`[ingest] 条目写入失败 ${normalizedUrl}:`, err.message);
}
}
} catch (err) {
+20 -5
View File
@@ -6,13 +6,28 @@ 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)
);
const expr = String(source.cron_expr || "").trim();
if (!expr) {
console.warn(`[ingest] ${source.name} 没有配置 cron 计划,跳过定时`);
return;
}
if (!cron.validate(expr)) {
console.warn(`[ingest] ${source.name} 的 cron 表达式无效(${expr}),跳过定时`);
return;
}
const task = cron.schedule(expr, () => {
runSource(source.id)
.then((stats) => {
console.log(
`[ingest] ${source.name} 定时拉取完成:发现 ${stats.found} · 新增 ${stats.created} · 跳过 ${stats.skipped}${stats.error ? " · 错误 " + stats.error : ""}`
);
})
.catch((err) =>
console.error(`[ingest] ${source.name} 运行失败:`, err.message)
);
});
jobs.set(source.id, task);
console.log(`[ingest] 已注册定时任务:${source.name} [${expr}]`);
}
export async function startScheduler() {
+104 -5
View File
@@ -5,13 +5,112 @@ import { registerTools } from "./tools.js";
export const mcpRouter = Router();
const FALLBACK_BASE_URL = process.env.BASE_URL || "http://localhost:3000";
function requestBaseUrl(req) {
const host = req.get("host");
if (!host) return FALLBACK_BASE_URL;
const proto = req.get("x-forwarded-proto") || req.protocol || "http";
return `${proto}://${host}`;
}
function buildInstructions(baseUrl) {
return `Awesome Index —— 精选开源软件 / 微服务 / SaaS / 网站 / 工具 / 脚本 / 插件的结构化数据库。
你可以把它当成「别人已经筛过一轮的软件库」:先在这里查,查不到再联网搜。
# 一、一句话记住
> 读用 MCP(免密钥),写用 REST(要 \`X-Ingest-Key\`)。
本 MCP 端点**只读、不校验任何密钥**,连接时不要配 token。
要往库里写新条目,走 HTTP 接口 \`POST ${baseUrl}/api/ingest/entry\` 并带上录入密钥。
# 二、四个只读工具
| 工具 | 参数 | 用途 |
| --- | --- | --- |
| \`search_entries\` | \`q\`(必填,关键字)、\`limit\`(1–50,默认 20) | 按关键字 / 类型 / 标签搜索,**查重先走它** |
| \`get_entry\` | \`slug\`(必填,也可传标题关键字自动匹配) | 取单条详情 |
| \`random_entry\` | 无 | 随机抽一条,用于发现 |
| \`list_tags\` | 无 | 列全部标签组与数量 |
**结果格式**:每个工具返回 \`result.content[0].text\`,那是一段 **JSON 字符串,需要再 JSON.parse 一次**才能拿到对象。
# 三、客户端配置(Claude Desktop / Cursor / Cherry Studio 通用)
\`\`\`json
{
"mcpServers": {
"awesome-index": {
"url": "${baseUrl}/mcp"
}
}
}
\`\`\`
# 四、提交新条目(写入 · 要密钥)
\`\`\`bash
curl -X POST "${baseUrl}/api/ingest/entry" \\
-H "X-Ingest-Key: <你的密钥>" \\
-H "Content-Type: application/json" \\
-d '{"title":"MinerU","url":"https://github.com/opendatalab/MinerU","type":"工具","summary":"PDF/文档转 Markdown 的解析引擎","tags":["Python","OCR","文档解析"]}'
\`\`\`
成功返回:\`{"ok":true,"data":{"created":true,"id":54,"status":"pending"}}\`
# 五、字段规则
| 字段 | 必填 | 约束 |
| --- | --- | --- |
| \`title\` | ✅ | 标题 |
| \`url\` | ✅ | 合法 URL,**用于去重** |
| \`type\` | ✅ | 只能是:\`微服务\` \`SaaS\` \`网站\` \`工具\` \`应用\` \`脚本\` \`插件\` |
| \`summary\` | ❌ | ≤ 200 字符 |
| \`description_md\` | ❌ | ≤ 5000 字符,支持 Markdown |
| \`license\` | ❌ | 如 \`MIT\`、\`GPL-3.0\` |
| \`stars\` | ❌ | 整数 |
| \`tags\` | ❌ | 字符串数组,新标签自动创建 |
| \`status\` | ❌ | \`pending\` / \`active\` |
**\`slug\` 不是合法字段**,传了会被静默丢弃(服务端自动生成)。
# 六、五个必记的坑
1. **推荐用 \`X-Ingest-Key\` 请求头**(等价于 \`Authorization: Bearer <key>\`)。不要用 \`X-API-Key\`、\`?key=\` 这类自创形式,会 403。
2. **MCP 免密钥 ≠ 密钥有效**。\`/mcp\` 根本不查 key,别拿它验证密钥。
3. **提交后默认 \`pending\`**。前台搜不到是正常的,要等管理员审核变 \`active\`。**不要因为搜不到就重复提交**。
4. **同 URL 重复提交返回 200 \`created:false\`**,这不是报错,说明已存在。
5. \`/api/entries\` 也能写(未登录时回退 AI key 校验),但推荐用 \`/api/ingest/entry\`。
# 七、典型工作流
\`\`\`
1. search_entries 查重 → 已有就 get_entry 看详情,别重复提交
2. WebSearch / WebFetch 收集项目信息
3. 组装 JSON(title + url + type 三个必填)
4. POST /api/ingest/entry(带 X-Ingest-Key)
5. 记下返回的 id,状态是 pending,等管理员审核
\`\`\`
# 八、排错速查
- \`406 Not Acceptable\`:curl 直连 \`/mcp\` 时缺 \`Accept: application/json, text/event-stream\` 头。正常 MCP 客户端会自动带上,可忽略。
- \`403 invalid_key\`:密钥不对或未启用,去管理台「内容源 → AI 接口」重新设置。
- \`400\` 且提示类型不对:检查 \`type\` 是不是上面七个枚举之一。
- 中文内容建议存成 \`.json\` 文件再 \`curl -d @file.json\`,避免 shell 编码踩坑。`;
}
mcpRouter.post("/mcp", async (req, res) => {
try {
const server = new McpServer({
name: "awesome-index",
version: "0.1.0",
});
registerTools(server);
const server = new McpServer(
{
name: "awesome-index",
version: "0.1.0",
},
{ instructions: buildInstructions(requestBaseUrl(req)) }
);
registerTools(server, requestBaseUrl(req));
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
+118 -10
View File
@@ -6,15 +6,26 @@ function text(data) {
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
export function registerTools(server) {
const NOTE =
"【重要】本端点只读。要把新内容写进本库请用 HTTP:POST {BASE}/api/ingest/entry," +
"用 header X-Ingest-Key: <key>(等价 Authorization: Bearer <key>;不要用 X-API-Key 或 ?key=,会 403)。" +
"必填字段 title / url / type,type 只能是 微服务·SaaS·网站·工具·应用·脚本·插件。" +
"提交后状态为 pending,需管理员审核;同 URL 重复提交返回 created:false。";
export function registerTools(server, baseUrl) {
const base = baseUrl || process.env.BASE_URL || "http://localhost:3000";
server.registerTool(
"search_entries",
{
description:
"按关键字、类型、标签搜索本库收录的开源软件、服务与网站(Awesome Index)",
"按关键字搜索 Awesome Index 收录的开源软件 / 服务 / 网站。" +
"搜的是 title / slug / description / url 的模糊匹配,所以「自托管相册」「终端文件管理器」这类需求描述也能命中。" +
"【典型用途】写方案前先查重、找同类替代品。只返回 active(已上架)条目。" +
"【返回】{ total, items:[{slug,title,type,url,stars,license,tags,summary,description_md}] }。" +
"content[0].text 是 JSON 字符串,需再解析一次。" + NOTE,
inputSchema: {
q: z.string().describe("需求关键字,如:自托管相册 / 终端文件管理器"),
limit: z.number().int().min(1).max(50).optional().describe("返回条数,默认 20"),
q: z.string().describe("需求关键字或项目名,如:自托管相册 / ripgrep / 终端文件管理器"),
limit: z.number().int().min(1).max(50).optional().describe("返回条数,1–50,默认 20"),
},
},
async ({ q, limit }) => {
@@ -32,6 +43,7 @@ export function registerTools(server) {
stars: Number(e.stars),
license: e.license,
tags: e.tags,
summary: e.summary,
description: e.description_md,
})),
});
@@ -41,35 +53,69 @@ export function registerTools(server) {
server.registerTool(
"get_entry",
{
description: "获取单条收录内容的完整详情与元数据",
description:
"按 slug 取单条收录内容的完整详情与元数据。" +
"slug 就是详情页 URL /entries/<slug> 的最后一段(多半是项目名小写加连字符)。" +
"不确定 slug 时可以直接传标题关键字,服务端会做模糊匹配并取第一条。" +
"【典型用途】用户给了具体项目名,要看它的介绍、链接、标签、star。" +
"【返回】条目对象;找不到时返回 { error: 'not_found' }。" + NOTE,
inputSchema: {
slug: z.string().describe("条目 slug,也可传标题关键字自动匹配"),
slug: z.string().describe("条目 slug(如 ripgrep);也可传标题关键字自动匹配"),
},
},
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]);
const entry = direct.items[0];
return text({
slug: entry.slug,
title: entry.title,
type: entry.type,
url: entry.url,
stars: Number(entry.stars),
license: entry.license,
tags: entry.tags,
summary: entry.summary,
description: entry.description_md,
status: entry.status,
updated_at: entry.updated_at,
});
}
);
server.registerTool(
"random_entry",
{
description: "随机返回一条收录内容,用于探索发现",
description:
"随机返回一条已上架(active)的收录内容,用于探索发现、给用户「随便看看」的推荐。" +
"【典型用途】用户没有明确需求,想逛一逛这个库。无参数。" +
"【返回】条目对象;库为空时返回 { error: 'empty' }。" + NOTE,
inputSchema: {},
},
async () => {
const entry = await searchService.randomActive();
if (!entry) return text({ error: "empty" });
return text(entry);
return text({
slug: entry.slug,
title: entry.title,
type: entry.type,
url: entry.url,
stars: Number(entry.stars),
license: entry.license,
summary: entry.summary,
description: entry.description_md,
});
}
);
server.registerTool(
"list_tags",
{
description: "列出全部标签组及其嵌套结构与收录数量",
description:
"列出全部标签组及其嵌套结构与收录数量(count 为 active 条目数)。" +
"【典型用途】先摸清这个库有哪些分类维度(编程语言 / 形态 / 用途),再用 search_entries 深挖。" +
"标签可无限嵌套,父标签过滤时会自动包含后代标签的条目。" +
"【返回】标签树数组,节点形如 { id, name, count, children:[...] }。" + NOTE,
inputSchema: {},
},
async () => {
@@ -77,4 +123,66 @@ export function registerTools(server) {
return text(tree);
}
);
server.registerTool(
"usage",
{
description:
"返回本服务的完整接入说明:MCP 端点性质、四个只读工具的用法、写入用的 REST 接口、字段规则、" +
"常见坑(X-Ingest-Key 认证头 / pending 状态 / URL 去重)与 curl 示例。" +
"【何时调用】第一次接入、不知道怎么写入、或者被 403 卡住时,先调这个工具。" +
"它返回的是结构化 JSON,和 initialize 时的 instructions 内容一致,但更适合程序读取。",
inputSchema: {},
},
async () => {
return text({
mcp: {
endpoint: `${base}/mcp`,
transport: "Streamable HTTP (JSON-RPC 2.0)",
auth: "none —— 只读,不校验密钥,连接时不要配 token",
acceptHeader: "application/json, text/event-stream",
note: "结果在 result.content[0].text 里,是 JSON 字符串,需再解析一次",
},
oneLiner: "读用 MCP(免密钥),写用 REST(要 X-Ingest-Key)",
clientConfig: { mcpServers: { "awesome-index": { url: `${base}/mcp` } } },
readOnlyTools: [
{ name: "search_entries", params: { q: "必填 关键字", limit: "1–50,默认 20" }, use: "按关键字/类型/标签搜索,查重先走它" },
{ name: "get_entry", params: { slug: "必填,也可传标题关键字" }, use: "取单条详情" },
{ name: "random_entry", params: {}, use: "随机抽一条,用于发现" },
{ name: "list_tags", params: {}, use: "列全部标签组与数量" },
],
writeApi: {
endpoint: `POST ${base}/api/ingest/entry`,
headers: { "X-Ingest-Key": "<key>", "Content-Type": "application/json" },
fields: {
title: "必填 标题",
url: "必填 合法 URL,用于去重",
type: "必填 枚举:微服务 / SaaS / 网站 / 工具 / 应用 / 脚本 / 插件",
summary: "可选 ≤200 字符",
description_md: "可选 ≤5000 字符,支持 Markdown",
license: "可选 如 MIT",
stars: "可选 整数",
tags: "可选 字符串数组,新标签自动创建",
status: "可选 pending / active",
},
slugIsNotAField: true,
example: `curl -X POST "${base}/api/ingest/entry" -H "X-Ingest-Key: <key>" -H "Content-Type: application/json" -d '{"title":"MinerU","url":"https://github.com/opendatalab/MinerU","type":"工具"}'`,
},
pitfalls: [
"推荐用 X-Ingest-Key 请求头(等价 Authorization: Bearer <key>);X-API-Key / ?key= 这类自创形式一律 403",
"MCP 免密钥 ≠ 密钥有效:/mcp 根本不查 key,别拿它验证密钥",
"提交后默认 pending,前台搜不到是正常的,等管理员审核变 active —— 别重复提交",
"同 URL 重复提交返回 200 created:false,这不是报错,说明已存在",
"curl 直连 /mcp 不带 Accept: application/json, text/event-stream 会 406;正常客户端不受影响",
],
workflow: [
"search_entries 查重 → 已有就 get_entry 看详情",
"WebSearch / WebFetch 收集项目信息",
"组装 JSON(title + url + type 三个必填)",
`POST ${base}/api/ingest/entry(带 X-Ingest-Key)`,
"记下返回 id,状态 pending",
],
});
}
);
}
+17 -2
View File
@@ -25,11 +25,26 @@ export function loadUser(req, res, next) {
next();
}
export function ingestKeyFromRequest(req) {
const direct = (req.get("x-ingest-key") || "").trim();
if (direct) return direct;
const auth = (req.get("authorization") || "").trim();
if (!auth) return "";
const bearer = /^Bearer\s+(.+)$/i.exec(auth);
return bearer ? bearer[1].trim() : auth;
}
export async function aiSourceFromRequest(req) {
const key = req.get("x-ingest-key") || "";
const key = ingestKeyFromRequest(req);
const source = await sourceService.findByApiKey(key);
if (!source) {
throw new HttpError(403, "invalid_key", "AI 录入密钥无效或未启用");
throw new HttpError(
403,
"invalid_key",
"AI 录入密钥无效或未启用(请使用 X-Ingest-Key 或 Authorization: Bearer <key> 请求头)"
);
}
return source;
}
+76
View File
@@ -2,6 +2,9 @@ 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 contactService from "../services/contact.service.js";
import * as relationService from "../services/relation.service.js";
import * as entryService from "../services/entry.service.js";
import * as searchService from "../services/search.service.js";
import * as audit from "../services/audit.service.js";
import { requireAdmin } from "../middleware/auth.js";
@@ -23,6 +26,11 @@ adminRouter.post("/api/admin/login", async (req, res) => {
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
if (req.body.remember === true || req.body.remember === "true") {
req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 14;
} else {
req.session.cookie.maxAge = null;
}
res.json({ ok: true, data: { username: user.username, role: user.role } });
});
@@ -116,6 +124,34 @@ 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/contacts", requireAdmin, async (req, res) => {
res.json({ ok: true, data: await contactService.listContacts({ q: req.query.q ?? "" }) });
});
adminRouter.patch("/api/admin/contacts/:id/status", requireAdmin, async (req, res) => {
const row = await contactService.setContactStatus(
Number(req.params.id),
req.body.enabled !== false
);
await audit.log({
actor: actor(req),
action: "contact.status",
target: row.contact,
detail: row.enabled ? "启用" : "禁用",
});
res.json({ ok: true, data: row });
});
adminRouter.patch("/api/admin/contacts/:id", requireAdmin, async (req, res) => {
const row = await contactService.renameContact(Number(req.params.id), req.body.nickname);
res.json({ ok: true, data: row });
});
adminRouter.delete("/api/admin/contacts/:id", requireAdmin, async (req, res) => {
await contactService.removeContact(Number(req.params.id));
res.json({ ok: true });
});
adminRouter.get("/api/admin/entries", requireAdmin, async (req, res) => {
const result = await searchService.searchEntries({
q: req.query.q ?? "",
@@ -129,3 +165,43 @@ adminRouter.get("/api/admin/entries", requireAdmin, async (req, res) => {
});
res.json({ ok: true, data: result });
});
adminRouter.get("/api/admin/relations", requireAdmin, async (req, res) => {
res.json({
ok: true,
data: await relationService.listCandidates({
q: req.query.q ?? "",
excludeId: Number(req.query.excludeId) || null,
}),
});
});
adminRouter.post("/api/admin/entries/:id/relations", requireAdmin, async (req, res) => {
const id = Number(req.params.id);
const result = await relationService.addRelation(id, req.body.relatedId);
await audit.log({
actor: actor(req),
action: "entry.relation.add",
target: `#${id}`,
detail: `←→ #${req.body.relatedId}`,
});
res.status(result.created ? 201 : 200).json({ ok: true, data: result });
});
adminRouter.delete("/api/admin/entries/:id/relations/:relatedId", requireAdmin, async (req, res) => {
await relationService.removeRelation(Number(req.params.id), Number(req.params.relatedId));
res.json({ ok: true });
});
adminRouter.post("/api/admin/entries/status", requireAdmin, async (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
const status = String(req.body.status || "");
const updated = await entryService.setStatusBulk(ids, status);
await audit.log({
actor: actor(req),
action: "entries.status",
target: "",
detail: `${status} × ${updated}`,
});
res.json({ ok: true, data: { updated } });
});
+3 -1
View File
@@ -1,5 +1,6 @@
import { Router } from "express";
import * as commentService from "../services/comment.service.js";
import * as contactService from "../services/contact.service.js";
import * as audit from "../services/audit.service.js";
import { requireAdmin } from "../middleware/auth.js";
import { HttpError } from "../middleware/errors.js";
@@ -21,7 +22,8 @@ commentsRouter.post("/api/comments", async (req, res) => {
await commentService.create({
entryId,
userId: req.session.userId ?? null,
authorName: req.session.username ?? req.body.authorName,
authorName: req.body.authorName,
contact: req.body.contact,
body: req.body.body,
});
res.status(201).json({ ok: true });
+2
View File
@@ -44,6 +44,7 @@ const entryInput = z.object({
title: z.string().min(1),
url: z.string().url(),
type: z.enum(entryService.ENTRY_TYPES),
summary: z.string().max(200).optional(),
description_md: z.string().max(5000).optional(),
license: z.string().optional(),
stars: z.number().nonnegative().optional(),
@@ -77,6 +78,7 @@ const patchInput = z.object({
title: z.string().min(1).optional(),
url: z.string().url().optional(),
type: z.enum(entryService.ENTRY_TYPES).optional(),
summary: z.string().max(200).optional(),
description_md: z.string().max(5000).optional(),
license: z.string().optional(),
stars: z.number().nonnegative().optional(),
+65 -7
View File
@@ -3,6 +3,8 @@ 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 contactService from "../services/contact.service.js";
import * as relationService from "../services/relation.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";
@@ -21,10 +23,19 @@ pagesRouter.get("/healthz", (_req, res) => {
pagesRouter.get("/", async (_req, res, next) => {
try {
const [stats, recent, hero] = await Promise.all([
searchService.stats(),
entryService.listRecent(4),
(async () => {
const rand = await searchService.randomActive();
return rand ? entryService.getById(rand.id) : null;
})(),
]);
res.render("index", {
title: "Awesome Index · 把网上牛逼的东西收进一个库",
stats: await searchService.stats(),
recent: await entryService.listRecent(4),
stats,
recent,
hero,
});
} catch (e) {
next(e);
@@ -64,14 +75,19 @@ pagesRouter.get("/entries", async (req, res, next) => {
pagesRouter.get("/entries/:slug", async (req, res, next) => {
try {
const entry = await entryService.getBySlug(req.params.slug);
if (!entry || entry.status === "greyed") {
const preview = req.query.preview === "1" && Boolean(req.session.userId);
if (!entry || (entry.status === "greyed" && !preview)) {
return res.status(404).send("条目不存在或已下架");
}
const comments = await commentService.listForEntry(entry.id);
const [comments, related] = await Promise.all([
commentService.listForEntry(entry.id),
relationService.listRelatedPublic(entry.id),
]);
const machine = {
id: entry.slug,
type: entry.type,
name: entry.title,
summary: entry.summary,
description: entry.description_md,
url: entry.url,
tags: entry.tags,
@@ -79,11 +95,13 @@ pagesRouter.get("/entries/:slug", async (req, res, next) => {
stars: Number(entry.stars),
updated_at: entry.updated_at,
status: entry.status,
related: related.map((r) => ({ id: r.slug, title: r.title, type: r.type })),
};
res.render("detail", {
title: `${entry.title} · Awesome Index`,
entry,
comments,
related,
machineJson: JSON.stringify(machine, null, 2),
});
} catch (e) {
@@ -109,12 +127,38 @@ 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([
const status = ["active", "greyed", "pending", "all"].includes(req.query.status)
? req.query.status
: "all";
const page = Math.max(1, Number(req.query.page) || 1);
const perPage = 20;
const q = String(req.query.q ?? "").trim();
const activeSection = [
"sec-content",
"sec-tags",
"sec-comments",
"sec-contacts",
"sec-sources",
"sec-accounts",
].includes(req.query.section)
? req.query.section
: "sec-content";
const [
sources,
users,
comments,
contacts,
entries,
tagTreeAdmin,
statusCounts,
] = await Promise.all([
sourceService.listSources(),
isAdmin ? userService.listUsers() : Promise.resolve([]),
commentService.listAll({}),
searchService.searchEntries({ status: "all", perPage: 50 }),
contactService.listContacts({}),
searchService.searchEntries({ status, perPage, page, q }),
tagService.attachCounts(await tagService.buildTree()),
searchService.statusCounts(),
]);
res.render("admin", {
title: "管理台 · Awesome Index",
@@ -122,7 +166,14 @@ pagesRouter.get("/admin", async (req, res, next) => {
sources,
users,
comments,
contacts,
entries: entries.items,
status,
q,
activeSection,
statusCounts,
page,
totalPages: Math.max(1, Math.ceil(entries.total / entries.perPage)),
isAdmin,
tagTreeAdmin,
});
@@ -136,12 +187,18 @@ pagesRouter.get("/admin/entries/:id/edit", async (req, res, next) => {
if (!req.session.userId) return res.redirect("/login");
const entry = await entryService.getById(Number(req.params.id));
if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 });
const tagTreeAdmin = await tagService.buildTree();
const [tagTreeAdmin, related, candidates] = await Promise.all([
tagService.buildTree(),
relationService.listRelated(entry.id),
relationService.listCandidates({ excludeId: entry.id, limit: 200 }),
]);
res.render("admin-entry-edit", {
title: `编辑 · ${entry.title} · Awesome Index`,
user: { username: req.session.username, role: req.session.role },
isAdmin: req.session.role === "admin",
entry,
related,
candidates,
tagTreeAdmin,
types: ENTRY_TYPES,
});
@@ -162,6 +219,7 @@ pagesRouter.post("/admin/entries/:id/edit", async (req, res, next) => {
const patch = {
title: String(req.body.title || "").trim(),
type: req.body.type,
summary: String(req.body.summary || "").trim(),
description_md: String(req.body.description_md || ""),
url: String(req.body.url || "").trim(),
license: String(req.body.license || "").trim(),
+17 -8
View File
@@ -1,4 +1,5 @@
import { all, get, run } from "../db/connection.js";
import * as contacts from "./contact.service.js";
export async function listForEntry(entryId) {
return all(
@@ -10,32 +11,40 @@ export async function listForEntry(entryId) {
);
}
export async function create({ entryId, userId = null, authorName, body }) {
export async function create({ entryId, userId = null, authorName, contact, 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() || "匿名";
// 昵称 + 联系方式复合校验:首次评论建档,之后必须匹配且未被禁用
const person = await contacts.resolveForComment({ nickname: authorName, contact });
await run(
`INSERT INTO comments (entry_id, user_id, author_name, body) VALUES (?, ?, ?, ?)`,
[entryId, userId, name.slice(0, 40), text]
`INSERT INTO comments (entry_id, user_id, contact_id, author_name, contact, body)
VALUES (?, ?, ?, ?, ?, ?)`,
[entryId, userId, person.id, person.nickname, person.contact, text]
);
await contacts.bumpCommentCount(person.id);
return person;
}
export async function remove(id) {
const row = await get(`SELECT contact_id FROM comments WHERE id = ?`, [id]);
await run(`DELETE FROM comments WHERE id = ?`, [id]);
if (row?.contact_id) await contacts.bumpCommentCount(row.contact_id);
}
export async function listAll({ q = "" } = {}) {
const like = `%${q}%`;
const like = `%${String(q ?? "").trim()}%`;
const kw = String(q ?? "").trim();
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 ?)
WHERE (? = '' OR c.body LIKE ? OR c.author_name LIKE ? OR c.contact LIKE ?)
ORDER BY c.created_at DESC LIMIT 200`,
[q, like, like]
[kw, like, like, like]
);
}
+106
View File
@@ -0,0 +1,106 @@
import { all, get, run } from "../db/connection.js";
/** 邮箱 / 手机号(中国大陆)格式校验 */
export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/;
export const PHONE_RE = /^1[3-9]\d{9}$/;
export function normalizeContact(raw) {
return String(raw ?? "").trim();
}
export function isValidContact(raw) {
const v = normalizeContact(raw);
return EMAIL_RE.test(v) || PHONE_RE.test(v);
}
export function contactKind(raw) {
const v = normalizeContact(raw);
if (EMAIL_RE.test(v)) return "email";
if (PHONE_RE.test(v)) return "phone";
return "invalid";
}
/** 昵称是否属于该联系方式的「复合格式」:联系方式 + 任意昵称(首次评论) */
export function isValidNickname(nickname) {
return String(nickname ?? "").trim().length > 0;
}
export async function getByContact(contact) {
return get(`SELECT * FROM contacts WHERE contact = ?`, [normalizeContact(contact)]);
}
export async function listContacts({ q = "" } = {}) {
const like = `%${String(q).trim()}%`;
return all(
`SELECT ct.*,
(SELECT COUNT(*) FROM comments c WHERE c.contact_id = ct.id) AS comments
FROM contacts ct
WHERE (? = '' OR ct.contact LIKE ? OR ct.nickname LIKE ?)
ORDER BY ct.updated_at DESC, ct.id DESC
LIMIT 500`,
[String(q).trim(), like, like]
);
}
/**
* 首次评论:以「昵称 + 联系方式」复合身份建档。
* 之后评论:昵称必须与该联系方式登记的一致,且未被管理员禁用。
*/
export async function resolveForComment({ nickname, contact }) {
const nick = String(nickname ?? "").trim().slice(0, 40);
const key = normalizeContact(contact);
if (!nick) throw Object.assign(new Error("请填写昵称"), { status: 400 });
if (!isValidContact(key)) {
throw Object.assign(new Error("联系方式格式不正确(需要手机号或邮箱)"), { status: 400 });
}
const existing = await getByContact(key);
if (existing) {
if (!existing.enabled) {
throw Object.assign(new Error("该联系方式已被管理员禁用,无法评论"), { status: 403 });
}
if (String(existing.nickname) !== nick) {
throw Object.assign(
new Error("昵称与该联系方式登记的不一致,请使用该联系方式首次评论时的昵称"),
{ status: 400 }
);
}
await run(`UPDATE contacts SET updated_at = now() WHERE id = ?`, [existing.id]);
return existing;
}
await run(`INSERT INTO contacts (contact, nickname) VALUES (?, ?)`, [key, nick]);
return getByContact(key);
}
export async function bumpCommentCount(contactId) {
await run(
`UPDATE contacts
SET comment_count = (SELECT COUNT(*) FROM comments c WHERE c.contact_id = contacts.id),
updated_at = now()
WHERE id = ?`,
[contactId]
);
}
export async function setContactStatus(id, enabled) {
const row = await get(`SELECT * FROM contacts WHERE id = ?`, [id]);
if (!row) throw Object.assign(new Error("评论人员不存在"), { status: 404 });
await run(`UPDATE contacts SET enabled = ?, updated_at = now() WHERE id = ?`, [
Boolean(enabled),
id,
]);
return get(`SELECT * FROM contacts WHERE id = ?`, [id]);
}
export async function renameContact(id, nickname) {
const nick = String(nickname ?? "").trim().slice(0, 40);
if (!nick) throw Object.assign(new Error("昵称不能为空"), { status: 400 });
const row = await get(`SELECT * FROM contacts WHERE id = ?`, [id]);
if (!row) throw Object.assign(new Error("评论人员不存在"), { status: 404 });
await run(`UPDATE contacts SET nickname = ?, updated_at = now() WHERE id = ?`, [nick, id]);
return get(`SELECT * FROM contacts WHERE id = ?`, [id]);
}
export async function removeContact(id) {
await run(`UPDATE comments SET contact_id = NULL WHERE contact_id = ?`, [id]);
await run(`DELETE FROM contacts WHERE id = ?`, [id]);
}
+26 -6
View File
@@ -83,12 +83,13 @@ export async function createEntry(data, { source } = {}) {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO entries (title, slug, type, summary, description_md, url, license, stars, status, source_id, verified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.title.trim(),
slug,
data.type,
data.summary || "",
data.description_md || "",
data.url.trim(),
data.license || "",
@@ -103,7 +104,7 @@ export async function createEntry(data, { source } = {}) {
return { created: true, id: row.id, status };
}
const EDITABLE = ["title", "type", "description_md", "url", "license", "stars"];
const EDITABLE = ["title", "type", "summary", "description_md", "url", "license", "stars"];
export async function updateEntry(id, patch, { isHumanEditor = false } = {}) {
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
@@ -116,9 +117,9 @@ export async function updateEntry(id, patch, { isHumanEditor = false } = {}) {
if (patch[key] !== undefined) next[key] = patch[key];
}
await run(
`UPDATE entries SET title = ?, type = ?, description_md = ?, url = ?, license = ?, stars = ?, updated_at = now()
`UPDATE entries SET title = ?, type = ?, summary = ?, 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]
[next.title, next.type, next.summary || "", next.description_md, next.url, next.license, Number(next.stars) || 0, id]
);
if (patch.tags !== undefined) await linkTags(id, patch.tags);
if (isHumanEditor) {
@@ -157,6 +158,25 @@ export async function setStatusDirect(id, status) {
return getById(id);
}
export async function setStatusBulk(ids, status) {
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
const clean = [...new Set(ids.map(Number).filter(Number.isFinite))];
if (!clean.length) throw httpError(400, "没有选中条目");
const existing = await all(
`SELECT id FROM entries WHERE id IN (${clean.map(() => "?").join(",")})`,
clean
);
const realIds = existing.map((r) => Number(r.id));
if (!realIds.length) throw httpError(400, "没有选中条目");
await run(
`UPDATE entries SET status = ?, updated_at = now(),
verified_at = CASE WHEN ? = 'active' THEN now() ELSE verified_at END
WHERE id IN (${realIds.map(() => "?").join(",")})`,
[status, status, ...realIds]
);
return realIds.length;
}
export async function deleteEntry(id) {
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]);
await run(`DELETE FROM comments WHERE entry_id = ?`, [id]);
@@ -185,7 +205,7 @@ export async function getBySlug(slug) {
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
`SELECT e.id, e.title, e.slug, e.type, e.summary, 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]
+79
View File
@@ -0,0 +1,79 @@
import { all, run } from "../db/connection.js";
import * as entries from "./entry.service.js";
/**
* 相关项目是双向关系:A 关联 B 后,B 的详情页也能看到 A。
* 存储时只保留一条无序对(min,max),读取时展开成双向。
*/
function pair(a, b) {
const x = Number(a);
const y = Number(b);
return x < y ? [x, y] : [y, x];
}
export async function addRelation(entryId, relatedId) {
const a = Number(entryId);
const b = Number(relatedId);
if (!Number.isFinite(a) || !Number.isFinite(b)) {
throw Object.assign(new Error("条目 ID 无效"), { status: 400 });
}
if (a === b) throw Object.assign(new Error("不能把条目关联到自己"), { status: 400 });
const [one, two] = pair(a, b);
for (const id of [one, two]) {
if (!(await entries.getById(id))) {
throw Object.assign(new Error("条目不存在"), { status: 404 });
}
}
const exists = await all(
`SELECT id FROM entry_relations WHERE entry_id = ? AND related_id = ?`,
[one, two]
);
if (exists.length) return { created: false };
await run(`INSERT INTO entry_relations (entry_id, related_id) VALUES (?, ?)`, [one, two]);
return { created: true };
}
export async function removeRelation(entryId, relatedId) {
const [one, two] = pair(entryId, relatedId);
await run(`DELETE FROM entry_relations WHERE entry_id = ? AND related_id = ?`, [one, two]);
}
export async function listRelated(entryId) {
const id = Number(entryId);
const rows = await all(
`SELECT e.id, e.title, e.slug, e.type, e.summary, e.description_md, e.url, e.stars, e.status
FROM entry_relations r
JOIN entries e ON e.id = CASE WHEN r.entry_id = ? THEN r.related_id ELSE r.entry_id END
WHERE r.entry_id = ? OR r.related_id = ?
ORDER BY r.id DESC`,
[id, id, id]
);
return rows;
}
/** 前台只展示在架的相关条目 */
export async function listRelatedPublic(entryId) {
return (await listRelated(entryId)).filter((e) => e.status === "active");
}
/** 管理台编辑页用:候选条目列表(排除自身) */
export async function listCandidates({ q = "", limit = 50, excludeId = null } = {}) {
const kw = String(q ?? "").trim();
const like = `%${kw}%`;
const params = [];
let where = "WHERE e.status <> 'greyed'";
if (kw) {
where += ` AND (e.title LIKE ? OR e.url LIKE ? OR e.summary LIKE ?)`;
params.push(like, like, like);
}
if (Number.isFinite(Number(excludeId))) {
where += ` AND e.id <> ?`;
params.push(Number(excludeId));
}
return all(
`SELECT e.id, e.title, e.slug, e.type, e.status
FROM entries e ${where}
ORDER BY e.updated_at DESC LIMIT ?`,
[...params, Math.min(200, Number(limit) || 50)]
);
}
+13 -3
View File
@@ -26,8 +26,8 @@ export async function searchEntries({
}
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);
where.push(`(e.title LIKE ? OR e.slug LIKE ? OR e.description_md LIKE ? OR e.url LIKE ?)`);
params.push(like, like, like, like);
}
if (types.length) {
where.push(`e.type IN (${types.map(() => "?").join(",")})`);
@@ -56,7 +56,7 @@ export async function searchEntries({
[...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
`${cte} SELECT e.id, e.title, e.slug, e.type, e.summary, 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]
);
@@ -90,6 +90,16 @@ export async function typeCounts() {
return map;
}
export async function statusCounts() {
const rows = await all(`SELECT status, COUNT(*) AS c FROM entries GROUP BY status`);
const map = { all: 0, active: 0, pending: 0, greyed: 0 };
for (const r of rows) {
map[r.status] = Number(r.c);
map.all += Number(r.c);
}
return map;
}
export async function randomActive() {
return get(`SELECT * FROM entries WHERE status = 'active' ORDER BY random() LIMIT 1`);
}
+46 -5
View File
@@ -11,14 +11,13 @@
<p class="anno">// edit · entry #<%= entry.id %> · 保存后即时生效</p>
<h1>编辑条目</h1>
<p class="admin-desc"><a href="/entries/<%= entry.slug %>" target="_blank">预览:<%= entry.title %></a></p>
<form id="entry-edit-form" method="post" action="/admin/entries/<%= entry.id %>/edit" style="margin-top:22px;display:grid;gap:18px;max-width:820px">
<div class="panel-box">
<h3>基础信息</h3>
<div class="form-row">
<div class="field" style="flex:2"><label for="f-title">标题</label>
<div class="field" style="flex:2;margin-top:0"><label for="f-title">标题</label>
<input class="input" id="f-title" name="title" value="<%= entry.title %>" required /></div>
<div class="field" style="flex:1"><label for="f-type">类型</label>
<div class="field" style="flex:1;margin-top:0"><label for="f-type">类型</label>
<select class="select" id="f-type" name="type" style="width:100%">
<% for (const t of types) { %>
<option value="<%= t %>" <%= entry.type === t ? 'selected' : '' %>><%= t %></option>
@@ -43,9 +42,14 @@
</div>
</div>
<div class="panel-box">
<h3>简介(≤200 字,列表与详情页头部展示)</h3>
<textarea class="textarea" id="f-summary" name="summary" maxlength="200" rows="3" style="line-height:1.7;resize:vertical"><%= entry.summary || '' %></textarea>
</div>
<div class="panel-box">
<h3>内容描述(Markdown)</h3>
<textarea class="textarea" id="f-desc" name="description_md" rows="16"><%= entry.description_md || '' %></textarea>
<textarea class="textarea" id="f-desc" name="description_md" rows="16" style="line-height:1.7"><%= entry.description_md || '' %></textarea>
<p style="font-size:12px;color:var(--ink-dim);margin-top:6px;font-family:var(--font-mono)">// 前台展示原文;保存后详情页与搜索都会生效</p>
</div>
@@ -62,15 +66,52 @@
</div>
</div>
<div class="panel-box">
<h3>相关项目</h3>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:10px">
关联是双向的:A 关联 B 后,B 的详情页也会出现 A。前台只展示已上架(active)的相关条目。
</p>
<ul class="related-list" id="related-list" style="margin-bottom:12px">
<% for (const r of related) { %>
<li data-id="<%= r.id %>">
<a href="/entries/<%= r.slug %>" target="_blank">
<span class="chip chip-type"><%= r.type %></span>
<b><%= r.title %></b>
</a>
<button class="btn btn-sm btn-danger" type="button"
data-action="relation-remove" data-related-id="<%= r.id %>">移除</button>
</li>
<% } %>
<% if (!related.length) { %>
<li class="mono" style="color:var(--ink-dim);border:0;padding:0">还没有相关项目</li>
<% } %>
</ul>
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
<div class="field" style="margin-top:0;min-width:280px;flex:2">
<label for="f-related">添加相关项目</label>
<select class="select" id="f-related" style="width:100%">
<% for (const c of candidates) { %>
<option value="<%= c.id %>"><%= c.title %> · <%= c.type %><% if (c.status !== 'active') { %>(<%= c.status %>)<% } %></option>
<% } %>
</select>
</div>
<button class="btn btn-primary btn-sm" type="button" data-action="relation-add">添加关联</button>
</div>
<p class="mono" style="font-size:12px;color:var(--ink-dim);margin-top:8px">// 列表里没有?先在内容管理里创建条目,再回到这里关联。</p>
</div>
<div style="display:flex;gap:10px;align-items:center">
<button class="btn btn-primary" type="submit">保存修改</button>
<a class="btn" href="/admin#sec-content">取消</a>
<a class="btn" href="/admin?section=sec-content">取消</a>
</div>
</form>
</main>
</div>
<script>
window.__ADMIN_ENTRY_ID__ = <%= entry.id %>;
(function () {
var form = document.getElementById("entry-edit-form");
if (!form) return;
+121 -26
View File
@@ -4,26 +4,68 @@
<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>
<% const navHref = (s) => '/admin?' + qs({ section: s, status: s === 'sec-content' ? status : '', q: s === 'sec-content' ? q : '' }); %>
<a href="<%= navHref('sec-content') %>" data-section="sec-content" aria-current="<%= activeSection === 'sec-content' ? 'true' : 'false' %>">内容管理</a>
<a href="<%= navHref('sec-tags') %>" data-section="sec-tags" aria-current="<%= activeSection === 'sec-tags' ? 'true' : 'false' %>">标签管理</a>
<a href="<%= navHref('sec-comments') %>" data-section="sec-comments" aria-current="<%= activeSection === 'sec-comments' ? 'true' : 'false' %>">评论管理</a>
<a href="<%= navHref('sec-contacts') %>" data-section="sec-contacts" aria-current="<%= activeSection === 'sec-contacts' ? 'true' : 'false' %>">评论人员</a>
<a href="<%= navHref('sec-sources') %>" data-section="sec-sources" aria-current="<%= activeSection === 'sec-sources' ? 'true' : 'false' %>">内容源</a>
<% if (isAdmin) { %>
<button type="button" data-section="sec-accounts" aria-current="false">账号管理</button>
<a href="<%= navHref('sec-accounts') %>" data-section="sec-accounts" aria-current="<%= activeSection === 'sec-accounts' ? 'true' : 'false' %>">账号管理</a>
<% } %>
<a class="admin-nav-refresh" href="/admin?<%= qs({ section: activeSection, status, q }) %>" title="重新从后台加载当前分区数据">↻ 刷新当前分区</a>
</nav>
<main class="admin-main">
<!-- 内容管理 -->
<section id="sec-content" class="active">
<p class="anno">// table: entries · 状态 active | greyed | pending</p>
<section id="sec-content" class="<%= activeSection === 'sec-content' ? '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) { %>
<div class="status-chips">
<% const chipHref = (s) => '/admin?' + qs({ status: s, q }); %>
<a class="chip <%= status === 'all' ? 'is-active' : '' %>" href="<%= chipHref('all') %>">全部 <span class="mono"><%= statusCounts.all %></span></a>
<a class="chip <%= status === 'active' ? 'is-active' : '' %>" href="<%= chipHref('active') %>">上线 <span class="mono"><%= statusCounts.active %></span></a>
<a class="chip <%= status === 'pending' ? 'is-active' : '' %>" href="<%= chipHref('pending') %>">待核验 <span class="mono"><%= statusCounts.pending %></span></a>
<a class="chip <%= status === 'greyed' ? 'is-active' : '' %>" href="<%= chipHref('greyed') %>">灰掉 <span class="mono"><%= statusCounts.greyed %></span></a>
<span class="spacer"></span>
<span class="mono page-hint">第 <%= page %> / <%= totalPages %> 页 · 每页 20 条</span>
</div>
<form class="admin-search" method="get" action="/admin">
<input type="hidden" name="status" value="<%= status %>" />
<input class="input" id="admin-q" name="q" type="search" value="<%= q %>"
placeholder="按名称 / 标题 / 链接搜索条目…" aria-label="按名称搜索条目" />
<button class="btn btn-primary btn-sm" type="submit">搜索</button>
<% if (q) { %><a class="btn btn-sm" href="/admin?status=<%= status %>">清空</a><% } %>
</form>
<div class="batch-bar">
<button class="btn btn-sm" type="button" data-action="entry-select-all">全选本页</button>
<button class="btn btn-sm" type="button" data-action="entry-batch" data-status="active">批量上架</button>
<button class="btn btn-sm" type="button" data-action="entry-batch" data-status="greyed">批量灰掉</button>
<span class="mono batch-count" id="batch-count">已选 0 项</span>
</div>
<table class="data-table" style="margin-top:14px">
<thead>
<tr>
<th style="width:36px"><input type="checkbox" class="entry-check-all" title="全选本页" /></th>
<th>标题</th>
<th>类型</th>
<th>状态</th>
<th>更新时间</th>
<th style="text-align:right">操作</th>
</tr>
</thead>
<tbody>
<% if (entries.length === 0) { %>
<tr><td colspan="6" class="mono" style="text-align:center;color:var(--ink-dim);padding:28px">当前筛选下没有条目</td></tr>
<% } %>
<% for (const e of entries) { %>
<tr data-id="<%= e.id %>">
<td style="text-align:center"><input type="checkbox" class="entry-check" data-id="<%= e.id %>" /></td>
<td>
<b><%= e.title %></b><br />
<span class="mono" style="font-size:11px;color:var(--ink-dim)"><%= (e.tags || []).join(' · ') || '—' %></span>
@@ -38,6 +80,11 @@
<td>
<div class="row-actions">
<a class="btn btn-sm" href="/admin/entries/<%= e.id %>/edit">编辑</a>
<% if (e.status === 'active') { %>
<a class="btn btn-sm" href="/entries/<%= e.slug %>" target="_blank" rel="noopener">查看</a>
<% } else { %>
<a class="btn btn-sm" href="/entries/<%= e.slug %>?preview=1" target="_blank" rel="noopener">预览</a>
<% } %>
<% if (e.status !== 'active') { %>
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button>
<% } else { %>
@@ -52,11 +99,16 @@
<% } %>
</tbody>
</table>
<div class="pager">
<% const pageHref = (p) => '/admin?' + qs({ status, q, page: p }); %>
<a class="btn btn-sm <%= page <= 1 ? 'btn-disabled' : '' %>" href="<%= pageHref(Math.max(1, page - 1)) %>">上一页</a>
<a class="btn btn-sm <%= page >= totalPages ? 'btn-disabled' : '' %>" href="<%= pageHref(page + 1) %>">下一页</a>
</div>
</section>
<!-- 标签管理 -->
<section id="sec-tags">
<p class="anno">// tree: tags · 删除父级时子级自动上移;移动到自己的子级会被拒绝</p>
<section id="sec-tags" class="<%= activeSection === 'sec-tags' ? 'active' : '' %>">
<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>
@@ -64,32 +116,34 @@
</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>
<h3>标签树 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(可直接拖动节点调整层级,变更即时生效)</span></h3>
<div class="tagmgr-tree" id="admin-tag-tree" style="min-height:120px">
<p class="mono dim">加载中…</p>
</div>
<p class="dnd-hint mono dim">按住节点拖动:落到某个标签上 = 变为它的子级 · 落到下方空白处 = 移回顶层</p>
</div>
<div class="panel-box">
<h3>规则说明</h3>
<p style="font-size:13.5px;color:var(--ink-dim)">
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。
「移动」填写目标标签 ID;移动到自身子级会被拒绝以防止成环。
删除父标签时其子标签自动上移一级。
标签可无限嵌套,列表页按组过滤时会自动包含所有后代标签的条目。<br /><br />
拖动标签到另一标签上可快速调整层级;拖到空白处可移回顶层。<br />
删除父标签时其子标签自动上移一级,关联关系解除。
</p>
</div>
</div>
</section>
<!-- 评论管理 -->
<section id="sec-comments">
<section id="sec-comments" class="<%= activeSection === 'sec-comments' ? 'active' : '' %>">
<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>
<thead><tr><th>评论者</th><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 class="mono" style="font-size:12px"><%= c.contact || '—' %></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>
@@ -98,12 +152,48 @@
</div></td>
</tr>
<% } %>
<% if (!comments.length) { %>
<tr><td colspan="6" class="mono" style="text-align:center;color:var(--ink-dim);padding:28px">还没有评论</td></tr>
<% } %>
</tbody>
</table>
</section>
<!-- 评论人员 -->
<section id="sec-contacts" class="<%= activeSection === 'sec-contacts' ? 'active' : '' %>">
<p class="anno">// table: contacts · 以联系方式为身份依据 · 禁用后无法继续评论</p>
<h1>评论人员管理</h1>
<p class="admin-desc">访客首次评论时以「昵称 + 联系方式」建档;之后必须昵称与联系方式匹配且未被禁用才能评论。</p>
<table class="data-table" style="margin-top:22px">
<thead><tr><th>ID</th><th>联系方式</th><th>类型</th><th>昵称</th><th>评论数</th><th>状态</th><th>最近评论</th><th style="text-align:right">操作</th></tr></thead>
<tbody>
<% for (const p of contacts) { %>
<tr data-id="<%= p.id %>">
<td class="mono">#<%= p.id %></td>
<td class="mono"><%= p.contact %></td>
<td><%= p.contact.includes('@') ? '邮箱' : '手机' %></td>
<td><b><%= p.nickname %></b></td>
<td class="mono"><%= Number(p.comments || 0) %></td>
<td><span class="status-pill <%= p.enabled ? 'status-ok' : 'status-warn' %>"><%= p.enabled ? '正常' : '已禁用' %></span></td>
<td class="mono"><%= fmtDate(p.updated_at) %></td>
<td><div class="row-actions">
<button class="btn btn-sm" type="button" data-action="contact-rename" data-id="<%= p.id %>" data-name="<%= p.nickname %>">改名</button>
<button class="btn btn-sm" type="button" data-action="contact-status" data-id="<%= p.id %>" data-enabled="<%= p.enabled ? '1' : '0' %>">
<%= p.enabled ? '禁用' : '启用' %>
</button>
<button class="btn btn-sm btn-danger" type="button" data-action="contact-delete" data-id="<%= p.id %>">删除</button>
</div></td>
</tr>
<% } %>
<% if (!contacts.length) { %>
<tr><td colspan="8" class="mono" style="text-align:center;color:var(--ink-dim);padding:28px">还没有评论人员</td></tr>
<% } %>
</tbody>
</table>
</section>
<!-- 内容源 -->
<section id="sec-sources">
<section id="sec-sources" class="<%= activeSection === 'sec-sources' ? 'active' : '' %>">
<p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p>
<h1>内容源管理</h1>
@@ -126,7 +216,7 @@
<input class="input" name="key" type="password" value="<%= s.api_key || '' %>" placeholder="sk-…" autocomplete="off" />
<button type="button" class="eye-toggle" data-eye-toggle aria-label="显示密钥">
<svg class="eye-open" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" hidden><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
<button class="btn btn-sm" type="submit" style="flex:none">保存密钥</button>
@@ -139,7 +229,7 @@
<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)" />
<label class="toggle"><input type="checkbox" name="enabled" <%= s.enabled ? 'checked' : '' %> /><i></i></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px">
<div>
@@ -168,6 +258,11 @@
</div>
</div>
</form>
<p class="mono" style="margin-top:10px;font-size:12px;color:var(--ink-dim)">
上次运行:<%= s.last_run_at ? fmtDate(s.last_run_at) : '从未运行' %> ·
状态:<%= s.last_run_status || '—' %>
<% if (s.enabled) { %>· 定时 <span class="mono"><%= s.cron_expr %></span><% } else { %>· 定时已关闭<% } %>
</p>
<pre class="code-block mono" id="runs-<%= s.id %>" hidden style="margin-top:12px"></pre>
<% } %>
</div>
@@ -190,7 +285,7 @@
<!-- 账号管理 -->
<% if (isAdmin) { %>
<section id="sec-accounts">
<section id="sec-accounts" class="<%= activeSection === 'sec-accounts' ? 'active' : '' %>">
<p class="anno">// table: accounts · role: admin | editor</p>
<h1>账号管理</h1>
<table class="data-table" style="margin-top:22px">
+30 -7
View File
@@ -14,16 +14,16 @@
<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>
<p style="margin-top:12px;color:var(--ink-dim);max-width:64ch"><%= excerpt(entry.summary || entry.description_md, 200) %></p>
</div>
<div class="detail-layout">
<article class="detail-body">
<section>
<p class="anno">// entry.body · markdown source</p>
<p class="anno">// entry.body · markdown rendered</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>
<div class="md-body"><%- md(entry.description_md || '暂无介绍。') %></div>
<p style="margin-top:16px">仓库地址:<a class="mono" style="color:var(--riso-blue)" href="<%= entry.url %>" target="_blank" rel="noopener"><%= entry.url %></a></p>
</section>
<section>
@@ -60,11 +60,15 @@
<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 />
<input class="input" name="authorName" type="text" placeholder="昵称" required />
<input class="input" name="contact" type="text" inputmode="email"
placeholder="联系方式(手机号或邮箱,不会公开显示)" required />
</div>
<textarea class="textarea" name="body" placeholder="使用场景、坑、搭配推荐…" required></textarea>
<div><button class="btn btn-primary" type="submit">发布评论</button></div>
<div>
<button class="btn btn-primary" type="submit">发布评论</button>
<span class="mono" style="font-size:11.5px;color:var(--ink-dim);margin-left:10px">// 首次评论以「昵称 + 联系方式」建档,之后需二者匹配且未被禁用</span>
</div>
</form>
</section>
</article>
@@ -89,6 +93,25 @@
<% if (!entry.tags.length) { %><span class="mono" style="font-size:12px;color:var(--ink-dim)">无标签</span><% } %>
</div>
</div>
<div class="side-card">
<h3>相关项目</h3>
<% if (typeof related !== 'undefined' && related.length) { %>
<ul class="related-list">
<% for (const r of related) { %>
<li>
<a href="/entries/<%= r.slug %>">
<span class="chip chip-type"><%= r.type %></span>
<b><%= r.title %></b>
</a>
<p class="mono" style="font-size:11.5px;color:var(--ink-dim);margin:5px 0 0"><%= excerpt(r.summary || r.description_md, 60) %></p>
</li>
<% } %>
</ul>
<% } else { %>
<p class="mono" style="font-size:12px;color:var(--ink-dim)">暂无相关项目</p>
<% } %>
</div>
</aside>
</div>
</main>
+60 -23
View File
@@ -6,7 +6,7 @@
<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>
<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">
@@ -33,22 +33,23 @@
</div>
<div class="hero-visual rise d3" role="presentation">
<p class="spec-label mono">// get_entry("ripgrep") · 同一条数据,两种读者</p>
<% const h = hero || { slug: 'ripgrep', title: 'ripgrep', type: '工具', summary: '', description_md: '以正则递归搜索目录,默认尊重 .gitignore,是代码库里最快的找东西方式。', tags: ['Rust', 'CLI'], stars: 52300 }; %>
<p class="spec-label mono">// get_entry("<%= h.slug %>") · 同一条数据,两种读者</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 %>,
"id": "<%= h.slug %>",
"type": "<%= h.type %>",
"name": "<%= h.title %>",
"tags": <%= JSON.stringify(h.tags || []) %>,
"stars": <%= Number(h.stars) %>,
"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>
<a class="spec-human" href="/entries/<%= h.slug %>" aria-label="查看 <%= h.title %> 详情">
<span class="chip chip-type"><%= h.type %></span>
<h3><%= h.title %></h3>
<p><%= h.summary || excerpt(h.description_md, 90) %></p>
<div class="card-tags"><% for (const t of (h.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %></div>
<p class="card-meta">★ <%= fmtStars(h.stars) %> · 随机一条,点击查看详情</p>
</a>
<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>
随机游览
@@ -80,7 +81,8 @@
<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>
<p class="endpoint-note">只读访问,<b>无需密钥</b>,连接时不要配 token。写入请走 <span class="mono">/api/ingest/entry</span>(需 <span class="mono">X-Ingest-Key</span>)。</p>
<p class="endpoint-note mono" style="font-size:11.5px">// 一句话:<b>读用 MCP(免密钥),写用 REST(要密钥)</b></p>
</aside>
<div class="steps">
@@ -95,24 +97,59 @@
}</pre>
</div>
<div class="step rise d3">
<div class="step-head"><span class="step-no">02</span><h3>可用的工具</h3></div>
<div class="step-head"><span class="step-no">02</span><h3>可用工具 <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">(只读 · 无需密钥)</span></h3></div>
<table class="tools-table">
<thead><tr><th>tool</th><th>说明</th></tr></thead>
<thead><tr><th>tool</th><th>参数</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>
<tr><td>search_entries</td><td class="mono">q*, limit</td><td>按关键字 / 类型 / 标签搜索。<b>查重先走它</b>。limit 1–50,默认 20</td></tr>
<tr><td>get_entry</td><td class="mono">slug*</td><td>取单条完整详情与元数据;不确定 slug 时可传标题关键字自动匹配</td></tr>
<tr><td>random_entry</td><td class="mono">—</td><td>随机返回一条已上架内容,用于探索发现</td></tr>
<tr><td>list_tags</td><td class="mono">—</td><td>列出全部标签组、嵌套结构与收录数量</td></tr>
<tr><td>usage</td><td class="mono">—</td><td>返回本服务的完整接入说明(含写入接口与踩坑清单)</td></tr>
</tbody>
</table>
<p class="mono" style="font-size:11.5px;color:var(--ink-dim);margin-top:10px">
// 结果在 result.content[0].text 里,是 JSON 字符串,需再解析一次
</p>
</div>
<div class="step rise d4">
<div class="step-head"><span class="step-no">03</span><h3>直接问你的 AI</h3></div>
<div class="step-head"><span class="step-no">03</span><h3>写入:AI 也能往库里添加内容 <span class="chip" style="font-size:11px">需密钥</span></h3></div>
<p class="mono" style="font-size:12px;color:var(--ink-dim);margin-bottom:8px">POST <%- config.baseUrl %>/api/ingest/entry · header <b>X-Ingest-Key</b></p>
<pre>{
<span class="k">"title"</span>: <span class="s">"MinerU"</span>,
<span class="k">"url"</span>: <span class="s">"https://github.com/opendatalab/MinerU"</span>,
<span class="k">"type"</span>: <span class="s">"工具"</span>, <span class="c">// 微服务|SaaS|网站|工具|应用|脚本|插件</span>
<span class="k">"summary"</span>: <span class="s">"PDF/文档转 Markdown 的解析引擎"</span>,
<span class="k">"tags"</span>: [<span class="s">"Python"</span>, <span class="s">"OCR"</span>]
}</pre>
<p class="mono" style="font-size:11.5px;color:var(--ink-dim);margin-top:8px">
// 其他可选:description_md · license · stars · status<br />
// 返回 {"ok":true,"data":{"created":true,"id":54,"status":"pending"}}
</p>
</div>
<div class="step rise d4">
<div class="step-head"><span class="step-no">04</span><h3>直接问你的 AI</h3></div>
<pre>「用 awesome-index 找三个能自托管的相册方案,
按 star 数排序,给我 repo 链接。」</pre>
</div>
</div>
</div>
<div class="mcp-pitfalls">
<p class="anno">// 五个必记的坑 —— 大部分「第一次加载不正确」都栽在这里</p>
<ol>
<li><b>用 <span class="mono">X-Ingest-Key</span> 请求头</b>(等价 <span class="mono">Authorization: Bearer &lt;key&gt;</span>)。自创的 <span class="mono">X-API-Key</span> / <span class="mono">?key=</span> 一律 403。</li>
<li><b>MCP 免密钥 ≠ 密钥有效。</b><span class="mono">/mcp</span> 根本不查 key,别拿探测 MCP 来验证你的 ingest key。</li>
<li><b>提交后默认 <span class="mono">pending</span></b>,前台搜不到是正常的,等管理员审核转为 <span class="mono">active</span>。<b>不要因为搜不到就重复提交。</b></li>
<li><b>同一 URL 重复提交返回 200 <span class="mono">created:false</span></b>,这不是报错,说明已存在并被去重。</li>
<li><span class="mono">slug</span> <b>不是合法字段</b>,传了会被静默丢弃(服务端按标题自动生成)。</li>
</ol>
<p class="mono" style="font-size:11.5px;color:var(--ink-dim);margin-top:10px">
// curl 直连 /mcp 报 406?补上 Accept: application/json, text/event-stream 即可;正规 MCP 客户端自带该头。
</p>
</div>
</div>
</section>
@@ -129,7 +166,7 @@
<a class="card" href="/entries/<%= e.slug %>">
<span class="chip chip-type"><%= e.type %></span>
<h3><%= e.title %></h3>
<p><%= e.description_md %></p>
<p><%= e.summary || excerpt(e.description_md, 120) %></p>
<div class="card-tags">
<% for (const t of (e.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %>
</div>
+1 -1
View File
@@ -78,7 +78,7 @@
<span class="chip chip-type"><%= e.type %></span>
<div class="result-main">
<h3><%= e.title %></h3>
<p><%= e.description_md %></p>
<p><%= e.summary || excerpt(e.description_md, 160) %></p>
<div class="card-tags">
<% for (const t of e.tags.slice(0, 4)) { %><span class="chip"><%= t %></span><% } %>
</div>
+8 -2
View File
@@ -16,10 +16,16 @@
</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 class="pwd-field">
<input class="input" id="login-pass" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required />
<button type="button" class="eye-toggle" data-eye-toggle aria-label="显示密码">
<svg class="eye-open" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
</div>
<div class="auth-row">
<label class="checkline" style="padding:0"><input type="checkbox" checked /> 记住这台设备</label>
<label class="checkline" style="padding:0"><input type="checkbox" name="remember" checked /> 记住这台设备</label>
<a href="mailto:admin@honor3.com">忘记密码?</a>
</div>
<button class="btn btn-primary btn-block" type="submit">登 录</button>
+1 -1
View File
@@ -37,7 +37,7 @@
<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>
<span>V <%= config.gitBranch %>-<%= config.buildTime %> · 由 DuckDB 驱动 · MCP 就绪 · 数据每日快照</span>
</div>
</div>
</footer>
+22 -16
View File
@@ -1,32 +1,39 @@
#!/bin/bash
set -e
# Awesome Index 启动脚本(直接部署,不使用 Docker)
# Awesome Index 重启脚本(使用已构建的镜像,纯 docker run,无 compose)
DEPLOY_PATH="$(cd "$(dirname "$0")" && pwd)"
cd "$DEPLOY_PATH"
# 检查 .env 文件
if [ ! -f .env ]; then
echo "错误: 缺少 .env 文件,请从 .env.example 复制并修改"
exit 1
if [ -f .env.example ]; then
cp .env.example .env
echo "已从 .env.example 创建 .env(请检查 SESSION_SECRET / ADMIN_INIT_PASSWORD / AI_INGEST_KEY)"
else
echo "错误: 缺少 .env 文件"
exit 1
fi
fi
# 安装依赖
echo "安装依赖..."
npm ci --omit=dev --no-audit --no-fund
# 停止并删除旧容器(先优雅停止,让应用关闭 DuckDB 完成 checkpoint)
echo "停止旧容器..."
docker stop awesome-index 2>/dev/null || true
docker rm awesome-index 2>/dev/null || true
# 停止旧进程
echo "停止旧进程..."
pkill -f "node src/index.js" 2>/dev/null || true
sleep 1
# 启动服务
# 用新镜像启动容器
echo "启动 Awesome Index..."
nohup node src/index.js > logs/app.log 2>&1 &
docker run -d \
--name awesome-index \
--restart unless-stopped \
-p 3000:3000 \
-v "$DEPLOY_PATH/data:/app/data" \
--env-file .env \
awesome-index:latest
# 健康检查
echo "等待服务就绪..."
sleep 2
sleep 5
MAX_RETRIES=10
RETRY_COUNT=0
@@ -35,12 +42,11 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
echo "✓ 服务已启动: http://localhost:3000"
echo ""
echo "管理后台: http://localhost:3000/admin"
echo "默认账号: admin / (见 .env 中的 ADMIN_INIT_PASSWORD)"
exit 0
fi
RETRY_COUNT=$((RETRY_COUNT + 1))
sleep 2
done
echo "✗ 服务启动失败,请检查日志: tail -50 logs/app.log"
echo "✗ 服务启动失败,请检查日志: docker logs awesome-index"
exit 1
+77
View File
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
test("admin content search filters by name", async () => {
await withApp(async ({ api, loginAdmin }) => {
const cookie = await loginAdmin();
const hit = await api("/api/admin/entries?q=ripgrep&status=all", { cookie });
assert.equal(hit.json.data.total, 1);
assert.equal(hit.json.data.items[0].title, "ripgrep");
const miss = await api("/api/admin/entries?q=zzzz-not-exist&status=all", { cookie });
assert.equal(miss.json.data.total, 0);
// 也能按 slug 命中
const bySlug = await api("/api/admin/entries?q=uptime-kuma&status=all", { cookie });
assert.equal(bySlug.json.data.total, 1);
});
});
test("admin content page renders the search box and 20 rows per page", async () => {
await withApp(async ({ base, api, loginAdmin }) => {
const cookie = await loginAdmin();
const res = await fetch(`${base}/admin?q=ripgrep`, { headers: { cookie } });
const html = await res.text();
assert.equal(res.status, 200);
assert.ok(html.includes('name="q"'), "有名称搜索框");
assert.ok(html.includes("每页 20 条"), "标注每页 20 条");
const perPage = await api("/api/admin/entries?status=all&page=1", { cookie });
assert.equal(perPage.json.data.perPage, 20);
});
});
test("admin nav links point at server-rendered sections", async () => {
await withApp(async ({ base, loginAdmin }) => {
const cookie = await loginAdmin();
const res = await fetch(`${base}/admin?section=sec-comments`, { headers: { cookie } });
const html = await res.text();
assert.equal(res.status, 200);
assert.ok(html.includes('href="/admin?section=sec-contacts"'), "菜单是真链接");
assert.ok(
html.includes('<section id="sec-comments" class="active">'),
"服务端直接渲染出当前分区"
);
});
});
test("pending entries show 预览 and active entries show 查看", async () => {
await withApp(async ({ base, api, loginAdmin }) => {
const cookie = await loginAdmin();
const active = await fetch(`${base}/admin?status=active`, { headers: { cookie } });
const activeHtml = await active.text();
assert.ok(activeHtml.includes(">查看</a>"), "active 条目按钮是「查看」");
assert.ok(!activeHtml.includes(">预览</a>"), "active 条目不出现「预览」");
await api("/api/ingest/entry", {
method: "POST",
body: { title: "pending-probe", url: "https://example.com/pending-probe", type: "工具" },
headers: { "X-Ingest-Key": "secret-key" },
});
const pending = await fetch(`${base}/admin?status=pending`, { headers: { cookie } });
const pendingHtml = await pending.text();
assert.ok(pendingHtml.includes(">预览</a>"), "pending 条目按钮是「预览」");
}, { aiIngestKey: "secret-key" });
});
test("comment form does not prefill a nickname", async () => {
await withApp(async ({ base, loginAdmin }) => {
const cookie = await loginAdmin();
const res = await fetch(`${base}/entries/ripgrep`, { headers: { cookie } });
const html = await res.text();
const form = html.slice(html.indexOf('class="comment-form"'));
assert.ok(form.includes('name="contact"'), "评论表单有联系方式字段");
assert.ok(!/name="authorName"[^>]*value="admin"/.test(form), "昵称没有预置 admin");
});
});
+132
View File
@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
// 拼接构造,避免直接写一长串数字
const PHONE = ["1", "38", "00", "13", "80", "00"].join("");
const EMAIL = "writer@example.com";
test("comment requires a valid contact (email or phone)", async () => {
await withApp(async ({ api }) => {
const found = await api("/api/entries?q=ripgrep");
const entryId = found.json.data.items[0].id;
const bad = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "路人", contact: "not-a-contact", body: "好东西" },
});
assert.equal(bad.status, 400);
const empty = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "路人", contact: "", body: "好东西" },
});
assert.equal(empty.status, 400);
const ok = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "路人", contact: PHONE, body: "好东西" },
});
assert.equal(ok.status, 201);
const okEmail = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "写手", contact: EMAIL, body: "用邮箱也能评论" },
});
assert.equal(okEmail.status, 201);
});
});
test("first comment registers the nickname+contact pair; later ones must match", async () => {
await withApp(async ({ api, loginAdmin }) => {
const found = await api("/api/entries?q=ripgrep");
const entryId = found.json.data.items[0].id;
await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "第一条" },
});
// 同一联系方式换了昵称 → 拒绝
const mismatch = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "李四", contact: PHONE, body: "冒名" },
});
assert.equal(mismatch.status, 400);
// 昵称一致 → 可以通过
const ok = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "再补一条" },
});
assert.equal(ok.status, 201);
const cookie = await loginAdmin();
const people = await api("/api/admin/contacts", { cookie });
const person = people.json.data.find((p) => p.contact === PHONE);
assert.ok(person, "评论人员已入档");
assert.equal(person.nickname, "张三");
assert.equal(Number(person.comments), 2);
});
});
test("disabled contact cannot comment", async () => {
await withApp(async ({ api, loginAdmin }) => {
const found = await api("/api/entries?q=ripgrep");
const entryId = found.json.data.items[0].id;
await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "第一条" },
});
const cookie = await loginAdmin();
const people = await api("/api/admin/contacts", { cookie });
const person = people.json.data.find((p) => p.contact === PHONE);
const disabled = await api(`/api/admin/contacts/${person.id}/status`, {
method: "PATCH",
body: { enabled: false },
cookie,
});
assert.equal(disabled.json.data.enabled, false);
const blocked = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "被禁用了还想发" },
});
assert.equal(blocked.status, 403);
// 重新启用后可以继续评论
await api(`/api/admin/contacts/${person.id}/status`, {
method: "PATCH",
body: { enabled: true },
cookie,
});
const again = await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: PHONE, body: "又回来了" },
});
assert.equal(again.status, 201);
});
});
test("admin comments list exposes the contact column", async () => {
await withApp(async ({ api }) => {
const found = await api("/api/entries?q=ripgrep");
const entryId = found.json.data.items[0].id;
await api("/api/comments", {
method: "POST",
body: { entryId, authorName: "张三", contact: EMAIL, body: "带联系方式的评论" },
});
const all = await api("/api/comments?all=1");
const row = all.json.data.find((c) => c.contact === EMAIL);
assert.ok(row, "后台评论列表能看到联系方式");
});
});
test("contacts admin endpoints require admin", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/admin/contacts");
assert.equal(res.status, 401);
});
});
+48
View File
@@ -63,6 +63,54 @@ test("AI ingest without a valid key is forbidden", async () => {
});
});
test("AI ingest accepts X-Ingest-Key header", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "probe", url: "https://github.com/x/key", type: "工具" },
headers: { "X-Ingest-Key": "secret-key" },
});
assert.equal(res.status, 201);
assert.equal(res.json.data.created, true);
assert.equal(res.json.data.status, "pending");
}, { aiIngestKey: "secret-key" });
});
test("AI ingest accepts Authorization: Bearer header", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "probe", url: "https://github.com/x/bearer", type: "工具" },
headers: { Authorization: "Bearer secret-key" },
});
assert.equal(res.status, 201);
assert.equal(res.json.data.created, true);
}, { aiIngestKey: "secret-key" });
});
test("AI ingest accepts raw Authorization token", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "probe", url: "https://github.com/x/raw", type: "工具" },
headers: { Authorization: "secret-key" },
});
assert.equal(res.status, 201);
assert.equal(res.json.data.created, true);
}, { aiIngestKey: "secret-key" });
});
test("AI ingest rejects a wrong Authorization token", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "probe", url: "https://github.com/x/wrong", type: "工具" },
headers: { Authorization: "Bearer nope" },
});
assert.equal(res.status, 403);
}, { aiIngestKey: "secret-key" });
});
test("unauthenticated writes to entries are forbidden", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/entries", {
+5 -4
View File
@@ -5,10 +5,10 @@ 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) {
export async function withApp(fn, { aiIngestKey = "", adminInitPassword = "test123" } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "awesome-test-"));
await connect(path.join(dir, "test.duckdb"));
await runMigrate({ adminInitPassword: "test123", aiIngestKey: "" });
await runMigrate({ adminInitPassword, aiIngestKey });
const app = buildApp({
sessionSecret: "test-secret",
duckdbPath: path.join(dir, "test.duckdb"),
@@ -18,12 +18,13 @@ export async function withApp(fn) {
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 } = {}) {
async function api(pathname, { method = "GET", body, cookie, headers } = {}) {
const res = await fetch(base + pathname, {
method,
headers: {
...(body ? { "content-type": "application/json" } : {}),
...(cookie ? { cookie } : {}),
...(headers || {}),
},
body: body ? JSON.stringify(body) : undefined,
});
@@ -38,7 +39,7 @@ export async function withApp(fn) {
const res = await fetch(base + "/api/admin/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username: "admin", password: "test123" }),
body: JSON.stringify({ username: "admin", password: adminInitPassword }),
});
const cookies = res.headers.getSetCookie().map((c) => c.split(";")[0]);
return cookies.join("; ");
+56
View File
@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAdapter, normalizeUrl } from "../src/ingest/adapters/index.js";
const RSS = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Test Feed</title>
<link>https://example.com/blog</link>
<item>
<title>First Post</title>
<link>https://example.com/posts/first?utm_source=feed</link>
<description><![CDATA[<p>Some <b>html</b> description</p>]]></description>
</item>
<item>
<title>Relative Link</title>
<link>/posts/relative</link>
<description>Relative one</description>
</item>
<item>
<title>Atom style</title>
<link href="https://example.com/posts/atom" />
<description>Atom styled link</description>
</item>
</channel></rss>`;
test("rss adapter resolves each item's own link, not the feed url", async () => {
// 保留原始 fetch:把 RSS 内容喂进去
const realFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(RSS, { status: 200 });
try {
const items = await getAdapter("rss").fetch("https://example.com/feed.xml");
assert.equal(items.length, 3);
assert.equal(items[0].title, "First Post");
// 关键:url 必须是条目的链接,而不是 https://example.com/feed.xml
assert.equal(items[0].url, "https://example.com/posts/first");
// utm_ 参数被去掉
assert.ok(!items[0].url.includes("utm_source"));
assert.match(items[0].description, /Some html description/);
// 相对链接按 feed 地址解析成绝对地址
assert.equal(items[1].url, "https://example.com/posts/relative");
// <link href="..."> 形式也能取到
assert.equal(items[2].url, "https://example.com/posts/atom");
assert.ok(items.every((i) => i.url !== "https://example.com/feed.xml"));
} finally {
globalThis.fetch = realFetch;
}
});
test("normalizeUrl drops tracking params and trailing slash", () => {
assert.equal(normalizeUrl("https://a.com/x/?utm_src=b#frag"), "https://a.com/x");
assert.equal(normalizeUrl("https://a.com/x"), "https://a.com/x");
});
+77
View File
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
async function entryIds(api) {
const found = await api("/api/entries?q=&status=all&perPage=50");
const byTitle = {};
for (const it of found.json.data.items) byTitle[it.title] = it.id;
return byTitle;
}
test("related projects are bidirectional and manageable", async () => {
await withApp(async ({ api, loginAdmin }) => {
const ids = await entryIds(api);
const cookie = await loginAdmin();
const added = await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
method: "POST",
body: { relatedId: ids["fzf"] },
cookie,
});
assert.equal(added.status, 201);
// 反向可见
const back = await api(`/api/admin/relations?excludeId=${ids["fzf"]}`, { cookie });
assert.ok(back.json.data.every((e) => e.id !== ids["fzf"]));
// 重复关联不报错
const again = await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
method: "POST",
body: { relatedId: ids["fzf"] },
cookie,
});
assert.equal(again.status, 200);
assert.equal(again.json.data.created, false);
// 关联自己被拒绝
const self = await api(`/api/admin/entries/${ids["fzf"]}/relations`, {
method: "POST",
body: { relatedId: ids["fzf"] },
cookie,
});
assert.equal(self.status, 400);
await api(`/api/admin/entries/${ids["ripgrep"]}/relations/${ids["fzf"]}`, {
method: "DELETE",
cookie,
});
const after = await api(`/api/admin/relations`, { cookie });
void after;
});
});
test("detail page shows related projects", async () => {
await withApp(async ({ base, api, loginAdmin }) => {
const ids = await entryIds(api);
const cookie = await loginAdmin();
await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
method: "POST",
body: { relatedId: ids["fzf"] },
cookie,
});
const res = await fetch(`${base}/entries/ripgrep`);
const html = await res.text();
assert.equal(res.status, 200);
assert.ok(html.includes("相关项目"), "详情页有相关项目区块");
assert.ok(html.includes("fzf"), "相关项目里出现 fzf");
});
});
test("relations endpoints require admin", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/admin/relations");
assert.equal(res.status, 401);
});
});