You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
405 lines
19 KiB
405 lines
19 KiB
import argparse
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import time
|
|
import urllib.request
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
|
|
YEAR = 2025
|
|
DATASET = "choucsan/Gaokao-Compass-11M"
|
|
REVISION = "main"
|
|
TREE_URL = f"https://huggingface.co/api/datasets/{DATASET}/tree/{REVISION}/data/{YEAR}?recursive=true&expand=false&limit=1000"
|
|
LANDING_URL = f"https://huggingface.co/datasets/{DATASET}"
|
|
USER_AGENT = "Mozilla/5.0 (compatible; ZhiyuantongDataAudit/1.0)"
|
|
|
|
PROVINCES = {
|
|
"anhui": "安徽省", "beijing": "北京市", "chongqing": "重庆市", "fujian": "福建省",
|
|
"gansu": "甘肃省", "guangdong": "广东省", "guangxi": "广西壮族自治区",
|
|
"guizhou": "贵州省", "hainan": "海南省", "hebei": "河北省",
|
|
"heilongjiang": "黑龙江省", "henan": "河南省", "hubei": "湖北省", "hunan": "湖南省",
|
|
"jiangsu": "江苏省", "jiangxi": "江西省", "jilin": "吉林省", "liaoning": "辽宁省",
|
|
"neimenggu": "内蒙古自治区", "ningxia": "宁夏回族自治区", "qinghai": "青海省",
|
|
"shaanxi": "陕西省", "shandong": "山东省", "shanghai": "上海市", "shanxi": "山西省",
|
|
"sichuan": "四川省", "tianjin": "天津市", "xinjiang": "新疆维吾尔自治区",
|
|
"xizang": "西藏自治区", "yunnan": "云南省", "zhejiang": "浙江省",
|
|
}
|
|
|
|
EXPECTED_HEADERS = {
|
|
"enrollment-plan.csv": [
|
|
"province", "year", "batch", "category", "university_code", "university_name",
|
|
"major_group", "major_code", "major_name", "major_note", "subject_req", "plan_count",
|
|
"duration", "tuition",
|
|
],
|
|
"school-admission.csv": [
|
|
"province", "year", "category", "batch", "university_code", "university_name",
|
|
"major_group", "subject_req", "min_score", "min_rank", "control_score", "score_diff",
|
|
"admit_count", "school_province", "school_nature", "is_985", "is_211",
|
|
],
|
|
"major-admission.csv": [
|
|
"province", "year", "category", "batch", "university_code", "university_name",
|
|
"major_group", "major_code", "major_name", "major_note", "subject_req", "min_score",
|
|
"min_rank", "max_score", "avg_score", "admit_count", "school_province", "school_nature",
|
|
"is_985", "is_211",
|
|
],
|
|
"score-range.csv": [
|
|
"province", "year", "category", "batch", "control_score", "score", "score_range",
|
|
"segment_count", "cumulative_count", "rank_range",
|
|
],
|
|
}
|
|
|
|
HEADER_VARIANTS = {
|
|
("fujian", "enrollment-plan.csv"): [
|
|
"province", "year", "batch", "category", "university_code", "university_name",
|
|
"major_group", "major_group", "major_code", "major_name", "major_note", "subject_req",
|
|
"plan_count", "duration", "tuition",
|
|
],
|
|
("shanghai", "enrollment-plan.csv"): [
|
|
"province", "year", "batch", "category", "university_code", "university_name",
|
|
"major_group", "major_group", "major_code", "major_name", "major_note", "subject_req",
|
|
"plan_count", "duration", "tuition",
|
|
],
|
|
("guangdong", "enrollment-plan.csv"): [
|
|
"province", "year", "batch", "category", "university_code", "university_name",
|
|
"major_group", "major_code", "major_name", "major_note", "major_note", "subject_req",
|
|
"plan_count", "duration", "tuition",
|
|
],
|
|
}
|
|
|
|
STAGING = {
|
|
"enrollment-plan.csv": (
|
|
"stg_gc_enrollment_plans",
|
|
"@raw_province,@raw_year,batch_name,subject_category,source_school_code,school_name_raw,"
|
|
"group_code,major_code,major_name,major_note,subject_requirements,plan_count_text,study_duration,tuition_text",
|
|
),
|
|
"school-admission.csv": (
|
|
"stg_gc_school_admissions",
|
|
"@raw_province,@raw_year,subject_category,batch_name,source_school_code,school_name_raw,"
|
|
"group_code,subject_requirements,min_score_text,min_rank_text,control_score_text,score_diff_text,"
|
|
"admit_count_text,school_province,school_nature,is_985_text,is_211_text",
|
|
),
|
|
"major-admission.csv": (
|
|
"stg_gc_major_admissions",
|
|
"@raw_province,@raw_year,subject_category,batch_name,source_school_code,school_name_raw,"
|
|
"group_code,major_code,major_name,major_note,subject_requirements,min_score_text,min_rank_text,"
|
|
"max_score_text,avg_score_text,admit_count_text,school_province,school_nature,is_985_text,is_211_text",
|
|
),
|
|
"score-range.csv": (
|
|
"stg_gc_score_ranges",
|
|
"@raw_province,@raw_year,subject_category,batch_name,control_score_text,score_text,score_range_text,"
|
|
"segment_count_text,cumulative_count_text,rank_range_text",
|
|
),
|
|
}
|
|
|
|
|
|
def sql_string(value):
|
|
if value is None:
|
|
return "NULL"
|
|
text = str(value).replace("\\", "\\\\").replace("'", "\\'")
|
|
text = text.replace("\x00", "").replace("\r", " ").replace("\n", " ")
|
|
return f"'{text}'"
|
|
|
|
|
|
def request_bytes(url, timeout=180):
|
|
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
last_error = None
|
|
for attempt in range(3):
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return response.read()
|
|
except Exception as error: # noqa: BLE001 - 重试后由调用方报告完整URL
|
|
last_error = error
|
|
if attempt < 2:
|
|
time.sleep(2 ** attempt)
|
|
raise RuntimeError(f"下载失败: {url}: {last_error}")
|
|
|
|
|
|
def sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def download_one(item, raw_root, refresh):
|
|
relative = Path(item["path"]).relative_to(f"data/{YEAR}")
|
|
target = raw_root / relative
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
expected_size = int(item["size"])
|
|
if not refresh and target.exists() and target.stat().st_size == expected_size:
|
|
return target
|
|
url = f"https://huggingface.co/datasets/{DATASET}/resolve/{REVISION}/{item['path']}"
|
|
content = request_bytes(url)
|
|
if len(content) != expected_size:
|
|
raise RuntimeError(f"文件长度异常: {item['path']},期望{expected_size},实际{len(content)}")
|
|
partial = target.with_suffix(target.suffix + ".part")
|
|
partial.write_bytes(content)
|
|
os.replace(partial, target)
|
|
return target
|
|
|
|
|
|
def csv_summary(path):
|
|
expected = HEADER_VARIANTS.get((path.parent.name, path.name), EXPECTED_HEADERS[path.name])
|
|
row_count = 0
|
|
invalid_width = 0
|
|
with path.open("r", encoding="utf-8-sig", newline="") as stream:
|
|
reader = csv.reader(stream)
|
|
header = next(reader, None)
|
|
if header != expected:
|
|
raise RuntimeError(f"字段不匹配: {path}\n期望: {expected}\n实际: {header}")
|
|
for row in reader:
|
|
row_count += 1
|
|
if len(row) != len(expected):
|
|
invalid_width += 1
|
|
if invalid_width:
|
|
raise RuntimeError(f"CSV列数异常: {path},异常行数{invalid_width}")
|
|
return row_count
|
|
|
|
|
|
def load_mapping(path):
|
|
slug = path.parent.name
|
|
if path.name == "enrollment-plan.csv" and slug in {"fujian", "shanghai"}:
|
|
return (
|
|
"@raw_province,@raw_year,batch_name,subject_category,source_school_code,school_name_raw,"
|
|
"@composite_group_code,group_code,major_code,major_name,major_note,subject_requirements,"
|
|
"plan_count_text,study_duration,tuition_text",
|
|
"",
|
|
)
|
|
if path.name == "enrollment-plan.csv" and slug == "guangdong":
|
|
return (
|
|
"@raw_province,@raw_year,batch_name,subject_category,source_school_code,school_name_raw,"
|
|
"group_code,major_code,major_name,@major_note_one,@major_note_two,subject_requirements,"
|
|
"plan_count_text,study_duration,tuition_text",
|
|
", major_note=NULLIF(CONCAT_WS(';', NULLIF(TRIM(@major_note_one), ''), NULLIF(TRIM(@major_note_two), '')), '')",
|
|
)
|
|
return STAGING[path.name][1], ""
|
|
|
|
|
|
def mysql_command(args, extra=None):
|
|
if not os.environ.get("MYSQL_PWD"):
|
|
raise RuntimeError("必须通过MYSQL_PWD环境变量提供数据库密码")
|
|
command = [
|
|
args.mysql_bin, "--protocol=TCP", "-h", args.host, "-P", str(args.port),
|
|
"-u", args.user, "--default-character-set=utf8mb4",
|
|
]
|
|
if extra:
|
|
command.extend(extra)
|
|
return command
|
|
|
|
|
|
def mysql_run(args, sql, local_infile=False, capture=True):
|
|
extra = ["--local-infile=1"] if local_infile else []
|
|
process = subprocess.run(
|
|
mysql_command(args, extra), input=sql, text=True, encoding="utf-8",
|
|
capture_output=capture,
|
|
)
|
|
if process.returncode:
|
|
raise RuntimeError(process.stderr.strip() or process.stdout.strip())
|
|
return process.stdout
|
|
|
|
|
|
def mysql_run_file(args, path):
|
|
mysql_run(args, path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def build_load_sql(csv_files, summaries, database):
|
|
statements = [
|
|
"SET NAMES utf8mb4;", f"USE `{database}`;",
|
|
"SET SESSION sql_log_bin=0;",
|
|
"SET SESSION sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';",
|
|
"TRUNCATE TABLE stg_gc_enrollment_plans;",
|
|
"TRUNCATE TABLE stg_gc_school_admissions;",
|
|
"TRUNCATE TABLE stg_gc_major_admissions;",
|
|
"TRUNCATE TABLE stg_gc_score_ranges;",
|
|
]
|
|
for path in csv_files:
|
|
slug = path.parent.name
|
|
province = PROVINCES[slug]
|
|
filename = path.name
|
|
source_url = f"https://huggingface.co/datasets/{DATASET}/resolve/{REVISION}/data/{YEAR}/{slug}/{filename}"
|
|
digest = sha256(path)
|
|
title_map = {
|
|
"enrollment-plan.csv": "招生计划",
|
|
"school-admission.csv": "院校投档线",
|
|
"major-admission.csv": "专业录取线",
|
|
"score-range.csv": "一分一段",
|
|
}
|
|
notes = (
|
|
f"GaokaoCompass开放数据集{YEAR}年{province}{title_map[filename]}CSV;"
|
|
"来源方说明其由各省教育考试院、阳光高考等公开渠道清洗整理。"
|
|
"本系统将该文件作为待审核快照,不替代省级考试院原始公告。"
|
|
)
|
|
statements.append(f"""
|
|
INSERT INTO data_sources (
|
|
source_type, publisher, title, landing_page_url, file_url, published_on,
|
|
retrieved_at, sha256, raw_file_path, notes
|
|
) VALUES (
|
|
'open_dataset_csv', 'GaokaoCompass',
|
|
{sql_string(f'{province}{YEAR}年{title_map[filename]}')},
|
|
{sql_string(LANDING_URL)}, {sql_string(source_url)}, NULL, NOW(),
|
|
{sql_string(digest)}, {sql_string(str(path.resolve()))}, {sql_string(notes)}
|
|
) AS incoming ON DUPLICATE KEY UPDATE
|
|
retrieved_at=incoming.retrieved_at, raw_file_path=incoming.raw_file_path,
|
|
file_url=incoming.file_url, notes=incoming.notes;
|
|
SET @source_id=(SELECT source_id FROM data_sources WHERE sha256={sql_string(digest)});
|
|
SET @row_no=0;
|
|
""")
|
|
table = STAGING[filename][0]
|
|
columns, extra_set = load_mapping(path)
|
|
local_path = path.resolve().as_posix()
|
|
statements.append(f"""
|
|
LOAD DATA LOCAL INFILE {sql_string(local_path)} INTO TABLE {table}
|
|
CHARACTER SET utf8mb4
|
|
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '"'
|
|
LINES TERMINATED BY '\n'
|
|
IGNORE 1 LINES
|
|
({columns})
|
|
SET source_id=@source_id,
|
|
source_row_number=(@row_no:=@row_no+1),
|
|
province_name={sql_string(province)},
|
|
admission_year=CAST(TRIM(@raw_year) AS UNSIGNED){extra_set};
|
|
""")
|
|
statements.append("SELECT 'staging_loaded' AS status;")
|
|
return "\n".join(statements)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="下载并覆盖导入GaokaoCompass 2025全国招录数据")
|
|
parser.add_argument("--mysql-bin", required=True)
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--port", type=int, default=3306)
|
|
parser.add_argument("--user", default="root")
|
|
parser.add_argument("--database", default="zhiyuantong")
|
|
parser.add_argument("--root", default=str(Path(__file__).resolve().parents[1]))
|
|
parser.add_argument("--refresh", action="store_true", help="强制重新下载文件")
|
|
parser.add_argument("--workers", type=int, default=8)
|
|
parser.add_argument("--province", choices=sorted(PROVINCES), help="只覆盖指定省份目录(英文目录名)")
|
|
args = parser.parse_args()
|
|
|
|
root = Path(args.root).resolve()
|
|
raw_root = root / "data" / "raw" / "gaokao_compass" / str(YEAR)
|
|
raw_root.mkdir(parents=True, exist_ok=True)
|
|
tree = json.loads(request_bytes(TREE_URL, timeout=60).decode("utf-8"))
|
|
items = [
|
|
item for item in tree
|
|
if item.get("type") == "file"
|
|
and Path(item["path"]).name in set(EXPECTED_HEADERS) | {"meta.json"}
|
|
]
|
|
expected_provinces = {Path(item["path"]).parts[-2] for item in items if Path(item["path"]).name == "enrollment-plan.csv"}
|
|
if expected_provinces != set(PROVINCES):
|
|
raise RuntimeError(f"招生计划省份不完整: {sorted(set(PROVINCES) - expected_provinces)}")
|
|
|
|
print(f"开始下载/校验{len(items)}个文件,共{sum(int(item['size']) for item in items):,}字节")
|
|
downloaded = []
|
|
with ThreadPoolExecutor(max_workers=max(1, min(args.workers, 12))) as executor:
|
|
futures = {executor.submit(download_one, item, raw_root, args.refresh): item for item in items}
|
|
for index, future in enumerate(as_completed(futures), 1):
|
|
downloaded.append(future.result())
|
|
if index % 15 == 0 or index == len(items):
|
|
print(f"已完成 {index}/{len(items)} 个文件")
|
|
|
|
csv_files = sorted(path for path in downloaded if path.suffix.lower() == ".csv")
|
|
summaries = {str(path): csv_summary(path) for path in csv_files}
|
|
manifest = {
|
|
"dataset": DATASET, "revision": REVISION, "year": YEAR,
|
|
"landing_url": LANDING_URL, "downloaded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
"files": [
|
|
{
|
|
"path": str(path.relative_to(root)).replace("\\", "/"),
|
|
"size": path.stat().st_size, "sha256": sha256(path),
|
|
"rows": summaries.get(str(path)),
|
|
}
|
|
for path in sorted(downloaded)
|
|
],
|
|
}
|
|
(raw_root / "manifest.json").write_text(
|
|
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
print(f"CSV结构校验通过:{len(csv_files)}个文件,共{sum(summaries.values()):,}行")
|
|
|
|
migration = root / "database" / "migrations" / "007_add_gaokao_compass_2025_tables.sql"
|
|
replacement = root / "database" / "import_gaokao_compass_2025.sql"
|
|
migration_state = mysql_run(args, f"""
|
|
SELECT COUNT(*) FROM information_schema.columns
|
|
WHERE table_schema={sql_string(args.database)}
|
|
AND table_name='admission_groups' AND column_name='batch_name';
|
|
""").strip().splitlines()[-1]
|
|
if migration_state == "0":
|
|
mysql_run_file(args, migration)
|
|
else:
|
|
print("数据库007迁移已存在,跳过重复执行")
|
|
mysql_run(args, "SET GLOBAL local_infile=ON;")
|
|
try:
|
|
province_slugs = [args.province] if args.province else sorted(PROVINCES)
|
|
for index, slug in enumerate(province_slugs, 1):
|
|
province_files = [path for path in csv_files if path.parent.name == slug]
|
|
if not any(path.name == "enrollment-plan.csv" for path in province_files):
|
|
raise RuntimeError(f"{PROVINCES[slug]}缺少招生计划文件,不能执行省级覆盖")
|
|
load_output = mysql_run(
|
|
args, build_load_sql(province_files, summaries, args.database), local_infile=True
|
|
)
|
|
if "staging_loaded" not in load_output:
|
|
raise RuntimeError(f"{PROVINCES[slug]}暂存表装载未返回完成标志")
|
|
stage_counts = mysql_run(args, f"""
|
|
USE `{args.database}`;
|
|
SELECT 'enrollment-plan.csv', COUNT(*) FROM stg_gc_enrollment_plans
|
|
UNION ALL SELECT 'school-admission.csv', COUNT(*) FROM stg_gc_school_admissions
|
|
UNION ALL SELECT 'major-admission.csv', COUNT(*) FROM stg_gc_major_admissions
|
|
UNION ALL SELECT 'score-range.csv', COUNT(*) FROM stg_gc_score_ranges;
|
|
""")
|
|
actual = {}
|
|
for line in stage_counts.splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) == 2 and parts[1].isdigit():
|
|
actual[parts[0]] = int(parts[1])
|
|
expected_by_type = {
|
|
filename: sum(
|
|
summaries[str(path)] for path in province_files if path.name == filename
|
|
)
|
|
for filename in EXPECTED_HEADERS
|
|
}
|
|
if actual != expected_by_type:
|
|
raise RuntimeError(
|
|
f"{PROVINCES[slug]}暂存表行数校验失败,期望{expected_by_type},实际{actual}"
|
|
)
|
|
distinct_provinces = mysql_run(args, f"""
|
|
USE `{args.database}`;
|
|
SELECT COUNT(DISTINCT province_name), MIN(province_name)
|
|
FROM stg_gc_enrollment_plans;
|
|
""").strip().splitlines()[-1].split("\t")
|
|
if distinct_provinces != ["1", PROVINCES[slug]]:
|
|
raise RuntimeError(f"暂存表省份边界校验失败:{distinct_provinces}")
|
|
mysql_run_file(args, replacement)
|
|
print(
|
|
f"[{index}/{len(province_slugs)}] {PROVINCES[slug]}覆盖完成:"
|
|
+ ", ".join(f"{name}={count:,}" for name, count in actual.items()),
|
|
flush=True,
|
|
)
|
|
finally:
|
|
mysql_run(args, "SET GLOBAL local_infile=OFF;")
|
|
|
|
verification = mysql_run(args, f"""
|
|
USE `{args.database}`;
|
|
SELECT '专业招生计划', COUNT(*) FROM admission_major_plans plans
|
|
JOIN admission_groups groups_ ON groups_.admission_group_id=plans.admission_group_id
|
|
WHERE groups_.admission_year=2025
|
|
UNION ALL SELECT '院校专业组', COUNT(*) FROM admission_groups WHERE admission_year=2025
|
|
UNION ALL SELECT '院校投档线', COUNT(*) FROM admission_cutoff_lines WHERE admission_year=2025
|
|
UNION ALL SELECT '专业录取线', COUNT(*) FROM admission_major_lines WHERE admission_year=2025
|
|
UNION ALL SELECT '一分一段', COUNT(*) FROM score_rank_points WHERE admission_year=2025
|
|
UNION ALL SELECT '省级控制线', COUNT(*) FROM control_score_lines WHERE admission_year=2025;
|
|
SELECT '覆盖省份', COUNT(DISTINCT province_name) FROM admission_major_plans plans
|
|
JOIN admission_groups groups_ ON groups_.admission_group_id=plans.admission_group_id
|
|
WHERE groups_.admission_year=2025;
|
|
""")
|
|
print("覆盖导入完成:")
|
|
print(verification.strip())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|