174 lines
6.1 KiB
JavaScript
174 lines
6.1 KiB
JavaScript
import fs from 'node:fs';
|
|||
|
|
import path from 'node:path';
|
||
|
|
import zlib from 'node:zlib';
|
||
|
|
|
||
|
|
const root = process.cwd();
|
||
|
|
const backupPath = process.env.TYPECHO_SQL_GZ ?? 'C:/Users/hi/Desktop/备份/www_luming_cool_2026-08-01_01-30-01_mysql_data.sql.gz';
|
||
|
|
const postsDir = path.join(root, 'src', 'content', 'posts');
|
||
|
|
const coversDir = path.join(root, 'public', 'images', 'typecho-covers');
|
||
|
|
|
||
|
|
function sqlString(value) {
|
||
|
|
if (value === 'NULL') return null;
|
||
|
|
if (value.startsWith("'") && value.endsWith("'")) {
|
||
|
|
return value.slice(1, -1).replace(/\\([\\'"\\0bnrtZ])/g, (_, character) => ({ '\\': '\\', "'": "'", '"': '"', '0': '\0', b: '\b', n: '\n', r: '\r', t: '\t', Z: '\x1a' }[character] ?? character));
|
||
|
|
}
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
function splitSqlValues(input) {
|
||
|
|
const values = [];
|
||
|
|
let current = '';
|
||
|
|
let quote = false;
|
||
|
|
let escaped = false;
|
||
|
|
for (const character of input) {
|
||
|
|
if (quote) {
|
||
|
|
current += character;
|
||
|
|
if (escaped) escaped = false;
|
||
|
|
else if (character === '\\') escaped = true;
|
||
|
|
else if (character === "'") quote = false;
|
||
|
|
} else if (character === "'") {
|
||
|
|
quote = true;
|
||
|
|
current += character;
|
||
|
|
} else if (character === ',') {
|
||
|
|
values.push(sqlString(current.trim()));
|
||
|
|
current = '';
|
||
|
|
} else {
|
||
|
|
current += character;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
values.push(sqlString(current.trim()));
|
||
|
|
return values;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseInsertRows(sql, table) {
|
||
|
|
const marker = `INSERT INTO \`${table}\` VALUES `;
|
||
|
|
const start = sql.indexOf(marker);
|
||
|
|
if (start < 0) return [];
|
||
|
|
let end = start + marker.length;
|
||
|
|
let scanQuote = false;
|
||
|
|
let scanEscaped = false;
|
||
|
|
for (; end < sql.length; end += 1) {
|
||
|
|
const character = sql[end];
|
||
|
|
if (scanQuote) {
|
||
|
|
if (scanEscaped) scanEscaped = false;
|
||
|
|
else if (character === '\\') scanEscaped = true;
|
||
|
|
else if (character === "'") scanQuote = false;
|
||
|
|
} else if (character === "'") {
|
||
|
|
scanQuote = true;
|
||
|
|
} else if (character === ';') {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const input = sql.slice(start + marker.length, end);
|
||
|
|
const rows = [];
|
||
|
|
let row = '';
|
||
|
|
let depth = 0;
|
||
|
|
let rowQuote = false;
|
||
|
|
let rowEscaped = false;
|
||
|
|
for (const character of input) {
|
||
|
|
if (rowQuote) {
|
||
|
|
row += character;
|
||
|
|
if (rowEscaped) rowEscaped = false;
|
||
|
|
else if (character === '\\') rowEscaped = true;
|
||
|
|
else if (character === "'") rowQuote = false;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (character === "'") rowQuote = true;
|
||
|
|
if (character === '(') depth += 1;
|
||
|
|
if (character === ')') depth -= 1;
|
||
|
|
if (depth > 0) row += character;
|
||
|
|
if (depth === 0 && row) {
|
||
|
|
rows.push(splitSqlValues(row.slice(1, -1)));
|
||
|
|
row = '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return rows;
|
||
|
|
}
|
||
|
|
|
||
|
|
function tableColumns(sql, table) {
|
||
|
|
const tick = String.fromCharCode(96);
|
||
|
|
const match = sql.match(new RegExp('CREATE TABLE ' + tick + table + tick + ' \\(([\\s\\S]*?)\\)\\s+ENGINE', 'i'));
|
||
|
|
if (!match) throw new Error(`missing CREATE TABLE for ${table}`);
|
||
|
|
return [...match[1].matchAll(/^\s*`([^`]+)`/gm)].map((item) => item[1]);
|
||
|
|
}
|
||
|
|
|
||
|
|
function records(sql, table) {
|
||
|
|
const columns = tableColumns(sql, table);
|
||
|
|
return parseInsertRows(sql, table).map((values) => Object.fromEntries(columns.map((column, index) => [column, values[index] ?? null])));
|
||
|
|
}
|
||
|
|
|
||
|
|
function frontmatterRange(content) {
|
||
|
|
if (!content.startsWith('---')) return null;
|
||
|
|
const end = content.indexOf('\n---', 3);
|
||
|
|
return end < 0 ? null : [0, end + 4];
|
||
|
|
}
|
||
|
|
|
||
|
|
function yamlString(value) {
|
||
|
|
return JSON.stringify(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
function findPosts() {
|
||
|
|
const files = [];
|
||
|
|
const visit = (directory) => {
|
||
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||
|
|
const target = path.join(directory, entry.name);
|
||
|
|
if (entry.isDirectory()) visit(target);
|
||
|
|
else if (/\.md$|\.mdx$/i.test(entry.name)) files.push(target);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
visit(postsDir);
|
||
|
|
return files.map((file) => ({ file, content: fs.readFileSync(file, 'utf8') }));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const sql = zlib.gunzipSync(fs.readFileSync(backupPath)).toString('utf8');
|
||
|
|
const contents = records(sql, 'luming_contents');
|
||
|
|
const fields = records(sql, 'luming_fields');
|
||
|
|
const banners = new Map(fields.filter((field) => field.name === 'banner' && field.str_value).map((field) => [String(field.cid), field.str_value]));
|
||
|
|
const posts = findPosts();
|
||
|
|
const byTitle = new Map(posts.map((post) => [post.content.match(/^title:\s*["']?(.*?)["']?\s*$/m)?.[1]?.replace(/^['"]|['"]$/g, ''), post]));
|
||
|
|
fs.mkdirSync(coversDir, { recursive: true });
|
||
|
|
let downloaded = 0;
|
||
|
|
let failed = 0;
|
||
|
|
let changed = 0;
|
||
|
|
let matched = 0;
|
||
|
|
let existing = 0;
|
||
|
|
const coverFiles = new Map();
|
||
|
|
for (const [cid, url] of banners) {
|
||
|
|
const filename = path.basename(new URL(url).pathname);
|
||
|
|
const target = path.join(coversDir, filename);
|
||
|
|
if (!coverFiles.has(url)) {
|
||
|
|
try {
|
||
|
|
if (!fs.existsSync(target)) {
|
||
|
|
const response = await fetch(url);
|
||
|
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
||
|
|
fs.writeFileSync(target, Buffer.from(await response.arrayBuffer()));
|
||
|
|
}
|
||
|
|
coverFiles.set(url, `/images/typecho-covers/${filename}`);
|
||
|
|
downloaded += 1;
|
||
|
|
} catch (error) {
|
||
|
|
coverFiles.set(url, url);
|
||
|
|
failed += 1;
|
||
|
|
console.error(`download failed: ${url}: ${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const content = contents.find((item) => String(item.cid) === cid);
|
||
|
|
const post = content && byTitle.get(content.title);
|
||
|
|
if (!post) continue;
|
||
|
|
matched += 1;
|
||
|
|
const range = frontmatterRange(post.content);
|
||
|
|
if (!range) continue;
|
||
|
|
if (/^cover:\s*/m.test(post.content.slice(...range))) {
|
||
|
|
existing += 1;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const insertion = `cover: ${yamlString(coverFiles.get(url))}\noriginalCover: ${yamlString(url)}\n`;
|
||
|
|
const next = `${post.content.slice(0, range[0])}${post.content.slice(range[0], range[1]).replace(/^---\n/, `---\n${insertion}`)}${post.content.slice(range[1])}`;
|
||
|
|
fs.writeFileSync(post.file, next, 'utf8');
|
||
|
|
changed += 1;
|
||
|
|
}
|
||
|
|
console.log(JSON.stringify({ sqlContents: contents.length, bannerFields: banners.size, matchedPosts: matched, existingCovers: existing, changedFiles: changed, successfulDownloads: downloaded, failedDownloads: failed, unresolvedBanners: banners.size - matched }, null, 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
await main();
|