Gmeek/plugins/GmeekSEO.js

139 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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); });
}
})();