mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-15 05:16:19 -07:00
Add inline character portraits and dictionary search workflow (#83)
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { CharacterNameImage } from '../../types';
|
||||
import { readCachedSnapshots } from './cache';
|
||||
import type {
|
||||
CharacterDictionaryGlossaryEntry,
|
||||
CharacterDictionarySnapshot,
|
||||
CharacterDictionarySnapshotImage,
|
||||
CharacterDictionaryTermEntry,
|
||||
} from './types';
|
||||
|
||||
const CHARACTER_IMAGE_PATH_PATTERN = /^img\/m\d+-c\d+\.[a-z0-9]+$/i;
|
||||
|
||||
type StructuredContentNode = {
|
||||
tag?: unknown;
|
||||
path?: unknown;
|
||||
alt?: unknown;
|
||||
title?: unknown;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
function normalizeLookupTerm(term: string): string {
|
||||
return term.trim();
|
||||
}
|
||||
|
||||
function getSnapshotsDir(outputDir: string): string {
|
||||
return path.join(outputDir, 'snapshots');
|
||||
}
|
||||
|
||||
function getImageMimeType(imagePath: string, dataBase64: string): string {
|
||||
const signature = Buffer.from(dataBase64.slice(0, 64), 'base64');
|
||||
if (
|
||||
signature.length >= 8 &&
|
||||
signature[0] === 0x89 &&
|
||||
signature[1] === 0x50 &&
|
||||
signature[2] === 0x4e &&
|
||||
signature[3] === 0x47
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (
|
||||
signature.length >= 12 &&
|
||||
signature.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
signature.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (
|
||||
signature.length >= 6 &&
|
||||
(signature.subarray(0, 6).toString('ascii') === 'GIF89a' ||
|
||||
signature.subarray(0, 6).toString('ascii') === 'GIF87a')
|
||||
) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (signature.length >= 3 && signature[0] === 0xff && signature[1] === 0xd8) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (
|
||||
signature.length >= 12 &&
|
||||
signature.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
||||
signature.subarray(8, 12).toString('ascii') === 'avif'
|
||||
) {
|
||||
return 'image/avif';
|
||||
}
|
||||
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||
if (ext === '.png') return 'image/png';
|
||||
if (ext === '.webp') return 'image/webp';
|
||||
if (ext === '.gif') return 'image/gif';
|
||||
if (ext === '.avif') return 'image/avif';
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
function buildImageByPath(
|
||||
images: ReadonlyArray<CharacterDictionarySnapshotImage>,
|
||||
): Map<string, CharacterDictionarySnapshotImage> {
|
||||
const imageByPath = new Map<string, CharacterDictionarySnapshotImage>();
|
||||
for (const image of images) {
|
||||
if (image.path && image.dataBase64) {
|
||||
imageByPath.set(image.path, image);
|
||||
}
|
||||
}
|
||||
return imageByPath;
|
||||
}
|
||||
|
||||
function findCharacterImageNode(value: unknown): StructuredContentNode | null {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findCharacterImageNode(item);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = value as StructuredContentNode;
|
||||
if (
|
||||
node.tag === 'img' &&
|
||||
typeof node.path === 'string' &&
|
||||
CHARACTER_IMAGE_PATH_PATTERN.test(node.path)
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return findCharacterImageNode(node.content);
|
||||
}
|
||||
|
||||
function findCharacterImageNodeInGlossary(
|
||||
glossary: ReadonlyArray<CharacterDictionaryGlossaryEntry>,
|
||||
): StructuredContentNode | null {
|
||||
for (const entry of glossary) {
|
||||
const found = findCharacterImageNode(entry);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createCharacterNameImage(
|
||||
entry: CharacterDictionaryTermEntry,
|
||||
imageByPath: ReadonlyMap<string, CharacterDictionarySnapshotImage>,
|
||||
): CharacterNameImage | null {
|
||||
const term = normalizeLookupTerm(entry[0]);
|
||||
if (!term) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageNode = findCharacterImageNodeInGlossary(entry[5]);
|
||||
const imagePath = typeof imageNode?.path === 'string' ? imageNode.path : '';
|
||||
const image = imageByPath.get(imagePath);
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawAlt =
|
||||
typeof imageNode?.alt === 'string'
|
||||
? imageNode.alt
|
||||
: typeof imageNode?.title === 'string'
|
||||
? imageNode.title
|
||||
: term;
|
||||
const alt = rawAlt.trim() || term;
|
||||
return {
|
||||
src: `data:${getImageMimeType(image.path, image.dataBase64)};base64,${image.dataBase64}`,
|
||||
alt,
|
||||
};
|
||||
}
|
||||
|
||||
function appendSnapshotImages(
|
||||
index: Map<string, CharacterNameImage>,
|
||||
snapshot: CharacterDictionarySnapshot,
|
||||
): void {
|
||||
const imageByPath = buildImageByPath(snapshot.images);
|
||||
for (const entry of snapshot.termEntries) {
|
||||
const term = normalizeLookupTerm(entry[0]);
|
||||
if (!term || index.has(term)) {
|
||||
continue;
|
||||
}
|
||||
const image = createCharacterNameImage(entry, imageByPath);
|
||||
if (image) {
|
||||
index.set(term, image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotHasCharacterNameImages(snapshot: CharacterDictionarySnapshot): boolean {
|
||||
const imageByPath = buildImageByPath(snapshot.images);
|
||||
return snapshot.termEntries.some(
|
||||
(entry) => createCharacterNameImage(entry, imageByPath) !== null,
|
||||
);
|
||||
}
|
||||
|
||||
function getSnapshotDirectorySignature(outputDir: string): string {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const snapshotPath = path.join(getSnapshotsDir(outputDir), entry.name);
|
||||
try {
|
||||
const stat = fs.statSync(snapshotPath);
|
||||
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
|
||||
} catch {
|
||||
// Ignore files that disappear during refresh; next lookup will rebuild.
|
||||
}
|
||||
}
|
||||
return parts.sort().join('|');
|
||||
}
|
||||
|
||||
export function buildCharacterNameImageIndexFromSnapshots(
|
||||
outputDir: string,
|
||||
): Map<string, CharacterNameImage> {
|
||||
const index = new Map<string, CharacterNameImage>();
|
||||
for (const snapshot of readCachedSnapshots(outputDir)) {
|
||||
appendSnapshotImages(index, snapshot);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function createCharacterDictionaryImageLookup(deps: {
|
||||
userDataPath?: string;
|
||||
outputDir?: string;
|
||||
}): {
|
||||
get: (term: string) => CharacterNameImage | null;
|
||||
invalidate: () => void;
|
||||
} {
|
||||
const outputDir =
|
||||
deps.outputDir ??
|
||||
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
|
||||
let signature: string | null = null;
|
||||
let index = new Map<string, CharacterNameImage>();
|
||||
|
||||
function refreshIfNeeded(): void {
|
||||
if (!outputDir) {
|
||||
index = new Map<string, CharacterNameImage>();
|
||||
signature = '';
|
||||
return;
|
||||
}
|
||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||
if (nextSignature === signature) {
|
||||
return;
|
||||
}
|
||||
signature = nextSignature;
|
||||
index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||
}
|
||||
|
||||
return {
|
||||
get(term: string): CharacterNameImage | null {
|
||||
const normalizedTerm = normalizeLookupTerm(term);
|
||||
if (!normalizedTerm) {
|
||||
return null;
|
||||
}
|
||||
refreshIfNeeded();
|
||||
return index.get(normalizedTerm) ?? null;
|
||||
},
|
||||
invalidate(): void {
|
||||
signature = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user