1002 lines
26 KiB
JavaScript
1002 lines
26 KiB
JavaScript
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import Fontmin from "fontmin";
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
// 读取配置文件获取语言设置和字体配置
|
||
async function getConfig() {
|
||
const configPath = path.join(__dirname, "../src/config.ts");
|
||
const configContent = fs.readFileSync(configPath, "utf-8");
|
||
|
||
// 提取语言设置
|
||
const langMatch = configContent.match(/const SITE_LANG = ["'](.+?)["']/);
|
||
const lang = langMatch ? langMatch[1] : "zh_CN";
|
||
|
||
// 提取字体配置
|
||
const fontConfigMatch = configContent.match(/font:\s*\{([\s\S]*?)\n\t\},/);
|
||
if (!fontConfigMatch) {
|
||
console.log("⚠ Font config not found, using default settings");
|
||
return { lang, fonts: [] };
|
||
}
|
||
|
||
const fontConfigStr = fontConfigMatch[1];
|
||
const fonts = [];
|
||
|
||
// 解析每个字体类别(asciiFont, cjkFont)
|
||
const fontTypes = ["asciiFont", "cjkFont"];
|
||
|
||
for (const fontType of fontTypes) {
|
||
const regex = new RegExp(`${fontType}:\\s*\\{([\\s\\S]*?)\\}`, "m");
|
||
const match = fontConfigStr.match(regex);
|
||
|
||
if (match) {
|
||
const fontConfig = match[1];
|
||
|
||
// 提取 enableCompress
|
||
const compressMatch = fontConfig.match(/enableCompress:\s*(true|false)/);
|
||
const enableCompress = compressMatch
|
||
? compressMatch[1] === "true"
|
||
: false;
|
||
|
||
// 提取 localFonts 数组
|
||
const localFontsMatch = fontConfig.match(/localFonts:\s*\[(.*?)\]/s);
|
||
let localFonts = [];
|
||
|
||
if (localFontsMatch?.[1].trim()) {
|
||
// 提取数组中的字符串
|
||
const fontsStr = localFontsMatch[1];
|
||
localFonts =
|
||
fontsStr
|
||
.match(/["']([^"']+)["']/g)
|
||
?.map((s) => s.replace(/["']/g, "")) || [];
|
||
}
|
||
|
||
if (enableCompress && localFonts.length > 0) {
|
||
fonts.push({
|
||
type: fontType,
|
||
files: localFonts,
|
||
enableCompress,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return { lang, fonts };
|
||
}
|
||
|
||
// 递归读取目录下所有文件
|
||
function readFilesRecursively(dir, fileList = []) {
|
||
const files = fs.readdirSync(dir);
|
||
|
||
files.forEach((file) => {
|
||
const filePath = path.join(dir, file);
|
||
const stat = fs.statSync(filePath);
|
||
|
||
if (stat.isDirectory()) {
|
||
readFilesRecursively(filePath, fileList);
|
||
} else {
|
||
fileList.push(filePath);
|
||
}
|
||
});
|
||
|
||
return fileList;
|
||
}
|
||
|
||
// 提取文本内容
|
||
function extractText(content, ext) {
|
||
let text = content;
|
||
let frontmatterText = "";
|
||
|
||
// 提取并处理 frontmatter 中的文本
|
||
if (ext === ".md" || ext === ".mdx") {
|
||
const frontmatterMatch = content.match(/^---[\s\S]*?---/m);
|
||
if (frontmatterMatch) {
|
||
const frontmatter = frontmatterMatch[0];
|
||
|
||
// 提取 frontmatter 中的字符串值(包括有引号和无引号的)
|
||
// 匹配 key: value 格式(无引号)
|
||
const unquotedMatches = frontmatter.match(/^\s*\w+:\s*([^'"\n]+)$/gm);
|
||
if (unquotedMatches) {
|
||
unquotedMatches.forEach((match) => {
|
||
const value = match.replace(/^\s*\w+:\s*/, "").trim();
|
||
// 排除布尔值、日期、数字等非文本内容
|
||
if (!value.match(/^(true|false|\d{4}-\d{2}-\d{2}|\d+)$/)) {
|
||
frontmatterText += `${value} `;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 提取带引号的字符串值
|
||
const quotedMatches = frontmatter.match(/:\s*['"]([^'"]+)['"]/g);
|
||
if (quotedMatches) {
|
||
quotedMatches.forEach((match) => {
|
||
const value = match.replace(/:\s*['"]([^'"]+)['"]/, "$1");
|
||
frontmatterText += `${value} `;
|
||
});
|
||
}
|
||
|
||
// 提取列表项中的文本(如 tags 列表)
|
||
const listMatches = frontmatter.match(/^\s*-\s*([^\n]+)$/gm);
|
||
if (listMatches) {
|
||
listMatches.forEach((match) => {
|
||
const value = match.replace(/^\s*-\s*/, "").trim();
|
||
frontmatterText += `${value} `;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 移除 frontmatter 后继续处理正文
|
||
text = text.replace(/^---[\s\S]*?---\s*/m, "");
|
||
|
||
// 移除代码块中的内容(通常不需要特殊字体)
|
||
text = text.replace(/```[\s\S]*?```/g, "");
|
||
text = text.replace(/`[^`]+`/g, "");
|
||
}
|
||
|
||
// 移除 HTML 标签
|
||
text = text.replace(/<[^>]*>/g, " ");
|
||
|
||
// 移除 Markdown 语法
|
||
text = text.replace(/[#*_~`[\]()]/g, " ");
|
||
|
||
// 移除 URL
|
||
text = text.replace(/https?:\/\/[^\s]+/g, "");
|
||
|
||
// 移除多余的空白字符
|
||
text = text.replace(/\s+/g, " ").trim();
|
||
|
||
// 合并 frontmatter 文本和正文
|
||
const finalText = `${frontmatterText} ${text}`.trim();
|
||
|
||
return finalText;
|
||
}
|
||
|
||
// 获取 ASCII 字符集(用于 asciiFont)
|
||
function getAsciiCharset() {
|
||
const chars = new Set();
|
||
|
||
// 基本 ASCII 字符:空格到波浪号 (32-126)
|
||
for (let i = 32; i <= 126; i++) {
|
||
chars.add(String.fromCharCode(i));
|
||
}
|
||
|
||
// 常用符号和标点
|
||
const common = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
||
for (const char of common) {
|
||
chars.add(char);
|
||
}
|
||
|
||
// 数字
|
||
for (let i = 0; i <= 9; i++) {
|
||
chars.add(String(i));
|
||
}
|
||
|
||
// 英文字母
|
||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||
for (const char of alphabet) {
|
||
chars.add(char);
|
||
}
|
||
|
||
const text = Array.from(chars).sort().join("");
|
||
|
||
return text;
|
||
}
|
||
|
||
// 获取 Meting API 歌单数据中的文字
|
||
async function fetchMetingPlaylistText() {
|
||
try {
|
||
// 读取配置文件获取音乐播放器配置
|
||
const configPath = path.join(__dirname, "../src/config.ts");
|
||
const configContent = fs.readFileSync(configPath, "utf-8");
|
||
|
||
// 检查音乐播放器是否启用
|
||
const enableMatch = configContent.match(
|
||
/musicPlayerConfig:\s*MusicPlayerConfig\s*=\s*\{[\s\S]*?enable:\s*(true|false)/,
|
||
);
|
||
if (!enableMatch || enableMatch[1] === "false") {
|
||
console.log(
|
||
"ℹ Music player disabled, skipping Meting API text collection",
|
||
);
|
||
return new Set();
|
||
}
|
||
|
||
// 提取音乐播放器配置(使用默认值,因为配置可能不完整)
|
||
// 在实际的音乐播放器组件中,如果配置中没有指定模式,默认使用 "meting"
|
||
const musicConfigMatch = configContent.match(
|
||
/musicPlayerConfig:\s*MusicPlayerConfig\s*=\s*\{([\s\S]*?)\}/,
|
||
);
|
||
let mode = "meting"; // 默认模式
|
||
let meting_api =
|
||
"https://www.bilibili.uno/api?server=:server&type=:type&id=:id&auth=:auth&r=:r";
|
||
let meting_id = "14164869977";
|
||
let meting_server = "netease";
|
||
let meting_type = "playlist";
|
||
|
||
if (musicConfigMatch) {
|
||
const configStr = musicConfigMatch[1];
|
||
|
||
const modeMatch = configStr.match(/mode:\s*["']([^"']+)["']/);
|
||
if (modeMatch) {
|
||
mode = modeMatch[1];
|
||
}
|
||
|
||
const apiMatch = configStr.match(/meting_api:\s*["']([^"']+)["']/);
|
||
if (apiMatch) {
|
||
meting_api = apiMatch[1];
|
||
}
|
||
|
||
const idMatch = configStr.match(/id:\s*["']([^"']+)["']/);
|
||
if (idMatch) {
|
||
meting_id = idMatch[1];
|
||
}
|
||
|
||
const serverMatch = configStr.match(/server:\s*["']([^"']+)["']/);
|
||
if (serverMatch) {
|
||
meting_server = serverMatch[1];
|
||
}
|
||
|
||
const typeMatch = configStr.match(/type:\s*["']([^"']+)["']/);
|
||
if (typeMatch) {
|
||
meting_type = typeMatch[1];
|
||
}
|
||
}
|
||
|
||
if (mode !== "meting") {
|
||
console.log(
|
||
'ℹ Music player mode is not "meting", skipping API text collection',
|
||
);
|
||
return new Set();
|
||
}
|
||
|
||
// 构建 API URL
|
||
const apiUrl = meting_api
|
||
.replace(":server", meting_server)
|
||
.replace(":type", meting_type)
|
||
.replace(":id", meting_id)
|
||
.replace(":auth", "")
|
||
.replace(":r", Date.now().toString());
|
||
|
||
console.log("ℹ Fetching music playlist from Meting API...");
|
||
console.log(` URL: ${apiUrl}`);
|
||
|
||
// 设置请求超时
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||
|
||
const textSet = new Set();
|
||
|
||
try {
|
||
const response = await fetch(apiUrl, {
|
||
signal: controller.signal,
|
||
headers: {
|
||
"User-Agent":
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
},
|
||
});
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const playlist = await response.json();
|
||
|
||
if (!Array.isArray(playlist)) {
|
||
throw new Error("API response is not an array");
|
||
}
|
||
|
||
console.log(
|
||
`✓ Successfully fetched ${playlist.length} songs from Meting API`,
|
||
);
|
||
|
||
// 提取歌曲信息中的文字
|
||
let songCount = 0;
|
||
playlist.forEach((song) => {
|
||
const title = song.name ?? song.title ?? "";
|
||
const artist = song.artist ?? song.author ?? "";
|
||
|
||
// 只处理有效的歌曲信息
|
||
if (title.trim() || artist.trim()) {
|
||
songCount++;
|
||
|
||
// 添加歌名中的字符
|
||
for (const char of title) {
|
||
textSet.add(char);
|
||
}
|
||
|
||
// 添加歌手名中的字符
|
||
for (const char of artist) {
|
||
textSet.add(char);
|
||
}
|
||
}
|
||
});
|
||
if (songCount === 0) {
|
||
console.log("⚠ No valid song data found in API response");
|
||
}
|
||
} catch (fetchError) {
|
||
clearTimeout(timeoutId);
|
||
|
||
if (fetchError.name === "AbortError") {
|
||
console.log(
|
||
"⚠ Meting API request timeout (10s), skipping music text collection",
|
||
);
|
||
} else {
|
||
console.log(
|
||
`⚠ Failed to fetch Meting API data: ${fetchError.message}, skipping music text collection`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return textSet;
|
||
} catch (error) {
|
||
console.log(
|
||
`⚠ Error processing Meting API config: ${error.message}, skipping music text collection`,
|
||
);
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
// 获取 Bangumi API 番剧数据中的文字
|
||
async function fetchBangumiAnimeText() {
|
||
try {
|
||
// 读取配置文件获取番剧配置
|
||
const configPath = path.join(__dirname, "../src/config.ts");
|
||
const configContent = fs.readFileSync(configPath, "utf-8");
|
||
|
||
// 检查番剧页面是否启用
|
||
const featurePagesMatch = configContent.match(
|
||
/featurePages:\s*\{([\s\S]*?)\}/,
|
||
);
|
||
if (featurePagesMatch) {
|
||
const featureConfig = featurePagesMatch[1];
|
||
const animeMatch = featureConfig.match(/anime:\s*(true|false)/);
|
||
if (!animeMatch || animeMatch[1] === "false") {
|
||
console.log(
|
||
"ℹ Anime page disabled, skipping Bangumi API text collection",
|
||
);
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
// 提取番剧配置
|
||
const bangumiUserIdMatch = configContent.match(
|
||
/bangumi:\s*\{[\s\S]*?userId:\s*["']([^"']+)["']/,
|
||
);
|
||
const animeModeMatch = configContent.match(
|
||
/anime:\s*\{[\s\S]*?mode:\s*["']([^"']+)["']/,
|
||
);
|
||
|
||
const userId = bangumiUserIdMatch ? bangumiUserIdMatch[1] : null;
|
||
const mode = animeModeMatch ? animeModeMatch[1] : "bangumi";
|
||
|
||
if (mode !== "bangumi" || !userId) {
|
||
console.log(
|
||
`ℹ Anime mode is not "bangumi" or no userId configured, skipping Bangumi API text collection`,
|
||
);
|
||
return new Set();
|
||
}
|
||
|
||
console.log("ℹ Fetching anime data from Bangumi API...");
|
||
console.log(` User ID: ${userId}`);
|
||
|
||
const textSet = new Set();
|
||
const BANGUMI_API_BASE = "https://api.bgm.tv";
|
||
|
||
// Bangumi 收藏类型:1=想看,2=看过,3=在看,4=搁置,5=抛弃
|
||
const collectionTypes = [1, 2, 3, 4, 5];
|
||
|
||
// 获取单个收藏列表
|
||
async function fetchCollection(userId, subjectType, type) {
|
||
try {
|
||
let allData = [];
|
||
let offset = 0;
|
||
const limit = 50;
|
||
let hasMore = true;
|
||
|
||
while (hasMore) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||
|
||
const response = await fetch(
|
||
`${BANGUMI_API_BASE}/v0/users/${userId}/collections?subject_type=${subjectType}&type=${type}&limit=${limit}&offset=${offset}`,
|
||
{
|
||
signal: controller.signal,
|
||
headers: {
|
||
"User-Agent":
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
},
|
||
},
|
||
);
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.data && data.data.length > 0) {
|
||
allData = [...allData, ...data.data];
|
||
}
|
||
|
||
if (!data.data || data.data.length < limit) {
|
||
hasMore = false;
|
||
} else {
|
||
offset += limit;
|
||
}
|
||
|
||
// 防止请求过于频繁
|
||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||
}
|
||
|
||
return allData;
|
||
} catch (error) {
|
||
console.log(
|
||
`⚠ Failed to fetch collection type ${type}: ${error.message}`,
|
||
);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 获取相关人员信息(制作公司等)
|
||
async function fetchSubjectPersons(subjectId) {
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||
|
||
const response = await fetch(
|
||
`${BANGUMI_API_BASE}/v0/subjects/${subjectId}/persons`,
|
||
{
|
||
signal: controller.signal,
|
||
headers: {
|
||
"User-Agent":
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
},
|
||
},
|
||
);
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
return [];
|
||
}
|
||
|
||
const data = await response.json();
|
||
return Array.isArray(data) ? data : [];
|
||
} catch (_error) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
let totalItems = 0;
|
||
|
||
// 遍历所有收藏类型
|
||
for (const type of collectionTypes) {
|
||
const collections = await fetchCollection(userId, 2, type); // 2=动画
|
||
|
||
if (collections.length === 0) {
|
||
continue;
|
||
}
|
||
|
||
console.log(
|
||
`✓ Fetched ${collections.length} items from collection type ${type}`,
|
||
);
|
||
totalItems += collections.length;
|
||
|
||
// 处理每个动画条目
|
||
for (const item of collections) {
|
||
const subject = item.subject || {};
|
||
|
||
// 提取标题
|
||
const titleCn = subject.name_cn || "";
|
||
const title = subject.name || "";
|
||
|
||
for (const char of titleCn) {
|
||
textSet.add(char);
|
||
}
|
||
for (const char of title) {
|
||
textSet.add(char);
|
||
}
|
||
|
||
// 提取简介
|
||
const summary = subject.short_summary || "";
|
||
for (const char of summary) {
|
||
textSet.add(char);
|
||
}
|
||
|
||
// 提取标签
|
||
if (subject.tags && Array.isArray(subject.tags)) {
|
||
subject.tags.forEach((tag) => {
|
||
if (tag.name) {
|
||
for (const char of tag.name) {
|
||
textSet.add(char);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 获取制作公司信息(限制并发请求)
|
||
if (item.subject_id && Math.random() < 0.3) {
|
||
// 只获取30%的详细信息,避免请求过多
|
||
const persons = await fetchSubjectPersons(item.subject_id);
|
||
|
||
persons.forEach((person) => {
|
||
if (person.name) {
|
||
for (const char of person.name) {
|
||
textSet.add(char);
|
||
}
|
||
}
|
||
if (person.relation) {
|
||
for (const char of person.relation) {
|
||
textSet.add(char);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 请求间隔
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (totalItems > 0) {
|
||
console.log(
|
||
`✓ Successfully processed ${totalItems} anime items from Bangumi API`,
|
||
);
|
||
} else {
|
||
console.log("⚠ No anime data found from Bangumi API");
|
||
}
|
||
|
||
return textSet;
|
||
} catch (error) {
|
||
console.log(
|
||
`⚠ Error processing Bangumi API config: ${error.message}, skipping anime text collection`,
|
||
);
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
// 收集所有使用的文字(用于 CJK 字体)
|
||
async function collectText() {
|
||
const { lang } = await getConfig();
|
||
|
||
const textSet = new Set();
|
||
|
||
// 1. 读取 src/data 目录
|
||
const dataDir = path.join(__dirname, "../src/data");
|
||
const dataFiles = readFilesRecursively(dataDir);
|
||
|
||
dataFiles.forEach((file) => {
|
||
if (file.endsWith(".ts") || file.endsWith(".js")) {
|
||
const content = fs.readFileSync(file, "utf-8");
|
||
|
||
// 改进的字符串匹配
|
||
const patterns = [
|
||
// 双引号字符串
|
||
/"([^"\\]|\\.|\\n|\\t)*"/g,
|
||
// 单引号字符串
|
||
/'([^'\\]|\\.|\\n|\\t)*'/g,
|
||
// 模板字符串
|
||
/`([^`\\]|\\.|\\n|\\t)*`/g,
|
||
];
|
||
|
||
patterns.forEach((pattern) => {
|
||
const matches = content.match(pattern);
|
||
if (matches) {
|
||
matches.forEach((match) => {
|
||
let text = match;
|
||
|
||
// 清理引号
|
||
if (
|
||
(text.startsWith('"') && text.endsWith('"')) ||
|
||
(text.startsWith("'") && text.endsWith("'")) ||
|
||
(text.startsWith("`") && text.endsWith("`"))
|
||
) {
|
||
text = text.slice(1, -1);
|
||
}
|
||
|
||
// 处理转义字符
|
||
text = text
|
||
.replace(/\\n/g, "\n")
|
||
.replace(/\\t/g, "\t")
|
||
.replace(/\\"/g, '"')
|
||
.replace(/\\'/g, "'");
|
||
|
||
for (const char of text) {
|
||
textSet.add(char);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// 简单正则作为补充
|
||
const stringMatches = content.match(/["'`]([^"'`]+)["'`]/g);
|
||
if (stringMatches) {
|
||
stringMatches.forEach((match) => {
|
||
const text = match.slice(1, -1);
|
||
for (const char of text) {
|
||
textSet.add(char);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// 2. 读取 src/config.ts 文件
|
||
const configFile = path.join(__dirname, "../src/config.ts");
|
||
if (fs.existsSync(configFile)) {
|
||
const content = fs.readFileSync(configFile, "utf-8");
|
||
|
||
// 改进的字符串匹配
|
||
const patterns = [
|
||
// 双引号字符串
|
||
/"([^"\\]|\\.|\\n|\\t)*"/g,
|
||
// 单引号字符串
|
||
/'([^'\\]|\\.|\\n|\\t)*'/g,
|
||
// 模板字符串
|
||
/`([^`\\]|\\.|\\n|\\t)*`/g,
|
||
];
|
||
|
||
patterns.forEach((pattern) => {
|
||
const matches = content.match(pattern);
|
||
if (matches) {
|
||
matches.forEach((match) => {
|
||
// 清理引号和注释标记
|
||
let text = match;
|
||
|
||
// 移除字符串的引号
|
||
if (
|
||
(text.startsWith('"') && text.endsWith('"')) ||
|
||
(text.startsWith("'") && text.endsWith("'")) ||
|
||
(text.startsWith("`") && text.endsWith("`"))
|
||
) {
|
||
text = text.slice(1, -1);
|
||
}
|
||
|
||
// 处理转义字符
|
||
text = text
|
||
.replace(/\\n/g, "\n")
|
||
.replace(/\\t/g, "\t")
|
||
.replace(/\\"/g, '"')
|
||
.replace(/\\'/g, "'");
|
||
|
||
// 提取所有字符(包括中文)
|
||
for (const char of text) {
|
||
textSet.add(char);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// 作为补充,还用原来的简单正则再扫一遍,确保不遗漏
|
||
const simpleMatches = content.match(/["'`]([^"'`]+)["'`]/g);
|
||
if (simpleMatches) {
|
||
simpleMatches.forEach((match) => {
|
||
const text = match.slice(1, -1);
|
||
for (const char of text) {
|
||
textSet.add(char);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 3. 读取对应语言的 i18n 文件
|
||
const i18nFile = path.join(__dirname, `../src/i18n/languages/${lang}.ts`);
|
||
if (fs.existsSync(i18nFile)) {
|
||
const content = fs.readFileSync(i18nFile, "utf-8");
|
||
|
||
// 改进的字符串匹配
|
||
const patterns = [
|
||
/"([^"\\]|\\.|\\n|\\t)*"/g,
|
||
/'([^'\\]|\\.|\\n|\\t)*'/g,
|
||
/`([^`\\]|\\.|\\n|\\t)*`/g,
|
||
];
|
||
|
||
patterns.forEach((pattern) => {
|
||
const matches = content.match(pattern);
|
||
if (matches) {
|
||
matches.forEach((match) => {
|
||
let text = match;
|
||
|
||
if (
|
||
(text.startsWith('"') && text.endsWith('"')) ||
|
||
(text.startsWith("'") && text.endsWith("'")) ||
|
||
(text.startsWith("`") && text.endsWith("`"))
|
||
) {
|
||
text = text.slice(1, -1);
|
||
}
|
||
|
||
// 处理转义字符
|
||
text = text
|
||
.replace(/\\n/g, "\n")
|
||
.replace(/\\t/g, "\t")
|
||
.replace(/\\"/g, '"')
|
||
.replace(/\\'/g, "'");
|
||
|
||
for (const char of text) {
|
||
textSet.add(char);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// 简单正则作为补充
|
||
const stringMatches = content.match(/["'`]([^"'`]+)["'`]/g);
|
||
if (stringMatches) {
|
||
stringMatches.forEach((match) => {
|
||
const text = match.slice(1, -1);
|
||
for (const char of text) {
|
||
textSet.add(char);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 4. 读取 content 目录(根据环境变量决定路径)
|
||
let contentDir;
|
||
if (process.env.ENABLE_CONTENT_SYNC === "true" && process.env.CONTENT_DIR) {
|
||
// 使用环境变量指定的目录(以项目根目录为基准)
|
||
contentDir = path.join(__dirname, "..", process.env.CONTENT_DIR);
|
||
console.log(
|
||
`ℹ Using external content directory: ${process.env.CONTENT_DIR}`,
|
||
);
|
||
} else {
|
||
// 使用默认的 src/content 目录
|
||
contentDir = path.join(__dirname, "../src/content");
|
||
}
|
||
|
||
// 检查目录是否存在
|
||
if (!fs.existsSync(contentDir)) {
|
||
console.log(`⚠ Content directory does not exist: ${contentDir}`);
|
||
console.log(" Skipping content text collection");
|
||
} else {
|
||
const contentFiles = readFilesRecursively(contentDir);
|
||
|
||
contentFiles.forEach((file) => {
|
||
const ext = path.extname(file);
|
||
if ([".md", ".mdx", ".ts", ".js"].includes(ext)) {
|
||
const content = fs.readFileSync(file, "utf-8");
|
||
const text = extractText(content, ext);
|
||
for (const char of text) {
|
||
// 只保留中文、日文、韩文等 CJK 字符和常用标点
|
||
if (
|
||
char.match(
|
||
/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/,
|
||
)
|
||
) {
|
||
textSet.add(char);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 添加常用标点符号和数字
|
||
const commonChars = "0123456789,。!?;:\"\"''()【】《》、·—…「」『』";
|
||
for (const char of commonChars) {
|
||
textSet.add(char);
|
||
}
|
||
|
||
// 添加英文字母(如果字体支持)
|
||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||
for (const char of alphabet) {
|
||
textSet.add(char);
|
||
}
|
||
|
||
// 5. 从 Meting API 获取歌单数据中的文字
|
||
const metingTextSet = await fetchMetingPlaylistText();
|
||
|
||
// 将 Meting API 的文字添加到主文字集合中
|
||
for (const char of metingTextSet) {
|
||
textSet.add(char);
|
||
}
|
||
|
||
if (metingTextSet.size > 0) {
|
||
console.log(
|
||
`✓ Added ${metingTextSet.size} unique characters from music playlist`,
|
||
);
|
||
}
|
||
|
||
// 6. 从 Bangumi API 获取番剧数据中的文字
|
||
const bangumiTextSet = await fetchBangumiAnimeText();
|
||
|
||
// 将 Bangumi API 的文字添加到主文字集合中
|
||
for (const char of bangumiTextSet) {
|
||
textSet.add(char);
|
||
}
|
||
|
||
if (bangumiTextSet.size > 0) {
|
||
console.log(
|
||
`✓ Added ${bangumiTextSet.size} unique characters from anime data`,
|
||
);
|
||
}
|
||
|
||
// 漏网之鱼(如散落在各处未纳入统计的UI文本等)
|
||
const otherWords = ["示例", "歌曲", "艺术家"];
|
||
|
||
for (const term of otherWords) {
|
||
for (const char of term) {
|
||
textSet.add(char);
|
||
}
|
||
}
|
||
|
||
const allText = Array.from(textSet).sort().join("");
|
||
|
||
return allText;
|
||
}
|
||
|
||
// 压缩字体并输出到 dist 目录
|
||
async function compressFonts() {
|
||
try {
|
||
// 读取配置
|
||
const { fonts } = await getConfig();
|
||
|
||
if (fonts.length === 0) {
|
||
console.log(
|
||
"⚠ No fonts to compress (enableCompress=false or localFonts is empty)",
|
||
);
|
||
return;
|
||
}
|
||
|
||
console.log(`Found ${fonts.length} font configs to compress`);
|
||
|
||
// 检查 dist 目录是否存在
|
||
const distDir = path.join(__dirname, "../dist");
|
||
if (!fs.existsSync(distDir)) {
|
||
console.log(
|
||
"⚠ dist directory does not exist, please run astro build first",
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 创建 dist/assets/font 目录
|
||
const distFontDir = path.join(distDir, "assets/font");
|
||
if (!fs.existsSync(distFontDir)) {
|
||
fs.mkdirSync(distFontDir, { recursive: true });
|
||
}
|
||
|
||
// 根据字体类型收集不同的字符集
|
||
const cjkText = await collectText(); // CJK 字体使用完整字符集
|
||
const asciiText = getAsciiCharset(); // ASCII 字体只使用 ASCII 字符集
|
||
|
||
console.log("Starting font compression...");
|
||
|
||
let totalOriginalSize = 0;
|
||
let totalCompressedSize = 0;
|
||
let processedCount = 0;
|
||
|
||
// 用于收集所有错误
|
||
const errors = [];
|
||
|
||
// 遍历所有需要压缩的字体
|
||
for (const fontConfig of fonts) {
|
||
// 根据字体类型选择字符集
|
||
const text = fontConfig.type === "asciiFont" ? asciiText : cjkText;
|
||
|
||
for (const fontFile of fontConfig.files) {
|
||
const fontSrc = path.join(__dirname, "../public/assets/font", fontFile);
|
||
const ext = path.extname(fontFile).toLowerCase();
|
||
const baseName = path.basename(fontFile, ext);
|
||
|
||
if (!fs.existsSync(fontSrc)) {
|
||
const errorMsg = `❌ Config error [${fontConfig.type}]: Font file does not exist In config: "${fontFile}"\n Expected path: public/assets/font/${fontFile}\n \n Please check:\n 1. Is the filename correct (case sensitive)?\n 2. Is the file in public/assets/font/?\n 3. Is ${fontConfig.type}.localFonts in src/config.ts correct?`;
|
||
|
||
errors.push(errorMsg);
|
||
console.log(`\n${errorMsg}\n`);
|
||
continue;
|
||
}
|
||
|
||
const originalSize = fs.statSync(fontSrc).size;
|
||
totalOriginalSize += originalSize;
|
||
|
||
// 根据文件类型决定处理方式
|
||
if (ext === ".woff2" || ext === ".woff") {
|
||
// woff/woff2 已经是 Web 优化格式,不支持进一步子集化压缩
|
||
console.log(`⚠ Skipping ${fontFile} (already web-optimized format)`);
|
||
|
||
// 直接复制到 dist
|
||
const destFile = path.join(distFontDir, fontFile);
|
||
fs.copyFileSync(fontSrc, destFile);
|
||
totalCompressedSize += originalSize;
|
||
// 不计入处理数量
|
||
} else if (ext === ".ttf" || ext === ".otf") {
|
||
// TTF/OTF 需要压缩为 woff2
|
||
console.log(`Compressing ${fontFile}...`);
|
||
|
||
const fontmin = new Fontmin()
|
||
.src(fontSrc)
|
||
.use(
|
||
Fontmin.glyph({
|
||
text: text,
|
||
hinting: false,
|
||
}),
|
||
)
|
||
.use(
|
||
Fontmin.ttf2woff2({
|
||
deflate: true,
|
||
}),
|
||
)
|
||
.dest(distFontDir);
|
||
|
||
await new Promise((resolve, reject) => {
|
||
fontmin.run((err, files) => {
|
||
if (err) {
|
||
reject(err);
|
||
} else {
|
||
resolve(files);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 检查压缩结果
|
||
const compressedFile = path.join(distFontDir, `${baseName}.woff2`);
|
||
|
||
if (fs.existsSync(compressedFile)) {
|
||
const compressedSize = fs.statSync(compressedFile).size;
|
||
totalCompressedSize += compressedSize;
|
||
const reduction = (
|
||
(1 - compressedSize / originalSize) *
|
||
100
|
||
).toFixed(2);
|
||
|
||
console.log(
|
||
`✓ ${fontFile} → ${baseName}.woff2 (${(compressedSize / 1024).toFixed(2)} KB, reduced ${reduction}%)`,
|
||
);
|
||
processedCount++;
|
||
}
|
||
} else {
|
||
console.log(`⚠ Unsupported font format, skipping: ${fontFile}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 输出总结
|
||
if (errors.length > 0) {
|
||
console.log("\n❌ Font compression encountered errors!");
|
||
console.log(`${errors.length} errors, please fix and retry.\n`);
|
||
|
||
// 列出实际存在的字体文件
|
||
const fontDir = path.join(__dirname, "../public/assets/font");
|
||
if (fs.existsSync(fontDir)) {
|
||
const actualFiles = fs
|
||
.readdirSync(fontDir)
|
||
.filter((f) =>
|
||
[".ttf", ".otf", ".woff", ".woff2"].includes(
|
||
path.extname(f).toLowerCase(),
|
||
),
|
||
);
|
||
|
||
if (actualFiles.length > 0) {
|
||
console.log("Available font files:");
|
||
actualFiles.forEach((f) => console.log(` - ${f}`));
|
||
} else {
|
||
console.log(" (font directory is empty)");
|
||
}
|
||
}
|
||
|
||
process.exit(1);
|
||
}
|
||
|
||
if (processedCount > 0) {
|
||
const totalReduction = (
|
||
(1 - totalCompressedSize / totalOriginalSize) *
|
||
100
|
||
).toFixed(2);
|
||
console.log("\n✓ Font optimization complete!");
|
||
console.log(
|
||
` Files processed: ${processedCount}, Overall reduction: ${totalReduction}%`,
|
||
);
|
||
} else {
|
||
console.log("\n⚠ No font files processed");
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ Font compression failed:", error);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// 运行压缩
|
||
compressFonts();
|