Files
hiddencode_project/server/import_json_to_mysql.py
2026-07-01 15:52:29 +08:00

392 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
将 jsons/compressed_group_time_issues.json 导入 MySQL。
只导入以下四类,并把同一 row 中二一教育和学科网的数据合并为同一条记录:
- single_files
- same_child_archive_time
- zxxk_child_earlier
- twole_child_earlier
"""
import argparse
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
import mysql.connector
from mysql.connector import Error
DB_CONFIG = {
'host': '127.0.0.1',
'port': 3306,
'user': 'root',
'password': 'root123456',
'database': 'testdb',
'charset': 'utf8mb4'
}
BASE_DIR = Path(__file__).resolve().parent
DEFAULT_JSON_FILE_PATH = BASE_DIR / "jsons" / "compressed_group_time_issues.json"
DEFAULT_TABLE_NAME = "compressed_group_time_records"
SELECTED_CATEGORIES = (
"single_files",
"same_child_archive_time",
"zxxk_child_earlier",
"twole_child_earlier",
)
CREATE_TABLE_TEMPLATE = """
CREATE TABLE IF NOT EXISTS `{table_name}` (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
category VARCHAR(64) NOT NULL COMMENT '分类',
source_folder VARCHAR(512) NOT NULL COMMENT '来源文件夹',
word_doc VARCHAR(512) NOT NULL COMMENT '关联 Word 文档',
row_index INT NOT NULL COMMENT 'Word 内容行索引',
earlier_site VARCHAR(64) COMMENT '更早的网站中文名',
earlier_site_key VARCHAR(32) COMMENT '更早的网站 key: twole/zxxk',
child_time_diff_seconds DOUBLE COMMENT '学科网子文件时间 - 二一教育子文件时间',
twole_title TEXT COMMENT '二一教育标题',
twole_url TEXT COMMENT '二一教育 URL',
twole_source_time VARCHAR(100) COMMENT 'Word 中二一教育时间',
twole_id VARCHAR(100) COMMENT '二一教育 URL ID',
twole_file_kind VARCHAR(32) COMMENT '二一教育文件类型',
twole_file_name VARCHAR(1024) COMMENT '二一教育文件名',
twole_file_mtime DOUBLE COMMENT '二一教育文件 mtime',
twole_file_datetime DATETIME COMMENT '二一教育文件时间',
twole_archive_mtime DOUBLE COMMENT '二一教育压缩包 mtime',
twole_archive_datetime DATETIME COMMENT '二一教育压缩包时间',
twole_latest_child_mtime DOUBLE COMMENT '二一教育压缩包内最新子文件 mtime',
twole_latest_child_datetime DATETIME COMMENT '二一教育压缩包内最新子文件时间',
twole_child_archive_time_relation VARCHAR(32) COMMENT '二一教育子文件与压缩包时间关系',
twole_child_archive_time_diff_seconds DOUBLE COMMENT '二一教育子文件与压缩包时间差',
zxxk_title TEXT COMMENT '学科网标题',
zxxk_url TEXT COMMENT '学科网 URL',
zxxk_source_time VARCHAR(100) COMMENT 'Word 中学科网时间',
zxxk_id VARCHAR(100) COMMENT '学科网 URL ID',
zxxk_file_kind VARCHAR(32) COMMENT '学科网文件类型',
zxxk_file_name VARCHAR(1024) COMMENT '学科网文件名',
zxxk_file_mtime DOUBLE COMMENT '学科网文件 mtime',
zxxk_file_datetime DATETIME COMMENT '学科网文件时间',
zxxk_archive_mtime DOUBLE COMMENT '学科网压缩包 mtime',
zxxk_archive_datetime DATETIME COMMENT '学科网压缩包时间',
zxxk_latest_child_mtime DOUBLE COMMENT '学科网压缩包内最新子文件 mtime',
zxxk_latest_child_datetime DATETIME COMMENT '学科网压缩包内最新子文件时间',
zxxk_child_archive_time_relation VARCHAR(32) COMMENT '学科网子文件与压缩包时间关系',
zxxk_child_archive_time_diff_seconds DOUBLE COMMENT '学科网子文件与压缩包时间差',
twole_json JSON COMMENT '二一教育原始 JSON',
zxxk_json JSON COMMENT '学科网原始 JSON',
raw_json JSON NOT NULL COMMENT '合并前原始 JSON',
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uniq_category_row (category, source_folder(191), word_doc(191), row_index),
KEY idx_category (category),
KEY idx_earlier_site_key (earlier_site_key),
KEY idx_source_row (source_folder(191), word_doc(191), row_index),
KEY idx_twole_id (twole_id),
KEY idx_zxxk_id (zxxk_id),
KEY idx_twole_latest_child_mtime (twole_latest_child_mtime),
KEY idx_zxxk_latest_child_mtime (zxxk_latest_child_mtime)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='压缩包时间分类记录';
"""
INSERT_COLUMNS = (
"category", "source_folder", "word_doc", "row_index",
"earlier_site", "earlier_site_key", "child_time_diff_seconds",
"twole_title", "twole_url", "twole_source_time", "twole_id", "twole_file_kind",
"twole_file_name", "twole_file_mtime", "twole_file_datetime",
"twole_archive_mtime", "twole_archive_datetime",
"twole_latest_child_mtime", "twole_latest_child_datetime",
"twole_child_archive_time_relation", "twole_child_archive_time_diff_seconds",
"zxxk_title", "zxxk_url", "zxxk_source_time", "zxxk_id", "zxxk_file_kind",
"zxxk_file_name", "zxxk_file_mtime", "zxxk_file_datetime",
"zxxk_archive_mtime", "zxxk_archive_datetime",
"zxxk_latest_child_mtime", "zxxk_latest_child_datetime",
"zxxk_child_archive_time_relation", "zxxk_child_archive_time_diff_seconds",
"twole_json", "zxxk_json", "raw_json",
)
def mysql_identifier(name: str) -> str:
if not re.match(r"^[A-Za-z0-9_]+$", name):
raise ValueError(f"非法表名:{name}")
return name
def epoch_to_datetime(value) -> Optional[str]:
if value in (None, ""):
return None
return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M:%S")
def normalize_datetime(value) -> Optional[str]:
if not value:
return None
if isinstance(value, str) and re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$", value):
return value
return None
def json_dumps(value) -> str:
return json.dumps(value or {}, ensure_ascii=False)
def extract_url_id(url: Optional[str], site: str) -> Optional[str]:
if not url:
return None
if site == "twole":
match = re.search(r"/(\d+)\.shtml(?:[?#].*)?$", url)
if match:
return match.group(1)
if site == "zxxk":
match = re.search(r"/(?:soft/)?(\d+)\.html(?:[?#].*)?$", url)
if match:
return match.group(1)
numbers = re.findall(r"\d+", url)
return numbers[-1] if numbers else None
def first_file_from_group(group: Dict) -> Dict:
files = group.get("files") or []
return files[0] if files else {}
def build_side_from_individual(record: Dict) -> Dict:
file_info = record.get("file") or {}
site = record.get("site") or ""
return {
"title": record.get("title"),
"url": record.get("url"),
"source_time": record.get("time"),
"id": extract_url_id(record.get("url"), site),
"file_kind": record.get("file_kind"),
"file_name": file_info.get("name"),
"file_mtime": file_info.get("mtime"),
"file_datetime": normalize_datetime(file_info.get("datetime")) or epoch_to_datetime(file_info.get("mtime")),
"archive_mtime": record.get("archive_mtime"),
"archive_datetime": normalize_datetime(record.get("archive_datetime")) or epoch_to_datetime(record.get("archive_mtime")),
"latest_child_mtime": record.get("latest_child_mtime"),
"latest_child_datetime": normalize_datetime(record.get("latest_child_datetime")) or epoch_to_datetime(record.get("latest_child_mtime")),
"child_archive_time_relation": record.get("child_archive_time_relation"),
"child_archive_time_diff_seconds": record.get("child_archive_time_diff_seconds"),
"json": record,
}
def build_side_from_pair(group: Dict, site: str, pair_record: Dict) -> Dict:
file_info = first_file_from_group(group)
return {
"title": group.get("title"),
"url": group.get("url"),
"source_time": group.get("time"),
"id": extract_url_id(group.get("url"), site),
"file_kind": "archive",
"file_name": file_info.get("name"),
"file_mtime": file_info.get("mtime"),
"file_datetime": normalize_datetime(file_info.get("datetime")) or epoch_to_datetime(file_info.get("mtime")),
"archive_mtime": file_info.get("mtime"),
"archive_datetime": normalize_datetime(file_info.get("datetime")) or epoch_to_datetime(file_info.get("mtime")),
"latest_child_mtime": pair_record.get(f"{site}_latest_child_mtime"),
"latest_child_datetime": normalize_datetime(pair_record.get(f"{site}_latest_child_datetime")) or epoch_to_datetime(pair_record.get(f"{site}_latest_child_mtime")),
"child_archive_time_relation": "different",
"child_archive_time_diff_seconds": None,
"json": group,
}
def empty_import_record(category: str, source_folder: str, word_doc: str, row_index: int, raw_json) -> Dict:
record = {column: None for column in INSERT_COLUMNS}
record.update({
"category": category,
"source_folder": source_folder,
"word_doc": word_doc,
"row_index": row_index,
"raw_json": json_dumps(raw_json),
})
return record
def apply_side(record: Dict, site: str, side: Dict) -> None:
prefix = "twole" if site == "twole" else "zxxk"
for key in (
"title", "url", "source_time", "id", "file_kind", "file_name", "file_mtime", "file_datetime",
"archive_mtime", "archive_datetime", "latest_child_mtime", "latest_child_datetime",
"child_archive_time_relation", "child_archive_time_diff_seconds",
):
record[f"{prefix}_{key}"] = side.get(key)
record[f"{prefix}_json"] = json_dumps(side.get("json"))
def row_key(record: Dict) -> Tuple[str, str, int]:
return (record.get("source_folder") or "", record.get("word_doc") or "", int(record.get("row_index") or 0))
def normalize_individual_category(category: str, records: Iterable[Dict]) -> List[Dict]:
grouped: Dict[Tuple[str, str, int], Dict] = {}
raw_records: Dict[Tuple[str, str, int], List[Dict]] = {}
for source_record in records:
key = row_key(source_record)
raw_records.setdefault(key, []).append(source_record)
if key not in grouped:
grouped[key] = empty_import_record(category, key[0], key[1], key[2], raw_records[key])
grouped[key]["raw_json"] = json_dumps(raw_records[key])
site = source_record.get("site")
if site in ("twole", "zxxk"):
apply_side(grouped[key], site, build_side_from_individual(source_record))
return [grouped[key] for key in sorted(grouped)]
def normalize_pair_category(category: str, records: Iterable[Dict]) -> List[Dict]:
normalized = []
for source_record in records:
key = row_key(source_record)
record = empty_import_record(category, key[0], key[1], key[2], source_record)
record["earlier_site"] = source_record.get("earlier_site")
record["earlier_site_key"] = source_record.get("earlier_site_key")
record["child_time_diff_seconds"] = source_record.get("child_time_diff_seconds")
apply_side(record, "twole", build_side_from_pair(source_record.get("twole") or {}, "twole", source_record))
apply_side(record, "zxxk", build_side_from_pair(source_record.get("zxxk") or {}, "zxxk", source_record))
normalized.append(record)
return normalized
def normalize_records_for_import(data: Dict) -> List[Dict]:
normalized: List[Dict] = []
normalized.extend(normalize_individual_category("single_files", data.get("single_files", [])))
normalized.extend(normalize_individual_category("same_child_archive_time", data.get("same_child_archive_time", [])))
normalized.extend(normalize_pair_category("zxxk_child_earlier", data.get("zxxk_child_earlier", [])))
normalized.extend(normalize_pair_category("twole_child_earlier", data.get("twole_child_earlier", [])))
return normalized
def load_json_data(file_path: Path) -> Dict:
with file_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("当前导入脚本只支持新版分类字典格式 JSON")
return data
def create_connection():
return mysql.connector.connect(**DB_CONFIG)
def create_table(connection, table_name: str) -> None:
cursor = connection.cursor()
try:
cursor.execute(CREATE_TABLE_TEMPLATE.format(table_name=mysql_identifier(table_name)))
connection.commit()
finally:
cursor.close()
def truncate_table(connection, table_name: str) -> None:
cursor = connection.cursor()
try:
cursor.execute(f"TRUNCATE TABLE `{mysql_identifier(table_name)}`")
connection.commit()
finally:
cursor.close()
def build_insert_sql(table_name: str) -> str:
columns = ", ".join(f"`{column}`" for column in INSERT_COLUMNS)
placeholders = ", ".join(["%s"] * len(INSERT_COLUMNS))
updates = ", ".join(
f"`{column}` = VALUES(`{column}`)"
for column in INSERT_COLUMNS
if column not in {"category", "source_folder", "word_doc", "row_index"}
)
return f"""
INSERT INTO `{mysql_identifier(table_name)}` ({columns})
VALUES ({placeholders})
ON DUPLICATE KEY UPDATE {updates}
"""
def insert_records(connection, table_name: str, records: List[Dict], batch_size: int = 500) -> int:
if not records:
return 0
insert_sql = build_insert_sql(table_name)
cursor = connection.cursor()
inserted = 0
try:
for start in range(0, len(records), batch_size):
batch = records[start:start + batch_size]
values = [tuple(record.get(column) for column in INSERT_COLUMNS) for record in batch]
cursor.executemany(insert_sql, values)
connection.commit()
inserted += len(batch)
print(f"已写入/更新 {inserted}/{len(records)} 条记录")
finally:
cursor.close()
return inserted
def print_category_summary(records: List[Dict]) -> None:
counts = {category: 0 for category in SELECTED_CATEGORIES}
for record in records:
counts[record["category"]] = counts.get(record["category"], 0) + 1
print("待导入分类统计:")
for category in SELECTED_CATEGORIES:
print(f" {category}: {counts.get(category, 0)}")
print(f" total: {len(records)}")
def parse_args():
parser = argparse.ArgumentParser(description="导入 compressed_group_time_issues.json 到 MySQL")
parser.add_argument("--json-file", type=Path, default=DEFAULT_JSON_FILE_PATH, help="JSON 文件路径")
parser.add_argument("--table", default=DEFAULT_TABLE_NAME, help="MySQL 表名")
parser.add_argument("--truncate", action="store_true", help="导入前清空目标表")
parser.add_argument("--dry-run", action="store_true", help="只解析和统计,不连接 MySQL")
return parser.parse_args()
def main():
args = parse_args()
data = load_json_data(args.json_file)
records = normalize_records_for_import(data)
print_category_summary(records)
if args.dry_run:
print("dry-run 模式:未连接 MySQL未写入数据")
return
connection = None
try:
connection = create_connection()
print("成功连接到 MySQL")
create_table(connection, args.table)
print(f"已确认目标表:{args.table}")
if args.truncate:
truncate_table(connection, args.table)
print(f"已清空目标表:{args.table}")
inserted = insert_records(connection, args.table, records)
print(f"导入完成:写入/更新 {inserted} 条记录")
except Error as exc:
print(f"MySQL 操作失败:{exc}")
raise
finally:
if connection and connection.is_connected():
connection.close()
print("MySQL 连接已关闭")
if __name__ == "__main__":
main()