mirror of https://github.com/Meekdai/Gmeek.git
139 lines
6.6 KiB
JavaScript
139 lines
6.6 KiB
JavaScript
/* 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); });
|
||
}
|
||
})();
|