From cb87b885b94c3a5ec6d84256cccc4084b2c604fa Mon Sep 17 00:00:00 2001 From: Kizzen983 <8342749+kizzen983@user.noreply.gitee.com> Date: Thu, 23 Apr 2026 14:19:59 +0800 Subject: [PATCH 1/2] fix: correct skill power parsing, deduplicate skills, update list page parser --- rocom_scraper.py | 48 +++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/rocom_scraper.py b/rocom_scraper.py index 1b905a8..15abdd0 100644 --- a/rocom_scraper.py +++ b/rocom_scraper.py @@ -111,30 +111,29 @@ def parse_list_page() -> list[dict]: entries = [] content = soup.find("div", id="mw-content-text") or soup - for card in content.select("div.rocom_prop_img"): - # NO编号 - no_span = card.find("span", style=re.compile(r'font-size:10px')) - if not no_span: + # 每个精灵是 NO.xxx... + for a in content.find_all("a", href=re.compile(r'^/rocom/')): + span = a.find("span", string=re.compile(r'^NO\.\d+')) + if not span: continue - no_m = re.search(r'NO\.(\d+)', no_span.get_text()) + no_m = re.search(r'NO\.(\d+)', span.get_text()) if not no_m: continue no = int(no_m.group(1)) - # 主链接 - a = card.find("a", href=re.compile(r'^/rocom/')) - if not a: - continue href = a["href"] url = urljoin(BASE_URL, href) + name_raw = unquote(href.split("/rocom/")[-1]) - # 名字从 block_2,形态从 block_3 - name_p = card.find("p", class_="block_2") - name = name_p.get_text(strip=True) if name_p else unquote(href.split("/rocom/")[-1]) - form_p = card.find("p", class_="block_3") - form = form_p.get_text(strip=True) or None if form_p else None + form_m = re.match(r'^(.+?)((.+))$', name_raw) + if form_m: + name = form_m.group(1) + form = form_m.group(2) + else: + name = name_raw + form = None - has_shiny = "异色" in card.get_text() + has_shiny = "异色" in a.get_text() entries.append({ "no": no, @@ -275,12 +274,13 @@ def parse_skills(soup: BeautifulSoup) -> list[dict]: else: category = "" - # 威力: 类别图后的数字 + # 威力: rocom_sprite_skill_power div 内的数字 power = 0 - if category_img: - sib = category_img.find_next_sibling(string=True) - if sib and sib.strip().lstrip('-').isdigit(): - power = int(sib.strip()) + power_div = container.find(class_="rocom_sprite_skill_power") + if power_div: + pt = power_div.get_text(strip=True) + if pt.lstrip('-').isdigit(): + power = int(pt) # 描述: ✦ 开头的文本 full_text = container.get_text(" ", strip=True) @@ -299,7 +299,13 @@ def parse_skills(soup: BeautifulSoup) -> list[dict]: except Exception: continue - return skills + seen = set() + deduped = [] + for sk in skills: + if sk["name"] not in seen: + seen.add(sk["name"]) + deduped.append(sk) + return deduped def parse_attributes_from_detail(soup: BeautifulSoup) -> list[str]: From c9b525802cbdaab9c3086a8f86990d71a139e41d Mon Sep 17 00:00:00 2001 From: Kizzen983 <8342749+kizzen983@user.noreply.gitee.com> Date: Thu, 23 Apr 2026 13:17:36 +0800 Subject: [PATCH 2/2] feat: skill level, ability/image scraping, skills.csv, urls.csv, --force flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse_skills: add level field (LV\d+ from rocom_sprite_skill_level) - parse_sprite_detail: scrape ability icon, sprite image, matchup icons - _add_url/_flush_urls: incremental urls.csv with per-type image download - _save_skills_csv: deduplicated skills.csv (no 所属精灵 column) - main: skip already-scraped sprites by default; --force clears and re-scrapes all --- README.md | 13 +- rocom_scraper.py | 343 +++++++++++++++++++++++++++++------------------ 2 files changed, 222 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 55abf10..432a6f8 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ pip install -r requirements.txt ### 方式二:命令行 ```bash -python rocom_scraper.py # 全量爬取 -python rocom_scraper.py --check-update # 检查更新 +python rocom_scraper.py # 全量爬取(自动跳过已有精灵) +python rocom_scraper.py --force # 强制重爬所有精灵 python viewer.py # 图鉴查看器 python battle.py # 战斗模拟器(含 AI 对战) python train.py # 批量训练 MCTS AI @@ -262,14 +262,19 @@ v2.0 的目标是接入 **游戏画面实时读取**,让 AI 真正用于游戏 | `--limit N` | 只爬前 N 只精灵(调试用) | 0(全部) | | `--delay N` | 每次请求间隔下限(秒),实际为 `delay ~ delay+1.5` 随机值 | 1.5 | | `--output path` | 输出 JSON 的路径 | `data/sprites.json` | -| `--check-update` | 增量更新模式 | — | +| `--force` | 强制重爬所有精灵,并重新下载所有图片(忽略已有缓存) | — | + +默认情况下,爬虫会自动跳过本地 `sprites.json` 中已存在的精灵,只爬取新增内容。使用 `--force` 可强制全量重爬。 ### 输出文件 | 文件 | 说明 | |------|------| -| `data/sprites.json` | 完整精灵数据,嵌套 JSON | +| `data/sprites.json` | 完整精灵数据,嵌套 JSON(含技能等级、特性) | | `data/sprites.csv` | 扁平化 CSV,可用 Excel / pandas 打开 | +| `data/skills.csv` | 全技能去重列表(列名同 `skills_all.csv`) | +| `data/urls.csv` | 爬取到的图片 URL 及本地路径,边爬边更新 | +| `data/images/` | 下载的图片,按类型分子目录(sprites/skills/attributes/abilities/matchup) | | `data/sprites.backup.json` | 每次爬取前自动备份的上一版 | ### 爬虫数据与战斗模拟器的同步关系 diff --git a/rocom_scraper.py b/rocom_scraper.py index 15abdd0..8a5c366 100644 --- a/rocom_scraper.py +++ b/rocom_scraper.py @@ -3,6 +3,9 @@ 目标: https://wiki.biligame.com/rocom/精灵图鉴 输出: data/sprites.json (精灵基础数据 + 技能 + 克制关系) data/sprites.csv (同上,CSV 格式,技能列用分号拼接) + data/skills.csv (全技能去重列表) + data/urls.csv (图片URL列表,边爬边更新) + data/images/ (下载的图片,按类型分子目录) 使用方法: pip install requests beautifulsoup4 @@ -21,6 +24,7 @@ import random import argparse import os +import shutil from pathlib import Path from urllib.parse import urljoin, unquote @@ -48,6 +52,69 @@ SESSION = requests.Session() SESSION.headers.update(HEADERS) +# ── URL 收集 & 图片下载 ──────────────────────────────────────────────────────── + +# 图片类型 -> 子目录名 +IMAGE_DIRS = { + "sprite": "images/sprites", # 精灵立绘 + "attribute": "images/attributes", # 属性图标 + "skill": "images/skills", # 技能图标 + "ability": "images/abilities", # 特性图标 + "matchup": "images/matchup", # 克制表属性图标 +} + +_urls_cache: dict[str, dict] = {} # url -> row, 去重用 +_urls_path: Path | None = None + +URL_COLUMNS = ["name", "type", "url", "local_path"] + + +def _init_urls(out_path: Path): + global _urls_path, _urls_cache + _urls_path = out_path.parent / "urls.csv" + _urls_cache = {} + if _urls_path.exists(): + with open(_urls_path, encoding="utf-8-sig", newline="") as f: + for row in csv.DictReader(f): + _urls_cache[row["url"]] = row + + +def _add_url(name: str, img_type: str, url: str, data_dir: Path, force: bool = False) -> str: + """记录一条图片URL,若未下载则立即下载,返回本地相对路径。""" + if url in _urls_cache and not force: + return _urls_cache[url]["local_path"] + + subdir = data_dir / IMAGE_DIRS[img_type] + subdir.mkdir(parents=True, exist_ok=True) + + ext = url.split(".")[-1].split("?")[0] or "png" + safe_name = re.sub(r'[\\/:*?"<>|]', "_", name) + local_path = f"{IMAGE_DIRS[img_type]}/{safe_name}.{ext}" + abs_path = data_dir / local_path + + if not abs_path.exists(): + try: + r = SESSION.get(url, timeout=15) + r.raise_for_status() + abs_path.write_bytes(r.content) + except Exception as e: + print(f"\n [!] 图片下载失败 {url}: {e}") + local_path = "" + + row = {"name": name, "type": img_type, "url": url, "local_path": local_path} + _urls_cache[url] = row + _flush_urls() + return local_path + + +def _flush_urls(): + if _urls_path is None: + return + with open(_urls_path, "w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=URL_COLUMNS) + writer.writeheader() + writer.writerows(_urls_cache.values()) + # ── 进度条 ──────────────────────────────────────────────────────────────────── @@ -228,53 +295,72 @@ def parse_type_matchup(soup: BeautifulSoup) -> dict: return matchup -def parse_skills(soup: BeautifulSoup) -> list[dict]: - """解析技能列表""" +def parse_skills(soup: BeautifulSoup, data_dir: Path | None = None, force: bool = False) -> list[dict]: + """解析技能列表,含等级要求和图标URL""" skills = [] - # 每个技能块包含: 属性图标, 技能名, 星数(能量消耗), 类别, 威力, 描述 - # 结构: 技能区域在克制表之后 - - # 找所有包含技能信息的行 - # 技能的特征: 有"图标 技能 星星背景"图片 (能量消耗标识) skill_cost_imgs = soup.find_all("img", alt=re.compile(r'图标 技能 星星背景')) - + for cost_img in skill_cost_imgs: try: - # 向上找技能容器块 + # 向上找技能容器块 (rocom_sprite_skill_box) container = cost_img.find_parent() - for _ in range(5): # 最多向上找5层 + for _ in range(6): + if container and container.get("class") and "rocom_sprite_skill_box" in container.get("class", []): + break if container and container.find("img", alt=re.compile(r'图标 宠物 属性')): break container = container.find_parent() if container else None if not container: continue - # 属性 - attr_img = container.find("img", alt=re.compile(r'图标 宠物 属性')) + # 等级要求: rocom_sprite_skill_level div + level = 0 + level_div = container.find(class_="rocom_sprite_skill_level") + if level_div: + lv_m = re.search(r'LV\s*(\d+)', level_div.get_text()) + if lv_m: + level = int(lv_m.group(1)) + + # 属性图标 + attr_img = container.find("img", class_="rocom_sprite_skill_attr") + if not attr_img: + attr_img = container.find("img", alt=re.compile(r'图标 宠物 属性')) skill_attr = img_alt_to_attr(attr_img.get("alt", "")) if attr_img else "未知" - # 技能名 (通常是第一个非图标的文本或img alt) - # 技能图标 alt 格式: "技能图标 技能名.png" + # 技能图标 & 名称 skill_icon = container.find("img", alt=re.compile(r'^技能图标')) if skill_icon: skill_name = skill_icon.get("alt", "").replace("技能图标 ", "").replace(".png", "") + skill_icon_url = skill_icon.get("src", "") else: skill_name = "" - - # 能量消耗 (星数): cost_img 后的第一个数字文本 + skill_icon_url = "" + + # 属性图标URL (取原图,不用缩略图) + attr_icon_url = "" + if attr_img: + src = attr_img.get("src", "") + # 从 srcset 取最大分辨率,或直接用 src + srcset = attr_img.get("srcset", "") + if srcset: + parts = [p.strip().split(" ")[0] for p in srcset.split(",") if p.strip()] + attr_icon_url = parts[-1] if parts else src + else: + attr_icon_url = src + + # 能量消耗 cost_text = cost_img.find_next_sibling(string=True) cost = int(cost_text.strip()) if cost_text and cost_text.strip().isdigit() else 0 - # 类别 (物攻/魔攻/状态/防御) + # 类别 category_img = container.find("img", alt=re.compile(r'图标 技能 类别')) if category_img: - cat_alt = category_img.get("alt", "") - cat_m = re.search(r'类别\s+(\S+?)(?:\.png)?$', cat_alt) + cat_m = re.search(r'类别\s+(\S+?)(?:\.png)?$', category_img.get("alt", "")) category = cat_m.group(1) if cat_m else "" else: category = "" - # 威力: rocom_sprite_skill_power div 内的数字 + # 威力 power = 0 power_div = container.find(class_="rocom_sprite_skill_power") if power_div: @@ -282,20 +368,29 @@ def parse_skills(soup: BeautifulSoup) -> list[dict]: if pt.lstrip('-').isdigit(): power = int(pt) - # 描述: ✦ 开头的文本 + # 描述 full_text = container.get_text(" ", strip=True) desc_m = re.search(r'✦(.+?)(?:$)', full_text) description = desc_m.group(1).strip() if desc_m else "" - if skill_name: - skills.append({ - "name": skill_name, - "attribute": skill_attr, - "category": category, - "cost": cost, - "power": power, - "description": description, - }) + if not skill_name: + continue + + # 下载图标 + if data_dir and skill_icon_url: + _add_url(skill_name, "skill", skill_icon_url, data_dir, force) + if data_dir and attr_icon_url: + _add_url(skill_attr, "attribute", attr_icon_url, data_dir, force) + + skills.append({ + "name": skill_name, + "attribute": skill_attr, + "category": category, + "cost": cost, + "power": power, + "level": level, + "description": description, + }) except Exception: continue @@ -336,7 +431,6 @@ def parse_evolution_chain(soup: BeautifulSoup) -> list[dict] | None: if not box: return None - # 收集各阶段精灵 stages = [] for i in range(1, 4): div = box.find("div", class_=f"rocom_spirit_evolution_{i}") @@ -353,14 +447,12 @@ def parse_evolution_chain(soup: BeautifulSoup) -> list[dict] | None: if len(stages) <= 1: return None - # 进化等级:在 evolution_1 和 evolution_2 之间,evolution_2 和 evolution_3 之间 level_divs = box.find_all("div", class_="rocom_spirit_evolution_level") levels = [] for ld in level_divs: p = ld.find("p", class_="rocom_spirit_evolution_level_num") levels.append(p.get_text(strip=True) if p else None) - # 进化条件(整个 rightBox 范围内) rightbox = soup.find("div", class_="rocom_sprite_temp_evolve_rightBox") condition = None if rightbox: @@ -368,7 +460,6 @@ def parse_evolution_chain(soup: BeautifulSoup) -> list[dict] | None: if cond_p: condition = cond_p.get_text(strip=True) - # 组装:每个非首阶段附上进化到它的等级/条件 result = [{"name": stages[0]["name"], "no": None, "evolves_from": None, "level": None, "condition": None}] for i, stage in enumerate(stages[1:]): level = levels[i] if i < len(levels) else None @@ -378,21 +469,17 @@ def parse_evolution_chain(soup: BeautifulSoup) -> list[dict] | None: return result -def parse_sprite_detail(entry: dict) -> dict: +def parse_sprite_detail(entry: dict, data_dir: Path | None = None, force: bool = False) -> dict: """爬取并解析单个精灵的详情页""" soup = fetch(entry["url"]) content = soup.find("div", id="mw-content-text") or soup - # 种族值 stats = parse_stat_block(content) - # 属性 (从页面顶部的属性图标) - # 简单方式: 找页面title区域附近的属性图 + # 属性图标 (h1 之后前几个) attrs = [] - # 在 h1 标题附近找属性 h1 = soup.find("h1") if h1: - # 找h1之后的前几个属性图标 for img in h1.find_all_next("img", limit=10): alt = img.get("alt", "") if "图标 宠物 属性" in alt: @@ -403,28 +490,52 @@ def parse_sprite_detail(entry: dict) -> dict: break # 特性 - ability_section = content.find(string=re.compile(r'^特性$')) ability = None + ability_section = content.find(string=re.compile(r'^特性$')) if ability_section: p = ability_section.find_parent() if p: - # 找特性图片的alt作为名字 nxt = p.find_next("img", alt=re.compile(r'^(?!图标|界面|页面)')) if nxt: ability_name = nxt.get("alt", "").replace(".png", "") - # 特性描述: 该图片后的文字 desc_node = nxt.find_next(string=re.compile(r'.{5,}')) ability_desc = desc_node.strip() if desc_node else "" + ability_icon_url = nxt.get("src", "") + if data_dir and ability_icon_url and ability_name: + _add_url(ability_name, "ability", ability_icon_url, data_dir, force) ability = {"name": ability_name, "description": ability_desc} - # 克制关系 - matchup = parse_type_matchup(content) + # 精灵立绘 (rocom_sprite_grament_img 内第一个可见 img) + if data_dir: + grament_div = content.find("div", class_="rocom_sprite_grament_img") + if grament_div: + sprite_img = grament_div.find("img") + if sprite_img: + sprite_url = sprite_img.get("src", "") + sprite_label = f"{entry['name']}{'_'+entry['form'] if entry.get('form') else ''}" + if sprite_url: + _add_url(sprite_label, "sprite", sprite_url, data_dir, force) + + # 克制表属性图标 + matchup_section = content.find(string=re.compile(r'^克制$')) + if matchup_section: + matchup_container = matchup_section.find_parent() + if matchup_container: + outer = matchup_container.find_parent() + if outer: + for img in outer.find_all("img"): + alt = img.get("alt", "") + if "属性" in alt: + attr_name = img_alt_to_attr(alt) + src = img.get("src", "") + if src and attr_name: + _add_url(attr_name, "matchup", src, data_dir, force) # 进化链 evolution_chain = parse_evolution_chain(content) - # 技能 - skills = parse_skills(content) + matchup = parse_type_matchup(content) + skills = parse_skills(content, data_dir, force) return { **entry, @@ -437,75 +548,6 @@ def parse_sprite_detail(entry: dict) -> dict: } -# ── 检查更新 ────────────────────────────────────────────────────────────────── - -def check_update(out_path: Path, delay: float = 1.5): - """对比本地数据与 wiki 列表,按需增量爬取""" - if not out_path.exists(): - print("[!] 本地数据不存在,请先执行全量爬取") - return - - with open(out_path, encoding="utf-8") as f: - local_data = json.load(f) - - local_keys = {(d["no"], d["name"], d.get("form")) for d in local_data} - print(f"[*] 本地共有 {len(local_data)} 条记录") - print(f"[*] 正在获取 wiki 精灵列表...") - - try: - wiki_entries = parse_list_page() - except RuntimeError as e: - print(f"\n[!] 无法连接 wiki: {e}") - print("[!] 可能是网络问题或服务器限速,请稍后重试") - return - wiki_map = {(e["no"], e["name"], e.get("form")): e for e in wiki_entries} - - new_keys = sorted(set(wiki_map) - local_keys, key=lambda x: (x[0], x[1], x[2] or "")) - - if not new_keys: - print("\n[✓] 数据已是最新版本,无需更新") - print(" 3 秒后自动关闭...") - time.sleep(3) - return - - print(f"\n[+] 发现 {len(new_keys)} 条新精灵:") - for no, name, form in new_keys: - form_str = f"({form})" if form else "" - print(f" NO.{no:03d} {name}{form_str}") - - print() - answer = input(f"是否爬取这 {len(new_keys)} 条新数据?[Y/n]: ").strip().lower() - if answer not in ("", "y", "yes"): - print("已取消") - return - - new_entries = [wiki_map[k] for k in new_keys] - results = list(local_data) - failed = [] - - print() - for i, entry in enumerate(new_entries, 1): - name_display = f"{entry['name']}{'('+entry['form']+')' if entry['form'] else ''}" - print_progress(i, len(new_entries), f"NO.{entry['no']:03d} {name_display}") - try: - data = parse_sprite_detail(entry) - results.append(data) - except Exception as e: - print(f"\n [!] 失败: {e}") - failed.append(entry["url"]) - time.sleep(random.uniform(delay, delay + 1.5)) - - _save(results, out_path) - csv_path = out_path.with_suffix(".csv") - _save_csv(results, csv_path) - - print(f"\n[完成] 已更新,本地共 {len(results)} 条,本次失败 {len(failed)} 条") - if failed: - fail_path = out_path.with_name("failed_urls.txt") - fail_path.write_text("\n".join(failed)) - print(f"[完成] 失败URL已记录至: {fail_path}") - - # ── 主流程 ──────────────────────────────────────────────────────────────────── def main(): @@ -514,17 +556,28 @@ def main(): parser.add_argument("--delay", type=float, default=1.5, help="请求间隔下限(秒),实际为 delay~(delay+1.5) 随机值,默认 1.5") parser.add_argument("--output", default="data/sprites.json", help="输出路径") - parser.add_argument("--check-update", action="store_true", help="检查并增量更新数据") + parser.add_argument("--force", action="store_true", help="强制重爬所有精灵(含图片)") args = parser.parse_args() out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) + data_dir = out_path.parent - if args.check_update: - check_update(out_path, delay=args.delay) - return + _init_urls(out_path) + + if args.force: + for p in [data_dir / "skills.csv", data_dir / "urls.csv"]: + if p.exists(): + p.unlink() + _urls_cache.clear() + + # 加载已有数据,按 (no, name, form) 建索引 + existing: dict[tuple, dict] = {} + if out_path.exists() and not args.force: + with open(out_path, encoding="utf-8") as f: + for d in json.load(f): + existing[(d["no"], d["name"], d.get("form"))] = d - # 1. 获取精灵列表 try: entries = parse_list_page() except RuntimeError as e: @@ -535,16 +588,22 @@ def main(): entries = entries[:args.limit] print(f"[*] 限制模式: 只处理前 {args.limit} 只") - # 2. 逐一爬取详情 results = [] failed = [] + skipped = 0 for i, entry in enumerate(entries, 1): + key = (entry["no"], entry["name"], entry.get("form")) name_display = f"{entry['name']}{'('+entry['form']+')' if entry['form'] else ''}" print_progress(i, len(entries), f"NO.{entry['no']:03d} {name_display}") + if key in existing: + results.append(existing[key]) + skipped += 1 + continue + try: - data = parse_sprite_detail(entry) + data = parse_sprite_detail(entry, data_dir, args.force) results.append(data) if i % 10 == 0: _save(results, out_path) @@ -554,17 +613,17 @@ def main(): time.sleep(random.uniform(args.delay, args.delay + 1.5)) - # 3. 回填进化链 id(名字 → no) _backfill_evolution_ids(results) - - # 4. 最终保存 _save(results, out_path) csv_path = out_path.with_suffix(".csv") _save_csv(results, csv_path) + _save_skills_csv(results, data_dir / "skills.csv") - print(f"\n[完成] 成功: {len(results)}, 失败: {len(failed)}") + print(f"\n[完成] 成功: {len(results)-skipped}, 跳过: {skipped}, 失败: {len(failed)}") print(f"[完成] JSON 已保存至: {out_path.resolve()}") print(f"[完成] CSV 已保存至: {csv_path.resolve()}") + print(f"[完成] 技能 已保存至: {(data_dir / 'skills.csv').resolve()}") + print(f"[完成] URLs 已保存至: {(data_dir / 'urls.csv').resolve()}") if failed: fail_path = out_path.with_name("failed_urls.txt") @@ -587,14 +646,38 @@ def _backfill_evolution_ids(results: list): def _save(data: list, path: Path): - # 写入前先备份原文件 if path.exists(): - import shutil shutil.copy2(path, path.with_suffix(".backup.json")) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) +SKILLS_CSV_COLUMNS = ["技能名", "属性", "类型", "威力", "耗能", "效果描述"] + + +def _save_skills_csv(data: list, path: Path): + seen = set() + rows = [] + for sprite in data: + for sk in (sprite.get("skills") or []): + name = sk.get("name", "") + if name and name not in seen: + seen.add(name) + rows.append({ + "技能名": name, + "属性": sk.get("attribute", ""), + "类型": sk.get("category", ""), + "威力": sk.get("power", ""), + "耗能": sk.get("cost", ""), + "效果描述": sk.get("description", ""), + }) + with open(path, "w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=SKILLS_CSV_COLUMNS) + writer.writeheader() + writer.writerows(rows) + + + CSV_COLUMNS = [ "no", "name", "form", "url", "has_shiny", "attributes", "total_stats", @@ -614,6 +697,7 @@ def _sprite_to_csv_row(d: dict) -> dict: def skill_str(s: dict) -> str: return ( f"{s.get('name', '')}(" + f"LV{s.get('level', 0)}/" f"{s.get('attribute', '')}/" f"{s.get('category', '')}/" f"{s.get('power', '')}/" @@ -651,7 +735,6 @@ def skill_str(s: dict) -> str: def _save_csv(data: list, path: Path): if path.exists(): - import shutil shutil.copy2(path, path.with_suffix(".backup.csv")) with open(path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS)