#!/usr/bin/env python3 import os import sys import time import argparse import xml.etree.ElementTree as ET from io import BytesIO import re import json import UtilPack as util # ComicInfo.xsd에서 파싱한 default 값 딕셔너리 DEFAULT_VALUES = { 'Count': '-1', 'Volume': '', 'Number': '', 'Series': '', 'Title': '', 'Writer': '', 'LanguageISO': '', 'Manga': 'Yes', 'Pages': None, # 특수 처리 } # def main(argc, argv): parser = argparse.ArgumentParser(description="ComicInfo.XML 생성기") parser.add_argument('--root', dest='root_dir', required=False, help='입력 폴더 지정') parser.add_argument('--in', dest='arc_file', required=False, help='대상 파일 지정') parser.add_argument('--title', dest='info_title', required=False, help='제목') parser.add_argument('--author', dest='info_writer', required=False, help='작가') parser.add_argument('--series', dest='info_series', required=False, help='시리즈') parser.add_argument('--tags', dest='info_tags', required=False, help='태그 (쉼표로 구분)') parser.add_argument('--volume', dest='info_volume', required=False, help='권/화 묶음 번호 (예: 3)') parser.add_argument('--number', dest='info_number', required=False, help='개별 권/화 번호 (예: 1)') parser.add_argument('--count', dest='info_count', required=False, help='시리즈 총 권수/화수 (기본값: -1)') parser.add_argument('--print', dest='bPrint', action='store_true', help='comicinfo.xml 출력') parser.add_argument('--metadata', dest='bMetadata', action='store_true', help='.metadata 파일로부터 ComicInfo.xml 생성 및 CBZ에 추가') parser.add_argument('--verbose', dest='bVerbose', action='store_true', help='상세 로그 출력') parser.add_argument('-v', '--version', action='version', version='ComicInfoXMLConv 1.0') args = parser.parse_args(argv[1:]) # argv[0]은 스크립트 이름 제외 # verbose 모드 설정 util.SetVerbose(args.bVerbose) if True == args.bVerbose: util.DbgOut("환영합니다! ComicInfo.XML 생성기를 시작합니다.", True) # 태그 파싱 if args.info_tags is not None and len(args.info_tags) > 0: util.DbgOut(f"입력된 태그: {args.info_tags}", True) listTags = args.info_tags.split(',') util.DbgOut(f"태그 리스트: {listTags}", True) # 단일 파일 처리 (--in 옵션) if True == util.IsExistsPath(args.arc_file): util.DbgOut(f"대상 파일: {args.arc_file}", True) # Path 변수 확장 arc_title = ExpandPathVars(args.info_title, args.arc_file) arc_writer = ExpandPathVars(args.info_writer, args.arc_file) arc_series = ExpandPathVars(args.info_series, args.arc_file) arc_volume = ExpandPathVars(args.info_volume, args.arc_file) arc_number = ExpandPathVars(args.info_number, args.arc_file) arc_tags = args.info_tags.split(',') if args.info_tags else [] # .metadata 옵션이 활성화된 경우 if True == args.bMetadata: ProcessMetadataToCBZ(args.arc_file, args.bPrint, arc_volume, arc_number, args.info_count, arc_title, arc_writer, arc_series, arc_tags if arc_tags else None) return listArcContents = util.GetZipContentList(args.arc_file) if True == any(item.lower() == "comicinfo.xml" for item in listArcContents): util.DbgOut(f"comicinfo.xml 발견됨: {args.arc_file}", True) comicinfo_xml_bytes = util.GetZippedFileByte(args.arc_file, "ComicInfo.xml") if None != comicinfo_xml_bytes: modified = ModComicInfoXML(comicinfo_xml_bytes, arc_title, arc_writer, arc_series, arc_tags, info_number=arc_number, info_volume=arc_volume, info_count=args.info_count) if True == args.bPrint: if modified is not None: print(modified.decode('utf-8')) else: print(comicinfo_xml_bytes.decode('utf-8')) elif modified is not None: util.AddFileToZip(args.arc_file, "ComicInfo.xml", modified) util.DbgOut(f"ComicInfo.xml 업데이트됨: {args.arc_file}", True) else: util.DbgOut(f"comicinfo.xml 없음: {args.arc_file}", True) if arc_volume or arc_number or arc_title or arc_writer or arc_series or arc_tags or args.info_count: new_xml = CreateComicInfoXML( info_title=arc_title if arc_title else "", info_writer=arc_writer if arc_writer else "", info_series=arc_series if arc_series else "", tags=arc_tags if arc_tags else [], pages=[], volume=arc_volume if arc_volume else "", number=arc_number if arc_number else "", count=int(args.info_count) if args.info_count is not None else -1 ) if new_xml: if True == args.bPrint: print(new_xml) else: util.AddFileToZip(args.arc_file, "ComicInfo.xml", new_xml.encode('utf-8')) util.DbgOut(f"ComicInfo.xml 생성됨: {args.arc_file}", True) return # 폴더 처리 (--root 옵션) - 하위 폴더 포함, CBZ 파일만 처리 if True == util.IsExistsPath(args.root_dir): util.DbgOut(f"입력 폴더: {args.root_dir}", True) # CBZ 확장자 파일만 리커시브로 검색 listArcFiles = util.ListFileExtRcr(args.root_dir, ['cbz']) for strFullPath in listArcFiles: if False == os.path.exists(strFullPath): continue util.DbgOut(f"파일 발견: {strFullPath}", True) # Path 변수 확장 (파일마다 달라짐) arc_title = ExpandPathVars(args.info_title, strFullPath) arc_writer = ExpandPathVars(args.info_writer, strFullPath) arc_series = ExpandPathVars(args.info_series, strFullPath) arc_volume = ExpandPathVars(args.info_volume, strFullPath) arc_number = ExpandPathVars(args.info_number, strFullPath) arc_tags = args.info_tags.split(',') if args.info_tags else [] # .metadata 옵션이 활성화된 경우 if True == args.bMetadata: ProcessMetadataToCBZ(strFullPath, args.bPrint, arc_volume, arc_number, args.info_count, arc_title, arc_writer, arc_series, arc_tags if arc_tags else None) continue listArcContents = util.GetZipContentList(strFullPath) if True == any(item.lower() == "comicinfo.xml" for item in listArcContents): util.DbgOut(f"comicinfo.xml 발견됨: {strFullPath}", True) comicinfo_xml_bytes = util.GetZippedFileByte(strFullPath, "ComicInfo.xml") if None != comicinfo_xml_bytes: modified = ModComicInfoXML(comicinfo_xml_bytes, arc_title, arc_writer, arc_series, arc_tags, info_number=arc_number, info_volume=arc_volume, info_count=args.info_count) if True == args.bPrint: if modified is not None: print(modified.decode('utf-8')) else: print(comicinfo_xml_bytes.decode('utf-8')) elif modified is not None: util.AddFileToZip(strFullPath, "ComicInfo.xml", modified) util.DbgOut(f"ComicInfo.xml 업데이트됨: {strFullPath}", True) else: util.DbgOut(f"comicinfo.xml 없음: {strFullPath}", True) if arc_volume or arc_number or arc_title or arc_writer or arc_series or arc_tags or args.info_count: new_xml = CreateComicInfoXML( info_title=arc_title if arc_title else "", info_writer=arc_writer if arc_writer else "", info_series=arc_series if arc_series else "", tags=arc_tags if arc_tags else [], pages=[], volume=arc_volume if arc_volume else "", number=arc_number if arc_number else "", count=int(args.info_count) if args.info_count is not None else -1 ) if new_xml: if True == args.bPrint: print(new_xml) else: util.AddFileToZip(strFullPath, "ComicInfo.xml", new_xml.encode('utf-8')) util.DbgOut(f"ComicInfo.xml 생성됨: {strFullPath}", True) return # def ExpandPathVars(text: str | None, filepath: str) -> str | None: """Replace path template variables (, , etc.) with actual values. After variable expansion, if the result has a :s/pattern/replacement/ suffix, the regex is applied to the expanded text. """ if not text: return text dirpath = os.path.dirname(filepath) cur_folder = os.path.basename(dirpath) if dirpath else "" par_folder = os.path.basename(os.path.dirname(dirpath)) if dirpath and os.path.dirname(dirpath) else "" filename_no_ext = os.path.splitext(os.path.basename(filepath))[0] ext = os.path.splitext(filepath)[1] var_map = { "filename": filename_no_ext, "parentfolder": cur_folder, "currentfolder": cur_folder, "parentfolder": par_folder, "fullpath": filepath, "ext": ext, } result = text # Plain variable replacement for key, value in var_map.items(): result = result.replace(f"<{key}>", value) # After variable expansion, check for :s/pattern/replacement/ suffix m = re.match(r'^(.*):s(.)(.+?)\2(.*?)(?:\2([ig]*))?$', result, re.DOTALL) if m: source = m.group(1) delim = m.group(2) pattern = m.group(3) replacement = m.group(4) flags_str = m.group(5) or '' replacement_safe = re.sub(r'\$(\d+)', r'\\\1', replacement) flags = 0 if 'i' in flags_str: flags |= re.IGNORECASE count = 0 if 'g' in flags_str else 1 try: result = re.sub(pattern, replacement_safe, source, count=count, flags=flags) except re.error: pass return result def ResolveRegexTransform(text: str | None, current_value: str | None) -> str | None: """Apply regex substitution if text uses s/pattern/replacement/ syntax. Returns the transformed value, or the original text if no regex pattern detected.""" if not text or current_value is None: return text m = re.match(r'^s(.)(.+?)\1(.*?)(?:\1([ig]*))?$', text, re.DOTALL) if m: pattern = m.group(2) replacement = m.group(3) replacement = re.sub(r'\$(\d+)', r'\\\1', replacement) flags_str = m.group(4) or '' flags = 0 if 'i' in flags_str: flags |= re.IGNORECASE count = 0 if 'g' in flags_str else 1 try: return re.sub(pattern, replacement, current_value, count=count, flags=flags) except re.error: return text return text def ParsePathForComicInfo(strFullPath: str) -> dict: """ 파일 경로에서 작가, 제목, 번호를 추출합니다. 예: /some/path/[Kim] Some Comic/blablabla_01.cbz 반환: {'author': 'Kim', 'title': 'Some Comic', 'number': '01'} Args: strFullPath: 파일 전체 경로 Returns: {'author': str, 'title': str, 'number': str} 딕셔너리 """ result = { 'author': None, 'title': None, 'number': None } try: # 경로를 / 로 분리 path_parts = strFullPath.replace('\\', '/').split('/') # [] 가 포함된 부분 찾기 folder_with_bracket = None for part in path_parts: if '[' in part and ']' in part: folder_with_bracket = part break # [] 로 분리해서 작가와 제목 추출 if folder_with_bracket: # [작가] 제목 형식으로 분리 match = re.match(r'\[([^\]]+)\]\s*(.+)', folder_with_bracket) if match: result['author'] = match.group(1).strip() result['title'] = match.group(2).strip() # 파일명에서 번호 추출 (마지막 _ 다음 숫자) filename = path_parts[-1] # 파일명 (blablabla_01.cbz) # _숫자 형식 찾기 number_match = re.search(r'_(\d+)', filename) if number_match: result['number'] = number_match.group(1) return result except Exception as e: util.DbgOut(f"경로 파싱 오류: {e}", True) return result # def GetComicInfoXMLfromARC(strArcPath) -> bytes: # zip 파일에서 comicinfo.xml을 추출하는 로직을 여기에 구현 # 예시로, zip 파일에서 comicinfo.xml을 읽어와서 bytes 형태로 반환하는 과정을 생각할 수 있습니다. listContents = util.GetZipContentList(strArcPath) for item in listContents: if item.lower() == "comicinfo.xml": return util.GetZippedFileByte(strArcPath, item) return None # def ModComicInfoXMLFile(strXMLPath, info_title, info_writer, info_series, tags: list) -> bool: if False == util.IsExistsPath(strXMLPath): util.DbgOut(f"comicinfo.xml 파일이 존재하지 않습니다: {strXMLPath}", True) return False with open(strXMLPath, "rb") as f: xml_bytes = f.read() modified = ModComicInfoXML(xml_bytes, info_title, info_writer, info_series, tags) if modified is not None: with open(strXMLPath, "wb") as f: f.write(modified) return True return False # def ModComicInfoXML(pBytes, info_title, info_writer, info_series, tags: list, info_number=None, info_volume=None, info_count=None) -> bytes | None: try: root = ET.fromstring(pBytes) cur_title = root.findtext('Title') if root.find('Title') is not None else "" cur_writer = root.findtext('Writer') if root.find('Writer') is not None else "" cur_series = root.findtext('Series') if root.find('Series') is not None else "" info_title = ResolveRegexTransform(info_title, cur_title) info_writer = ResolveRegexTransform(info_writer, cur_writer) info_series = ResolveRegexTransform(info_series, cur_series) if info_title is not None and info_title != "": title_elem = root.find('Title') if title_elem is None: title_elem = ET.SubElement(root, 'Title') title_elem.text = info_title if info_writer is not None and info_writer != "": writer_elem = root.find('Writer') if writer_elem is None: writer_elem = ET.SubElement(root, 'Writer') writer_elem.text = info_writer if info_series is not None and info_series != "": series_elem = root.find('Series') if series_elem is None: series_elem = ET.SubElement(root, 'Series') series_elem.text = info_series if tags is not None and len(tags) > 0: tags_elem = root.find('Tags') if tags_elem is not None: root.remove(tags_elem) tags_elem = ET.SubElement(root, 'Tags') tags_elem.text = ','.join(tags) if info_number is not None and info_number != "" and info_number != "-1": number_elem = root.find('Number') if number_elem is None: number_elem = ET.SubElement(root, 'Number') number_elem.text = str(info_number) if info_volume is not None and info_volume != "" and info_volume != "-1": volume_elem = root.find('Volume') if volume_elem is None: volume_elem = ET.SubElement(root, 'Volume') volume_elem.text = str(info_volume) if info_count is not None and info_count != -1: count_elem = root.find('Count') if count_elem is None: count_elem = ET.SubElement(root, 'Count') count_elem.text = str(info_count) output = BytesIO() tree = ET.ElementTree(root) tree.write(output, encoding='utf-8', xml_declaration=True) modified = output.getvalue() if modified != pBytes: return modified return None except ET.ParseError as e: util.DbgOut(f"XML 파싱 오류: {e}", True) return None except Exception as e: util.DbgOut(f"ComicInfo.xml 수정 중 오류 발생: {e}", True) return None # def ConvertLanguageToISO(lang_name: str) -> str: """ 언어 이름을 ISO 639-1 코드로 변환합니다. Args: lang_name: 언어 이름 (예: "korean", "japanese", "english") Returns: ISO 639-1 코드 (예: "ko", "ja", "en") """ lang_map = { 'korean': 'ko', 'japanese': 'ja', 'english': 'en', 'chinese': 'zh', 'french': 'fr', 'german': 'de', 'spanish': 'es', 'italian': 'it', 'russian': 'ru', 'arabic': 'ar', 'portuguese': 'pt', 'korea': 'ko', # 별칭 'japan': 'ja', # 별칭 } if lang_name: return lang_map.get(lang_name.lower(), lang_name) return "" # def ProcessMetadataToCBZ(strArcPath: str, bPrint: bool = False, volume: str = "-1", number: str = "", count: int = -1, info_title: str = None, info_writer: str = None, info_series: str = None, tags: list[str] = None) -> bool: """ CBZ 파일의 .metadata를 읽어 ComicInfo.xml을 생성하고 CBZ에 추가합니다. info_title/info_writer/info_series에 s/pattern/replacement/ 문법을 사용하면 .metadata의 원본 값에 정규식 치환을 적용합니다. Args: strArcPath: CBZ 파일 경로 bPrint: ComicInfo.xml 내용을 출력할지 여부 volume: 권/화 묶음 번호 (예: 3) number: 개별 권/화 번호 (예: 1) count: 시리즈 총 권수/화수 info_title: 제목 오버라이드 (또는 s/// 정규식) info_writer: 작가 오버라이드 (또는 s/// 정규식) info_series: 시리즈 오버라이드 (또는 s/// 정규식) tags: 태그 리스트 오버라이드 Returns: 성공 여부 """ try: util.DbgOut(f".metadata 처리 중: {strArcPath}", True) xml_content = MetadataToComicInfoXML(strArcPath, volume=volume, number=number, count=count) if not xml_content: util.DbgOut(f"ComicInfo.xml 생성 실패: {strArcPath}", True) return False # XML 파싱해서 추가 필드 삽입 try: root = ET.fromstring(xml_content) # 현재 값 읽기 (regex 치환용) cur_title = root.findtext('Title') if root.find('Title') is not None else "" cur_writer = root.findtext('Writer') if root.find('Writer') is not None else "" cur_series = root.findtext('Series') if root.find('Series') is not None else "" # Regex 치환 적용 info_title = ResolveRegexTransform(info_title, cur_title) info_writer = ResolveRegexTransform(info_writer, cur_writer) info_series = ResolveRegexTransform(info_series, cur_series) # Title 오버라이드 if info_title is not None and info_title != "": title_elem = root.find('Title') if title_elem is None: title_elem = ET.SubElement(root, 'Title') title_elem.text = info_title # Writer 오버라이드 if info_writer is not None and info_writer != "": writer_elem = root.find('Writer') if writer_elem is None: writer_elem = ET.SubElement(root, 'Writer') writer_elem.text = info_writer # Series 오버라이드 if info_series is not None and info_series != "": series_elem = root.find('Series') if series_elem is None: series_elem = ET.SubElement(root, 'Series') series_elem.text = info_series # Tags 오버라이드 if tags is not None and len(tags) > 0: tags_elem = root.find('Tags') if tags_elem is not None: root.remove(tags_elem) tags_elem = ET.SubElement(root, 'Tags') tags_elem.text = ','.join(tags) # Volume 추가 if volume is not None and volume != "-1" and volume != "": volume_elem = root.find('Volume') if volume_elem is None: volume_elem = ET.SubElement(root, 'Volume') volume_elem.text = str(volume) # Number 추가 if number is not None and number != "" and number != "-1": number_elem = root.find('Number') if number_elem is None: number_elem = ET.SubElement(root, 'Number') number_elem.text = str(number) # Count 추가 if count is not None and count != -1: count_elem = root.find('Count') if count_elem is None: count_elem = ET.SubElement(root, 'Count') count_elem.text = str(count) # XML을 문자열로 변환 tree = ET.ElementTree(root) output = BytesIO() tree.write(output, encoding='utf-8', xml_declaration=True) xml_content = output.getvalue().decode('utf-8') except Exception as e: util.DbgOut(f"XML 수정 중 오류: {e}", True) # 출력 옵션이 켜져 있으면 내용 출력 if True == bPrint: print(xml_content) # XML을 바이트로 변환하여 CBZ에 추가 xml_bytes = xml_content.encode('utf-8') if True == util.AddFileToZip(strArcPath, "ComicInfo.xml", xml_bytes): util.DbgOut(f"ComicInfo.xml이 CBZ에 추가됨: {strArcPath}", True) return True else: util.DbgOut(f"CBZ에 ComicInfo.xml 추가 실패: {strArcPath}", True) return False except Exception as e: util.DbgOut(f".metadata 처리 중 오류 발생: {e}", True) return False # def MetadataToComicInfoXML(strArcPath: str, volume: str = "", number: str = "", count: int = -1) -> str: try: listContents = util.GetZipContentList(strArcPath) metadata_file = None for item in listContents: if item.lower() == ".metadata": metadata_file = item break if not metadata_file: util.DbgOut(f".metadata 파일을 찾을 수 없습니다: {strArcPath}", True) return "" metadata_bytes = util.GetZippedFileByte(strArcPath, metadata_file) if not metadata_bytes: util.DbgOut(f".metadata 파일을 읽을 수 없습니다: {strArcPath}", True) return "" metadata_json = json.loads(metadata_bytes.decode('utf-8')) info_title = "" info_writer = "" info_series = "" info_language = "" tags = [] characters = "" teams = "" pages = [] gallery_block = metadata_json.get('galleryBlock', {}) info_title = gallery_block.get('title', '') artists = gallery_block.get('artists', []) if artists: info_writer = ', '.join(artists) series = gallery_block.get('series', []) if series: info_series = series[0] lang_name = gallery_block.get('language', '') info_language = ConvertLanguageToISO(lang_name) related_tags = gallery_block.get('relatedTags', []) if related_tags: tags = related_tags groups_block = gallery_block.get('groups', []) if groups_block and isinstance(groups_block, list): teams = ','.join([str(g) for g in groups_block if g]) gallery_info = metadata_json.get('galleryInfo', {}) chars_info = gallery_info.get('characters', []) if chars_info and isinstance(chars_info, list): char_names = [c.get('character', '') for c in chars_info if isinstance(c, dict)] characters = ','.join([c for c in char_names if c]) groups_info = gallery_info.get('groups', []) if groups_info and isinstance(groups_info, list): group_names = [g.get('group', '') for g in groups_info if isinstance(g, dict)] teams = ','.join([g for g in group_names if g]) files = gallery_info.get('files', []) for image_index, file_info in enumerate(files): page_data = { 'Image': image_index } pages.append(page_data) return CreateComicInfoXML(info_title, info_writer, info_series, tags, pages, info_language, characters, teams, volume, number, count) except json.JSONDecodeError as e: util.DbgOut(f"JSON 파싱 오류: {e}", True) return "" except Exception as e: util.DbgOut(f".metadata 변환 중 오류 발생: {e}", True) return "" # def CreateComicInfoXML(info_title, info_writer, info_series, tags: list, pages: list, language: str = "", characters: str = "", teams: str = "", volume: str = "", number: str = "", count: int = -1) -> str: """ 새로운 ComicInfo.xml을 생성합니다. Args: info_title: 제목 info_writer: 작가 info_series: 시리즈 tags: 태그 리스트 pages: 페이지 정보 리스트 (dict 또는 객체) language: 언어 정보 (LanguageISO) Returns: ComicInfo.xml의 XML 문자열 """ try: # 루트 엘리먼트 생성 root = ET.Element('ComicInfo') # Title 추가 if info_title is not None and info_title != "": title_elem = ET.SubElement(root, 'Title') title_elem.text = info_title # Series 추가 if info_series is not None and info_series != "": series_elem = ET.SubElement(root, 'Series') series_elem.text = info_series # Writer 추가 if info_writer is not None and info_writer != "": writer_elem = ET.SubElement(root, 'Writer') writer_elem.text = info_writer # Tags 추가 (쉼표로 구분) if tags is not None and len(tags) > 0: # 빈 문자열 제거 filtered_tags = [tag.strip() for tag in tags if tag and tag.strip()] if filtered_tags: tags_elem = ET.SubElement(root, 'Tags') tags_elem.text = ','.join(filtered_tags) # Characters 추가 if characters is not None and characters != "": char_elem = ET.SubElement(root, 'Characters') char_elem.text = characters # Teams 추가 (groups -> Teams) if teams is not None and teams != "": teams_elem = ET.SubElement(root, 'Teams') teams_elem.text = teams # LanguageISO 추가 if language is not None and language != "": language_elem = ET.SubElement(root, 'LanguageISO') language_elem.text = language # Manga 항목 추가 (만화 유형 - Yes/No/YesAndRightToLeft) manga_elem = ET.SubElement(root, 'Manga') manga_elem.text = "Yes" # Volume 추가 (기본값 -1) if volume is not None and volume != "": volume_elem = ET.SubElement(root, 'Volume') volume_elem.text = str(volume) # Number 추가 (개별 권/화 번호) if number is not None and number != "" and number != "-1": number_elem = ET.SubElement(root, 'Number') number_elem.text = str(number) # Count 추가 (기본값 -1) if count is not None: count_elem = ET.SubElement(root, 'Count') count_elem.text = str(count) # Pages 추가 if pages is not None and len(pages) > 0: pages_elem = ET.SubElement(root, 'Pages') for page in pages: page_elem = ET.SubElement(pages_elem, 'Page') # Type 속성 추가 (고정값: Story) page_elem.set('Type', 'Story') # Image 속성 추가 if isinstance(page, dict): if 'Image' in page and page['Image'] is not None: page_elem.set('Image', str(page['Image'])) else: # page가 객체인 경우, Image 속성만 추출 if hasattr(page, 'Image'): page_elem.set('Image', str(page.Image)) # XML을 문자열로 변환 tree = ET.ElementTree(root) output = BytesIO() tree.write(output, encoding='utf-8', xml_declaration=True) return output.getvalue().decode('utf-8') except Exception as e: util.DbgOut(f"ComicInfo.xml 생성 중 오류 발생: {e}", True) return "" # For Main Loop if __name__ == '__main__': argc = len(sys.argv) argv = sys.argv main(argc, argv) # def printHowto(): print("Usage: python main.py [options]") print("Options:") print(" -h, --help Show help message.") print(" -v, --version Show version.")