Files
memind/mindspace-geo-backfill.mjs
T
john 357e26ab49
Memind CI / Test, build, and release guards (pull_request) Failing after 7m12s
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(seo-geo): 落盘自动补齐与 143 全量回填支持
Finish/交付写盘前自动回填 mindspace-geo,扩展 storage publications 扫描与 geo meta 解析,并增加 143 生产回填脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 10:26:02 +08:00

361 lines
13 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { injectBeforeDocumentClosingBody } from './html-document-injection.mjs';
import { parseMindspaceCoverMeta, upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
import {
extractFaqFromHeadings,
parseMindspaceGeoMeta,
resolveGeoFaq,
resolveGeoKeywords,
} from './mindspace-geo-meta.mjs';
function htmlWithoutEmbeddedCode(html) {
return String(html ?? '')
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ');
}
import { ensurePlatformBrandFooter, injectPlatformBrandVisibilityStyle } from './mindspace-page-tag.mjs';
import { PLATFORM_BRAND_ICON_PATH } from './mindspace-og-tags.mjs';
import { extractCoverSignals } from './mindspace-thumbnails.mjs';
import { extractSeoDescription } from './mindspace-seo-tags.mjs';
import { hasRasterShareImageHint } from './wechat/verify/share-preview.mjs';
export const DEFAULT_RASTER_COVER_URL = 'https://m.tkmind.cn/brand/tkmind-icon.png';
function escapeMetaAttribute(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
}
function stripHtml(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function rawTitleFromHtml(html) {
return String(html ?? '').match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? '';
}
function rawH1FromHtml(html) {
const match = String(html ?? '').match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
return match ? stripHtml(match[1]) : '';
}
function rawFirstParagraph(html) {
const match = String(html ?? '').match(/<p[^>]*>([\s\S]*?)<\/p>/i);
return match ? stripHtml(match[1]) : '';
}
function hasMeta(html, name) {
return new RegExp(`<meta[^>]+name=["']${name}["']`, 'i').test(String(html ?? ''));
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function countH2(html) {
return (String(html ?? '').match(/<h2\b/gi) ?? []).length;
}
function hasShareDescription(html) {
return Boolean(extractSeoDescription(html));
}
export function repairMindspaceGeoMeta(html) {
const source = String(html ?? '');
const parsed = parseMindspaceGeoMeta(source);
const hasValidSummary = String(parsed.summary ?? '').trim().length > 0;
const hasTag = /<meta[^>]*name=["']mindspace-geo["']/i.test(source);
if (hasTag && hasValidSummary) return source;
return upsertMindspaceGeoMeta(source, buildMindspaceGeoPayload(source), { forceReplace: true });
}
export function buildMindspaceGeoPayload(html) {
const source = htmlWithoutEmbeddedCode(html);
const title = rawH1FromHtml(source) || rawTitleFromHtml(source) || 'TKMind 页面';
const cover = extractCoverSignals(source);
const summary =
String(parseMindspaceGeoMeta(source).summary ?? '').trim() ||
extractSeoDescription(source) ||
String(cover.subtitle ?? '').trim() ||
rawFirstParagraph(source) ||
title;
let keywords = resolveGeoKeywords(source);
if (keywords.length < 2) {
keywords = [title, cover.tag, 'TKMind'].map((item) => String(item ?? '').trim()).filter(Boolean);
}
keywords = [...new Set(keywords)].slice(0, 8);
let faq = resolveGeoFaq(source);
if (faq.length < 1) {
faq = extractFaqFromHeadings(source);
}
if (faq.length < 1 && summary) {
faq = [{ q: `${title}讲什么?`, a: summary.slice(0, 220) }];
}
return {
summary: summary.slice(0, 320),
keywords,
faq: faq.slice(0, 6),
};
}
export function upsertMindspaceGeoMeta(html, geoPayload, { forceReplace = false } = {}) {
const payload = forceReplace
? { ...(geoPayload ?? {}) }
: {
...buildMindspaceGeoPayload(html),
...(geoPayload && typeof geoPayload === 'object' ? geoPayload : {}),
};
payload.summary = String(payload.summary ?? '').trim().slice(0, 320);
payload.keywords = Array.isArray(payload.keywords)
? payload.keywords.map((item) => String(item ?? '').trim()).filter(Boolean).slice(0, 12)
: [];
payload.faq = Array.isArray(payload.faq)
? payload.faq
.map((item) => ({
q: String(item?.q ?? '').trim().slice(0, 120),
a: String(item?.a ?? '').trim().slice(0, 500),
}))
.filter((item) => item.q && item.a && item.a.length >= 4)
.slice(0, 6)
: [];
if (payload.faq.length < 1 && payload.summary) {
const topic = String(rawH1FromHtml(html) || rawTitleFromHtml(html) || payload.summary).trim();
payload.faq = [{ q: `${topic}讲什么?`, a: payload.summary.slice(0, 220) }];
}
const metaTag = `<meta name="mindspace-geo" content="${escapeMetaAttribute(JSON.stringify(payload))}">`;
const source = String(html ?? '');
if (hasMeta(source, 'mindspace-geo')) {
return source.replace(/<meta[^>]*name=["']mindspace-geo["'][^>]*>\s*/i, `${metaTag.trim()}\n `);
}
if (/<meta[^>]*name=["']mindspace-cover["'][^>]*>/i.test(source)) {
return source.replace(
/(<meta[^>]*name=["']mindspace-cover["'][^>]*>)/i,
`$1\n ${metaTag.trim()}`,
);
}
if (/<meta[^>]*name=["']description["'][^>]*>/i.test(source)) {
return source.replace(
/(<meta[^>]*name=["']description["'][^>]*>)/i,
`$1\n ${metaTag.trim()}`,
);
}
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n ${metaTag.trim()}`);
}
return `${metaTag.trim()}\n${source}`;
}
export function upsertDescriptionMeta(html, description) {
const text = String(description ?? '').trim();
if (!text || hasMeta(html, 'description')) return String(html ?? '');
const metaTag = `<meta name="description" content="${escapeMetaAttribute(text)}">`;
const source = String(html ?? '');
if (/<title[^>]*>/i.test(source)) {
return source.replace(/(<title[^>]*>[^<]*<\/title>)/i, `$1\n ${metaTag}`);
}
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n ${metaTag}`);
}
return `${metaTag}\n${source}`;
}
export function buildMinimalCoverMeta(html, { title = '' } = {}) {
const cover = parseMindspaceCoverMeta(html);
if (cover) return cover;
const signals = extractCoverSignals(String(html ?? ''));
const resolvedTitle = title || rawH1FromHtml(html) || rawTitleFromHtml(html) || 'TKMind 页面';
const description = extractSeoDescription(html) || rawFirstParagraph(html) || resolvedTitle;
return {
tag: signals.tag || '报告',
emoji: '📄',
accent: signals.accent || '#3b82f6',
accent2: signals.accent2 || '#1e293b',
subtitle: signals.subtitle || description.slice(0, 80),
};
}
function humanizeFilename(filePath) {
const base = path.basename(String(filePath ?? ''), '.html');
const decoded = decodeURIComponent(base);
return decoded
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function isWeakTitle(title) {
const value = String(title ?? '').trim();
if (!value || value.length < 2) return true;
if (/^(页面|我的页面|未命名|demo|test|index|MindSpace 页面)$/i.test(value)) return true;
if (/^[\x00-\x7F]+$/.test(value) && value.length < 4) return true;
return false;
}
function buildFallbackTitle(html, htmlFilePath = '') {
const cover = parseMindspaceCoverMeta(html);
const fromCover = [cover?.tag, cover?.subtitle]
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join(' · ');
if (fromCover && !isWeakTitle(fromCover)) return fromCover;
const fromH1 = rawH1FromHtml(html);
if (fromH1 && !isWeakTitle(fromH1)) return fromH1;
const fromFile = humanizeFilename(htmlFilePath);
if (fromFile && !isWeakTitle(fromFile)) return fromFile;
if (fromFile) return `TKMind · ${fromFile}`;
return 'TKMind 精选页面';
}
export function improveTitleFromContext(html, htmlFilePath = '') {
const source = String(html ?? '');
const current = rawTitleFromHtml(source);
if (!isWeakTitle(current)) return source;
const candidate =
[rawH1FromHtml(source), parseMindspaceCoverMeta(source)?.subtitle, buildFallbackTitle(source, htmlFilePath)]
.map((item) => String(item ?? '').trim())
.find((item) => item && !isWeakTitle(item)) ?? '';
if (!candidate) return source;
if (/<title[^>]*>[^<]*<\/title>/i.test(source)) {
return source.replace(/<title[^>]*>[^<]*<\/title>/i, `<title>${candidate}</title>`);
}
if (/<head[^>]*>/i.test(source)) {
return source.replace(/<head([^>]*)>/i, `<head$1>\n <title>${candidate}</title>`);
}
return source;
}
export function ensureH1FromTitle(html) {
const source = String(html ?? '');
if (/<h1\b/i.test(source)) return source;
const title = rawTitleFromHtml(source);
if (!title) return source;
if (/<body[^>]*>/i.test(source)) {
return source.replace(/<body([^>]*)>/i, `<body$1>\n <h1>${title}</h1>`);
}
return `${source}\n<h1>${title}</h1>`;
}
export function fixBrokenLocalCover(html, htmlFilePath = '') {
const coverMeta = parseMindspaceCoverMeta(html);
if (!coverMeta || !htmlFilePath) return String(html ?? '');
const cover = String(coverMeta.cover ?? coverMeta.image ?? '').trim();
if (!cover || /^https?:\/\//i.test(cover) || cover.startsWith('data:')) {
return String(html ?? '');
}
const clean = cover.split('?')[0].split('#')[0].replace(/^\.\//, '');
const base = path.dirname(htmlFilePath);
const target = path.resolve(base, clean);
if (target.startsWith(`${base}${path.sep}`) && fs.existsSync(target)) {
return String(html ?? '');
}
const nextMeta = { ...coverMeta };
delete nextMeta.cover;
delete nextMeta.image;
return upsertMindspaceCoverMeta(html, nextMeta, { replace: true });
}
export function ensureDefaultRasterCover(html, { coverUrl = DEFAULT_RASTER_COVER_URL } = {}) {
const source = String(html ?? '');
if (hasRasterShareImageHint(source)) return source;
const coverMeta = parseMindspaceCoverMeta(source) ?? buildMinimalCoverMeta(source);
return upsertMindspaceCoverMeta(source, {
...coverMeta,
cover: coverUrl || DEFAULT_RASTER_COVER_URL,
});
}
export function buildSeoOutlineSection(html) {
const geo = parseMindspaceGeoMeta(html);
const title = rawH1FromHtml(html) || rawTitleFromHtml(html) || '本页';
const summary = String(geo.summary ?? extractSeoDescription(html) ?? title).trim();
const faq = resolveGeoFaq(html, geo);
const blocks = [];
if (summary) {
blocks.push(
`<h2 style="font-size:17px;color:#334155;margin:0 0 8px">内容概要</h2>`,
`<p style="margin:0 0 16px">${escapeHtml(summary)}</p>`,
);
}
for (const item of faq.slice(0, 2)) {
blocks.push(
`<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">${escapeHtml(item.q)}</h2>`,
`<p style="margin:0 0 12px">${escapeHtml(item.a)}</p>`,
);
}
if (countH2(blocks.join('\n')) < 2) {
blocks.push(
`<h2 style="font-size:17px;color:#334155;margin:16px 0 8px">关于${escapeHtml(title)}</h2>`,
`<p style="margin:0 0 12px">${escapeHtml(summary || `${title} 由 TKMind 生成,可在浏览器中直接阅读与分享。`)}</p>`,
);
}
if (blocks.length === 0) return '';
return `<section data-mindspace-seo-outline="1" aria-label="页面提纲" style="margin:32px auto 20px;padding:20px 16px;max-width:920px;border-top:1px solid rgba(15,23,42,.08);color:#64748b;font-size:14px;line-height:1.75">${blocks.join('\n ')}</section>`;
}
export function ensureSeoHeadingStructure(html) {
const source = String(html ?? '');
if (countH2(source) >= 2 || /data-mindspace-seo-outline=/i.test(source)) return source;
const outline = buildSeoOutlineSection(source);
if (!outline) return source;
return injectBeforeDocumentClosingBody(source, `\n${outline}\n`);
}
export function ensureSeoGeoWarningsFixed(html, options = {}) {
let next = String(html ?? '');
if (options.fixPlatformBrand !== false) {
next = injectPlatformBrandVisibilityStyle(ensurePlatformBrandFooter(next));
}
if (options.fixRasterCover !== false) {
next = ensureDefaultRasterCover(next, { coverUrl: options.coverUrl });
}
if (options.fixHeadingStructure !== false) {
next = ensureSeoHeadingStructure(next);
}
return next;
}
export function backfillMindspaceSeoGeoHtml(html, options = {}) {
const htmlFilePath = String(options.htmlFilePath ?? '');
let next = String(html ?? '');
next = improveTitleFromContext(next, htmlFilePath);
next = fixBrokenLocalCover(next, htmlFilePath);
next = ensureH1FromTitle(next);
const title = rawH1FromHtml(next) || rawTitleFromHtml(next);
let geoPayload = buildMindspaceGeoPayload(next);
if (!parseMindspaceCoverMeta(next) && options.ensureCover !== false) {
next = upsertMindspaceCoverMeta(next, buildMinimalCoverMeta(next, { title }));
}
next = upsertDescriptionMeta(next, geoPayload.summary);
const existingGeo = parseMindspaceGeoMeta(next);
const needsGeoUpsert =
!hasMeta(next, 'mindspace-geo') ||
options.forceGeo ||
!String(existingGeo.summary ?? '').trim();
if (needsGeoUpsert) {
next = upsertMindspaceGeoMeta(next, geoPayload, {
forceReplace: options.forceGeo || !String(existingGeo.summary ?? '').trim(),
});
}
next = repairMindspaceGeoMeta(next);
if (options.fixWarnings !== false) {
next = ensureSeoGeoWarningsFixed(next, options);
}
return next;
}