From 806761103b520368ce9b114cf687dc88812f396c Mon Sep 17 00:00:00 2001 From: cheney Date: Tue, 27 Oct 2026 11:22:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=8F=B0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2/=E5=88=86=E5=8C=BA=E5=88=B7=E6=96=B0=E3=80=81?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E4=BA=BA=E5=91=98=E4=BD=93=E7=B3=BB=E3=80=81?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=A1=B9=E7=9B=AE=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=BA=90=E8=87=AA=E5=8A=A8=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=EF=BC=8C=E5=AE=8C=E5=96=84=20MCP=20=E8=87=AA=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理台: - 内容管理新增名称搜索(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 个全部通过 --- README.md | 114 ++++++++++++++++- public/css/main.css | 121 ++++++++++++++++++ public/js/admin.js | 96 +++++++++++++-- public/js/main.js | 25 ++++ src/db/migrate.js | 39 ++++++ src/db/schema.sql | 21 ++++ src/ingest/adapters/index.js | 202 +++++++++++++++++++++++++------ src/ingest/pipeline.js | 35 +++++- src/ingest/scheduler.js | 25 +++- src/mcp/server.js | 109 ++++++++++++++++- src/mcp/tools.js | 127 +++++++++++++++++-- src/middleware/auth.js | 19 ++- src/routes/admin.routes.js | 57 +++++++++ src/routes/comments.routes.js | 4 +- src/routes/pages.routes.js | 37 +++++- src/services/comment.service.js | 25 ++-- src/services/contact.service.js | 106 ++++++++++++++++ src/services/relation.service.js | 79 ++++++++++++ src/services/search.service.js | 4 +- src/views/admin-entry-edit.ejs | 40 +++++- src/views/admin.ejs | 95 ++++++++++++--- src/views/detail.ejs | 29 ++++- src/views/index.ejs | 52 ++++++-- test/admin.test.js | 77 ++++++++++++ test/comments.test.js | 132 ++++++++++++++++++++ test/entries.test.js | 48 ++++++++ test/helpers.js | 9 +- test/ingest-adapters.test.js | 56 +++++++++ test/relations.test.js | 77 ++++++++++++ 29 files changed, 1727 insertions(+), 133 deletions(-) create mode 100644 src/services/contact.service.js create mode 100644 src/services/relation.service.js create mode 100644 test/admin.test.js create mode 100644 test/comments.test.js create mode 100644 test/ingest-adapters.test.js create mode 100644 test/relations.test.js diff --git a/README.md b/README.md index 92ae3c4..1caf65e 100644 --- a/README.md +++ b/README.md @@ -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`)。 @@ -33,24 +33,126 @@ bash start.sh # 用新镜像重启容器 |---|---| | 首页(搜索 / 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: ` | +| 标准 Bearer(等价) | `Authorization: Bearer ` | + +请求体字段:`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: " \ + -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":,"status":"pending"}}`;URL 已存在时 `200 {"created":false,"id":}`(自动去重);鉴权失败 `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)。运行记录见管理台「内容源 → 运行记录」。 diff --git a/public/css/main.css b/public/css/main.css index 35c7675..982f0ad 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -732,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; @@ -2035,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 { diff --git a/public/js/admin.js b/public/js/admin.js index df60c50..98a2df6 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -35,19 +35,48 @@ }); } - 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); + 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") + ); }); }); + // 兼容旧链接 /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; @@ -260,6 +289,45 @@ 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"); @@ -317,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 { diff --git a/public/js/main.js b/public/js/main.js index 0c128d6..22f50ab 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -150,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 () { diff --git a/src/db/migrate.js b/src/db/migrate.js index 7c523aa..52b3de5 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -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; @@ -82,6 +118,9 @@ 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) { diff --git a/src/db/schema.sql b/src/db/schema.sql index 2872fab..cc4b933 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -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 ( @@ -72,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, diff --git a/src/ingest/adapters/index.js b/src/ingest/adapters/index.js index 2582c35..5e3e001 100644 --- a/src/ingest/adapters/index.js +++ b/src/ingest/adapters/index.js @@ -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 */ +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 输出里每个项目长这样: + *
    + *
  • 项目地址:Name
  • + *
  • 项目说明:描述文字…
  • + *
+ *
+ * 所以描述不在 所在的
  • 里,得往上找到整个