@xingwangzhe/stalux 1.11.0 → 1.13.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/layouts/Stalux.astro +7 -1
- package/src/pages/api/posts.json.ts +33 -0
- package/src/scripts/webmcp.ts +296 -41
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") },
|
package/src/layouts/Stalux.astro
CHANGED
|
@@ -87,9 +87,15 @@ const llmPromote = llmPromoteRaw
|
|
|
87
87
|
siteTitle: siteConfig.title,
|
|
88
88
|
siteUrl: siteConfig.url,
|
|
89
89
|
siteDesc: siteConfig.description,
|
|
90
|
+
siteLang: siteConfig.lang,
|
|
90
91
|
}}
|
|
91
92
|
>
|
|
92
|
-
window.__STALUX_SITE_INFO__ = {
|
|
93
|
+
window.__STALUX_SITE_INFO__ = {
|
|
94
|
+
title: siteTitle,
|
|
95
|
+
url: siteUrl,
|
|
96
|
+
description: siteDesc,
|
|
97
|
+
lang: siteLang,
|
|
98
|
+
};
|
|
93
99
|
</script>
|
|
94
100
|
<script>
|
|
95
101
|
import "../scripts/webmcp.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,12 @@
|
|
|
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/(全文索引)。
|
|
9
|
+
*
|
|
10
|
+
* 工具描述跟随站点语言(site.yml 的 lang):中文站显示中文说明,
|
|
11
|
+
* 英文站显示英文说明,保证 AI 代理与评审(如 webmcp.com)能读懂。
|
|
8
12
|
*
|
|
9
13
|
* 浏览器无原生 modelContext 时,由布局注入的 @mcp-b/webmcp-polyfill
|
|
10
14
|
* 提供兜底实现;两者都没有则静默跳过注册,不影响普通访问者。
|
|
@@ -22,6 +26,7 @@ declare global {
|
|
|
22
26
|
title?: string;
|
|
23
27
|
url?: string;
|
|
24
28
|
description?: string;
|
|
29
|
+
lang?: string;
|
|
25
30
|
};
|
|
26
31
|
}
|
|
27
32
|
}
|
|
@@ -47,6 +52,22 @@ interface DocumentWithModelContext extends Document {
|
|
|
47
52
|
modelContext?: ModelContext;
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// 语言选择(跟随站点 site.yml 的 lang)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const SITE_LANG = (
|
|
60
|
+
typeof window !== "undefined" ? (window.__STALUX_SITE_INFO__?.lang ?? "zh-CN") : "zh-CN"
|
|
61
|
+
).toLowerCase();
|
|
62
|
+
|
|
63
|
+
/** 站点为英文站时为 true */
|
|
64
|
+
const IS_EN = SITE_LANG === "en" || SITE_LANG === "en-us" || SITE_LANG === "en-gb";
|
|
65
|
+
|
|
66
|
+
/** 按站点语言选择文案:pick(中文, English) */
|
|
67
|
+
function pick(zh: string, en: string): string {
|
|
68
|
+
return IS_EN ? en : zh;
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
// ---------------------------------------------------------------------------
|
|
51
72
|
// 工具执行辅助
|
|
52
73
|
// ---------------------------------------------------------------------------
|
|
@@ -87,33 +108,102 @@ const MAX_LIST_PAGE_SIZE = 50;
|
|
|
87
108
|
const MAX_SEARCH_RESULTS = 10;
|
|
88
109
|
|
|
89
110
|
// ---------------------------------------------------------------------------
|
|
90
|
-
//
|
|
111
|
+
// 文章元信息数据层(/api/posts.json)
|
|
91
112
|
// ---------------------------------------------------------------------------
|
|
92
113
|
|
|
93
|
-
interface
|
|
114
|
+
interface PostMeta {
|
|
94
115
|
title: string;
|
|
95
|
-
abbrlink: string
|
|
116
|
+
abbrlink: string;
|
|
117
|
+
date?: string;
|
|
118
|
+
updated?: string;
|
|
119
|
+
tags?: string[];
|
|
120
|
+
categories?: string[];
|
|
121
|
+
desc?: string;
|
|
122
|
+
wordCount?: number;
|
|
123
|
+
url: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let postsMetaPromise: Promise<PostMeta[] | null> | null = null;
|
|
127
|
+
|
|
128
|
+
/** 加载文章元信息索引(惰性 + 缓存) */
|
|
129
|
+
function loadPostsMeta(): Promise<PostMeta[] | null> {
|
|
130
|
+
if (!postsMetaPromise) {
|
|
131
|
+
postsMetaPromise = fetchJSON("/api/posts.json").then((data) =>
|
|
132
|
+
Array.isArray(data) ? (data as PostMeta[]) : null,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return postsMetaPromise;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 按 abbrlink 精确查找 */
|
|
139
|
+
function findById(list: PostMeta[], id: string): PostMeta | undefined {
|
|
140
|
+
return list.find((p) => p.abbrlink === id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 按标题/标签关键词模糊查找(取第一条) */
|
|
144
|
+
function findByKeyword(list: PostMeta[], keyword: string): PostMeta | undefined {
|
|
145
|
+
const kw = keyword.toLowerCase();
|
|
146
|
+
return (
|
|
147
|
+
list.find((p) => p.title.toLowerCase().includes(kw)) ??
|
|
148
|
+
list.find((p) => (p.tags ?? []).some((t) => t.toLowerCase().includes(kw)))
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 简化为输出用的元信息视图 */
|
|
153
|
+
function briefMeta(p: PostMeta): Record<string, unknown> {
|
|
154
|
+
return {
|
|
155
|
+
title: p.title,
|
|
156
|
+
abbrlink: p.abbrlink,
|
|
157
|
+
date: p.date ?? undefined,
|
|
158
|
+
tags: p.tags ?? [],
|
|
159
|
+
categories: p.categories ?? [],
|
|
160
|
+
desc: p.desc ?? undefined,
|
|
161
|
+
wordCount: p.wordCount ?? undefined,
|
|
162
|
+
url: p.url,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 从当前 URL 解析 abbrlink(/posts/{abbrlink}/ 或 /posts/{abbrlink}.md) */
|
|
167
|
+
function currentAbbrlinkFromPath(): string | null {
|
|
168
|
+
const m = location.pathname.match(/^\/posts\/([^/]+?)(?:\.md)?\/?$/);
|
|
169
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
96
170
|
}
|
|
97
171
|
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// 工具定义(描述中英双语,随站点语言切换)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
98
176
|
function listPostsTool(): WebMCPTool {
|
|
99
177
|
return {
|
|
100
178
|
name: "stalux_list_posts",
|
|
101
|
-
title: "列出博客文章",
|
|
102
|
-
description:
|
|
103
|
-
"分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink
|
|
104
|
-
|
|
105
|
-
|
|
179
|
+
title: pick("列出博客文章", "List blog posts"),
|
|
180
|
+
description: pick(
|
|
181
|
+
"分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、日期、分类、标签、摘要与文章页 URL。" +
|
|
182
|
+
"需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
|
|
183
|
+
"只要某篇元信息用 stalux_get_post,读正文请用 stalux_read_post。",
|
|
184
|
+
"Paginated list of all published posts, returning title, permanent link (abbrlink), date, categories, tags, description and post page URL. " +
|
|
185
|
+
"Use it to browse the whole blog or find a post's abbrlink; it does not return body content — " +
|
|
186
|
+
"use stalux_get_post for one post's metadata, or stalux_read_post for the full body.",
|
|
187
|
+
),
|
|
106
188
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
107
189
|
inputSchema: {
|
|
108
190
|
type: "object",
|
|
109
191
|
properties: {
|
|
110
|
-
page: {
|
|
192
|
+
page: {
|
|
193
|
+
type: "integer",
|
|
194
|
+
minimum: 1,
|
|
195
|
+
default: 1,
|
|
196
|
+
description: pick("页码,从 1 开始", "Page number, starting at 1"),
|
|
197
|
+
},
|
|
111
198
|
pageSize: {
|
|
112
199
|
type: "integer",
|
|
113
200
|
minimum: 1,
|
|
114
201
|
maximum: MAX_LIST_PAGE_SIZE,
|
|
115
202
|
default: 10,
|
|
116
|
-
description:
|
|
203
|
+
description: pick(
|
|
204
|
+
"每页条数,最大 " + MAX_LIST_PAGE_SIZE,
|
|
205
|
+
"Items per page, max " + MAX_LIST_PAGE_SIZE,
|
|
206
|
+
),
|
|
117
207
|
},
|
|
118
208
|
},
|
|
119
209
|
additionalProperties: false,
|
|
@@ -124,15 +214,22 @@ function listPostsTool(): WebMCPTool {
|
|
|
124
214
|
MAX_LIST_PAGE_SIZE,
|
|
125
215
|
Math.max(1, Number(input.pageSize) || 10),
|
|
126
216
|
);
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
129
|
-
|
|
217
|
+
const list = await loadPostsMeta();
|
|
218
|
+
if (!list)
|
|
219
|
+
return err(
|
|
220
|
+
"INDEX_UNAVAILABLE",
|
|
221
|
+
pick("无法获取文章列表", "Unable to fetch post list"),
|
|
222
|
+
);
|
|
223
|
+
const total = list.length;
|
|
130
224
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
|
131
225
|
const start = (page - 1) * pageSize;
|
|
132
226
|
if (start >= total) {
|
|
133
227
|
return err(
|
|
134
228
|
"OUT_OF_RANGE",
|
|
135
|
-
|
|
229
|
+
pick(
|
|
230
|
+
"第 " + page + " 页没有文章(共 " + totalPages + " 页)",
|
|
231
|
+
"Page " + page + " has no posts (" + totalPages + " pages total)",
|
|
232
|
+
),
|
|
136
233
|
{
|
|
137
234
|
page,
|
|
138
235
|
totalPages,
|
|
@@ -140,34 +237,152 @@ function listPostsTool(): WebMCPTool {
|
|
|
140
237
|
},
|
|
141
238
|
);
|
|
142
239
|
}
|
|
143
|
-
const posts =
|
|
144
|
-
title: p.title,
|
|
145
|
-
abbrlink: String(p.abbrlink),
|
|
146
|
-
url: "/posts/" + p.abbrlink + "/",
|
|
147
|
-
}));
|
|
240
|
+
const posts = list.slice(start, start + pageSize).map(briefMeta);
|
|
148
241
|
return { ok: true, page, pageSize, total, totalPages, posts };
|
|
149
242
|
},
|
|
150
243
|
};
|
|
151
244
|
}
|
|
152
245
|
|
|
246
|
+
function getPostTool(): WebMCPTool {
|
|
247
|
+
return {
|
|
248
|
+
name: "stalux_get_post",
|
|
249
|
+
title: pick("查看文章信息", "Get post info"),
|
|
250
|
+
description: pick(
|
|
251
|
+
"按 abbrlink 或标题关键词定位一篇文章,返回其元信息(标题、日期、分类、标签、摘要、字数、文章页 URL)。" +
|
|
252
|
+
"只要元信息时用它,比读取正文便宜得多;读正文请用 stalux_read_post。" +
|
|
253
|
+
"获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
|
|
254
|
+
"Locate a post by abbrlink or title keyword and return its metadata (title, date, categories, tags, description, word count, post page URL). " +
|
|
255
|
+
"Use it when only metadata is needed — cheaper than reading the full body; use stalux_read_post for the body. " +
|
|
256
|
+
"To get an abbrlink, first use stalux_list_posts or stalux_search_posts.",
|
|
257
|
+
),
|
|
258
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: "object",
|
|
261
|
+
properties: {
|
|
262
|
+
id: {
|
|
263
|
+
type: "string",
|
|
264
|
+
description: pick(
|
|
265
|
+
"文章 abbrlink(如 f4442947)或标题关键词(模糊匹配,取第一条命中)",
|
|
266
|
+
"Post abbrlink (e.g. f4442947) or a title keyword (fuzzy match, first hit wins)",
|
|
267
|
+
),
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
required: ["id"],
|
|
271
|
+
additionalProperties: false,
|
|
272
|
+
},
|
|
273
|
+
execute: async (input) => {
|
|
274
|
+
const id = String(input.id ?? "").trim();
|
|
275
|
+
if (!id) {
|
|
276
|
+
return err(
|
|
277
|
+
"BAD_INPUT",
|
|
278
|
+
pick(
|
|
279
|
+
"请提供文章 abbrlink 或标题关键词",
|
|
280
|
+
"Provide a post abbrlink or title keyword",
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const list = await loadPostsMeta();
|
|
285
|
+
if (!list)
|
|
286
|
+
return err(
|
|
287
|
+
"INDEX_UNAVAILABLE",
|
|
288
|
+
pick("无法获取文章索引", "Unable to fetch post index"),
|
|
289
|
+
);
|
|
290
|
+
const post = findById(list, id) ?? findByKeyword(list, id);
|
|
291
|
+
if (!post) return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
|
|
292
|
+
return { ok: true, post: briefMeta(post) };
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function currentPostTool(): WebMCPTool {
|
|
298
|
+
return {
|
|
299
|
+
name: "stalux_current_post",
|
|
300
|
+
title: pick("当前文章信息", "Current post info"),
|
|
301
|
+
description: pick(
|
|
302
|
+
"返回用户当前正在浏览的那篇文章的元信息。当用户说「这篇文章」「当前页面」时先调用它确定上下文," +
|
|
303
|
+
"比读正文便宜得多。不在文章页时返回错误。",
|
|
304
|
+
"Return the metadata of the post the user is currently viewing. " +
|
|
305
|
+
"Call it first when the user says 'this post' or 'the current page' to establish context — cheaper than reading the body. " +
|
|
306
|
+
"Returns an error when not on a post page.",
|
|
307
|
+
),
|
|
308
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
309
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
310
|
+
execute: async () => {
|
|
311
|
+
const id = currentAbbrlinkFromPath();
|
|
312
|
+
if (!id) {
|
|
313
|
+
return err(
|
|
314
|
+
"NOT_ON_POST_PAGE",
|
|
315
|
+
pick("当前不在文章页(路径: ", "Not on a post page (path: ") +
|
|
316
|
+
location.pathname +
|
|
317
|
+
")",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const list = await loadPostsMeta();
|
|
321
|
+
if (!list)
|
|
322
|
+
return err(
|
|
323
|
+
"INDEX_UNAVAILABLE",
|
|
324
|
+
pick("无法获取文章索引", "Unable to fetch post index"),
|
|
325
|
+
);
|
|
326
|
+
const post = findById(list, id);
|
|
327
|
+
if (!post)
|
|
328
|
+
return err("NOT_FOUND", pick("未找到当前文章: ", "Current post not found: ") + id);
|
|
329
|
+
return { ok: true, post: briefMeta(post) };
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function randomPostTool(): WebMCPTool {
|
|
335
|
+
return {
|
|
336
|
+
name: "stalux_random_post",
|
|
337
|
+
title: pick("随机一篇文章", "Random post"),
|
|
338
|
+
description: pick(
|
|
339
|
+
"随机挑选一篇文章并返回其元信息(标题、abbrlink、日期、摘要、URL)。" +
|
|
340
|
+
"适合推荐、探索、或不确定从哪篇开始时使用;不返回正文,不导航。",
|
|
341
|
+
"Pick a random post and return its metadata (title, abbrlink, date, description, URL). " +
|
|
342
|
+
"Useful for recommendations, discovery, or when unsure where to start; it does not return body content and does not navigate.",
|
|
343
|
+
),
|
|
344
|
+
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
345
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
346
|
+
execute: async () => {
|
|
347
|
+
const list = await loadPostsMeta();
|
|
348
|
+
if (!list || list.length === 0) {
|
|
349
|
+
return err(
|
|
350
|
+
"INDEX_UNAVAILABLE",
|
|
351
|
+
pick("无法获取文章索引", "Unable to fetch post index"),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
const post = list[Math.floor(Math.random() * list.length)];
|
|
355
|
+
return { ok: true, post: briefMeta(post) };
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
153
360
|
function searchPostsTool(): WebMCPTool {
|
|
154
361
|
return {
|
|
155
362
|
name: "stalux_search_posts",
|
|
156
|
-
title: "搜索博客文章",
|
|
157
|
-
description:
|
|
363
|
+
title: pick("搜索博客文章", "Search blog posts"),
|
|
364
|
+
description: pick(
|
|
158
365
|
"用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
|
|
159
|
-
|
|
366
|
+
"适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题;" +
|
|
367
|
+
"定位到具体文章后再用 stalux_read_post 读正文、stalux_get_post 取元信息。",
|
|
368
|
+
"Search the full text of all posts (title, tags, categories, body) by keyword, returning matching posts with title, URL and excerpt snippets. " +
|
|
369
|
+
"Ideal for questions like 'has the blog covered X?' or 'find posts about Y'; " +
|
|
370
|
+
"once a post is located, use stalux_read_post for the body or stalux_get_post for metadata.",
|
|
371
|
+
),
|
|
160
372
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
161
373
|
inputSchema: {
|
|
162
374
|
type: "object",
|
|
163
375
|
properties: {
|
|
164
|
-
keyword: {
|
|
376
|
+
keyword: {
|
|
377
|
+
type: "string",
|
|
378
|
+
description: pick("搜索关键词", "Search keyword"),
|
|
379
|
+
},
|
|
165
380
|
limit: {
|
|
166
381
|
type: "integer",
|
|
167
382
|
minimum: 1,
|
|
168
383
|
maximum: MAX_SEARCH_RESULTS,
|
|
169
384
|
default: 10,
|
|
170
|
-
description: "最多返回条数",
|
|
385
|
+
description: pick("最多返回条数", "Maximum number of results to return"),
|
|
171
386
|
},
|
|
172
387
|
},
|
|
173
388
|
required: ["keyword"],
|
|
@@ -175,15 +390,24 @@ function searchPostsTool(): WebMCPTool {
|
|
|
175
390
|
},
|
|
176
391
|
execute: async (input) => {
|
|
177
392
|
const keyword = String(input.keyword ?? "").trim();
|
|
178
|
-
if (!keyword)
|
|
393
|
+
if (!keyword)
|
|
394
|
+
return err("BAD_INPUT", pick("请提供搜索关键词", "Provide a search keyword"));
|
|
179
395
|
const limit = Math.min(MAX_SEARCH_RESULTS, Math.max(1, Number(input.limit) || 10));
|
|
180
396
|
const pagefind = await loadPagefind();
|
|
181
|
-
if (!pagefind)
|
|
397
|
+
if (!pagefind) {
|
|
398
|
+
return err(
|
|
399
|
+
"INDEX_UNAVAILABLE",
|
|
400
|
+
pick("全文搜索索引不可用", "Full-text search index unavailable"),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
182
403
|
const res = (await pagefind.search(keyword)) as {
|
|
183
404
|
results?: Array<{ data: () => Promise<unknown> }>;
|
|
184
405
|
};
|
|
185
406
|
if (!res || !Array.isArray(res.results)) {
|
|
186
|
-
return err(
|
|
407
|
+
return err(
|
|
408
|
+
"SEARCH_FAILED",
|
|
409
|
+
pick("搜索失败,请重试", "Search failed, please retry"),
|
|
410
|
+
);
|
|
187
411
|
}
|
|
188
412
|
const items = await Promise.all(
|
|
189
413
|
res.results.slice(0, limit).map(async (r) => {
|
|
@@ -212,26 +436,44 @@ function searchPostsTool(): WebMCPTool {
|
|
|
212
436
|
function readPostTool(): WebMCPTool {
|
|
213
437
|
return {
|
|
214
438
|
name: "stalux_read_post",
|
|
215
|
-
title: "读取文章 Markdown",
|
|
216
|
-
description:
|
|
439
|
+
title: pick("读取文章 Markdown", "Read post Markdown"),
|
|
440
|
+
description: pick(
|
|
217
441
|
"按 abbrlink(文章永久链接 ID)读取一篇博客文章的原始 Markdown 全文,包含 frontmatter 与版权脚注。" +
|
|
218
|
-
|
|
442
|
+
"需要文章完整内容、引用或摘要时使用;只要元信息用 stalux_get_post(更便宜)。" +
|
|
443
|
+
"获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
|
|
444
|
+
"Read the full raw Markdown of a post by abbrlink (permanent link ID), including frontmatter and copyright footer. " +
|
|
445
|
+
"Use it for full content, quotes or summarization; for metadata only, stalux_get_post is cheaper. " +
|
|
446
|
+
"To get an abbrlink, first use stalux_list_posts or stalux_search_posts.",
|
|
447
|
+
),
|
|
219
448
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
220
449
|
inputSchema: {
|
|
221
450
|
type: "object",
|
|
222
451
|
properties: {
|
|
223
|
-
id: {
|
|
452
|
+
id: {
|
|
453
|
+
type: "string",
|
|
454
|
+
description: pick(
|
|
455
|
+
"文章的 abbrlink,如 f4442947",
|
|
456
|
+
"Post abbrlink, e.g. f4442947",
|
|
457
|
+
),
|
|
458
|
+
},
|
|
224
459
|
},
|
|
225
460
|
required: ["id"],
|
|
226
461
|
additionalProperties: false,
|
|
227
462
|
},
|
|
228
463
|
execute: async (input) => {
|
|
229
464
|
const id = String(input.id ?? "").trim();
|
|
230
|
-
if (!id)
|
|
465
|
+
if (!id)
|
|
466
|
+
return err("BAD_INPUT", pick("请提供文章 abbrlink", "Provide a post abbrlink"));
|
|
231
467
|
const url = "/posts/" + encodeURIComponent(id) + ".md";
|
|
232
468
|
const r = await fetch(url, { headers: { Accept: "text/markdown" } });
|
|
233
|
-
if (r.status === 404)
|
|
234
|
-
|
|
469
|
+
if (r.status === 404)
|
|
470
|
+
return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
|
|
471
|
+
if (!r.ok) {
|
|
472
|
+
return err(
|
|
473
|
+
"FETCH_FAILED",
|
|
474
|
+
pick("无法获取文章 Markdown:", "Failed to fetch post Markdown: ") + url,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
235
477
|
const markdown = await r.text();
|
|
236
478
|
return { ok: true, url, markdown: markdown.slice(0, 50000) };
|
|
237
479
|
},
|
|
@@ -241,11 +483,15 @@ function readPostTool(): WebMCPTool {
|
|
|
241
483
|
function siteInfoTool(): WebMCPTool {
|
|
242
484
|
return {
|
|
243
485
|
name: "stalux_site_info",
|
|
244
|
-
title: "站点信息",
|
|
245
|
-
description:
|
|
486
|
+
title: pick("站点信息", "Site info"),
|
|
487
|
+
description: pick(
|
|
246
488
|
"返回博客的站点信息(标题、简介、站点 URL),并给出更完整的机器可读内容入口:" +
|
|
247
|
-
|
|
248
|
-
|
|
489
|
+
"/llms.txt(站点导航与文章链接列表)与 /llms-full.txt(全站 Markdown 镜像,含全部文章正文)。" +
|
|
490
|
+
"适合需要了解博客主题、定位内容、或批量获取全站数据时使用。",
|
|
491
|
+
"Return the blog's site info (title, description, site URL) plus pointers to machine-readable content: " +
|
|
492
|
+
"/llms.txt (site navigation and post link list) and /llms-full.txt (full-site Markdown mirror with all post bodies). " +
|
|
493
|
+
"Use it to understand the blog's topic, orient, or bulk-fetch all content.",
|
|
494
|
+
),
|
|
249
495
|
annotations: { readOnlyHint: true, untrustedContentHint: false },
|
|
250
496
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
251
497
|
execute: async () => {
|
|
@@ -255,6 +501,7 @@ function siteInfoTool(): WebMCPTool {
|
|
|
255
501
|
title: info.title ?? "",
|
|
256
502
|
url: info.url ?? "",
|
|
257
503
|
description: info.description ?? "",
|
|
504
|
+
lang: info.lang ?? "",
|
|
258
505
|
llms: (info.url ?? "") + "/llms.txt",
|
|
259
506
|
llmsFull: (info.url ?? "") + "/llms-full.txt",
|
|
260
507
|
};
|
|
@@ -263,7 +510,15 @@ function siteInfoTool(): WebMCPTool {
|
|
|
263
510
|
}
|
|
264
511
|
|
|
265
512
|
function buildTools(): WebMCPTool[] {
|
|
266
|
-
return [
|
|
513
|
+
return [
|
|
514
|
+
listPostsTool(),
|
|
515
|
+
getPostTool(),
|
|
516
|
+
currentPostTool(),
|
|
517
|
+
randomPostTool(),
|
|
518
|
+
searchPostsTool(),
|
|
519
|
+
readPostTool(),
|
|
520
|
+
siteInfoTool(),
|
|
521
|
+
];
|
|
267
522
|
}
|
|
268
523
|
|
|
269
524
|
// ---------------------------------------------------------------------------
|