423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
/**
|
|
* KICE (한국교육과정평가원) 수능 기출 PDF import 스크립트.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { PrismaClient, Prisma } from '@prisma/client';
|
|
import { parseExamPaper, parseImageBasedExam, ParseResult } from '../src/problem-sets/parsing';
|
|
|
|
interface CliOptions {
|
|
dryRun: boolean;
|
|
year?: number;
|
|
subject?: string;
|
|
only?: 'answer' | 'problems' | 'all';
|
|
sample?: number;
|
|
}
|
|
|
|
function parseArgs(): CliOptions {
|
|
const args = process.argv.slice(2);
|
|
const options: CliOptions = { dryRun: false, only: 'all' };
|
|
|
|
for (const arg of args) {
|
|
if (arg === '--dry-run') options.dryRun = true;
|
|
else if (arg.startsWith('--year=')) options.year = Number(arg.split('=')[1]);
|
|
else if (arg.startsWith('--subject=')) options.subject = arg.split('=')[1];
|
|
else if (arg.startsWith('--only=')) options.only = arg.split('=')[1] as CliOptions['only'];
|
|
else if (arg.startsWith('--sample=')) options.sample = Number(arg.split('=')[1]);
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
const DATA_DIR = path.resolve(__dirname, '../../data/kice');
|
|
const PROBLEM_UPLOAD_DIR = path.resolve(__dirname, '../uploads/problems');
|
|
const TARGET_YEARS = [2025, 2026];
|
|
const TARGET_SUBJECTS = ['국어', '영어', '한국사', '생활과 윤리', '수학'] as const;
|
|
type TargetSubject = (typeof TARGET_SUBJECTS)[number];
|
|
type SubjectImportFormat = 'text' | 'image';
|
|
|
|
const EXPECTED_PROBLEM_COUNT: Record<TargetSubject, number> = {
|
|
국어: 45,
|
|
영어: 45,
|
|
한국사: 20,
|
|
'생활과 윤리': 20,
|
|
수학: 30,
|
|
};
|
|
|
|
const SUBJECT_IMPORT_FORMAT: Record<TargetSubject, SubjectImportFormat> = {
|
|
국어: 'text',
|
|
영어: 'text',
|
|
한국사: 'text',
|
|
'생활과 윤리': 'text',
|
|
수학: 'image',
|
|
};
|
|
|
|
interface PdfFile {
|
|
year: number;
|
|
subject: TargetSubject;
|
|
role: 'problems' | 'answer' | 'audio-script';
|
|
filepath: string;
|
|
sizeKB: number;
|
|
}
|
|
|
|
interface ScanResult {
|
|
files: PdfFile[];
|
|
audioMp3s: Map<string, string[]>;
|
|
}
|
|
|
|
function scanKiceData(filter: { year?: number; subject?: string }): ScanResult {
|
|
const files: PdfFile[] = [];
|
|
const audioMp3s = new Map<string, string[]>();
|
|
|
|
for (const year of TARGET_YEARS) {
|
|
if (filter.year && filter.year !== year) continue;
|
|
|
|
for (const subject of TARGET_SUBJECTS) {
|
|
if (filter.subject && filter.subject !== subject) continue;
|
|
|
|
if (subject === '생활과 윤리') {
|
|
const problemPath = path.join(DATA_DIR, String(year), '사회탐구/사회탐구영역_문제지');
|
|
const answerPath = path.join(DATA_DIR, String(year), '사회탐구/사회탐구영역_정답표');
|
|
const problemFile = findFile(problemPath, /01 생활과 윤리_(문제|문제지)\.pdf$/);
|
|
const answerFile = findFile(answerPath, /01 생활과 윤리_(정답|정답표)\.pdf$/);
|
|
|
|
if (problemFile) {
|
|
files.push(buildPdfFile(year, subject, 'problems', problemFile));
|
|
}
|
|
if (answerFile) {
|
|
files.push(buildPdfFile(year, subject, 'answer', answerFile));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const subjectDir = path.join(DATA_DIR, String(year), subject);
|
|
if (!fs.existsSync(subjectDir)) continue;
|
|
|
|
const problemFile =
|
|
findFile(subjectDir, /_문제지_홀수형\.pdf$/) ||
|
|
findFile(subjectDir, /_문제지\.pdf$/) ||
|
|
findFile(subjectDir, /_문제지_짝수형\.pdf$/) ||
|
|
findFile(subjectDir, /_문제\.pdf$/);
|
|
const answerFile = findFile(subjectDir, /_정답표?\.pdf$/);
|
|
const scriptFile = findFile(subjectDir, /_듣기평가대본\.pdf$/);
|
|
|
|
if (problemFile) {
|
|
files.push(buildPdfFile(year, subject, 'problems', problemFile));
|
|
}
|
|
if (answerFile) {
|
|
files.push(buildPdfFile(year, subject, 'answer', answerFile));
|
|
}
|
|
if (scriptFile) {
|
|
files.push(buildPdfFile(year, subject, 'audio-script', scriptFile));
|
|
}
|
|
|
|
if (subject === '영어') {
|
|
const audioDir1 = path.join(subjectDir, '영어영역_듣기평가음원');
|
|
const audioDir2 = path.join(subjectDir, '영어영역듣기평가음원');
|
|
const audioDir = fs.existsSync(audioDir1)
|
|
? audioDir1
|
|
: fs.existsSync(audioDir2)
|
|
? audioDir2
|
|
: null;
|
|
|
|
if (audioDir) {
|
|
const mp3s = fs
|
|
.readdirSync(audioDir)
|
|
.filter((entry) => entry.endsWith('.mp3'))
|
|
.sort()
|
|
.map((entry) => path.relative(DATA_DIR, path.join(audioDir, entry)));
|
|
if (mp3s.length > 0) {
|
|
audioMp3s.set(`${year}/영어`, mp3s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { files, audioMp3s };
|
|
}
|
|
|
|
function buildPdfFile(
|
|
year: number,
|
|
subject: TargetSubject,
|
|
role: PdfFile['role'],
|
|
filepath: string,
|
|
): PdfFile {
|
|
return {
|
|
year,
|
|
subject,
|
|
role,
|
|
filepath,
|
|
sizeKB: Math.round(fs.statSync(filepath).size / 1024),
|
|
};
|
|
}
|
|
|
|
function findFile(dir: string, pattern: RegExp): string | null {
|
|
if (!fs.existsSync(dir)) return null;
|
|
const match = fs.readdirSync(dir).find((entry) => pattern.test(entry));
|
|
return match ? path.join(dir, match) : null;
|
|
}
|
|
|
|
function buildProblemSetTitle(year: number, subject: TargetSubject): string {
|
|
return `${year}학년도 대학수학능력시험 ${subject}`;
|
|
}
|
|
|
|
async function importKiceSet(
|
|
prisma: PrismaClient,
|
|
year: number,
|
|
subject: TargetSubject,
|
|
files: PdfFile[],
|
|
audioMp3s: Map<string, string[]>,
|
|
): Promise<{ problems: number; passages: number; needsReview: number; warnings: string[] }> {
|
|
const answerFile = files.find((file) => file.role === 'answer');
|
|
const problemFile = files.find((file) => file.role === 'problems');
|
|
|
|
if (!problemFile) {
|
|
console.warn(`⚠ [${year} ${subject}] 문제지 PDF 누락`);
|
|
return { problems: 0, passages: 0, needsReview: 0, warnings: ['missing problem PDF'] };
|
|
}
|
|
|
|
const parsed = await parseSubjectFiles(year, subject, problemFile.filepath, answerFile?.filepath);
|
|
|
|
const answerMap = new Map(parsed.answers.map((answer) => [answer.number, answer.answerNumber]));
|
|
if (answerFile && parsed.answers.length === 0) {
|
|
console.warn(`⚠ [${year} ${subject}] 정답표 추출 실패 (이미지 기반 PDF?) — answerNumber 전부 null`);
|
|
}
|
|
|
|
for (const warning of parsed.warnings) {
|
|
console.warn(` ⚠ [${year} ${subject}] ${warning}`);
|
|
}
|
|
|
|
const audioUrls = subject === '영어' ? audioMp3s.get(`${year}/영어`) || null : null;
|
|
const problemSet = await prisma.problemSet.upsert({
|
|
where: {
|
|
year_examType_subjectName: {
|
|
year,
|
|
examType: 'sat',
|
|
subjectName: subject,
|
|
},
|
|
},
|
|
update: {
|
|
title: buildProblemSetTitle(year, subject),
|
|
sourceUrl: 'https://www.suneung.re.kr/',
|
|
audioUrls: audioUrls as Prisma.InputJsonValue | null | undefined,
|
|
},
|
|
create: {
|
|
title: buildProblemSetTitle(year, subject),
|
|
examType: 'sat',
|
|
year,
|
|
subjectName: subject,
|
|
sourceUrl: 'https://www.suneung.re.kr/',
|
|
audioUrls: audioUrls as Prisma.InputJsonValue | null | undefined,
|
|
},
|
|
});
|
|
|
|
await prisma.passage.deleteMany({ where: { problemSetId: problemSet.id } });
|
|
const createdPassages = await Promise.all(
|
|
parsed.passages.map((passage) =>
|
|
prisma.passage.create({
|
|
data: {
|
|
problemSetId: problemSet.id,
|
|
startNumber: passage.startNumber,
|
|
endNumber: passage.endNumber,
|
|
bodyText: passage.bodyText,
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
const passageIdByStart = new Map(createdPassages.map((passage) => [passage.startNumber, passage.id]));
|
|
|
|
let needsReviewCount = 0;
|
|
for (const problem of parsed.problems) {
|
|
const passageId = problem.passageStart
|
|
? passageIdByStart.get(problem.passageStart) || null
|
|
: null;
|
|
const answerNumber = answerMap.get(problem.number) || null;
|
|
const needsReview = problem.needsReview || !answerNumber;
|
|
if (needsReview) {
|
|
needsReviewCount++;
|
|
}
|
|
|
|
await prisma.problem.upsert({
|
|
where: {
|
|
problemSetId_number: { problemSetId: problemSet.id, number: problem.number },
|
|
},
|
|
update: {
|
|
bodyText: problem.bodyText,
|
|
choices: problem.choices as Prisma.InputJsonValue,
|
|
answerNumber,
|
|
passageId,
|
|
imageUrl: problem.imageUrl ?? null,
|
|
pageImageUrl: problem.pageImageUrl ?? null,
|
|
needsReview,
|
|
},
|
|
create: {
|
|
problemSetId: problemSet.id,
|
|
number: problem.number,
|
|
title: `${buildProblemSetTitle(year, subject)} ${problem.number}번`,
|
|
difficulty: 0.5,
|
|
bodyText: problem.bodyText,
|
|
choices: problem.choices as Prisma.InputJsonValue,
|
|
answerNumber,
|
|
passageId,
|
|
imageUrl: problem.imageUrl ?? null,
|
|
pageImageUrl: problem.pageImageUrl ?? null,
|
|
needsReview,
|
|
},
|
|
});
|
|
|
|
if (problem.needsReview) {
|
|
console.warn(
|
|
` ⚠ [${year} ${subject}] Problem ${problem.number} needsReview: ${problem.needsReviewReasons.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
problems: parsed.problems.length,
|
|
passages: parsed.passages.length,
|
|
needsReview: needsReviewCount,
|
|
warnings: parsed.warnings,
|
|
};
|
|
}
|
|
|
|
async function inspectParseResult(
|
|
year: number,
|
|
subject: TargetSubject,
|
|
files: PdfFile[],
|
|
mode: 'answer' | 'problems',
|
|
sample?: number,
|
|
) {
|
|
const answerFile = files.find((file) => file.role === 'answer');
|
|
const problemFile = files.find((file) => file.role === 'problems');
|
|
|
|
if (!problemFile) {
|
|
console.warn(`⚠ [${year} ${subject}] 문제지 PDF 누락`);
|
|
return;
|
|
}
|
|
|
|
const parsed = await parseSubjectFiles(year, subject, problemFile.filepath, answerFile?.filepath);
|
|
|
|
if (mode === 'answer') {
|
|
console.log(` ${year} / ${subject}: ${parsed.answers.length} 문항`);
|
|
for (const answer of parsed.answers.slice(0, sample ? 5 : 0)) {
|
|
console.log(` ${answer.number}번 → ${answer.answerNumber}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log(` ${year} / ${subject}: ${parsed.problems.length} problems, ${parsed.passages.length} passages`);
|
|
const badProblems = parsed.problems.filter((problem) => problem.needsReview);
|
|
if (badProblems.length > 0) {
|
|
console.log(` ⚠ needsReview: ${badProblems.length}`);
|
|
for (const problem of badProblems.slice(0, 5)) {
|
|
console.log(` #${problem.number}: ${problem.needsReviewReasons.join(', ')}`);
|
|
}
|
|
}
|
|
if (sample) {
|
|
const first = parsed.problems[0];
|
|
if (first) {
|
|
console.log(` sample #${first.number}: "${first.bodyText.slice(0, 80)}..."`);
|
|
console.log(` choices: ${Object.keys(first.choices).length} keys`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function parseSubjectFiles(
|
|
year: number,
|
|
subject: TargetSubject,
|
|
paperPdfPath: string,
|
|
answerPdfPath?: string,
|
|
): Promise<ParseResult> {
|
|
const expectedProblemCount = EXPECTED_PROBLEM_COUNT[subject];
|
|
const format = SUBJECT_IMPORT_FORMAT[subject];
|
|
|
|
if (format === 'image') {
|
|
const renderedImageDir = path.join(PROBLEM_UPLOAD_DIR, String(year), subject);
|
|
fs.mkdirSync(renderedImageDir, { recursive: true });
|
|
|
|
return parseImageBasedExam({
|
|
paperPdfPath,
|
|
answerPdfPath,
|
|
format: 'kice-math',
|
|
expectedProblemCount,
|
|
renderedImageDir,
|
|
renderedImageBaseUrl: `/uploads/problems/${year}/${subject}`,
|
|
});
|
|
}
|
|
|
|
return parseExamPaper({
|
|
paperPdfPath,
|
|
answerPdfPath,
|
|
format: 'kice',
|
|
expectedProblemCount,
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs();
|
|
console.log('🏫 KICE import 시작', options);
|
|
|
|
const scan = scanKiceData({ year: options.year, subject: options.subject });
|
|
console.log(`스캔: ${scan.files.length} files, ${scan.audioMp3s.size} audio sets`);
|
|
|
|
if (options.dryRun) {
|
|
console.log('\n=== 파일 목록 (dry-run) ===');
|
|
for (const file of scan.files) {
|
|
console.log(` ${file.year} / ${file.subject} / ${file.role.padEnd(14)} — ${file.sizeKB}KB — ${path.basename(file.filepath)}`);
|
|
}
|
|
for (const [key, mp3s] of scan.audioMp3s) {
|
|
console.log(` audio ${key}: ${mp3s.length} mp3 files`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (options.only === 'answer' || options.only === 'problems') {
|
|
console.log(options.only === 'answer' ? '\n=== 정답표 파서 테스트 ===' : '\n=== 문제지 파서 테스트 ===');
|
|
for (const year of TARGET_YEARS) {
|
|
if (options.year && options.year !== year) continue;
|
|
for (const subject of TARGET_SUBJECTS) {
|
|
if (options.subject && options.subject !== subject) continue;
|
|
const files = scan.files.filter((file) => file.year === year && file.subject === subject);
|
|
if (files.length === 0) continue;
|
|
await inspectParseResult(year, subject, files, options.only, options.sample);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const prisma = new PrismaClient();
|
|
try {
|
|
let totalProblems = 0;
|
|
let totalPassages = 0;
|
|
let totalNeedsReview = 0;
|
|
|
|
for (const year of TARGET_YEARS) {
|
|
if (options.year && options.year !== year) continue;
|
|
for (const subject of TARGET_SUBJECTS) {
|
|
if (options.subject && options.subject !== subject) continue;
|
|
const files = scan.files.filter((file) => file.year === year && file.subject === subject);
|
|
if (files.length === 0) continue;
|
|
|
|
const result = await importKiceSet(prisma, year, subject, files, scan.audioMp3s);
|
|
console.log(
|
|
`✓ ${year} ${subject}: ${result.problems} problems, ${result.passages} passages, ${result.needsReview} needsReview`,
|
|
);
|
|
totalProblems += result.problems;
|
|
totalPassages += result.passages;
|
|
totalNeedsReview += result.needsReview;
|
|
}
|
|
}
|
|
|
|
console.log(`\n🎯 합계: ${totalProblems} problems, ${totalPassages} passages, ${totalNeedsReview} needsReview`);
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|