有关志愿通的代码仓库
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.
 
 
 
 
 

356 lines
12 KiB

import argparse
import hashlib
import os
import re
import subprocess
import sys
import uuid
from datetime import date
from pathlib import Path
import pandas as pd
EXPECTED_COLUMNS = [
"序号",
"学校名称",
"学校标识码",
"主管部门",
"所在地",
"办学层次",
"备注",
]
def sql_string(value):
if value is None:
return "NULL"
text = str(value)
text = (
text.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\x00", "")
.replace("\r", " ")
.replace("\n", " ")
)
return f"'{text}'"
def clean_cell(value):
if value is None or pd.isna(value):
return None
value = str(value).strip()
return value or None
def normalize_code(value):
value = clean_cell(value)
if value is None:
return None
value = re.sub(r"\.0$", "", value)
return value
def read_rows(path):
frame = pd.read_excel(path, sheet_name=0, header=2, dtype=str, engine="xlrd")
if list(frame.columns) != EXPECTED_COLUMNS:
raise ValueError(f"Unexpected columns: {list(frame.columns)!r}")
province = None
records = []
rejected = []
province_pattern = re.compile(r"^(.+?)[((]\d+所[))]$")
for dataframe_index, row in frame.iterrows():
source_row_number = dataframe_index + 4
sequence = clean_cell(row["序号"])
name = clean_cell(row["学校名称"])
code = normalize_code(row["学校标识码"])
province_match = province_pattern.match(sequence or "")
if province_match and not name and not code:
province = province_match.group(1)
continue
if not name and not code:
continue
record = {
"source_row_number": source_row_number,
"moe_school_code": code,
"official_name": name,
"province_name": province,
"city_name": clean_cell(row["所在地"]),
"authority_name": clean_cell(row["主管部门"]),
"education_level": clean_cell(row["办学层次"]),
"source_remark": clean_cell(row["备注"]),
}
errors = []
if not re.fullmatch(r"\d{10}", code or ""):
errors.append("invalid_moe_school_code")
for required in [
"official_name",
"province_name",
"city_name",
"authority_name",
"education_level",
]:
if not record[required]:
errors.append(f"missing_{required}")
if errors:
rejected.append((source_row_number, errors, record))
else:
records.append(record)
codes = [row["moe_school_code"] for row in records]
if len(codes) != len(set(codes)):
raise ValueError("Duplicate Ministry of Education school codes in source file")
return records, rejected
def values_sql(rows, run_id):
values = []
for row in rows:
values.append(
"(" + ",".join(
[
sql_string(run_id),
str(row["source_row_number"]),
sql_string(row["moe_school_code"]),
sql_string(row["official_name"]),
sql_string(row["province_name"]),
sql_string(row["city_name"]),
sql_string(row["authority_name"]),
sql_string(row["education_level"]),
sql_string(row["source_remark"]),
]
) + ")"
)
return ",\n".join(values)
def build_sql(args, records, rejected, digest, run_id):
raw_path = str(Path(args.source_file).resolve())
source_title = f"全国普通高等学校名单(截至{args.published_on})"
statements = [
"SET NAMES utf8mb4;",
"SET time_zone = '+08:00';",
f"USE `{args.database}`;",
"START TRANSACTION;",
"""
INSERT INTO data_sources (
source_type, publisher, title, landing_page_url, file_url,
published_on, retrieved_at, sha256, raw_file_path, notes
) VALUES (
"""
+ ",".join(
[
sql_string("official_xls"),
sql_string("中华人民共和国教育部"),
sql_string(source_title),
sql_string(args.landing_page_url),
sql_string(args.file_url),
sql_string(args.published_on),
"NOW()",
sql_string(digest),
sql_string(raw_path),
sql_string("全国普通高校,不含成人高校及港澳台高校"),
]
)
+ ") AS incoming ON DUPLICATE KEY UPDATE retrieved_at=incoming.retrieved_at, raw_file_path=incoming.raw_file_path;",
f"SET @source_id = (SELECT source_id FROM data_sources WHERE sha256={sql_string(digest)});",
f"DELETE FROM stg_moe_schools WHERE ingestion_run_id={sql_string(run_id)};",
f"""
INSERT INTO ingestion_runs (
ingestion_run_id, source_id, dataset_name, dataset_year, started_at,
status, rows_read, rows_rejected
) VALUES (
{sql_string(run_id)}, @source_id, 'moe_regular_higher_education_institutions',
{args.year}, NOW(), 'running', {len(records) + len(rejected)}, {len(rejected)}
);
""",
]
batch_size = 400
for start in range(0, len(records), batch_size):
batch = records[start : start + batch_size]
statements.append(
"""
INSERT INTO stg_moe_schools (
ingestion_run_id, source_row_number, moe_school_code, official_name,
province_name, city_name, authority_name, education_level, source_remark
) VALUES
"""
+ values_sql(batch, run_id)
+ ";"
)
statements.extend(
[
f"""
SET @existing_count = (
SELECT COUNT(*)
FROM schools s
JOIN stg_moe_schools stg
ON stg.moe_school_code=s.moe_school_code
AND stg.ingestion_run_id={sql_string(run_id)}
);
""",
f"""
INSERT IGNORE INTO school_names (
school_id, school_name, name_type, valid_from_year,
valid_to_year, source_id
)
SELECT s.school_id, s.official_name, 'former', s.first_seen_year,
{args.year - 1}, s.source_id
FROM schools s
JOIN stg_moe_schools stg
ON stg.moe_school_code = s.moe_school_code
AND stg.ingestion_run_id = {sql_string(run_id)}
WHERE s.official_name <> stg.official_name;
""",
f"""
INSERT INTO schools (
school_id, moe_school_code, official_name, province_name, city_name,
authority_name, education_level, source_remark, ownership_type,
school_status, first_seen_year, last_seen_year, source_id
)
SELECT incoming.school_id, incoming.moe_school_code, incoming.official_name,
incoming.province_name, incoming.city_name, incoming.authority_name,
incoming.education_level, incoming.source_remark, incoming.ownership_type,
incoming.school_status, incoming.first_seen_year,
incoming.last_seen_year, incoming.source_id
FROM (
SELECT UUID() AS school_id, moe_school_code, official_name,
province_name, city_name, authority_name, education_level,
source_remark,
CASE
WHEN source_remark='民办' THEN '民办'
WHEN source_remark LIKE '%中外合作办学%' THEN '合作办学'
WHEN source_remark LIKE '%境外高等教育机构%' THEN '境外办学'
ELSE '公办'
END AS ownership_type,
'active' AS school_status,
{args.year} AS first_seen_year, {args.year} AS last_seen_year,
@source_id AS source_id
FROM stg_moe_schools
WHERE ingestion_run_id = {sql_string(run_id)}
) AS incoming
ON DUPLICATE KEY UPDATE
official_name=incoming.official_name,
province_name=incoming.province_name,
city_name=incoming.city_name,
authority_name=incoming.authority_name,
education_level=incoming.education_level,
source_remark=incoming.source_remark,
ownership_type=incoming.ownership_type,
school_status='active',
last_seen_year=incoming.last_seen_year,
source_id=incoming.source_id;
""",
f"""
INSERT IGNORE INTO school_names (
school_id, school_name, name_type, valid_from_year, source_id
)
SELECT s.school_id, s.official_name, 'official', {args.year}, @source_id
FROM schools s
JOIN stg_moe_schools stg
ON stg.moe_school_code = s.moe_school_code
AND stg.ingestion_run_id = {sql_string(run_id)};
""",
f"""
UPDATE schools s
SET s.school_status='inactive'
WHERE s.last_seen_year < {args.year}
AND NOT EXISTS (
SELECT 1 FROM stg_moe_schools stg
WHERE stg.ingestion_run_id={sql_string(run_id)}
AND stg.moe_school_code=s.moe_school_code
);
""",
f"""
SET @loaded_count = (
SELECT COUNT(*) FROM stg_moe_schools
WHERE ingestion_run_id={sql_string(run_id)}
);
UPDATE ingestion_runs
SET finished_at=NOW(), status='success',
rows_inserted=@loaded_count-@existing_count,
rows_updated=@existing_count,
message={sql_string(f'Validated {len(records)} records; rejected {len(rejected)} records')}
WHERE ingestion_run_id={sql_string(run_id)};
""",
"COMMIT;",
]
)
return "\n".join(statements)
def run_mysql(mysql_bin, host, port, user, sql):
if not os.environ.get("MYSQL_PWD"):
raise RuntimeError("MYSQL_PWD environment variable is required")
process = subprocess.run(
[
mysql_bin,
"--protocol=TCP",
"-h",
host,
"-P",
str(port),
"-u",
user,
"--default-character-set=utf8mb4",
],
input=sql,
text=True,
encoding="utf-8",
capture_output=True,
)
if process.returncode:
raise RuntimeError(process.stderr.strip() or process.stdout.strip())
return process.stdout.strip(), process.stderr.strip()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--source-file", required=True)
parser.add_argument("--year", type=int, required=True)
parser.add_argument("--published-on", type=date.fromisoformat, required=True)
parser.add_argument("--expected-count", type=int, required=True)
parser.add_argument("--landing-page-url", required=True)
parser.add_argument("--file-url", required=True)
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")
args = parser.parse_args()
source_path = Path(args.source_file)
digest = hashlib.sha256(source_path.read_bytes()).hexdigest()
records, rejected = read_rows(source_path)
if rejected:
first = rejected[0]
raise ValueError(f"Rejected source rows: {len(rejected)}; first={first!r}")
if len(records) != args.expected_count:
raise ValueError(
f"Expected {args.expected_count} schools, parsed {len(records)}"
)
run_id = str(uuid.uuid4())
sql = build_sql(args, records, rejected, digest, run_id)
stdout, stderr = run_mysql(
args.mysql_bin, args.host, args.port, args.user, sql
)
print(
f"run_id={run_id} parsed={len(records)} rejected={len(rejected)} sha256={digest}"
)
if stdout:
print(stdout)
if stderr:
print(stderr, file=sys.stderr)
if __name__ == "__main__":
main()