Дизайн-фиксы: - Перенесены картинки из старого WP в public/wp-content/uploads/ (header_bg.gif + 3 размера NY-baner.png 2014/12). migrate-wp.mjs обновлён: корректно обрабатывает <a><img></a>, переписывает абсолютные URL anotherreflections.ru/wp-content/... в относительные - Description в frontmatter теперь чистый plain text — без markdown-разметки - Слаги 11/95/152 → kto-my, s-23-fevralya-2011, s-nastupayushhim-novym-2012-godom - Hero-метрика: «N лет онлайн» → «с 2006 года» (последний пост 2015, активной жизни «20 лет» нет) - Drop cap (буквица) — только для постов длиннее 240 символов (короткие посты раньше выглядели обрезанными) Аналитика и конфиденциальность: - Яндекс.Метрика (counter 13938862, webvisor) и Google gtag (GT-PH39R3X) перенесены со старого WP - Cookie consent banner — самописный, без зависимостей: счётчики грузятся только после явного «Принять». Выбор хранится в localStorage + cookie ar-consent на 12 месяцев. Уведомление в духе 152-ФЗ - /privacy/ — полная политика конфиденциальности: что собираем (через Метрику и GA), для чего, как cookies устроены, права пользователя, контакт для запросов на удаление - В футере добавлены ссылки «Политика конфиденциальности» и «Контакты» - sitemap.txt + llms.txt включают /privacy/
157 lines
5.8 KiB
JavaScript
157 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
const EXPORT_PATH = path.join(ROOT, '_wp-export.json');
|
||
const POSTS_DIR = path.join(ROOT, 'src/content/posts');
|
||
const PAGES_DIR = path.join(ROOT, 'src/content/pages');
|
||
|
||
const data = JSON.parse(fs.readFileSync(EXPORT_PATH, 'utf8'));
|
||
|
||
fs.mkdirSync(POSTS_DIR, { recursive: true });
|
||
fs.mkdirSync(PAGES_DIR, { recursive: true });
|
||
|
||
const decodeEntities = (s) => s
|
||
.replace(/ /g, ' ')
|
||
.replace(/«/g, '«')
|
||
.replace(/»/g, '»')
|
||
.replace(/—/g, '—')
|
||
.replace(/–/g, '–')
|
||
.replace(/…/g, '…')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/'/g, "'")
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&');
|
||
|
||
/** Заменить старые WP-uploads URL на относительные (мы скачали в public/wp-content/uploads/). */
|
||
const rewriteWpUploads = (url) =>
|
||
url.replace(/^https?:\/\/anotherreflections\.ru(\/wp-content\/uploads\/.+)$/i, '$1');
|
||
|
||
const htmlToMd = (html) => {
|
||
if (!html) return '';
|
||
let s = html;
|
||
// Сначала "вложенные" <a href="..."><img src="..."></a> — превращаем в один markdown-image со ссылкой
|
||
s = s.replace(/<a\s+[^>]*?href=["']([^"']+)["'][^>]*>\s*<img[^>]*?src=["']([^"']+)["'][^>]*?(?:alt=["']([^"']*)["'])?[^>]*?\/?>\s*<\/a>/gi,
|
||
(_, href, src, alt) => `[})](${rewriteWpUploads(href)})`);
|
||
// одиночные images
|
||
s = s.replace(/<img[^>]*?src=["']([^"']+)["'][^>]*?(?:alt=["']([^"']*)["'])?[^>]*?\/?>/gi,
|
||
(_, src, alt) => `})`);
|
||
// links
|
||
s = s.replace(/<a\s+[^>]*?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
|
||
(_, href, text) => `[${text.replace(/<[^>]+>/g,'').trim()}](${rewriteWpUploads(href)})`);
|
||
// bold
|
||
s = s.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**');
|
||
// italic
|
||
s = s.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*');
|
||
// blockquote
|
||
s = s.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi,
|
||
(_, inner) => inner.trim().split(/\n+/).map(l => '> ' + l.trim()).join('\n'));
|
||
// lists
|
||
s = s.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n');
|
||
s = s.replace(/<\/?(ul|ol)[^>]*>/gi, '\n');
|
||
// paragraphs
|
||
s = s.replace(/<p[^>]*>/gi, '\n\n');
|
||
s = s.replace(/<\/p>/gi, '\n\n');
|
||
// br
|
||
s = s.replace(/<br\s*\/?>/gi, '\n');
|
||
// strip remaining span/div etc.
|
||
s = s.replace(/<\/?(?:span|div|font|center)[^>]*>/gi, '');
|
||
// any remaining tags — drop
|
||
s = s.replace(/<[^>]+>/g, '');
|
||
// entities
|
||
s = decodeEntities(s);
|
||
// collapse 3+ blank lines
|
||
s = s.replace(/\n{3,}/g, '\n\n');
|
||
return s.trim();
|
||
};
|
||
|
||
const yamlEscape = (s) => {
|
||
if (s == null) return '""';
|
||
const str = String(s);
|
||
if (/^[\w\-.,!?:; À-()«»—–"']+$/.test(str) && !/^[-?:&*!|>%@`]/.test(str)) {
|
||
return JSON.stringify(str);
|
||
}
|
||
return JSON.stringify(str);
|
||
};
|
||
|
||
const fmtFrontmatter = (obj) => {
|
||
const lines = ['---'];
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
if (v === undefined || v === null) continue;
|
||
if (Array.isArray(v)) {
|
||
if (v.length === 0) continue;
|
||
lines.push(`${k}:`);
|
||
v.forEach(item => lines.push(` - ${yamlEscape(item)}`));
|
||
} else if (typeof v === 'object') {
|
||
lines.push(`${k}:`);
|
||
Object.entries(v).forEach(([kk, vv]) => lines.push(` ${kk}: ${yamlEscape(vv)}`));
|
||
} else {
|
||
lines.push(`${k}: ${yamlEscape(v)}`);
|
||
}
|
||
}
|
||
lines.push('---');
|
||
return lines.join('\n');
|
||
};
|
||
|
||
const authorsMap = Object.fromEntries(data.authors.map(a => [a.id, a.display_name || a.login]));
|
||
|
||
// Posts
|
||
let postCount = 0;
|
||
for (const p of data.posts) {
|
||
const fm = {
|
||
title: p.title,
|
||
pubDate: p.date.replace(' ', 'T') + '+03:00',
|
||
updatedDate: p.modified && p.modified !== p.date ? p.modified.replace(' ','T') + '+03:00' : undefined,
|
||
slug: p.slug,
|
||
legacyId: p.id,
|
||
author: authorsMap[p.author_id] || 'admin',
|
||
categories: p.categories.map(c => c.name),
|
||
categorySlugs: p.categories.map(c => c.slug),
|
||
tags: p.tags.map(t => t.name),
|
||
description: (htmlToMd(p.excerpt) || htmlToMd(p.content_html))
|
||
.replace(/!?\[([^\]]*)\]\([^)]+\)/g, '$1') // strip markdown images/links для excerpt
|
||
.replace(/[*_>#]+/g, '') // strip md formatting символов
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.slice(0, 200),
|
||
};
|
||
const body = htmlToMd(p.content_html);
|
||
const out = `${fmtFrontmatter(fm)}\n\n${body}\n`;
|
||
const safeName = p.slug.replace(/[^a-z0-9а-я\-]/gi, '-').slice(0, 80) || `post-${p.id}`;
|
||
fs.writeFileSync(path.join(POSTS_DIR, `${safeName}.md`), out, 'utf8');
|
||
postCount++;
|
||
}
|
||
|
||
// Pages
|
||
let pageCount = 0;
|
||
for (const pg of data.pages) {
|
||
const fm = {
|
||
title: pg.title,
|
||
slug: pg.slug,
|
||
legacyId: pg.id,
|
||
menuOrder: pg.menu_order,
|
||
pubDate: pg.date.replace(' ', 'T') + '+03:00',
|
||
updatedDate: pg.modified !== pg.date ? pg.modified.replace(' ','T') + '+03:00' : undefined,
|
||
};
|
||
const body = htmlToMd(pg.content_html);
|
||
const out = `${fmtFrontmatter(fm)}\n\n${body}\n`;
|
||
const safeName = pg.slug.replace(/[^a-z0-9а-я\-]/gi, '-').slice(0, 80) || `page-${pg.id}`;
|
||
fs.writeFileSync(path.join(PAGES_DIR, `${safeName}.md`), out, 'utf8');
|
||
pageCount++;
|
||
}
|
||
|
||
// Categories dump (для построения /category/<slug>/ страниц и фидов)
|
||
const catsOut = data.categories.map(c => ({
|
||
name: c.name, slug: c.slug, description: c.description, count: c.count
|
||
}));
|
||
fs.writeFileSync(path.join(ROOT, 'src/content/_categories.json'), JSON.stringify(catsOut, null, 2), 'utf8');
|
||
|
||
console.log(`Migrated ${postCount} posts → ${POSTS_DIR}`);
|
||
console.log(`Migrated ${pageCount} pages → ${PAGES_DIR}`);
|
||
console.log(`Categories: ${catsOut.length} → src/content/_categories.json`);
|