#!/usr/bin/env python3 """Build a local-only Grade 3 catalog from verified Core Knowledge archives.""" import argparse import hashlib import json import re import zipfile from pathlib import Path ROLE_ORDER = {'Student Reader': 0, 'Teacher Guide': 1, 'Reference': 2} def is_grade_three_source(item): title = str(item.get('title', '')) filename = str(item.get('filename', '')) return bool(re.search(r'\bGrade 3\b', title, re.I) or re.search(r'(?:^|[_-])G3(?:[_-]|$)', filename, re.I)) def classify_unit(filename): if re.search(r'CKMath', filename, re.I): return 'Math' if re.search(r'CKSci', filename, re.I): return 'Science' if re.search(r'CKLA', filename, re.I): return 'Language Arts' if re.search(r'CKHG|CKIYS|LABB_CKHG', filename, re.I): return 'History & Geography' if re.search(r'CKMusic|CKVA', filename, re.I): return 'Arts' return 'Other' def role_for_pdf(filename): if re.search(r'(?:^|[_-])(TG|TeacherGuide|Teacher[_ -]?Guide)(?:[_-]|\.)', filename, re.I): return 'Teacher Guide' if re.search(r'(?:^|[_-])(SR|StudentReader|Student[_ -]?Reader|ActivityBook)(?:[_-]|\.)', filename, re.I): return 'Student Reader' return 'Reference' def material_id(archive_name, internal_path): raw = '%s\0%s' % (archive_name, internal_path) return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:20] def pdf_page_count(archive_path, internal_path): try: import fitz with zipfile.ZipFile(archive_path) as archive: document = fitz.open(stream=archive.read(internal_path), filetype='pdf') count = document.page_count document.close() return count except Exception: return None def inspect_archive(archive_path, include_page_counts=False): materials = [] with zipfile.ZipFile(archive_path) as archive: for info in archive.infolist(): internal_path = info.filename if internal_path.startswith('__MACOSX/') or not internal_path.lower().endswith('.pdf'): continue filename = Path(internal_path).name material = { 'id': material_id(archive_path.name, internal_path), 'filename': filename, 'internal_path': internal_path, 'role': role_for_pdf(filename), } if include_page_counts: material['pages'] = pdf_page_count(archive_path, internal_path) materials.append(material) materials.sort(key=lambda item: (ROLE_ORDER[item['role']], item['filename'].lower())) return materials def build_catalog(manifest_path, downloads_dir, include_page_counts=False): entries = json.loads(Path(manifest_path).read_text()) units = [] for item in entries: if item.get('type') != 'zip' or not is_grade_three_source(item): continue archive_path = Path(downloads_dir) / item['filename'] if not archive_path.is_file(): continue materials = inspect_archive(archive_path, include_page_counts) units.append({ 'id': hashlib.sha256(item['filename'].encode('utf-8')).hexdigest()[:16], 'title': item['title'], 'filename': item['filename'], 'source_url': item['source'], 'subject': classify_unit(item['filename']), 'materials': materials, 'teacher_guide_count': sum(material['role'] == 'Teacher Guide' for material in materials), 'student_material_count': sum(material['role'] == 'Student Reader' for material in materials), }) units.sort(key=lambda item: (item['subject'], item['title'].lower())) return { 'grade': 3, 'unit_count': len(units), 'teacher_guide_count': sum(unit['teacher_guide_count'] for unit in units), 'student_material_count': sum(unit['student_material_count'] for unit in units), 'units': units, } def main(): parser = argparse.ArgumentParser() parser.add_argument('--manifest', type=Path, required=True) parser.add_argument('--downloads-dir', type=Path, required=True) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--page-counts', action='store_true') args = parser.parse_args() catalog = build_catalog(args.manifest, args.downloads_dir, args.page_counts) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(json.dumps(catalog, indent=2) + '\n') print(json.dumps({ 'output': str(args.output), 'units': catalog['unit_count'], 'teacher_guides': catalog['teacher_guide_count'], 'student_materials': catalog['student_material_count'], })) if __name__ == '__main__': main()