Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
323 lines
13 KiB
Python
323 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
二期数据 ETL 脚本
|
|
将原始平台导出格式(每题一个Sheet + 15个学科独立文件)
|
|
转换为与一期相同的扁平表格式,供后续赋分/分析引擎使用
|
|
|
|
输入:二期/[整理前] 二期_课程实施+教材使用情况表/
|
|
输出:report-admin/data/era2/ 下的3张扁平表
|
|
- era2_基础信息表.xlsx
|
|
- era2_课程实施情况表.xlsx
|
|
- era2_学科课程实施情况表.xlsx
|
|
|
|
一期目标格式:
|
|
基础信息表列: 题号, 题型, 题目ID, 题目内容, 字段ID, 字段名称, 字段值, 年级, 学期, 区, 学校名称, 办学性质, 学校类别, 学校等级, 地域类型, 特色类型, 等级B, 等级C
|
|
课程实施情况表列: 题号, 题型, 字段ID, 字段名称, 字段取值, 选项文字, 学科, 学期, 年级, 学校简称, 所在区, 学校类别, 学校性质, 所处地区, 学校类型, 学校类型编号
|
|
学科课程实施情况表列: 题号, 题型, 字段ID, 字段名称, 字段取值, 学科, 选项文字, 学校性质, 所在区, 学校类别, 学校特色, 学校简称, 所处地区, 学校类型, 学校类型编号
|
|
"""
|
|
import pandas as pd
|
|
import numpy as np
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
import logging
|
|
import time
|
|
import glob
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ===== 路径配置 =====
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent # report-admin/
|
|
DATA_ROOT = PROJECT_ROOT.parent # 20260301-邱老师-校长画像/
|
|
ERA2_RAW_DIR = DATA_ROOT / "二期" / "[整理前] 二期_课程实施+教材使用情况表"
|
|
ERA2_OUTPUT_DIR = PROJECT_ROOT / "data" / "era2"
|
|
|
|
|
|
def load_raw_file(filepath: Path) -> Dict:
|
|
"""
|
|
加载一个二期原始 Excel 文件,返回:
|
|
{
|
|
'home': DataFrame, # 首页元信息
|
|
'data_sheets': {题号: DataFrame}, # 每题的数据
|
|
'field_map': DataFrame, # 字段映射关系
|
|
'dim_map': DataFrame, # 维度映射关系
|
|
'type_stats': DataFrame, # 题型统计
|
|
}
|
|
"""
|
|
logger.info(f" 加载: {filepath.name}")
|
|
xls = pd.ExcelFile(filepath)
|
|
|
|
result = {
|
|
'home': pd.read_excel(xls, sheet_name='首页'),
|
|
'data_sheets': {},
|
|
'field_map': None,
|
|
'dim_map': None,
|
|
'type_stats': None,
|
|
}
|
|
|
|
for sn in xls.sheet_names:
|
|
if sn == '首页':
|
|
continue
|
|
elif sn == '字段映射关系':
|
|
result['field_map'] = pd.read_excel(xls, sheet_name=sn)
|
|
elif sn == '维度映射关系':
|
|
result['dim_map'] = pd.read_excel(xls, sheet_name=sn)
|
|
elif sn == '题型统计':
|
|
result['type_stats'] = pd.read_excel(xls, sheet_name=sn)
|
|
elif sn.isdigit():
|
|
result['data_sheets'][int(sn)] = pd.read_excel(xls, sheet_name=sn)
|
|
|
|
xls.close()
|
|
total_rows = sum(len(df) for df in result['data_sheets'].values())
|
|
logger.info(f" → {len(result['data_sheets'])}个题Sheet, {total_rows}行数据")
|
|
return result
|
|
|
|
|
|
def build_type_map(type_stats: pd.DataFrame) -> Dict[int, str]:
|
|
"""从题型统计构建 题号→题型 的映射"""
|
|
return dict(zip(type_stats['题号'].astype(int), type_stats['题型']))
|
|
|
|
|
|
def extract_school_short_name(full_name: str) -> str:
|
|
"""
|
|
从全称提取简称
|
|
'上海市延安中学' → '延安中学'
|
|
'华东政法大学附属中学' → '华政附中'
|
|
'华东师范大学附属天山学校' → '天山学校'
|
|
保留全称作为默认
|
|
"""
|
|
# 不做过度简化,保留全称让下游配置来处理映射
|
|
return full_name
|
|
|
|
|
|
def extract_district_name(region_str: str) -> str:
|
|
"""
|
|
从区域维度提取区名
|
|
'上海市长宁区教育学院' → '长宁区'
|
|
'上海市青浦区教师进修学院' → '青浦区'
|
|
"""
|
|
if pd.isna(region_str):
|
|
return ""
|
|
s = str(region_str)
|
|
# 提取 "XX区" 部分
|
|
for suffix in ['教育学院', '教师进修学院', '教育委员会']:
|
|
s = s.replace(suffix, '')
|
|
s = s.replace('上海市', '')
|
|
return s.strip()
|
|
|
|
|
|
# ===== 1. 基础信息表 ETL =====
|
|
def transform_basic_info(raw: Dict) -> pd.DataFrame:
|
|
"""
|
|
将二期基础信息表转为一期格式
|
|
一期列: 题号, 题型, 题目ID, 题目内容, 字段ID, 字段名称, 字段值, 年级, 学期, 区, 学校名称, ...
|
|
二期列: 问题id, 字段id, 字段名称, 字段取值, 维度id, 维度名称, 学科维度, 年级维度, 学期维度, 学校维度, 区域维度, 用户维度, 状态, 问卷提交时间
|
|
"""
|
|
logger.info("转换基础信息表...")
|
|
type_map = build_type_map(raw['type_stats'])
|
|
rows = []
|
|
|
|
for q_num, df in sorted(raw['data_sheets'].items()):
|
|
q_type = type_map.get(q_num, '未知')
|
|
for _, row in df.iterrows():
|
|
rows.append({
|
|
'题号': q_num,
|
|
'题型': q_type,
|
|
'题目ID': row.get('问题id', ''),
|
|
'题目内容': '', # 二期原始数据无此字段
|
|
'字段ID': row.get('字段id', ''),
|
|
'字段名称': row.get('字段名称', ''),
|
|
'字段值': row.get('字段取值', ''),
|
|
'年级': row.get('年级维度', '不分年级'),
|
|
'学期': row.get('学期维度', ''),
|
|
'区': extract_district_name(row.get('区域维度', '')),
|
|
'学校名称': row.get('学校维度', ''),
|
|
# 以下字段二期原始数据无,留空后续由配置补充
|
|
'办学性质': '',
|
|
'学校类别': '',
|
|
'学校等级': '',
|
|
'地域类型': '',
|
|
'特色类型': '',
|
|
'等级B': '',
|
|
'等级C': '',
|
|
})
|
|
|
|
result = pd.DataFrame(rows)
|
|
# 强制所有列为字符串,避免混合类型导致 parquet 报错
|
|
result = result.astype(str)
|
|
logger.info(f" 基础信息表: {len(result)}行")
|
|
return result
|
|
|
|
|
|
# ===== 2. 课程实施情况表 ETL =====
|
|
def transform_course_impl(raw: Dict) -> pd.DataFrame:
|
|
"""
|
|
将二期课程实施情况表转为一期格式
|
|
一期列: 题号, 题型, 字段ID, 字段名称, 字段取值, 选项文字, 学科, 学期, 年级, 学校简称, 所在区, ...
|
|
"""
|
|
logger.info("转换课程实施情况表...")
|
|
type_map = build_type_map(raw['type_stats'])
|
|
rows = []
|
|
|
|
for q_num, df in sorted(raw['data_sheets'].items()):
|
|
q_type = type_map.get(q_num, '未知')
|
|
for _, row in df.iterrows():
|
|
rows.append({
|
|
'题号': str(q_num),
|
|
'题型': q_type,
|
|
'字段ID': row.get('字段id', ''),
|
|
'字段名称': row.get('字段名称', ''),
|
|
'字段取值': row.get('字段取值', ''),
|
|
'选项文字': row.get('维度名称', ''), # 二期中维度名称对应选项文字
|
|
'学科': row.get('学科维度', '不分学科'),
|
|
'学期': row.get('学期维度', ''),
|
|
'年级': row.get('年级维度', '不分年级'),
|
|
'学校简称': row.get('学校维度', ''),
|
|
'所在区': extract_district_name(row.get('区域维度', '')),
|
|
# 以下字段二期原始数据无,留空
|
|
'学校类别': '',
|
|
'学校性质': '',
|
|
'所处地区': '',
|
|
'学校类型': '',
|
|
'学校类型编号': '',
|
|
})
|
|
|
|
result = pd.DataFrame(rows)
|
|
result = result.astype(str)
|
|
logger.info(f" 课程实施情况表: {len(result)}行")
|
|
return result
|
|
|
|
|
|
# ===== 3. 学科课程实施情况表 ETL =====
|
|
def transform_subject_impl(raw_files: Dict[str, Dict]) -> pd.DataFrame:
|
|
"""
|
|
将二期的15个学科独立文件合并为一张表(一期格式)
|
|
一期列: 题号, 题型, 字段ID, 字段名称, 字段取值, 学科, 选项文字, 学校性质, 所在区, ...
|
|
"""
|
|
logger.info("转换学科课程实施情况表(合并15个学科文件)...")
|
|
all_rows = []
|
|
|
|
for subject, raw in sorted(raw_files.items()):
|
|
type_map = build_type_map(raw['type_stats'])
|
|
subject_rows = 0
|
|
|
|
for q_num, df in sorted(raw['data_sheets'].items()):
|
|
q_type = type_map.get(q_num, '未知')
|
|
for _, row in df.iterrows():
|
|
all_rows.append({
|
|
'题号': str(q_num),
|
|
'题型': q_type,
|
|
'字段ID': row.get('字段id', ''),
|
|
'字段名称': row.get('字段名称', ''),
|
|
'字段取值': row.get('字段取值', ''),
|
|
'学科': subject,
|
|
'选项文字': row.get('维度名称', ''),
|
|
'学校性质': '',
|
|
'所在区': extract_district_name(row.get('区域维度', '')),
|
|
'学校类别': '',
|
|
'学校特色': '',
|
|
'学校简称': row.get('学校维度', ''),
|
|
'所处地区': '',
|
|
'学校类型': '',
|
|
'学校类型编号': '',
|
|
})
|
|
subject_rows += 1
|
|
|
|
logger.info(f" {subject}: {subject_rows}行")
|
|
|
|
result = pd.DataFrame(all_rows)
|
|
result = result.astype(str)
|
|
logger.info(f" 学科课程实施情况表合计: {len(result)}行")
|
|
return result
|
|
|
|
|
|
# ===== 主流程 =====
|
|
def main():
|
|
start = time.time()
|
|
|
|
print("=" * 70)
|
|
print("📊 二期数据 ETL 转换")
|
|
print(f" 输入: {ERA2_RAW_DIR}")
|
|
print(f" 输出: {ERA2_OUTPUT_DIR}")
|
|
print("=" * 70)
|
|
|
|
# 检查输入目录
|
|
if not ERA2_RAW_DIR.exists():
|
|
logger.error(f"❌ 输入目录不存在: {ERA2_RAW_DIR}")
|
|
return
|
|
|
|
# 创建输出目录
|
|
ERA2_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ===== 1. 基础信息表 =====
|
|
print("\n[1/3] 基础信息表")
|
|
raw_basic = load_raw_file(ERA2_RAW_DIR / "第二期_学校基础信息表.xlsx")
|
|
df_basic = transform_basic_info(raw_basic)
|
|
out_basic = ERA2_OUTPUT_DIR / "era2_基础信息表.xlsx"
|
|
out_basic_pq = ERA2_OUTPUT_DIR / "era2_基础信息表.parquet"
|
|
df_basic.to_parquet(out_basic_pq, index=False)
|
|
logger.info(f" ✅ 保存 → {out_basic_pq}")
|
|
|
|
# ===== 2. 课程实施情况表 =====
|
|
print("\n[2/3] 课程实施情况表")
|
|
raw_course = load_raw_file(ERA2_RAW_DIR / "第二期_学校课程实施情况表.xlsx")
|
|
df_course = transform_course_impl(raw_course)
|
|
out_course_pq = ERA2_OUTPUT_DIR / "era2_课程实施情况表.parquet"
|
|
df_course.to_parquet(out_course_pq, index=False)
|
|
logger.info(f" ✅ 保存 → {out_course_pq}")
|
|
|
|
# ===== 3. 学科课程实施情况表 =====
|
|
print("\n[3/3] 学科课程实施情况表(15个学科文件)")
|
|
subject_files = sorted(ERA2_RAW_DIR.glob("第二期_*学科课程实施情况表.xlsx"))
|
|
raw_subjects = {}
|
|
for f in subject_files:
|
|
subject_name = f.name.replace("第二期_", "").replace("学科课程实施情况表.xlsx", "")
|
|
raw_subjects[subject_name] = load_raw_file(f)
|
|
|
|
df_subject = transform_subject_impl(raw_subjects)
|
|
|
|
# 学科表超过Excel行数上限(1,048,576),保存为parquet + 按区分片xlsx
|
|
out_subject_parquet = ERA2_OUTPUT_DIR / "era2_学科课程实施情况表.parquet"
|
|
df_subject.to_parquet(out_subject_parquet, index=False)
|
|
logger.info(f" ✅ 保存 (parquet) → {out_subject_parquet}")
|
|
|
|
# 注:如需xlsx格式可按区分片,但parquet格式已满足分析需求
|
|
|
|
# ===== 汇总 =====
|
|
elapsed = time.time() - start
|
|
print(f"\n{'=' * 70}")
|
|
print(f"✅ ETL 转换完成! 耗时 {elapsed:.1f}s")
|
|
print(f" 基础信息表: {len(df_basic):>8,}行")
|
|
print(f" 课程实施情况表: {len(df_course):>8,}行")
|
|
print(f" 学科课程实施情况表: {len(df_subject):>8,}行")
|
|
print(f" 学校数量: {df_course['学校简称'].nunique()}")
|
|
print(f" 区域数量: {df_course['所在区'].nunique()}")
|
|
print(f" 学科数量: {df_subject['学科'].nunique()}")
|
|
print(f" 输出目录: {ERA2_OUTPUT_DIR}")
|
|
print(f"{'=' * 70}")
|
|
|
|
# 保存元信息
|
|
meta = {
|
|
'基础信息表行数': len(df_basic),
|
|
'课程实施情况表行数': len(df_course),
|
|
'学科课程实施情况表行数': len(df_subject),
|
|
'学校列表': sorted(df_course['学校简称'].unique().tolist()),
|
|
'区域列表': sorted(df_course['所在区'].unique().tolist()),
|
|
'学科列表': sorted(df_subject['学科'].unique().tolist()),
|
|
'课程实施题数': raw_course['type_stats']['题号'].max(),
|
|
'各学科题数': {s: r['type_stats']['题号'].max() for s, r in raw_subjects.items()},
|
|
}
|
|
import json
|
|
meta_path = ERA2_OUTPUT_DIR / "etl_meta.json"
|
|
with open(meta_path, 'w', encoding='utf-8') as f:
|
|
json.dump(meta, f, ensure_ascii=False, indent=2, default=str)
|
|
logger.info(f" 元信息 → {meta_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|