@xingwangzhe/stalux 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/pages/api/posts.json.ts +33 -0
- package/src/scripts/webmcp.ts +158 -18
package/README.md
CHANGED
|
@@ -171,12 +171,15 @@ extension) interact with your blog directly — **no backend required**.
|
|
|
171
171
|
|
|
172
172
|
When a WebMCP-aware browser opens your site, these read-only tools are registered:
|
|
173
173
|
|
|
174
|
-
| Tool | What it does | Backing data
|
|
175
|
-
| --------------------- | ----------------------------------------------------------------------- |
|
|
176
|
-
| `stalux_list_posts` | Paginated list of all posts
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
174
|
+
| Tool | What it does | Backing data |
|
|
175
|
+
| --------------------- | ----------------------------------------------------------------------- | ----------------------- |
|
|
176
|
+
| `stalux_list_posts` | Paginated list of all posts (with meta) | `/api/posts.json` |
|
|
177
|
+
| `stalux_get_post` | Fetch one post's metadata by abbrlink or title keyword | `/api/posts.json` |
|
|
178
|
+
| `stalux_current_post` | Metadata of the post currently being viewed | `/api/posts.json` |
|
|
179
|
+
| `stalux_random_post` | Pick a random post's metadata | `/api/posts.json` |
|
|
180
|
+
| `stalux_search_posts` | Full-text search across posts | Pagefind `/pagefind/` |
|
|
181
|
+
| `stalux_read_post` | Fetch a post's raw Markdown | `/posts/{abbrlink}.md` |
|
|
182
|
+
| `stalux_site_info` | Site title, URL, description + pointers to `llms.txt` / `llms-full.txt` | `site.yml` (build-time) |
|
|
180
183
|
|
|
181
184
|
All tools are `readOnlyHint: true` — they never modify any state.
|
|
182
185
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -68,6 +68,10 @@ function getRoutes(baseDir: URL): RouteEntry[] {
|
|
|
68
68
|
pattern: "/api/post.abbrlink.json",
|
|
69
69
|
entrypoint: resolvePage("./pages/api/post.abbrlink.json.ts"),
|
|
70
70
|
},
|
|
71
|
+
{
|
|
72
|
+
pattern: "/api/posts.json",
|
|
73
|
+
entrypoint: resolvePage("./pages/api/posts.json.ts"),
|
|
74
|
+
},
|
|
71
75
|
|
|
72
76
|
// ---- Markdown 导出端点 ----
|
|
73
77
|
{ pattern: "/index.md", entrypoint: resolvePage("./pages/index.md.ts") },
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { getCollection } from "astro:content";
|
|
3
|
+
|
|
4
|
+
export const prerender = true;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 文章元信息索引(供 WebMCP 工具与外部消费)
|
|
8
|
+
*
|
|
9
|
+
* 相比 /api/post.abbrlink.json,包含完整元信息(日期/分类/标签/摘要/字数),
|
|
10
|
+
* 是 stalux_get_post / stalux_random_post 等 WebMCP 工具的廉价数据源。
|
|
11
|
+
*/
|
|
12
|
+
export const GET: APIRoute = async () => {
|
|
13
|
+
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
|
14
|
+
|
|
15
|
+
const payload = posts.map((post) => ({
|
|
16
|
+
title: post.data.title,
|
|
17
|
+
abbrlink: String(post.data.abbrlink),
|
|
18
|
+
date: post.data.date ?? undefined,
|
|
19
|
+
updated: post.data.updated ?? undefined,
|
|
20
|
+
tags: post.data.tags ?? [],
|
|
21
|
+
categories: post.data.categories ?? [],
|
|
22
|
+
desc: post.data.desc ?? "",
|
|
23
|
+
wordCount: post.data.wordCount ?? undefined,
|
|
24
|
+
url: `/posts/${post.data.abbrlink}/`,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
return new Response(JSON.stringify(payload), {
|
|
28
|
+
status: 200,
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
};
|
package/src/scripts/webmcp.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 遵循 W3C webmachinelearning/webmcp 草案:网页通过 document.modelContext
|
|
5
5
|
* 向浏览器/代理注册工具。所有数据源均为构建期静态产物——
|
|
6
|
-
* /api/post.abbrlink.json
|
|
7
|
-
* /llms.txt / /llms-full.txt
|
|
6
|
+
* /api/posts.json(文章元信息索引)、/api/post.abbrlink.json(兼容列表)、
|
|
7
|
+
* /posts/{id}.md(源码导出)、/llms.txt / /llms-full.txt(站点信息镜像)、
|
|
8
|
+
* /pagefind/(全文索引)。
|
|
8
9
|
*
|
|
9
10
|
* 浏览器无原生 modelContext 时,由布局注入的 @mcp-b/webmcp-polyfill
|
|
10
11
|
* 提供兜底实现;两者都没有则静默跳过注册,不影响普通访问者。
|
|
@@ -87,22 +88,79 @@ const MAX_LIST_PAGE_SIZE = 50;
|
|
|
87
88
|
const MAX_SEARCH_RESULTS = 10;
|
|
88
89
|
|
|
89
90
|
// ---------------------------------------------------------------------------
|
|
90
|
-
//
|
|
91
|
+
// 文章元信息数据层(/api/posts.json)
|
|
91
92
|
// ---------------------------------------------------------------------------
|
|
92
93
|
|
|
93
|
-
interface
|
|
94
|
+
interface PostMeta {
|
|
94
95
|
title: string;
|
|
95
|
-
abbrlink: string
|
|
96
|
+
abbrlink: string;
|
|
97
|
+
date?: string;
|
|
98
|
+
updated?: string;
|
|
99
|
+
tags?: string[];
|
|
100
|
+
categories?: string[];
|
|
101
|
+
desc?: string;
|
|
102
|
+
wordCount?: number;
|
|
103
|
+
url: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let postsMetaPromise: Promise<PostMeta[] | null> | null = null;
|
|
107
|
+
|
|
108
|
+
/** 加载文章元信息索引(惰性 + 缓存) */
|
|
109
|
+
function loadPostsMeta(): Promise<PostMeta[] | null> {
|
|
110
|
+
if (!postsMetaPromise) {
|
|
111
|
+
postsMetaPromise = fetchJSON("/api/posts.json").then((data) =>
|
|
112
|
+
Array.isArray(data) ? (data as PostMeta[]) : null,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return postsMetaPromise;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 按 abbrlink 精确查找 */
|
|
119
|
+
function findById(list: PostMeta[], id: string): PostMeta | undefined {
|
|
120
|
+
return list.find((p) => p.abbrlink === id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 按标题/标签关键词模糊查找(取第一条) */
|
|
124
|
+
function findByKeyword(list: PostMeta[], keyword: string): PostMeta | undefined {
|
|
125
|
+
const kw = keyword.toLowerCase();
|
|
126
|
+
return (
|
|
127
|
+
list.find((p) => p.title.toLowerCase().includes(kw)) ??
|
|
128
|
+
list.find((p) => (p.tags ?? []).some((t) => t.toLowerCase().includes(kw)))
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 简化为输出用的元信息视图 */
|
|
133
|
+
function briefMeta(p: PostMeta): Record<string, unknown> {
|
|
134
|
+
return {
|
|
135
|
+
title: p.title,
|
|
136
|
+
abbrlink: p.abbrlink,
|
|
137
|
+
date: p.date ?? undefined,
|
|
138
|
+
tags: p.tags ?? [],
|
|
139
|
+
categories: p.categories ?? [],
|
|
140
|
+
desc: p.desc ?? undefined,
|
|
141
|
+
wordCount: p.wordCount ?? undefined,
|
|
142
|
+
url: p.url,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 从当前 URL 解析 abbrlink(/posts/{abbrlink}/ 或 /posts/{abbrlink}.md) */
|
|
147
|
+
function currentAbbrlinkFromPath(): string | null {
|
|
148
|
+
const m = location.pathname.match(/^\/posts\/([^/]+?)(?:\.md)?\/?$/);
|
|
149
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
96
150
|
}
|
|
97
151
|
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// 工具定义
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
98
156
|
function listPostsTool(): WebMCPTool {
|
|
99
157
|
return {
|
|
100
158
|
name: "stalux_list_posts",
|
|
101
159
|
title: "列出博客文章",
|
|
102
160
|
description:
|
|
103
|
-
"分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink
|
|
161
|
+
"分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、日期、分类、标签、摘要与文章页 URL。" +
|
|
104
162
|
"需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
|
|
105
|
-
"
|
|
163
|
+
"只要某篇元信息用 stalux_get_post,读正文请用 stalux_read_post。",
|
|
106
164
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
107
165
|
inputSchema: {
|
|
108
166
|
type: "object",
|
|
@@ -124,9 +182,9 @@ function listPostsTool(): WebMCPTool {
|
|
|
124
182
|
MAX_LIST_PAGE_SIZE,
|
|
125
183
|
Math.max(1, Number(input.pageSize) || 10),
|
|
126
184
|
);
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
129
|
-
const total =
|
|
185
|
+
const list = await loadPostsMeta();
|
|
186
|
+
if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章列表");
|
|
187
|
+
const total = list.length;
|
|
130
188
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
|
131
189
|
const start = (page - 1) * pageSize;
|
|
132
190
|
if (start >= total) {
|
|
@@ -140,23 +198,96 @@ function listPostsTool(): WebMCPTool {
|
|
|
140
198
|
},
|
|
141
199
|
);
|
|
142
200
|
}
|
|
143
|
-
const posts =
|
|
144
|
-
title: p.title,
|
|
145
|
-
abbrlink: String(p.abbrlink),
|
|
146
|
-
url: "/posts/" + p.abbrlink + "/",
|
|
147
|
-
}));
|
|
201
|
+
const posts = list.slice(start, start + pageSize).map(briefMeta);
|
|
148
202
|
return { ok: true, page, pageSize, total, totalPages, posts };
|
|
149
203
|
},
|
|
150
204
|
};
|
|
151
205
|
}
|
|
152
206
|
|
|
207
|
+
function getPostTool(): WebMCPTool {
|
|
208
|
+
return {
|
|
209
|
+
name: "stalux_get_post",
|
|
210
|
+
title: "查看文章信息",
|
|
211
|
+
description:
|
|
212
|
+
"按 abbrlink 或标题关键词定位一篇文章,返回其元信息(标题、日期、分类、标签、摘要、字数、文章页 URL)。" +
|
|
213
|
+
"只要元信息时用它,比读取正文便宜得多;读正文请用 stalux_read_post。" +
|
|
214
|
+
"获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
|
|
215
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
id: {
|
|
220
|
+
type: "string",
|
|
221
|
+
description:
|
|
222
|
+
"文章 abbrlink(如 f4442947)或标题关键词(模糊匹配,取第一条命中)",
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
required: ["id"],
|
|
226
|
+
additionalProperties: false,
|
|
227
|
+
},
|
|
228
|
+
execute: async (input) => {
|
|
229
|
+
const id = String(input.id ?? "").trim();
|
|
230
|
+
if (!id) return err("BAD_INPUT", "请提供文章 abbrlink 或标题关键词");
|
|
231
|
+
const list = await loadPostsMeta();
|
|
232
|
+
if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章索引");
|
|
233
|
+
const post = findById(list, id) ?? findByKeyword(list, id);
|
|
234
|
+
if (!post) return err("NOT_FOUND", "未找到文章: " + id);
|
|
235
|
+
return { ok: true, post: briefMeta(post) };
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function currentPostTool(): WebMCPTool {
|
|
241
|
+
return {
|
|
242
|
+
name: "stalux_current_post",
|
|
243
|
+
title: "当前文章信息",
|
|
244
|
+
description:
|
|
245
|
+
"返回用户当前正在浏览的那篇文章的元信息。当用户说「这篇文章」「当前页面」时先调用它确定上下文," +
|
|
246
|
+
"比读正文便宜得多。不在文章页时返回错误。",
|
|
247
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
248
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
249
|
+
execute: async () => {
|
|
250
|
+
const id = currentAbbrlinkFromPath();
|
|
251
|
+
if (!id) {
|
|
252
|
+
return err("NOT_ON_POST_PAGE", "当前不在文章页(路径: " + location.pathname + ")");
|
|
253
|
+
}
|
|
254
|
+
const list = await loadPostsMeta();
|
|
255
|
+
if (!list) return err("INDEX_UNAVAILABLE", "无法获取文章索引");
|
|
256
|
+
const post = findById(list, id);
|
|
257
|
+
if (!post) return err("NOT_FOUND", "未找到当前文章: " + id);
|
|
258
|
+
return { ok: true, post: briefMeta(post) };
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function randomPostTool(): WebMCPTool {
|
|
264
|
+
return {
|
|
265
|
+
name: "stalux_random_post",
|
|
266
|
+
title: "随机一篇文章",
|
|
267
|
+
description:
|
|
268
|
+
"随机挑选一篇文章并返回其元信息(标题、abbrlink、日期、摘要、URL)。" +
|
|
269
|
+
"适合推荐、探索、或不确定从哪篇开始时使用;不返回正文,不导航。",
|
|
270
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
271
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
272
|
+
execute: async () => {
|
|
273
|
+
const list = await loadPostsMeta();
|
|
274
|
+
if (!list || list.length === 0) {
|
|
275
|
+
return err("INDEX_UNAVAILABLE", "无法获取文章索引");
|
|
276
|
+
}
|
|
277
|
+
const post = list[Math.floor(Math.random() * list.length)];
|
|
278
|
+
return { ok: true, post: briefMeta(post) };
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
153
283
|
function searchPostsTool(): WebMCPTool {
|
|
154
284
|
return {
|
|
155
285
|
name: "stalux_search_posts",
|
|
156
286
|
title: "搜索博客文章",
|
|
157
287
|
description:
|
|
158
288
|
"用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
|
|
159
|
-
"适合「博客里写过 X 吗」「帮我找一下关于 Y
|
|
289
|
+
"适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题;" +
|
|
290
|
+
"定位到具体文章后再用 stalux_read_post 读正文、stalux_get_post 取元信息。",
|
|
160
291
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
161
292
|
inputSchema: {
|
|
162
293
|
type: "object",
|
|
@@ -215,7 +346,8 @@ function readPostTool(): WebMCPTool {
|
|
|
215
346
|
title: "读取文章 Markdown",
|
|
216
347
|
description:
|
|
217
348
|
"按 abbrlink(文章永久链接 ID)读取一篇博客文章的原始 Markdown 全文,包含 frontmatter 与版权脚注。" +
|
|
218
|
-
"
|
|
349
|
+
"需要文章完整内容、引用或摘要时使用;只要元信息用 stalux_get_post(更便宜)。" +
|
|
350
|
+
"获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
|
|
219
351
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
220
352
|
inputSchema: {
|
|
221
353
|
type: "object",
|
|
@@ -263,7 +395,15 @@ function siteInfoTool(): WebMCPTool {
|
|
|
263
395
|
}
|
|
264
396
|
|
|
265
397
|
function buildTools(): WebMCPTool[] {
|
|
266
|
-
return [
|
|
398
|
+
return [
|
|
399
|
+
listPostsTool(),
|
|
400
|
+
getPostTool(),
|
|
401
|
+
currentPostTool(),
|
|
402
|
+
randomPostTool(),
|
|
403
|
+
searchPostsTool(),
|
|
404
|
+
readPostTool(),
|
|
405
|
+
siteInfoTool(),
|
|
406
|
+
];
|
|
267
407
|
}
|
|
268
408
|
|
|
269
409
|
// ---------------------------------------------------------------------------
|