feat: add GmeekSEO plugin (canonical, clean description, Twitter Card, BlogPosting JSON-LD)

This commit is contained in:
yeyangchen2009 2026-09-16 07:49:12 +08:00
parent 77a8aaa2b1
commit ba7eec5d7e
1 changed files with 138 additions and 0 deletions

138
plugins/GmeekSEO.js Normal file
View File

@ -0,0 +1,138 @@
/* GmeekSEO —— 文章页/固定页 head 元数据运行时补全
* 1) canonical 绝对 URL(复用框架已生成的 og:url,不自己拼域名)
* 2) 清洁 meta/og/twitter description(渲染后 #postBody.textContent,天然剥离 markdown 语法)
* 3) twitter:card 全套(summary_large_image;缺失时各社交平台本会回退读 OG)
* 4) 文章页 BlogPosting JSON-LD(日期/标签/作者/发布者,数据取自 /postList.json)
*
* 作用域:config 的 script 字段只注入文章页与固定页底部,首页/列表页不会加载本插件;
* 正则再兜底:只在 /post/N.html 与固定页(about.html/archive.html 等,
* 取路径末段判断,兼容 project pages 子路径部署)生效。
* JSON-LD 仅普通文章生成;固定页只有 canonical + 摘要 + twitter。
*
* 已知边界:canonical/JSON-LD 等是运行时注入,静态 HTML 源码(curl)里看不到,
* 依赖爬虫的渲染队列(Google/Bing 支持渲染;不渲染 JS 的爬虫读不到,
* 但框架构建期生成的静态 OG 标签不受影响,分享卡片仍可正常显示)。
*/
(function () {
'use strict';
if (window.__gmeekSEO) return;
window.__gmeekSEO = true;
var pathname = location.pathname;
var postMatch = pathname.match(/\/post\/(\d+)\.html$/);
// 取末段文件名判断,兼容用户站(/about.html)与 project pages(/repo/about.html)
var baseName = pathname.split('/').pop();
var isFixedPage = /^[A-Za-z0-9_-]+\.html$/.test(baseName);
if (!postMatch && !isFixedPage) return;
if (!document.getElementById('postBody')) return; // 没有正文容器,不碰 head
var DESC_LIMIT = 140;
var SENTENCE_MARKS = ['。', '!', '?', '.', '!', '?', ';', ';', ',', ','];
function $(sel) { return document.querySelector(sel); }
function ogContent(prop) {
var m = $('meta[property="og:' + prop + '"]');
return m ? m.getAttribute('content') : '';
}
function ensureMeta(selector, attr, key, content) {
var m = $(selector);
if (!m) {
m = document.createElement('meta');
m.setAttribute(attr, key);
document.head.appendChild(m);
}
m.setAttribute('content', content);
return m;
}
// 1) canonical:直接复用 og:url(框架构建期生成,绝对地址、无 query/hash)
var canonicalUrl = ogContent('url') || (location.origin + pathname);
if (!$('link[rel="canonical"]')) {
var link = document.createElement('link');
link.setAttribute('rel', 'canonical');
link.setAttribute('href', canonicalUrl);
document.head.appendChild(link);
}
// 2) 清洁摘要:克隆正文,剔除标题/代码块/插件 UI,textContent 归一化后按句读截断
function buildDescription() {
var body = document.getElementById('postBody');
if (!body) return '';
var clone = body.cloneNode(true);
var junk = clone.querySelectorAll(
'h1,h2,h3,h4,h5,h6,pre,script,style,nav,.gmeek-pn,#archiveTimeline,.toc');
for (var i = 0; i < junk.length; i++) {
junk[i].parentNode.removeChild(junk[i]);
}
var text = (clone.textContent || '').replace(/\s+/g, ' ').trim();
if (!text || text.length <= DESC_LIMIT) return text;
var head = text.slice(0, DESC_LIMIT);
var cut = -1;
for (var k = 0; k < SENTENCE_MARKS.length; k++) {
var p = head.lastIndexOf(SENTENCE_MARKS[k]);
if (p > cut) cut = p;
}
// 至少留 70 字才在句读处收,避免一个短引子后面跟着硬切
if (cut >= 70) return head.slice(0, cut + 1).trim();
return head.trim() + '…';
}
// 3) 同步补全(不依赖网络)
var title = ogContent('title') || document.title;
var image = ogContent('image');
// archive 这类正文只有挂载点的固定页:清洁结果为空 → 用干净标题,绝不用框架的脏原文
var desc = buildDescription() || ogContent('title') || ogContent('description');
if (desc) {
ensureMeta('meta[name="description"]', 'name', 'description', desc);
ensureMeta('meta[property="og:description"]', 'property', 'og:description', desc);
ensureMeta('meta[name="twitter:description"]', 'name', 'twitter:description', desc);
}
ensureMeta('meta[name="twitter:card"]', 'name', 'twitter:card', 'summary_large_image');
ensureMeta('meta[name="twitter:title"]', 'name', 'twitter:title', title);
if (image) ensureMeta('meta[name="twitter:image"]', 'name', 'twitter:image', image);
// 4) 文章页 JSON-LD(异步取 postList,不阻塞上面的同步部分)
if (postMatch) {
fetch('/postList.json', { cache: 'no-cache' })
.then(function (r) {
if (!r.ok) throw new Error('postList HTTP ' + r.status);
return r.json();
})
.then(function (list) {
var item = list['P' + postMatch[1]];
if (!item || !item.postUrl) return;
if ($('script[type="application/ld+json"]')) return;
var footerLink = $('#footer a');
var siteName = footerLink ? footerLink.textContent.trim() : location.hostname;
var authorName = siteName.replace(/的博客$/, '');
var ld = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: String(item.postTitle).slice(0, 110),
description: desc,
image: image || undefined, // 未配置 ogImage 时省略,避免输出空字符串被富结果测试判错
datePublished: item.createdDate,
keywords: Array.isArray(item.labels) ? item.labels.join(', ') : '',
author: { '@type': 'Person', name: authorName },
publisher: {
'@type': 'Organization',
name: siteName,
logo: image ? { '@type': 'ImageObject', url: image } : undefined
},
mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
url: canonicalUrl
};
var s = document.createElement('script');
s.setAttribute('type', 'application/ld+json');
s.textContent = JSON.stringify(ld);
document.head.appendChild(s);
})
.catch(function (e) { console.warn('GmeekSEO:', e && e.message); });
}
})();