diff options
Diffstat (limited to 'assets')
| -rw-r--r-- | assets/css/gitalk.css | 2 | ||||
| -rw-r--r-- | assets/css/style.scss | 14 | ||||
| -rw-r--r-- | assets/js/blog-console.js | 1147 | ||||
| -rw-r--r-- | assets/js/gitalk.min.js | 3 | ||||
| -rw-r--r-- | assets/js/jquery.min.js | 10 | ||||
| -rw-r--r-- | assets/js/jquery.pjax.min.js | 6 | ||||
| -rw-r--r-- | assets/js/main.js | 85 | ||||
| -rw-r--r-- | assets/js/main_new.js | 87 | ||||
| -rw-r--r-- | assets/js/pjax.js | 153 |
9 files changed, 1403 insertions, 104 deletions
diff --git a/assets/css/gitalk.css b/assets/css/gitalk.css index 52ce950..8d45ae7 100644 --- a/assets/css/gitalk.css +++ b/assets/css/gitalk.css @@ -1274,5 +1274,3 @@ transform: rotate(360deg); } } - -/*# sourceMappingURL=gitalk.css.map*/
\ No newline at end of file diff --git a/assets/css/style.scss b/assets/css/style.scss index 879154e..44632e2 100644 --- a/assets/css/style.scss +++ b/assets/css/style.scss @@ -190,4 +190,16 @@ td.h-entry { td.h-entry:hover { background: #f9f9f9; -}
\ No newline at end of file +} + +body.pjax-loading::after { + content: ''; + position: fixed; + top: 16px; + right: 16px; + width: 20px; + height: 20px; + background: url('/images/loading.svg') center / contain no-repeat; + z-index: 9999; + pointer-events: none; + }
\ No newline at end of file diff --git a/assets/js/blog-console.js b/assets/js/blog-console.js new file mode 100644 index 0000000..93bcf73 --- /dev/null +++ b/assets/js/blog-console.js @@ -0,0 +1,1147 @@ +/*! + * blog-console.js — Mayx's Blog Console API + WebMCP Tools + */ +(function (global) { + 'use strict'; + + /* ===================================================================== + * §0. 常量与配置 + * ===================================================================== */ + + var config = { + /** list() 默认每页条数 */ + pageSize: 10, + /** 工具单页最大条数,避免撑爆 Agent 上下文 */ + maxPageSize: 50, + /** show() 单次最多渲染的行数,防止超长文章刷屏 */ + maxShowLines: 600, + /** 摘要/预览截断长度 */ + previewLength: 120, + /** 工具返回 Markdown 的默认/最大字符数 */ + readChars: 8000, + maxReadChars: 50000, + /** grep 最长执行时间(毫秒),防止病态正则卡死页面 */ + grepTimeBudget: 2000, + /** 是否在脚本加载后自动注册 WebMCP 工具 */ + autoRegister: true, + /** 工具名前缀,须符合规范:仅 ASCII 字母数字与 _ - . */ + toolPrefix: 'blog_' + }; + + /* ===================================================================== + * §1. 控制台样式与打印器(DevTools 用 %c + CSS) + * ===================================================================== */ + + var S = { + reset: '', + title: 'font-weight:bold;font-size:13px;color:#3fb950', + sub: 'color:#8b949e', + num: 'color:#d29922;font-weight:bold', + date: 'color:#58a6ff', + strong: 'font-weight:bold', + link: 'color:#58a6ff;text-decoration:underline', + ok: 'color:#3fb950;font-weight:bold', + warn: 'color:#d29922', + err: 'color:#f85149;font-weight:bold', + dim: 'color:#8b949e', + code: 'color:#e3b341;font-family:ui-monospace,Consolas,monospace;background:rgba(110,118,129,.18);padding:0 3px;border-radius:3px', + quote: 'color:#8b949e;font-style:italic', + hr: 'color:#6e7781', + tag: 'color:#a371f7', + h: [ + '', + 'font-weight:bold;font-size:16px;color:#f85149', + 'font-weight:bold;font-size:15px;color:#3fb950', + 'font-weight:bold;font-size:14px;color:#d29922', + 'font-weight:bold;font-size:13px;color:#58a6ff', + 'font-weight:bold;font-size:13px;color:#a371f7', + 'font-weight:bold;font-size:13px;color:#39c5cf' + ] + }; + + function Printer(chunkSize) { + this.chunk = chunkSize || 160; + this.fmt = []; + this.css = []; + this.pending = 0; + } + Printer.prototype.push = function (text, css) { + this.fmt.push('%c' + String(text).replace(/%/g, '%%')); + this.css.push(css || ''); + this.pending++; + return this; + }; + Printer.prototype.line = function (text, css) { + this.push((text === undefined ? '' : text) + '\n', css); + if (this.pending >= this.chunk) this.flush(); + return this; + }; + Printer.prototype.br = function () { + this.push('\n', ''); + return this; + }; + Printer.prototype.flush = function () { + if (!this.fmt.length) return this; + var msg = this.fmt.join(''); + msg = msg.replace(/\n$/, ''); + console.log.apply(console, [msg].concat(this.css)); + this.fmt = []; + this.css = []; + this.pending = 0; + return this; + }; + + function banner(text) { + var p = new Printer(); + p.line('── ' + text + ' ' + repeat('─', Math.max(2, 46 - strWidth(text))), S.title); + p.flush(); + } + + function repeat(ch, n) { return n > 0 ? new Array(n + 1).join(ch) : ''; } + + function strWidth(s) { + var w = 0; + for (var i = 0; i < s.length; i++) { + w += /[\u2E80-\uFFFF]/.test(s[i]) ? 2 : 1; + } + return w; + } + + function fail(msg) { + console.log('%c⚠️ ' + msg, S.err); + return null; + } + + /* ===================================================================== + * §2. 宿主直读 + * + * 直接读博客页面已提供的宿主对象;不在缺失时做回退。 + * ===================================================================== */ + + var doc = global.document; + + /** + * GitHub Issues 访问用的 Basic 凭据。 + * @returns {{headers:object, owner:string, repo:string}} + */ + function githubAuth() { + var g = GitalkConfig; + return { + headers: { Authorization: 'Basic ' + btoa(g.clientID + ':' + g.clientSecret) }, + owner: g.owner, + repo: g.repo + }; + } + + /** + * 读取搜索索引 search.json,复用 main.js 的 getSearchJSON(带 localStorage 缓存)。 + * @returns {Promise<Array>} search.json 原始数组 + */ + function loadSearchJSON() { + return new Promise(function (resolve) { + getSearchJSON(resolve); + }); + } + + /* ===================================================================== + * §3. 通用请求工具 + * ===================================================================== */ + + /** 请求 JSON;网络失败时返回 null,交给上层返回结构化的领域错误。 */ + function fetchJSON(url, options) { + return fetch(url, options || {}) + .then(function (r) { return r.ok ? r.json() : null; }) + .catch(function () { return null; }); + } + + /** 请求纯文本;网络失败时返回 null。 */ + function fetchText(url, options) { + return fetch(url, options || {}) + .then(function (r) { return r.ok ? r.text() : null; }) + .catch(function () { return null; }); + } + + /** 解码 HTML 实体(search.json 的 title 经过 Liquid escape 过滤器处理)。 */ + function unescapeHTML(str) { + if (!str || str.indexOf('&') === -1) return str || ''; + if (!doc || !doc.createElement) return str; + var el = doc.createElement('textarea'); + el.innerHTML = str; + return el.value; + } + + /* ===================================================================== + * §4. 数据层 + * ===================================================================== */ + + var _articles = null; + + function normalize(item, index) { + var content = item.content || ''; + var tags = (item.tags || '') + .split(',') + .map(function (t) { return t.trim(); }) + .filter(Boolean); + return { + num: index + 1, + title: unescapeHTML(item.title || ''), + url: item.url || '', + date: item.date || '', + category: item.category || '', + tags: tags, + content: content, + excerpt: content.slice(0, config.previewLength) + + (content.length > config.previewLength ? '……' : ''), + link: item.url || '' + }; + } + + function getArticles(force) { + if (_articles && !force) return Promise.resolve(_articles); + return loadSearchJSON().then(function (data) { + if (!data) return null; + _articles = data.map(normalize); + return _articles; + }); + } + + function resolve(id) { + return getArticles().then(function (list) { + if (!list || !list.length) return null; + if (id === undefined || id === null || id === '') { + return matchByPath(list, global.location && global.location.pathname); + } + var n = parseInt(id, 10); + if (!isNaN(n) && String(n) === String(id).trim()) { + return (n >= 1 && n <= list.length) ? list[n - 1] : null; + } + var s = String(id).trim(); + var byUrl = matchByPath(list, s); + if (byUrl) return byUrl; + var low = s.toLowerCase(); + var hit = list.filter(function (a) { + return a.title.toLowerCase().indexOf(low) !== -1; + }); + return hit.length ? hit[0] : null; + }); + } + + function matchByPath(list, path) { + if (!path) return null; + var dec = path, p = path; + try { dec = decodeURIComponent(p); } catch (e) { } + for (var i = 0; i < list.length; i++) { + var u = list[i].url, ud = u; + try { ud = decodeURIComponent(u); } catch (e) { } + if (u === p || ud === dec || u === dec || ud === p) return list[i]; + } + return null; + } + + function rawUrlOf(article) { + var dateDash = (article.date || '').replace(/\//g, '-'); + var last = (article.url || '').split('/').pop(); + try { last = decodeURIComponent(last); } catch (e) { } + var slug = last.replace(/\.html$/, ''); + return 'https://raw.githubusercontent.com/Mabbs/mabbs.github.io/refs/heads/master/_posts/' + dateDash + '-' + slug + '.md'; + } + + function brief(a, extra) { + if (!a) return null; + var o = { + num: a.num, + title: a.title, + date: a.date, + url: a.url, + category: a.category || '', + tags: a.tags.slice(), + excerpt: a.excerpt, + wordCount: a.content.length + }; + if (extra) for (var k in extra) if (extra.hasOwnProperty.call(extra, k)) o[k] = extra[k]; + return o; + } + + function err(code, message, extra) { + var r = { ok: false, error: { code: code, message: message } }; + if (extra) for (var k in extra) if (extra.hasOwnProperty.call(extra, k)) r[k] = extra[k]; + return r; + } + + /** 字符串/正则 → 带 g 标志的 RegExp。 */ + function toRegExp(pattern, opts) { + opts = opts || {}; + if (pattern instanceof RegExp) { + var f = pattern.flags.indexOf('g') === -1 ? pattern.flags + 'g' : pattern.flags; + return new RegExp(pattern.source, f); + } + var src = String(pattern); + var flags = opts.flags || 'gi'; + if (flags.indexOf('g') === -1) flags += 'g'; + if (!opts.regex) src = src.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(src, flags); + } + + /** 去掉 Markdown 的 YAML front matter。 */ + function stripFrontMatter(md) { + var lines = md.split('\n'); + if (!/^---\s*$/.test(lines[0])) return md; + for (var i = 1; i < lines.length; i++) { + if (/^---\s*$/.test(lines[i])) return lines.slice(i + 1).join('\n').replace(/^\n+/, ''); + } + return md; + } + + function clamp(v, min, max, dflt) { + var n = parseInt(v, 10); + if (isNaN(n)) return dflt; + return Math.min(max, Math.max(min, n)); + } + + /** 子序列模糊匹配(fuzzy 搜索用)。 */ + function fuzzyMatch(needle, hay) { + var i = 0; + for (var j = 0; j < hay.length && i < needle.length; j++) { + if (hay[j] === needle[i]) i++; + } + return i === needle.length; + } + + /* ===================================================================== + * §5. 服务层 —— 无副作用的业务逻辑 + * + * 每个方法都返回 { ok: true, ... } 或 { ok:false, error:{code,message} }, + * 不打印任何东西。控制台层负责渲染,WebMCP 层负责序列化。 + * 预期内的失败以结构化对象返回,让调用方(Agent/控制台)能自行纠错; + * 非预期异常(如宿主对象缺失)自然抛出,不再被吞掉或回退。 + * ===================================================================== */ + + var Service = {}; + + Service.list = function (page, pageSize) { + page = parseInt(page, 10) || 1; + pageSize = clamp(pageSize, 1, config.maxPageSize, config.pageSize); + if (page < 1) return Promise.resolve(err('BAD_INPUT', '页码必须是正整数')); + + return getArticles().then(function (list) { + if (!list) return err('INDEX_UNAVAILABLE', '无法获取文章列表'); + var total = list.length; + var totalPages = Math.ceil(total / pageSize) || 1; + var start = (page - 1) * pageSize; + if (start >= total) { + return err('OUT_OF_RANGE', '第 ' + page + ' 页没有文章(共 ' + totalPages + ' 页)', + { page: page, totalPages: totalPages, total: total }); + } + return { + ok: true, + page: page, + pageSize: pageSize, + total: total, + totalPages: totalPages, + posts: list.slice(start, Math.min(start + pageSize, total)) + }; + }); + }; + + Service.get = function (id) { + return resolve(id).then(function (a) { + if (!a) return err('NOT_FOUND', '未找到文章: ' + (id === undefined ? '(当前页面)' : id)); + return { ok: true, post: a }; + }); + }; + + /** 搜索文章:标题/分类/标签/正文的大小写不敏感包含匹配,fuzzy 启用子序列匹配。 */ + Service.search = function (keyword, opts) { + opts = opts || {}; + var limit = clamp(opts.limit, 1, config.maxPageSize, 10); + var fuzzy = !!opts.fuzzy; + if (!keyword) return Promise.resolve(err('BAD_INPUT', '请提供关键词')); + + return getArticles().then(function (list) { + if (!list) return err('INDEX_UNAVAILABLE', '无法获取文章列表'); + var low = String(keyword).toLowerCase(); + var out = list.filter(function (a) { + var hay = (a.title + ' ' + a.tags.join(' ') + ' ' + a.category + ' ' + a.content).toLowerCase(); + return fuzzy ? fuzzyMatch(low, hay) : hay.indexOf(low) !== -1; + }).slice(0, limit); + return { ok: true, keyword: keyword, fuzzy: fuzzy, count: out.length, posts: out }; + }); + }; + + /** 正则全文检索(search.json 已内联全文,无需额外请求)。 */ + Service.grep = function (pattern, opts) { + if (!pattern) return Promise.resolve(err('BAD_INPUT', '请提供检索内容')); + opts = opts || {}; + var ctx = clamp(opts.context, 0, 400, 40); + var limit = clamp(opts.limit, 1, config.maxPageSize, 20); + var re; + try { + re = toRegExp(pattern, opts); + } catch (e) { + return Promise.resolve(err('BAD_PATTERN', '无效的正则表达式: ' + e.message)); + } + + return getArticles().then(function (list) { + if (!list) return err('INDEX_UNAVAILABLE', '无法获取文章列表'); + var out = [], t0 = Date.now(), timedOut = false; + for (var i = 0; i < list.length && out.length < limit; i++) { + if (Date.now() - t0 > config.grepTimeBudget) { timedOut = true; break; } + var a = list[i], m, hits = []; + re.lastIndex = 0; + while ((m = re.exec(a.content)) !== null && hits.length < 3) { + var s = Math.max(0, m.index - ctx); + var e = Math.min(a.content.length, m.index + m[0].length + ctx); + hits.push((s > 0 ? '…' : '') + a.content.slice(s, e) + (e < a.content.length ? '…' : '')); + if (m[0] === '') re.lastIndex++; + } + if (hits.length) out.push({ article: a, matches: hits }); + } + return { ok: true, pattern: String(re), regex: re, timedOut: timedOut, hits: out }; + }); + }; + + Service.read = function (id) { + return resolve(id).then(function (a) { + if (!a) return err('NOT_FOUND', '未找到文章: ' + (id === undefined ? '(当前页面)' : id)); + var url = rawUrlOf(a); + return fetchText(url).then(function (md) { + if (md === null) return err('FETCH_FAILED', '无法获取原始 Markdown:' + url, { source: url }); + return { ok: true, post: a, source: url, markdown: md }; + }); + }); + }; + + Service.comments = function (id) { + return resolve(id).then(function (a) { + if (!a) return err('NOT_FOUND', '未找到文章: ' + (id === undefined ? '(当前页面)' : id)); + var auth = githubAuth(); + var label = a.url.replace(/\.html$/, ''); + var api = 'https://api.github.com/repos/' + auth.owner + '/' + auth.repo + + '/issues?labels=' + encodeURIComponent('Gitalk,' + label); + + return fetchJSON(api, { headers: auth.headers }).then(function (issues) { + if (!issues || !issues.length) { + return { ok: true, post: a, issueUrl: null, comments: [], reason: 'NO_ISSUE' }; + } + return fetchJSON(issues[0].comments_url, { headers: auth.headers }).then(function (cs) { + var out = (cs || []).map(function (c) { + return { + author: (c.user && c.user.login) || 'unknown', + date: c.created_at || '', + body: c.body || '' + }; + }); + return { + ok: true, post: a, issueUrl: issues[0].html_url, + comments: out, reason: out.length ? null : 'NO_COMMENT' + }; + }); + }); + }); + }; + + Service.open = function (id, opts) { + opts = opts || {}; + return resolve(id).then(function (a) { + if (!a) return err('NOT_FOUND', '未找到文章: ' + (id === undefined ? '(当前页面)' : id)); + if (opts.newTab) { + var w = global.open ? global.open(a.link, '_blank', 'noopener,noreferrer') : null; + if (!w) return err('POPUP_BLOCKED', '浏览器阻止了弹出窗口,请允许后重试', { post: a }); + return { ok: true, post: a, navigated: true, method: 'newTab' }; + } + var method = go(a.url); + if (method === 'none') return err('NAVIGATION_UNAVAILABLE', '当前环境无法执行跳转', { post: a }); + return { ok: true, post: a, navigated: true, method: method }; + }); + }; + + Service.random = function (opts) { + opts = opts || {}; + var willOpen = opts.open !== false; + return getArticles().then(function (list) { + if (!list || !list.length) return err('INDEX_UNAVAILABLE', '无法获取文章列表'); + var a = list[Math.floor(Math.random() * list.length)]; + var method = willOpen ? go(a.url) : null; + return { ok: true, post: a, navigated: willOpen && method !== 'none', method: method }; + }); + }; + + Service.current = function () { + var path = (global.location && global.location.pathname) || ''; + return getArticles().then(function (list) { + var a = list ? matchByPath(list, path) : null; + if (!a) return err('NOT_A_POST', '当前页面不是文章页:' + path, { path: path }); + return { ok: true, post: a, path: path }; + }); + }; + + Service.about = function () { + return fetchText('/humans.txt').then(function (t) { + if (t === null) return err('FETCH_FAILED', '无法获取 humans.txt'); + return { ok: true, url: '/humans.txt', text: t }; + }); + }; + + /* ===================================================================== + * §6. Markdown → 控制台样式渲染 + * ===================================================================== */ + + function inline(p, text, baseCss) { + var re = /(`[^`]+`)|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\n]+\*|_[^_\n]+_)|(~~[^~]+~~)|(!?\[[^\]]*\]\([^)]*\))/g; + var last = 0, m; + while ((m = re.exec(text)) !== null) { + if (m.index > last) p.push(text.slice(last, m.index), baseCss); + var tok = m[0]; + if (m[1]) { + p.push(tok.slice(1, -1), S.code); + } else if (m[2]) { + p.push(tok.slice(2, -2), baseCss + ';font-weight:bold'); + } else if (m[3]) { + p.push(tok.slice(1, -1), baseCss + ';font-style:italic'); + } else if (m[4]) { + p.push(tok.slice(2, -2), baseCss + ';text-decoration:line-through;opacity:.7'); + } else if (m[5]) { + var mm = /^(!?)\[([^\]]*)\]\(([^)]*)\)$/.exec(tok); + if (mm) { + var label = mm[2] || (mm[1] ? '图片' : '链接'); + p.push((mm[1] ? '🖼 ' : '') + label, S.link); + if (mm[3]) p.push(' (' + mm[3] + ')', S.dim); + } else { + p.push(tok, baseCss); + } + } + last = m.index + tok.length; + } + if (last < text.length) p.push(text.slice(last), baseCss); + p.push('\n', ''); + } + + function renderMarkdown(md, opts) { + opts = opts || {}; + var maxLines = opts.maxLines || config.maxShowLines; + var showFM = opts.frontMatter !== false; + + var lines = md.split('\n'); + var p = new Printer(); + var inCode = false, inFM = false, rendered = 0; + + for (var i = 0; i < lines.length && rendered < maxLines; i++) { + var raw = lines[i]; + var t = raw.replace(/^\s+/, ''); + + if (i === 0 && /^---\s*$/.test(t)) { inFM = true; if (showFM) p.line(raw, S.dim); rendered++; continue; } + if (inFM) { + if (/^---\s*$/.test(t)) inFM = false; + if (showFM) { p.line(raw, S.dim); rendered++; } + continue; + } + + if (/^```/.test(t) || /^~~~/.test(t)) { + inCode = !inCode; p.line(raw, S.dim); rendered++; continue; + } + if (inCode) { p.line(raw, S.code); rendered++; continue; } + + if (t === '') { p.line(''); rendered++; continue; } + + var h = t.match(/^(#{1,6})\s+/); + if (h) { p.line(raw, S.h[h[1].length] || S.h[6]); rendered++; continue; } + + var hr = t.replace(/\s/g, ''); + if (/^-{3,}$/.test(hr) || /^\*{3,}$/.test(hr) || /^_{3,}$/.test(hr)) { + p.line(raw, S.hr); rendered++; continue; + } + + if (/^>/.test(t)) { p.line(raw, S.quote); rendered++; continue; } + + if (/^[-*+]\s/.test(t)) { + var ind = raw.match(/^(\s*)/)[1]; + p.push(ind + t[0] + ' ', S.ok); + inline(p, t.replace(/^[-*+]\s+/, ''), ''); + rendered++; + if (p.pending >= p.chunk) p.flush(); + continue; + } + + if (/^\d+\.\s/.test(t)) { + var ind2 = raw.match(/^(\s*)/)[1]; + var mk = t.match(/^\d+\./)[0]; + p.push(ind2 + mk + ' ', S.num); + inline(p, t.replace(/^\d+\.\s+/, ''), ''); + rendered++; + if (p.pending >= p.chunk) p.flush(); + continue; + } + + inline(p, raw, ''); + rendered++; + if (p.pending >= p.chunk) p.flush(); + } + p.flush(); + + if (lines.length > rendered) { + console.log('%c… 已省略 ' + (lines.length - rendered) + ' 行', S.warn); + } + } + + /* ===================================================================== + * §7. 控制台 API(window.Blog.*) + * + * 全部保留原有签名与返回值,内部改为调用服务层。 + * ===================================================================== */ + + var Blog = {}; + + Blog.config = config; + Blog.service = Service; + + // ---------------------------------------------------------------- 数据 + + Blog.get = function (id) { + return Service.get(id).then(function (r) { + if (!r.ok) return fail(r.error.message); + var a = r.post; + banner('文章 #' + a.num); + var p = new Printer(); + p.line(a.title, S.title); + p.push('日期 ', S.dim).line(a.date, S.date); + p.push('链接 ', S.dim).line(a.link, S.link); + if (a.category) p.push('分类 ', S.dim).line(a.category, S.tag); + if (a.tags.length) p.push('标签 ', S.dim).line(a.tags.join(' #'), S.tag); + p.push('字数 ', S.dim).line(String(a.content.length), S.num); + p.line(''); + p.line(a.excerpt, S.sub); + p.flush(); + return a; + }); + }; + + Blog.list = function (page, pageSize) { + return Service.list(page, pageSize).then(function (r) { + if (!r.ok) return fail(r.error.message); + banner('博客文章列表 · 第 ' + r.page + '/' + r.totalPages + ' 页'); + var obj = {}; + r.posts.forEach(function (a) { obj[a.num] = { '日期': a.date, '标题': a.title }; }); + console.table(obj); + console.log('%c共 ' + r.total + ' 篇 · Blog.show(id) 读正文 · Blog.open(id) 跳转 · Blog.comment(id) 看评论', S.sub); + return r.posts; + }); + }; + + Blog.search = function (keyword, opts) { + return Service.search(keyword, opts).then(function (r) { + if (!r.ok) return fail(r.error.message); + banner('搜索「' + keyword + '」· ' + r.posts.length + ' 条结果'); + if (!r.posts.length) { + console.log('%c没有匹配的文章。', S.warn); + return r.posts; + } + var obj = {}; + r.posts.forEach(function (a) { + obj[a.num] = { '日期': a.date, '标题': a.title, '摘要': a.excerpt.slice(0, 40) }; + }); + console.table(obj); + console.log('%c用 Blog.show(' + r.posts[0].num + ') 查看第一条结果。', S.sub); + return r.posts; + }); + }; + + Blog.grep = function (pattern, opts) { + return Service.grep(pattern, opts).then(function (r) { + if (!r.ok) return fail(r.error.message); + banner('grep ' + r.pattern + ' · ' + r.hits.length + ' 篇命中'); + if (!r.hits.length) { console.log('%c无命中。', S.warn); return r.hits; } + var p = new Printer(); + r.hits.forEach(function (h) { + p.push('#' + h.article.num + ' ', S.num).line(h.article.title, S.strong); + h.matches.forEach(function (t) { p.line(' ' + t, S.sub); }); + }); + p.flush(); + if (r.timedOut) console.log('%c(已达检索时间上限,结果可能不完整)', S.warn); + return r.hits; + }); + }; + + // ---------------------------------------------------------------- 内容 + + Blog.show = function (id, opts) { + opts = opts || {}; + return Service.read(id).then(function (r) { + if (!r.ok) return fail(r.error.message); + if (opts.raw) return r.markdown; + banner(r.post.title); + var p = new Printer(); + p.push('日期 ', S.dim).push(r.post.date, S.date) + .push(' 来源 ', S.dim).line(r.source, S.link); + p.line(''); + p.flush(); + renderMarkdown(r.markdown, opts); + }); + }; + + Blog.comment = function (id) { + return Service.comments(id).then(function (r) { + if (!r.ok) return fail(r.error.message); + if (!r.comments.length) { + console.log('%c📭 文章「' + r.post.title + '」暂无评论' + + (r.reason === 'NO_ISSUE' ? '(未找到对应 Issue)' : ''), S.warn); + return []; + } + banner('评论 · ' + r.post.title + ' · ' + r.comments.length + ' 条'); + var p = new Printer(); + r.comments.forEach(function (c, i) { + p.push((i + 1) + '. ', S.num).push(c.author, S.ok).line(' ' + c.date, S.dim); + c.body.split('\n').forEach(function (l) { p.line(' ' + l, ''); }); + p.line(''); + }); + p.flush(); + console.log('%c原 Issue: ' + r.issueUrl, S.sub); + return r.comments; + }); + }; + + // ---------------------------------------------------------------- 导航 + + Blog.open = function (id, opts) { + return Service.open(id, opts).then(function (r) { + if (!r.ok) return fail(r.error.message); + console.log('%c✅ ' + (r.method === 'newTab' ? '已在新标签页打开:' : '正在跳转:') + + '%c' + r.post.title, S.ok, S.strong); + console.log('%c' + r.post.link, S.link); + return r.post; + }); + }; + + Blog.random = function (opts) { + return Service.random(opts).then(function (r) { + if (!r.ok) return fail(r.error.message); + console.log('%c🎲 随机文章 #' + r.post.num + ':%c' + r.post.title, S.ok, S.strong); + console.log('%c' + r.post.link, S.link); + return r.post; + }); + }; + + Blog.current = function () { + return Service.current().then(function (r) { + if (!r.ok) { console.log('%c' + r.error.message, S.warn); return null; } + return Blog.get(r.post.num); + }); + }; + + // ---------------------------------------------------------------- 其他 + + Blog.about = function () { + return Service.about().then(function (r) { + if (!r.ok) return fail(r.error.message); + banner('关于本站'); + console.log('%c' + r.text, 'line-height:1.5'); + return r.text; + }); + }; + + Blog.help = function () { + var groups = [ + ['数据查询', [ + ['Blog.list(page, size)', '分页列出文章,表格输出,页码从 1 开始'], + ['Blog.get(id)', '查看单篇文章元信息,id 支持 序号/URL/标题'], + ['Blog.search(kw, opts)', '搜索文章'], + ['Blog.grep(re, opts)', '用正则检索全文并显示上下文片段'] + ]], + ['内容读取', [ + ['Blog.show(id, opts)', '阅读文章正文'], + ['Blog.comment(id)', '获取 Gitalk 评论(GitHub Issues)'], + ['Blog.current()', '当前页面对应的文章信息'] + ]], + ['导航跳转', [ + ['Blog.open(id, {newTab})', '打开文章'], + ['Blog.random({open})', '随机一篇文章'] + ]], + ['其他', [ + ['Blog.about()', '站点与作者信息'] + ]] + ]; + + var p = new Printer(); + p.line(''); + p.line(' Mayx 博客控制台 API', 'font-weight:bold;font-size:15px;color:#3fb950'); + p.line(''); + groups.forEach(function (g) { + p.line('▍' + g[0], S.h[3]); + g[1].forEach(function (row) { + var pad = repeat(' ', Math.max(1, 26 - strWidth(row[0]))); + p.push(' ' + row[0], S.code).line(pad + row[1], S.sub); + }); + p.line(''); + }); + p.line('示例:', S.strong); + p.line(' await Blog.list(1) 列出第 1 页文章', S.sub); + p.line(' await Blog.search("Jekyll") 搜索关键词', S.sub); + p.line(' await Blog.show(1) 阅读第 1 篇文章', S.sub); + p.line(' await Blog.open(1) 跳转到第 1 篇文章', S.sub); + p.line(''); + p.flush(); + }; + + /* ===================================================================== + * §8. WebMCP 适配层 + * + * 把服务层暴露成 ModelContextTool。execute 直接就是服务层调用; + * 预期内的失败以 { ok:false, error:{code,message} } 返回(结构化,Agent 可纠错), + * 非预期异常直接 reject,交给 WebMCP 宿主上报,不做任何 try/catch 兜底。 + * ===================================================================== */ + + var TOOL_NAME_RE = /^[A-Za-z0-9_.-]{1,128}$/; + + function toolName(suffix) { return config.toolPrefix + suffix; } + + function emptySchema() { + return { type: 'object', properties: {}, additionalProperties: false }; + } + + function idProp(extra) { + return { + type: 'string', + description: '文章标识:序号(如 "3",1 表示最新一篇)、URL 路径(如 "/2026/08/01/terminal.html")' + + '或标题关键词(模糊匹配,取第一条)。' + (extra || '留空表示当前正在浏览的文章。') + }; + } + + function buildTools() { + return [ + { + name: toolName('list_posts'), + title: '列出博客文章', + description: '按发布时间倒序分页列出 Mayx 博客的全部文章,返回序号、标题、日期、链接、分类、标签与摘要。' + + '需要浏览全站内容或确认某篇文章序号时使用;不返回正文,正文请用 ' + toolName('read_post') + '。', + annotations: { readOnlyHint: true, untrustedContentHint: false }, + inputSchema: { + type: 'object', + properties: { + page: { type: 'integer', minimum: 1, default: 1, description: '页码,从 1 开始' }, + pageSize: { + type: 'integer', minimum: 1, maximum: config.maxPageSize, + default: config.pageSize, description: '每页条数,最大 ' + config.maxPageSize + } + }, + additionalProperties: false + }, + execute: function (a) { + a = a || {}; + return Service.list(a.page, a.pageSize).then(function (r) { + if (!r.ok) return r; + return { + ok: true, page: r.page, pageSize: r.pageSize, + total: r.total, totalPages: r.totalPages, + posts: r.posts.map(function (x) { return brief(x); }) + }; + }); + } + }, + { + name: toolName('get_post'), + title: '查看文章信息', + description: '按序号、URL 或标题关键词定位一篇文章,返回其元信息(标题、日期、链接、分类、标签、字数、摘要)。' + + '只要元信息时用它,比读取正文便宜得多。', + annotations: { readOnlyHint: true, untrustedContentHint: false }, + inputSchema: { + type: 'object', + properties: { id: idProp() }, + additionalProperties: false + }, + execute: function (a) { + a = a || {}; + return Service.get(a.id).then(function (r) { + return r.ok ? { ok: true, post: brief(r.post) } : r; + }); + } + }, + { + name: toolName('search_posts'), + title: '搜索博客文章', + description: '用关键词搜索博客文章,匹配标题、分类、标签与正文,返回命中文章的元信息与摘要。' + + '适合「博客里写过 X 吗」这类问题。', + annotati |
