PKbGCs2..cmdlr/comicdb.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import sqlite3 import os import datetime as DT import pickle from . import stringprocess as SP class ComicDBException(Exception): pass class TitleConflictError(ComicDBException): pass def extend_sqlite3_datatype(): sqlite3.register_adapter(DT.datetime, pickle.dumps) sqlite3.register_converter('DATETIME', pickle.loads) sqlite3.register_adapter(dict, pickle.dumps) sqlite3.register_converter('DICT', pickle.loads) extend_sqlite3_datatype() class ComicDB(): def __init__(self, dbpath): def migrate(): def get_db_version(): return self.conn.execute( 'PRAGMA user_version;').fetchone()['user_version'] def set_db_version(version): self.conn.execute('PRAGMA user_version = {};'.format( int(version))) def from0to1(): self.conn.execute( 'CREATE TABLE comics (' # 已訂閱的漫畫 'comic_id TEXT PRIMARY KEY NOT NULL,' # e.g., xx123 'title TEXT NOT NULL UNIQUE,' # e.g., 海賊王 'desc TEXT NOT NULL,' # e.g., 關於海賊的漫畫 'extra_data DICT' # extra data package ');' ) self.conn.execute( 'CREATE TABLE volumes (' 'comic_id TEXT REFERENCES comics(comic_id)' ' ON DELETE CASCADE,' 'volume_id TEXT NOT NULL,' # vol NO. e.g., 15 'name TEXT NOT NULL,' # vol name. e.g., 第15回 'created_time DATETIME NOT NULL,' 'is_downloaded BOOLEAN NOT NULL DEFAULT 0,' 'gone BOOLEAN NOT NULL DEFAULT 0' # disappear in site ');' ) self.conn.execute( 'CREATE TABLE options (' 'option TEXT PRIMARY KEY NOT NULL,' 'value BLOB' ');' ) self.set_option( 'output_dir', os.path.join(os.path.expanduser('~'), 'comics')) self.set_option( 'backup_dir', os.path.join(os.path.expanduser('~'), 'comics_backup')) self.set_option('last_refresh_time', 'none') self.set_option('threads', 2) self.set_option('cbz', False) self.set_option('hanzi_mode', 'trad') self.set_option('analyzers_custom_data', {}) self.set_option('analyzers_black_list', set()) set_db_version(1) db_version = get_db_version() if db_version == 0: from0to1() self.conn.commit() def connection_setting(): sp = SP.StringProcess(hanzi_mode='trad') def is_same_title(title1, title2): if (sp.component_modified(title1) == sp.component_modified(title2)): return True else: return False self.conn.row_factory = sqlite3.Row self.conn.execute('PRAGMA foreign_keys = ON;') self.conn.create_function( 'is_same_title', 2, is_same_title) self.conn = sqlite3.connect(dbpath, detect_types=sqlite3.PARSE_DECLTYPES) connection_setting() migrate() def get_option(self, option, default=None): ''' return the option value ''' data = self.conn.execute( 'SELECT "value" FROM "options" where option = :option', {'option': option}).fetchone() if data: return pickle.loads(data['value']) else: return default def set_option(self, option, value): ''' set the option value, the value must be str or None. ''' data = {'value': pickle.dumps(value), 'option': option} cursor = self.conn.execute( 'UPDATE "options" SET "value" = :value' ' WHERE "option" = :option', data) if cursor.rowcount == 0: self.conn.execute( 'INSERT INTO "options"' ' (option, value)' ' VALUES (:option, :value)', data) self.conn.commit() def upsert_comic(self, comic_info): ''' Update or insert comic_info from ComicAnalyzer. Please refer the ComicAnalyzer to check the data format. This function will also maintain the volumes table. ''' def upsert_volume(comic_id, volume): now = DT.datetime.now() data = { 'comic_id': comic_id, 'volume_id': volume['volume_id'], 'created_time': now, 'name': volume['name'], } volume_count = self.conn.execute( # check already exists 'SELECT COUNT(*) FROM volumes' ' WHERE comic_id = :comic_id AND' ' volume_id = :volume_id', data).fetchone()[0] if volume_count == 0: self.conn.execute( 'INSERT INTO volumes' ' (comic_id, volume_id, name, created_time)' ' VALUES (' ' :comic_id,' ' :volume_id,' ' :name,' ' :created_time' ' )', data) else: self.conn.execute( 'UPDATE volumes SET' ' gone = 0' ' WHERE comic_id = :comic_id AND' ' volume_id = :volume_id' , data) def upsert_comic(comic_info): cursor = self.conn.execute( 'UPDATE comics SET' ' desc = :desc,' ' extra_data = :extra_data' ' WHERE comic_id = :comic_id', comic_info) if cursor.rowcount == 0: title_conflict_count = self.conn.execute( 'SELECT COUNT(title) FROM comics' ' WHERE is_same_title(comics.title, :title)' , comic_info).fetchone()[0] if title_conflict_count == 0: self.conn.execute( 'INSERT INTO comics' ' (comic_id, title, desc, extra_data)' ' VALUES (' ' :comic_id,' ' :title,' ' :desc,' ' :extra_data' ' )', comic_info) else: raise TitleConflictError def mark_disappear_volume(comic_info): volume_ids_text = ', '.join( ['"' + v['volume_id'] + '"' for v in comic_info['volumes']]) query = ('UPDATE volumes SET' ' gone = 1' ' WHERE comic_id = :comic_id AND' ' volume_id not in ({})').format(volume_ids_text) self.conn.execute(query, comic_info) upsert_comic(comic_info) for volume in comic_info['volumes']: upsert_volume(comic_info['comic_id'], volume) mark_disappear_volume(comic_info) self.conn.commit() def delete_comic(self, comic_id): self.conn.execute( 'DELETE FROM comics where comic_id = :comic_id', {'comic_id': comic_id}) self.conn.commit() def set_volume_is_downloaded( self, comic_id, volume_id, is_downloaded=True): ''' change volume downloaded status ''' self.conn.execute( 'UPDATE volumes' ' SET is_downloaded = :is_downloaded' ' WHERE comic_id = :comic_id AND volume_id = :volume_id', { 'comic_id': comic_id, 'volume_id': volume_id, 'is_downloaded': is_downloaded, }) self.conn.commit() def set_all_volumes_no_downloaded(self, comic_id): self.conn.execute(('UPDATE volumes SET is_downloaded = 0' ' WHERE comic_id = :comic_id'), {'comic_id': comic_id}) self.conn.commit() def get_no_downloaded_volumes(self): return self.conn.execute( 'SELECT * FROM comics INNER JOIN volumes' ' ON comics.comic_id = volumes.comic_id' ' WHERE volumes.is_downloaded = 0 AND' ' volumes.gone = 0' ' ORDER BY comics.title ASC,' ' comics.comic_id ASC,' ' volumes.name ASC').fetchall() def get_all_comics(self): return [self.get_comic(row['comic_id']) for row in self.get_all_comic_ids()] def get_all_comic_ids(self): return self.conn.execute( 'SELECT comics.comic_id FROM comics JOIN volumes' ' ON comics.comic_id = volumes.comic_id' ' GROUP BY comics.comic_id' ' ORDER BY volumes.is_downloaded DESC,' ' comics.title ASC,' ' comics.comic_id ASC').fetchall() def get_volumes_count(self): return self.conn.execute( 'SELECT COUNT(*) FROM volumes' ).fetchone()[0] def get_comic(self, comic_id): return self.conn.execute( 'SELECT * FROM comics' ' WHERE comic_id = :comic_id', {'comic_id': comic_id}).fetchone() def get_comic_volumes_status(self, comic_id): ''' For UI display. ''' volume_infos = self.conn.execute( 'SELECT * FROM volumes' ' WHERE comic_id = :comic_id', {'comic_id': comic_id}).fetchall() if len(volume_infos): last_incoming_time = max(v['created_time'] for v in volume_infos) else: last_incoming_time = None data = { 'last_incoming_time': last_incoming_time, 'volume_infos': volume_infos, 'downloaded_count': 0, 'gone_count': 0, } for volume_info in volume_infos: if volume_info['is_downloaded']: data['downloaded_count'] = data['downloaded_count'] + 1 if volume_info['gone']: data['gone_count'] = data['gone_count'] + 1 return data PK_GO{]## cmdlr/info.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## VERSION = '2.0.1' DESCRIPTION = ('A extensible command line tool use for subscribe online ' 'comic sites.') LICENSE = 'MIT' AUTHOR = 'Civa Lin' AUTHOR_EMAIL = 'larinawf@gmail.com' PROJECT_URL = 'https://bitbucket.org/civalin/cmdlr' PROJECT_NAME = 'cmdlr' PK:O^G;^^cmdlr/downloader.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import urllib.request as UR import urllib.error as UE import os class DownloadError(Exception): pass class Downloader(): """ General Download Toolkit """ def __init__(self, config): """ args: config: a Downloader config dict. { 'proxies': , # default: None (use env setting) # e.g., {'http': 'http://proxy.hinet.net:80/'} } """ proxyhandler = UR.ProxyHandler(proxies=config.get('proxies', None)) self.__opener = UR.build_opener(proxyhandler) def get(self, url, **options): ''' urllib.request.urlopen wrapper. args: options: { timeout: , # default: 60 method: , # default: None (auto select GET/POST) headers: , # default: {}, # e.g., {'User-Agent': Mozilla/5.0 Firefox/41.0} data: , # Post data, default: None } return: binary data pack which be downloaded. ''' req = UR.Request( url, data=options.get('data', None), headers=options.get('headers', {}), method=options.get('method', None), ) while True: try: resp = self.__opener.open(req, timeout=options.get('timeout', 60)) break except UE.HTTPError as err: # Like 404 no find if err.code in (408, 502, 503, 504, 507, 509): print('Retry {url} ->\n {err}'.format( url=url, err=err)) continue else: print('Skip {url} ->\n {err}'.format( url=url, err=err)) raise DownloadError() except UE.URLError as err: # Like timeout print('Retry {url} ->\n {err}'.format( url=url, err=err)) continue binary_data = resp.read() return binary_data def save(self, url, filepath, **options): ''' args: url: the file want to download filepath: the file location want to save ''' binary_data = self.get(url, **options) dirname = os.path.dirname(filepath) os.makedirs(dirname, exist_ok=True) with open(filepath, 'wb') as f: f.write(binary_data) _downloader = Downloader({}) get = _downloader.get save = _downloader.save PK:O^G244cmdlr/cmdline.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import os import sys import argparse import textwrap from . import comicdownloader from . import comicdb from . import comicpath from . import info DBPATH = '~/.cmdlr.db' def get_args(cmdlr): def parse_args(): def parser_setting(): parser = argparse.ArgumentParser( formatter_class=argparse.RawTextHelpFormatter, description=textwrap.fill(info.DESCRIPTION, 70)) parser.add_argument( "-v", action="count", dest='verbose', default=0, help="Change output verbosity. E.g., -v, -vvv") parser.add_argument( '--version', action='version', version=info.VERSION) return parser def azr_parser_setting(subparsers): azrparser = subparsers.add_parser( 'azr', formatter_class=argparse.RawTextHelpFormatter, help='List and custom any analyzer plugins', description='Usually you don\'t need to access the following ' 'function.\n' 'But if some analyzers bother you, you ' 'can view some detail and turn it off\n' 'in here.') all_analyzers_list = [codename for codename in sorted( cmdlr.am.all_analyzers.keys())] azrparser.add_argument( '-l', '--list', dest='list_analyzers', action='store_true', help='List all analyzers and checking they are on or off.') azrparser.add_argument( '-i', '--info', metavar='CODENAME', dest='analyzer_info', type=str, default=None, choices=all_analyzers_list, help='Show the analyzer\'s info message.') azrparser.add_argument( '--on', metavar='CODENAME', dest='analyzer_on', type=str, default=None, choices=all_analyzers_list, help='Turn on a analyzer.\n' 'By default all analyzers are enabled.') azrparser.add_argument( '--off', metavar='CODENAME', dest='analyzer_off', type=str, default=None, choices=all_analyzers_list, help='Turn off a analyzer.\n') azrparser.add_argument( '--custom', metavar='DATA', dest='analyzer_custom', type=str, default=None, help='Set analyzer\'s custom data.\n' 'Format: "codename/key1=value1,key2=value2"\n' 'Check analyzer\'s info message (-i) for more detail.') def opt_parser_setting(subparsers): cpath = comicpath.get_cpath(cmdlr.cdb) optparser = subparsers.add_parser( 'opt', formatter_class=argparse.RawTextHelpFormatter, help='List and modify any options.', description=None) optparser.add_argument( '-l', '--list', dest='list_options', action='store_true', help='List all options values.') optparser.add_argument( '-o', '--output-dir', metavar='DIR', dest='output_dir', type=str, help='Set comics directory.\n' '(= "{}")'.format(cpath.output_dir)) optparser.add_argument( '-b', '--backup-dir', metavar='DIR', dest='backup_dir', type=str, help='Set comics backup directory. Unsubscribed comics\n' 'will be moved in here.\n' '(= "{}")'.format(cpath.backup_dir)) optparser.add_argument( '--hanzi-mode', metavar="MODE", dest='hanzi_mode', type=str, choices=['trad', 'simp'], help='Select characters set converting rule for chinese.\n' 'Choice one of [%(choices)s]. (= "{}")'.format( cmdlr.cdb.get_option('hanzi_mode'))) optparser.add_argument( '--move', dest='move', action='store_true', help='Move *ALL* files in directories to new location.\n' 'Recommend use this with "--output-dir",\n' '"--backup-dir" and "--hanzi-mode".') optparser.add_argument( '-t', '--threads', metavar='NUM', dest='threads', type=int, choices=range(1, 11), help='Set download threads count. (= {})'.format( cmdlr.cdb.get_option('threads'))) optparser.add_argument( '--cbz', dest='cbz', action='store_true', help='Toggle new incoming volumes to cbz format.' ' (= {})'.format(cmdlr.cdb.get_option('cbz'))) def subscription_group_setting(parser): subscription_group = parser.add_argument_group('## Subscription') subscription_group.add_argument( '-s', '--subscribe', metavar='COMIC', dest='subscribe_comic_entrys', type=str, nargs='+', help='Subscribe some comic books.\n' 'COMIC can be a url or comic_id.') subscription_group.add_argument( '-u', '--unsubscribe', metavar='COMIC', dest='unsubscribe_comic_entrys', type=str, nargs='+', help='Unsubscribe some comic books.') subscription_group.add_argument( '--no-backup', dest='no_backup', action='store_true', help='No backup downloaded files when unsubscribed.\n' 'Must using with "-u" option') subscription_group.add_argument( '-l', '--list', metavar='COMIC', dest='list_info', type=str, nargs='*', help='List all (or some) subscribed books info.') subscription_group.add_argument( '-r', '--refresh', dest='refresh', action='store_true', help='Update all subscribed comic info.') subscription_group.add_argument( '--as-new', metavar='COMIC', dest='as_new_comics', type=str, nargs='+', help='Set all volumes to "no downloaded" status.\n') def downloading_group_setting(parser): downloading_group = parser.add_argument_group('## Downloading') downloading_group.add_argument( '-d', '--download', dest='download', action='store_true', help='Download all no downloaded volumes.') downloading_group.add_argument( '--skip-exists', dest='skip_exists', action='store_true', help='Do not re-download when localfile exists.\n' 'Must using with "-d" option.') parser = parser_setting() subparsers = parser.add_subparsers( title='## Sub Commands ##', help='Use "-h" to get help messages of sub commands.\n' '(e.g., "%(prog)s opt -h")') azr_parser_setting(subparsers) opt_parser_setting(subparsers) subscription_group_setting(parser) downloading_group_setting(parser) args = parser.parse_args() return args args = parse_args() return args def main(): def azr_cmds_process(cmdlr, args): if 'list_analyzers' not in args: return if args.analyzer_custom: cmdlr.am.set_custom_data(args.analyzer_custom) if args.analyzer_on or args.analyzer_off: if args.analyzer_on: cmdlr.am.on(args.analyzer_on) if args.analyzer_off: cmdlr.am.off(args.analyzer_off) cmdlr.am.print_analyzers_list() if args.list_analyzers: cmdlr.am.print_analyzers_list() if args.analyzer_info: cmdlr.am.print_analyzer_info(args.analyzer_info) def opt_cmds_process(cmdlr, args): def move_cpath(cmdlr): output_dir = cmdlr.cdb.get_option('output_dir') backup_dir = cmdlr.cdb.get_option('backup_dir') hanzi_mode = cmdlr.cdb.get_option('hanzi_mode') dst_cpath = comicpath.ComicPath( output_dir, backup_dir, hanzi_mode) cmdlr.move_cpath(dst_cpath) def print_threads(): print(' Threads count: {}'.format( cmdlr.cdb.get_option('threads'))) def print_cbz_mode(): print(' Convert to cbz: {}'.format( cmdlr.cdb.get_option('cbz'))) def print_output_dir(): print(' Output directory: {}'.format( cmdlr.cdb.get_option('output_dir'))) def print_backup_dir(): print(' Backup directory: {}'.format( cmdlr.cdb.get_option('backup_dir'))) def print_hanzi_mode(): hanzi_mode = cmdlr.cdb.get_option('hanzi_mode') if hanzi_mode == 'trad': hanzi_text = 'Traditional Chinese' elif hanzi_mode == 'simp': hanzi_text = 'Simplified Chinese' else: hanzi_text = 'Unknown' print(' Hanzi mode: {} - {}'.format( hanzi_mode, hanzi_text)) def print_all_options(): print('## Options table ## -----------------------------\n') print_output_dir() print_backup_dir() print_hanzi_mode() print_threads() print_cbz_mode() print('\nUse "-h" to find how to modify.') if 'output_dir' not in args: return if args.threads is not None: cmdlr.cdb.set_option('threads', args.threads) print_threads() if args.cbz: cmdlr.cdb.set_option('cbz', not cmdlr.cdb.get_option('cbz')) print_cbz_mode() if args.output_dir or args.backup_dir or args.hanzi_mode: if args.output_dir: cmdlr.cdb.set_option('output_dir', args.output_dir) print_output_dir() if args.backup_dir: cmdlr.cdb.set_option('backup_dir', args.backup_dir) print_backup_dir() if args.hanzi_mode: cmdlr.cdb.set_option('hanzi_mode', args.hanzi_mode) print_hanzi_mode() if args.move: move_cpath(cmdlr) sys.exit(0) elif args.move: print('Warning: The "--move" are useless without\n' ' "--output-dir", "--backup-dir" or "--hanzi-mode".') if args.list_options: print_all_options() def subscription_process(cmdlr, args): if args.as_new_comics: for comic_entry in args.as_new_comics: cmdlr.as_new(comic_entry, args.verbose + 1) if args.unsubscribe_comic_entrys: for comic_entry in args.unsubscribe_comic_entrys: cmdlr.unsubscribe( comic_entry, not args.no_backup, args.verbose) elif args.no_backup: print('Warning: The "--no-backup" are useless without' ' "--unsubscribe"') if args.subscribe_comic_entrys: for entry in args.subscribe_comic_entrys: cmdlr.subscribe(entry, args.verbose) if args.refresh: cmdlr.refresh_all(args.verbose + 1) if args.download: cmdlr.download_subscribed(args.skip_exists) elif args.skip_exists: print('Warning: The "--skip-exists" are useless without' ' "--download".') if args.list_info is not None: if len(args.list_info) == 0: cmdlr.list_info(args.verbose + 1) else: for comic_entry in args.list_info: cmdlr.print_comic_info(comic_entry, args.verbose + 2) cdb = comicdb.ComicDB(dbpath=os.path.expanduser(DBPATH)) cmdlr = comicdownloader.ComicDownloader(cdb) args = get_args(cmdlr) azr_cmds_process(cmdlr, args) opt_cmds_process(cmdlr, args) subscription_process(cmdlr, args) PK:O^Gs[Icmdlr/__init__.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## PK9aG)ۖCCcmdlr/comicdownloader.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import concurrent.futures as CF import datetime as DT import os import textwrap import shutil import queue import collections import zipfile from . import downloader from . import analyzersmanager as AM from . import comicpath from . import comicdb class ComicDownloader(): def __init__(self, cdb): self.__cdb = cdb self.__cpath = comicpath.get_cpath(cdb) self.__threads = cdb.get_option('threads') self.__cbz = cdb.get_option('cbz') self.am = AM.AnalyzersManager(cdb) @property def cdb(self): return self.__cdb def get_comic_info_text(self, comic_info, verbose=0): def get_data_package(comic_info): stat = self.__cdb.get_comic_volumes_status( comic_info['comic_id']) volumes_infos = stat['volume_infos'] total = len(volumes_infos) data = { 'comic_id': comic_info['comic_id'], 'title': self.__cpath.sp.hanziconv(comic_info['title']), 'desc': self.__cpath.sp.hanziconv(comic_info['desc']), 'total': total, 'no_downloaded_count': total - stat['downloaded_count'], 'downloaded_count': stat['downloaded_count'], 'gone_count': stat['gone_count'], 'last_incoming_time': stat['last_incoming_time'], 'v_infos': volumes_infos, } return data def get_text_string(data, verbose): texts = [] texts.append('{comic_id:<15} {title}') if verbose >= 1: texts.append(' ({downloaded_count}/{total})') if data['no_downloaded_count'] != 0: texts.insert(0, '{no_downloaded_count:<+4} ') else: texts.insert(0, ' ') if data['gone_count'] != 0: texts.append(' [{gone_count}]') text = ''.join(texts).format(**data) if verbose >= 2: text = '\n'.join([ text, textwrap.indent( textwrap.fill('{desc}'.format(**data), 35), ' ')]) if verbose >=3: texts2 = [] for v in data['v_infos']: texts2.append(' - {name} {down} {gone}'.format( name=v['name'], down='' if v['is_downloaded'] else '[no downloaded]', gone='[disappeared]' if v['gone'] else '', )) text2 = '\n'.join(texts2) text = '\n'.join([text, text2]) text = text + '\n' return text verbose = verbose % 4 data = get_data_package(comic_info) return get_text_string(data, verbose) def get_comic_info(self, comic_entry): azr, comic_id = self.am.get_analyzer_and_comic_id(comic_entry) if azr is None: print('"{}" not fits any analyzers.'.format(comic_entry)) comic_id = comic_entry comic_info = self.__cdb.get_comic(comic_id) return comic_info def print_comic_info(self, comic_entry, verbose): comic_info = self.get_comic_info(comic_entry) if comic_info is None: print('"{}" are not exists.'.format(comic_entry)) return None text = self.get_comic_info_text(comic_info, verbose) print(text) def subscribe(self, comic_entry, verbose): def try_revive_from_backup(comic_info): def merge_dir(root_src_dir, root_dst_dir): for src_dir, dirs, files in os.walk(root_src_dir): dst_dir = src_dir.replace(root_src_dir, root_dst_dir) if not os.path.exists(dst_dir): os.makedirs(dst_dir) for file in files: src_file = os.path.join(src_dir, file) dst_file = os.path.join(dst_dir, file) if os.path.exists(dst_file): os.remove(dst_file) shutil.move(src_file, dst_dir) backup_comic_dir = self.__cpath.get_backup_comic_dir( comic_info) comic_dir = self.__cpath.get_comic_dir(comic_info) if backup_comic_dir.exists(): merge_dir(str(backup_comic_dir), str(comic_dir)) shutil.rmtree(str(backup_comic_dir)) azr, comic_id = self.am.get_analyzer_and_comic_id(comic_entry) if azr is None: print('"{}" not fits any analyzers.'.format(comic_entry)) return None comic_info = azr.get_comic_info(comic_id) try: self.__cdb.upsert_comic(comic_info) except comicdb.TitleConflictError: print('Title "{title}" are already exists. Rejected.'.format( **comic_info)) return try_revive_from_backup(comic_info) text = self.get_comic_info_text(comic_info, verbose) print('[SUBSCRIBED] ' + text) def unsubscribe(self, comic_entry, request_backup, verbose): def backup_or_remove_data(comic_info, request_backup): comic_dir = self.__cpath.get_comic_dir(comic_info) if comic_dir.exists(): if request_backup: os.makedirs(str(self.__cpath.backup_dir), exist_ok=True) backup_comic_dir = self.__cpath.get_backup_comic_dir( comic_info) if backup_comic_dir.exists(): os.rmtree(str(backup_comic_dir)) shutil.move(str(comic_dir), str(backup_comic_dir)) else: shutil.rmtree(str(comic_dir), ignore_errors=True) def get_info_text(comic_info, request_backup, verbose): text = self.get_comic_info_text(comic_info, verbose) if request_backup: text = '[UNSUB & BAK] ' + text else: text = '[UNSUB & DEL] ' + text return text comic_info = self.get_comic_info(comic_entry) if comic_info is None: print('"{}" are not exists.'.format(comic_entry)) return None text = get_info_text(comic_info, request_backup, verbose) backup_or_remove_data(comic_info, request_backup) self.__cdb.delete_comic(comic_info['comic_id']) print(text) def list_info(self, verbose): def print_all_comics(all_comics, verbose): for comic_info in all_comics: text = self.get_comic_info_text(comic_info, verbose) print(text) def print_total(all_comics): print(' Total: ' '{:>4} comics / {:>6} volumes'.format( len(all_comics), self.__cdb.get_volumes_count(), )) def print_no_downloaded(): no_downloaded_volumes = self.__cdb.get_no_downloaded_volumes() print(' No Downloaded: ' '{:>4} comics / {:>6} volumes'.format( len(set(v['comic_id'] for v in no_downloaded_volumes)), len(no_downloaded_volumes), )) def print_last_refresh(): last_refresh_time = self.__cdb.get_option('last_refresh_time') if type(last_refresh_time) == DT.datetime: lrt_str = DT.datetime.strftime( last_refresh_time, '%Y-%m-%d %H:%M:%S') else: lrt_str = 'none' print(' Last refresh: {}'.format(lrt_str)) def print_download_directory(): print(' Download Directory: "{}"'.format( self.__cpath.output_dir)) def print_analyzers_used(): counter = collections.Counter([ self.am.get_analyzer_by_comic_id(comic_info['comic_id']) for comic_info in all_comics]) print(' Used Analyzers: {}'.format( ', '.join(['{} ({}): {}'.format( azr.name(), azr.codename(), count) for azr, count in counter.items() if azr is not None]))) all_comics = self.__cdb.get_all_comics() print_all_comics(all_comics, verbose) print(' ------------------------------------------') print_total(all_comics) print_no_downloaded() print_last_refresh() print_download_directory() print_analyzers_used() def refresh_all(self, verbose): que = queue.Queue() def get_data_one(comic_info): azr = self.am.get_analyzer_by_comic_id(comic_info['comic_id']) if azr is None: print(('Skip: Analyzer not exists / disabled ->' ' {title} ({comic_id})').format(**comic_info)) que.put(None) return try: comic_info = azr.get_comic_info(comic_info['comic_id']) que.put(comic_info) return except: print('Skip: Refresh failed -> {title} ({url})'.format( url=azr.comic_id_to_url(comic_info['comic_id']), title=comic_info['title'])) que.put(None) return def post_process(length, verbose): for index in range(1, length + 1): comic_info = que.get() if comic_info is None: continue else: self.__cdb.upsert_comic(comic_info) text = ''.join([ ' {:>5} '.format( '{}/{}'.format(index, length)), self.get_comic_info_text(comic_info, verbose) ]) print(text) self.__cdb.set_option( 'last_refresh_time', DT.datetime.now()) with CF.ThreadPoolExecutor( max_workers=self.__threads) as executor: all_comics = self.__cdb.get_all_comics() for comic_info in all_comics: executor.submit(get_data_one, comic_info) post_process(len(all_comics), verbose) def download_subscribed(self, skip_exists): def download_file(url, filepath, **kwargs): try: downloader.save(url, filepath) print('OK: "{}"'.format(filepath)) except downloader.DownloadError: pass def convert_cbz_to_dir_if_cbz_exists(cv_info): volume_cbz_path = self.__cpath.get_volume_cbz(cv_info, cv_info) comic_dir_path = self.__cpath.get_comic_dir(cv_info) if not volume_cbz_path.exists(): return else: with zipfile.ZipFile(str(volume_cbz_path), 'r') as zfile: zfile.extractall(str(comic_dir_path)) os.remove(str(volume_cbz_path)) def convert_to_cbz(cv_info): volume_cbz_path = self.__cpath.get_volume_cbz(cv_info, cv_info) volume_dir_path = self.__cpath.get_volume_dir(cv_info, cv_info) comic_dir_path = self.__cpath.get_comic_dir(cv_info) with zipfile.ZipFile(str(volume_cbz_path), 'w') as zfile: for path in volume_dir_path.glob('**/*'): in_zip_path = path.relative_to(comic_dir_path) zfile.write(str(path), str(in_zip_path)) shutil.rmtree(str(volume_dir_path)) return volume_cbz_path def volume_process(cv_info, skip_exists): def page_process(executor, page_info, skip_exists): pagepath = self.__cpath.get_page_path( cv_info, cv_info, page_info) if skip_exists and pagepath.exists(): return else: executor.submit(download_file, page_info['url'], filepath=str(pagepath)) def download_volume(cv_info, azr): with CF.ThreadPoolExecutor( max_workers=self.__threads) as executor: for page_info in azr.get_volume_pages( cv_info['comic_id'], cv_info['volume_id'], cv_info['extra_data']): page_process(executor, page_info, skip_exists) self.__cdb.set_volume_is_downloaded( cv_info['comic_id'], cv_info['volume_id'], True) azr = self.am.get_analyzer_by_comic_id(cv_info['comic_id']) if azr is None: print(('Skip: Analyzer not exists / disabled -> ' '{title} ({comic_id}): {name}').format(**cv_info)) return volume_dir = self.__cpath.get_volume_dir(cv_info, cv_info) os.makedirs(str(volume_dir), exist_ok=True) convert_cbz_to_dir_if_cbz_exists(cv_info) download_volume(cv_info, azr) if self.__cbz: cbz_path = convert_to_cbz(cv_info) print('## Archived: "{}"'.format(cbz_path)) for cv_info in self.__cdb.get_no_downloaded_volumes(): volume_process(cv_info, skip_exists) def as_new(self, comic_entry, verbose): azr, comic_id = self.am.get_analyzer_and_comic_id(comic_entry) if azr is None: print('"{}" not fits any analyzers.'.format(comic_entry)) return None comic_info = self.__cdb.get_comic(comic_id) if comic_info is None: print('"{}" are not exists.'.format(comic_entry)) return None self.__cdb.set_all_volumes_no_downloaded(comic_id) text = self.get_comic_info_text(comic_info, verbose) print('[AS NEW] ' + text) def move_cpath(self, dst_cpath): def move_path(src, dst): if not dst.parent.exists(): dst.parent.mkdir(parents=True) try: src.replace(dst) except: src.rmdir() def move_output_dir(src_cpath, dst_cpath): if src_cpath.output_dir.exists(): for src_path in sorted( src_cpath.output_dir.glob('**/*'), reverse=True): relative_src_path = src_path.relative_to( src_cpath.output_dir) relative_dst_path_str = dst_cpath.sp.path_modified( str(relative_src_path)) dst_path = dst_cpath.output_dir / relative_dst_path_str move_path(src_path, dst_path) try: src_cpath.output_dir.rmdir() except OSError: pass def move_backup_dir(src_cpath, dst_cpath): if src_cpath.backup_dir.exists(): for src_path in sorted( src_cpath.backup_dir.glob('**/*'), reverse=True): relative_src_path = src_path.relative_to( src_cpath.backup_dir) relative_dst_path_str = dst_cpath.sp.path_modified( str(relative_src_path)) dst_path = dst_cpath.backup_dir / relative_dst_path_str move_path(src_path, dst_path) try: src_cpath.backup_dir.rmdir() except OSError: pass src_cpath = self.__cpath move_output_dir(src_cpath, dst_cpath) move_backup_dir(src_cpath, dst_cpath) self.__cpath = dst_cpath PK:O^Gscmdlr/analyzersmanager.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import textwrap import itertools from . import comicanalyzer from . analyzers import * class AnalyzersManager(): custom_datas_key = 'analyzers_custom_data' def __init__(self, cdb): def initial_analyzers(custom_datas, black_list): ''' args: custom_datas: {'': } ''' analyzers, disabled_analyzers = {}, {} for a_cls in comicanalyzer.ComicAnalyzer.__subclasses__(): custom_data = custom_datas.get(a_cls.codename()) try: azr = a_cls(custom_data) if a_cls.codename() in black_list: disabled_analyzers[azr.codename()] = azr else: analyzers[azr.codename()] = azr except comicanalyzer.ComicAnalyzerDisableException: continue except: print(('** Error: Analyzer "{} ({})" cannot be' ' initialized.\n' ' -> Current custom data: {}').format( a_cls.name(), a_cls.codename(), custom_data)) return analyzers, disabled_analyzers self.__cdb = cdb custom_datas = cdb.get_option(type(self).custom_datas_key, {}) black_list = cdb.get_option('analyzers_black_list', set()) self.analyzers, self.disabled_analyzers = initial_analyzers( custom_datas, black_list) @property def all_analyzers(self): return {key: value for key, value in itertools.chain( self.analyzers.items(), self.disabled_analyzers.items())} def set_custom_data(self, custom_data_str): def parsed(custom_data_str): try: (codename, data_str) = custom_data_str.split('/', 1) if data_str == '': custom_data = {} else: pairs = [item.split('=', 1) for item in data_str.split(',')] custom_data = {key: value for key, value in pairs} except ValueError: print('"{}" cannot be parsed. Cancel.'.format( custom_data_str)) return (None, None) return (codename, custom_data) codename, custom_data = parsed(custom_data_str) if codename is None: print('Analyzer codename: "{}" not found. Cancel.'.format( codename)) else: azr = self.analyzers.get(codename) try: type(azr)(custom_data) key = type(self).custom_datas_key custom_datas = self.__cdb.get_option(key) custom_datas[codename] = custom_data self.__cdb.set_option(key, custom_datas) print('{} <= {}'.format(azr.name(), custom_data)) print('Updated done!') except: print('Custom data test failed. Cancel.') def get_analyzer_by_comic_id(self, comic_id): codename = comic_id.split('/')[0] return self.analyzers.get(codename) def get_analyzer_and_comic_id(self, comic_entry): def get_analyzer_by_url(url): for azr in self.analyzers.values(): comic_id = azr.url_to_comic_id(url) if comic_id: return azr return None azr = get_analyzer_by_url(comic_entry) if azr is None: azr = self.get_analyzer_by_comic_id(comic_entry) if azr is None: return (None, None) else: comic_id = comic_entry else: comic_id = azr.url_to_comic_id(comic_entry) return (azr, comic_id) def on(self, codename): black_list = self.__cdb.get_option('analyzers_black_list', set()) try: black_list.remove(codename) except: pass self.__cdb.set_option('analyzers_black_list', black_list) self.__init__(self.__cdb) def off(self, codename): black_list = self.__cdb.get_option('analyzers_black_list', set()) black_list.add(codename) self.__cdb.set_option('analyzers_black_list', black_list) self.__init__(self.__cdb) def print_analyzer_info(self, codename): azr = self.all_analyzers.get(codename) if azr: azr_info = textwrap.dedent(azr.info()).strip(' \n') print(azr_info) custom_datas = self.__cdb.get_option( type(self).custom_datas_key, {}) custom_data = custom_datas.get(codename, {}) print(' Current Custom Data: {}'.format(custom_data)) def print_analyzers_list(self): texts = [] all_analyzers = sorted(itertools.chain( self.analyzers.values(), self.disabled_analyzers.values()), key=lambda azr: azr.codename()) texts.append('## Analyzers table ## ---------------------------\n') for azr in all_analyzers: if azr.codename() in self.disabled_analyzers: disabled = 'x' else: disabled = '' text = ' {disabled:<1} {codename:<4} - {name} {site}'.format( codename=azr.codename(), name=azr.name(), site=azr.site(), disabled=disabled) texts.append(text) all_text = '\n'.join(texts) print(all_text) print('\n' 'Use "-i CODENAME" to find current custom data and more info.') PK:O^G6ZINNcmdlr/comicanalyzer.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## ###################################################################### # # Term description: # # codename: # A analyzer short name. # e.g., 8c # # comic_id: (str) # A comic identifier scope in the whole program. # Most internal interface using the comic_id to identify one comic. # e.g., 8c/123a97 # # local_comic_id: (str) # A comic identifier scope in the analyzer (comic site) # It is a string and a part of comic's url. # e.g., 123a97 # # (you should convert local_comic_id <-> comic_id by # `analyzer.convert_*` function) # # volume_id: (str) # A volume identifier scope in a comic. # e.g., "13" # # name: (str) # A short volume description. # e.g., "vol1" # # comic_entry_url: (str) # A url which reference a comic (in this site) # This url must contain the local_comic_id. # # extra_data: (dict) # A comic level cache data. # Analyzer designer can define it structure by her(his) self. # e.g., {} # ###################################################################### import abc class ComicAnalyzerException(Exception): pass class ComicAnalyzerDisableException(ComicAnalyzerException): ''' Raise in ComicAnalyzer's __init__() will disable this analyzer without warning messages. ''' class ComicAnalyzer(metaclass=abc.ABCMeta): '''Base class of all comic analyzer''' @classmethod @abc.abstractmethod def codename(cls): ''' Return analyzer code name. Keep it SHORT and CLEAR. and not conflict with other analyzer. Recommend use 2 chars. e.g., co, sm, rd ''' @classmethod @abc.abstractmethod def name(cls): ''' Return analyzer name. Recommand include the target site name. E.g., 8comic ''' @classmethod @abc.abstractmethod def site(cls): ''' Return short site url. E.g., "vipcomic.com" ''' @abc.abstractmethod def info(self): ''' Return Multi-line info message for end user. Include everything which the end user need to known. Recommend include: 1. Author, Maintainer, 2. E-mail, 3. Custom data fields description. 4. etc. ''' def __init__(self, custom_data): ''' args: custom_data: A dict format datapack which setting by end user. Analyzer can use those datas to do some user independent task (e.g., login, filename pattern). The default custom_data == {}. All keys and values will be *str* type. parsing by yourself and beware user may assign invalid data. Make sure __init__() never raise a exception to outside. ''' def convert_to_local_comic_id(self, comic_id): if comic_id.startswith(self.codename() + '/'): return comic_id[len(self.codename()) + 1:] else: return None def convert_to_comic_id(self, local_comic_id): return self.codename() + '/' + local_comic_id @abc.abstractmethod def url_to_comic_id(self, comic_entry_url): ''' Convert comic_entry_url to comic_id. If convert success return a str format url, else return None. ''' @abc.abstractmethod def comic_id_to_url(self, comic_id): ''' Convert comic_id to comic_entry_url If convert success return a str format comic_id, else return None. ''' @abc.abstractmethod def get_comic_info(self, comic_id): ''' Get comic info from the internet The return data will be saved into user's comic_db return: { comic_id: , title: , desc: , extra_data: {...}, volumes: [ { 'volume_id': , # e.g., '16', '045' 'name': , }, {...}, ... ] } ''' @abc.abstractmethod def get_volume_pages(self, comic_id, volume_id, extra_data): ''' Get images url for future download. args: comic_id: which comic you want to analysis volume_id: which volume you want to analysis extra_data: the comic extradata create by self.get_comic_info() yield: { 'url': , 'local_filename': , # e.g., 012.jpg } ''' PKl5bGg cmdlr/stringprocess.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import hanziconv class StringProcess(): trans_component_table = str.maketrans( '\?*<":>+[]/', '\?*<”:>+〔〕/') trans_path_table = str.maketrans( '?*<">+[]', '?*<”>+〔〕') def __init__(self, hanzi_mode=None): self.__hanzi_mode = hanzi_mode def replace_unsafe_characters(self, string): return string.translate(type(self).trans_component_table) def replace_unsafe_characters_for_path(self, string): return string.translate(type(self).trans_path_table) def hanziconv(self, string): '''convert chinese characters Simplified <-> Trnditional''' if self.__hanzi_mode == 'trad': string = hanziconv.HanziConv.toTraditional(string) elif self.__hanzi_mode == 'simp': string = hanziconv.HanziConv.toSimplified(string) return string def component_modified(self, component): safe_component = self.replace_unsafe_characters(component) answer = self.hanziconv(safe_component) return answer def path_modified(self, path): safe_path = self.replace_unsafe_characters_for_path(path) answer = self.hanziconv(safe_path) return answer PK72bGe_1i i cmdlr/comicpath.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import pathlib from . import stringprocess class ComicPath(): '''Comic Path Calculator''' def __init__(self, output_dir, backup_dir, hanzi_mode=None): self.__output_dir = pathlib.Path(output_dir) self.__backup_dir = pathlib.Path(backup_dir) self.sp = stringprocess.StringProcess(hanzi_mode=hanzi_mode) @property def output_dir(self): return self.__output_dir @property def backup_dir(self): return self.__backup_dir def get_comic_dir(self, comic_info): return self.output_dir / self.sp.component_modified( comic_info['title']) def get_volume_dir(self, comic_info, volume_info): volume_dir = '{}_{}'.format(volume_info['title'], volume_info['name']) return self.get_comic_dir( comic_info) / self.sp.component_modified(volume_dir) def get_volume_cbz(self, comic_info, volume_info): return self.get_volume_dir(comic_info, volume_info).with_suffix( '.cbz') def get_page_path(self, comic_info, volume_info, page): volume_dir = self.get_volume_dir(comic_info, volume_info) return volume_dir / self.sp.component_modified( page['local_filename']) def get_backup_comic_dir(self, comic_info): return self.backup_dir / self.sp.component_modified( '{title}({comic_id})'.format(**comic_info)) def get_cpath(cdb): data = { 'output_dir': cdb.get_option('output_dir'), 'backup_dir': cdb.get_option('backup_dir'), 'hanzi_mode': cdb.get_option('hanzi_mode'), } return ComicPath(**data) PK:O^GJުXXcmdlr/analyzers/cartoonmad.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import re import json import base64 import html from .. import comicanalyzer from .. import downloader class CartoonmadException(comicanalyzer.ComicAnalyzerException): pass class CartoonmadAnalyzer(comicanalyzer.ComicAnalyzer): @classmethod def codename(cls): return 'ctm' @classmethod def name(cls): return '動漫狂' @classmethod def site(cls): return 'www.cartoonmad.com' def info(self): return """ ## 動漫狂 Analyzer ## --------------------------------- # # This analyzer are focus on cartoonmad.com. # Typical comic url: # http://www.cartoonmad.com/comic/.html # # Custom data: Not required # # LICENSE: MIT # Author: Civa Lin # Bug report: https://bitbucket.org/civalin/cmdlr # Version: 2015.10.30 # #---------------------------------------------------------- """ def url_to_comic_id(self, comic_entry_url): match = re.search('cartoonmad.com/comic/(\d{1,8}).html', comic_entry_url) if match is None: return None else: local_comic_id = match.groups()[0] return self.convert_to_comic_id(local_comic_id) def comic_id_to_url(self, comic_id): local_comic_id = self.convert_to_local_comic_id(comic_id) if local_comic_id: return 'http://www.cartoonmad.com/comic/{}.html'.format( local_comic_id) else: return None def get_comic_info(self, comic_id): def get_title(comic_html): match_title = re.search(r'(.*?)', comic_html) title = match_title.group(1).strip() return title def get_desc(comic_html): match_desc = re.search( r'(.*?)', comic_html, re.M | re.DOTALL) desc = match_desc.group(1).strip() desc = re.sub('<.+?>', '', desc) desc = html.unescape(desc) return desc def get_volumes(comic_html): match_volumes = re.findall( '' '(.*?)', comic_html, re.M | re.DOTALL) volumes = [ { 'volume_id': v[0], 'name': '{}'.format( ' '.join(v[1].split()))} for index, v in enumerate(match_volumes)] return volumes comic_url = self.comic_id_to_url(comic_id) comic_html = downloader.get( comic_url).decode('big5', errors='ignore') answer = { 'comic_id': comic_id, 'title': get_title(comic_html), 'desc': get_desc(comic_html), 'extra_data': {}, 'volumes': get_volumes(comic_html) } return answer def get_volume_pages(self, comic_id, volume_id, extra_data): def get_volume_url(volume_id): return 'http://web.cartoonad.com/comic/{}.html'.format( volume_id) def get_pages(volume_html): def get_img_url_generator(volume_html): match_img_url = re.search( '.html # # Custom data: Not required # # LICENSE: MIT # Author: Civa Lin # Bug report: https://bitbucket.org/civalin/cmdlr # Version: 2015.10.30 # #---------------------------------------------------------- """ def __init__(self, custom_data): super().__init__(custom_data) # raise comicanalyzer.ComicAnalyzerDisableException # disable plugin def url_to_comic_id(self, comic_entry_url): match = re.search('comicvip.com/html/(\d+).html', comic_entry_url) if match is None: return None else: local_comic_id = match.groups()[0] return self.convert_to_comic_id(local_comic_id) def comic_id_to_url(self, comic_id): local_comic_id = self.convert_to_local_comic_id(comic_id) if local_comic_id: return 'http://www.comicvip.com/html/{}.html'.format( local_comic_id) else: return None def get_comic_info(self, comic_id): def get_title(one_page_html): match_title = re.search(r":\[(.*?)(.*?)', comic_html) desc = match_desc.group(1).strip() desc = re.sub('<.+?>', '', desc) desc = html.unescape(desc) return desc comic_url = self.comic_id_to_url(comic_id) comic_html = downloader.get( comic_url).decode('big5', errors='ignore') one_page_url = self.__get_one_page_url(comic_html, comic_url) one_page_html = downloader.get( one_page_url).decode('big5', errors='ignore') comic_code = self.__get_comic_code(one_page_html) answer = { 'comic_id': comic_id, 'title': get_title(one_page_html), 'desc': get_desc(comic_html), 'extra_data': {'comic_code': comic_code} } vol_code_list = self.__split_vol_code_list(comic_code) volume_info_list = [self.__decode_volume_code(vol_code) for vol_code in vol_code_list] volumes = [{ 'volume_id': v['volume_id'], 'name': '{:04}'.format(int(v['volume_id'])) } for v in volume_info_list] answer['volumes'] = volumes return answer def get_volume_pages(self, comic_id, volume_id, extra_data): def get_image_url(page_number, local_comic_id, did, sid, volume_number, volume_code, **kwargs): def get_hash(page_number): magic_number = (((page_number - 1) / 10) % 10) +\ (((page_number - 1) % 10) * 3)\ + 10 magic_number = int(magic_number) return volume_code[magic_number:magic_number+3] hash = get_hash(page_number) image_url = "http://img{sid}.8comic.com/{did}/{local_comic_id}/"\ "{volume_number}/{page_number:03}_{hash}.jpg".format( page_number=page_number, local_comic_id=local_comic_id, did=did, sid=sid, volume_number=volume_number, hash=hash, ) return image_url comic_code = extra_data['comic_code'] vol_code_list = self.__split_vol_code_list(comic_code) volume_info_list = [self.__decode_volume_code(vol_code) for vol_code in vol_code_list] volume_info_dict = {v['volume_id']: v for v in volume_info_list} volume_info = volume_info_dict[volume_id] local_comic_id = self.convert_to_local_comic_id(comic_id) pages = [] for page_number in range(1, volume_info['page_count'] + 1): url = get_image_url(page_number=page_number, local_comic_id=local_comic_id, did=volume_info['did'], sid=volume_info['sid'], volume_number=int(volume_id), volume_code=volume_info['volume_code']) local_filename = '{:03}.jpg'.format(page_number) pages.append({'url': url, 'local_filename': local_filename}) return pages def __get_one_page_url(self, comic_html, comic_url): def __get_page_url_fragment_and_catid(html): match = re.search(r"cview\('(.+?)',(\d+?)\)", html) if match is None: raise EightComicException( "CView decode Error: {}".format(comic_url)) else: answer = match.groups(1) return answer def __get_page_url(page_url_fragment, catid): catid = int(catid) if catid in (4, 6, 12, 22): baseurl = "http://www.comicvip.com/show/cool-" elif catid in (1, 17, 19, 21): baseurl = "http://www.comicvip.com/show/cool-" elif catid in (2, 5, 7, 9): baseurl = "http://www.comicvip.com/show/cool-" elif catid in (10, 11, 13, 14): baseurl = "http://www.comicvip.com/show/best-manga-" elif catid in (3, 8, 15, 16, 18, 20): baseurl = "http://www.comicvip.com/show/best-manga-" fragment = page_url_fragment.replace( ".html", "").replace("-", ".html?ch=") return baseurl + fragment page_url_fragment, catid = __get_page_url_fragment_and_catid( comic_html) page_url = __get_page_url(page_url_fragment, catid) return page_url def __get_comic_code(self, one_page_html): match_comic_code = re.search(r"var cs='(\w*)'", one_page_html) comic_code = match_comic_code.group(1) return comic_code def __split_vol_code_list(self, comic_code): '''split code for each volume''' chunk_size = 50 return [comic_code[i:i+chunk_size] for i in range(0, len(comic_code), chunk_size)] def __decode_volume_code(self, volume_code): def get_only_digit(string): return re.sub("\D", "", string) volume_info = { "volume_id": str(int(get_only_digit(volume_code[0:4]))), "sid": get_only_digit(volume_code[4:6]), "did": get_only_digit(volume_code[6:7]), "page_count": int(get_only_digit(volume_code[7:10])), "volume_code": volume_code, } return volume_info PK:O^Gxcmdlr/analyzers/u17.py######################################################################### # The MIT License (MIT) # # Copyright (c) 2014~2015 CIVA LIN (林雪凡) # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be included # in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS # OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ########################################################################## import re import json import base64 import html from .. import comicanalyzer from .. import downloader class U17ComicException(comicanalyzer.ComicAnalyzerException): pass class U17Analyzer(comicanalyzer.ComicAnalyzer): @classmethod def codename(cls): return 'u17' @classmethod def name(cls): return '有妖氣' @classmethod def site(cls): return 'www.u17.com' def info(self): return """ ## 有妖氣 Analyzer ## ------------------------------------- # # This analyzer are focus on u17.com. # Typical comic url: # http://www.u17.com/comic/.html # # Custom data: Not required # # LICENSE: MIT # Author: Civa Lin # Bug report: https://bitbucket.org/civalin/cmdlr # Version: 2015.10.30 # #---------------------------------------------------------- """ def url_to_comic_id(self, comic_entry_url): match = re.search('u17.com/comic/(\d+).html', comic_entry_url) if match is None: return None else: local_comic_id = match.groups()[0] return self.convert_to_comic_id(local_comic_id) def comic_id_to_url(self, comic_id): local_comic_id = self.convert_to_local_comic_id(comic_id) if local_comic_id: return 'http://www.u17.com/comic/{}.html'.format( local_comic_id) else: return None def get_comic_info(self, comic_id): def get_title(comic_html): match_title = re.search(r'(.*?)', comic_html) title = match_title.group(1).strip() return title def get_desc(comic_html): match_desc = re.search( r'

(.*?)

', comic_html, re.M | re.DOTALL) desc = match_desc.group(1).strip() desc = re.sub('<.+?>', '', desc) desc = html.unescape(desc) return desc def get_volumes(comic_html): match_volumes = re.findall( 'id="cpt_(\d+)"\s*href="[^"]+?"' '\s*title="([^"]+?)"\s*target="_blank"\s*>', comic_html, re.M | re.DOTALL) volumes = [ { 'volume_id': v[0].strip(), 'name': '{:>04}_{}'.format( index + 1, v[1].split()[0].strip())} for index, v in enumerate(match_volumes)] return volumes comic_url = self.comic_id_to_url(comic_id) comic_html = downloader.get( comic_url).decode('utf8', errors='ignore') answer = { 'comic_id': comic_id, 'title': get_title(comic_html), 'desc': get_desc(comic_html), 'extra_data': {}, 'volumes': get_volumes(comic_html) } return answer def get_volume_pages(self, comic_id, volume_id, extra_data): def get_volume_url(volume_id): return 'http://www.u17.com/chapter/{}.html?t=old'.format( volume_id) def get_pages(volume_html): match_ori_pages_json = re.search( "image_list: \$.evalJSON\('(.*?)'\)", volume_html) ori_pages_json = match_ori_pages_json.group(1) ori_pages = json.loads(ori_pages_json) pages_phase1 = [ (int(page), base64.b64decode(data['src']).decode('utf8')) for page, data in ori_pages.items() if data.get('url') is None] pages = [ { 'url': url, 'local_filename': '{:03}.{}'.format( page, url.rsplit('.', 1)[1]) } for page, url in sorted(pages_phase1)] return pages volume_html = downloader.get( get_volume_url(volume_id)).decode('utf8', errors='ignore') return get_pages(volume_html) PKmG^- %cmdlr-2.0.1.dist-info/DESCRIPTION.rstUNKNOWN PKmGkk&cmdlr-2.0.1.dist-info/entry_points.txt[console_scripts] cmdlr = cmdlr.cmdline:main [setuptools.installation] eggsecutable = cmdlr.cmdline:main PKmG""#cmdlr-2.0.1.dist-info/metadata.json{"classifiers": ["Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: SQL", "Environment :: Console", "Operating System :: POSIX :: Linux", "Topic :: Multimedia :: Graphics", "Topic :: System :: Archiving"], "extensions": {"python.commands": {"wrap_console": {"cmdlr": "cmdlr.cmdline:main"}}, "python.details": {"contacts": [{"email": "larinawf@gmail.com", "name": "Civa Lin", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://bitbucket.org/civalin/cmdlr"}}, "python.exports": {"console_scripts": {"cmdlr": "cmdlr.cmdline:main"}, "setuptools.installation": {"eggsecutable": "cmdlr.cmdline:main"}}}, "extras": [], "generator": "bdist_wheel (0.26.0)", "keywords": ["comic", "download", "archive"], "license": "MIT", "metadata_version": "2.0", "name": "cmdlr", "run_requires": [{"requires": ["hanziconv"]}], "summary": "A extensible command line tool use for subscribe online comic sites.", "version": "2.0.1"}PKmGUV#cmdlr-2.0.1.dist-info/top_level.txtcmdlr PKmG}\\cmdlr-2.0.1.dist-info/WHEELWheel-Version: 1.0 Generator: bdist_wheel (0.26.0) Root-Is-Purelib: true Tag: py3-none-any PKmG%ڧcmdlr-2.0.1.dist-info/METADATAMetadata-Version: 2.0 Name: cmdlr Version: 2.0.1 Summary: A extensible command line tool use for subscribe online comic sites. Home-page: https://bitbucket.org/civalin/cmdlr Author: Civa Lin Author-email: larinawf@gmail.com License: MIT Keywords: comic download archive Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: SQL Classifier: Environment :: Console Classifier: Operating System :: POSIX :: Linux Classifier: Topic :: Multimedia :: Graphics Classifier: Topic :: System :: Archiving Requires-Dist: hanziconv UNKNOWN PKmG_V]cmdlr-2.0.1.dist-info/RECORDcmdlr/__init__.py,sha256=VxSHpC3O3uQqSSy1yNKLbfGyuFlTZ8XGOvkIBky5VIA,1299 cmdlr/analyzersmanager.py,sha256=M8BwW5Z4NhmWZIyv6FhFSzWQAjuyLoVVRb-f9gFnjcw,7041 cmdlr/cmdline.py,sha256=3QAtwZIUdwtHkFVzyBoxlyjHj73XKm3BW-pDjNE7PQc,13553 cmdlr/comicanalyzer.py,sha256=htnFXKWy-Owg3tNhYlTFQGh0EnF7MLtl-bWEBCyaFTs,6222 cmdlr/comicdb.py,sha256=06Q7Y-aoTgh8FcR3Sz__NSlTaz0N2UftD8C7gLu2c2g,12013 cmdlr/comicdownloader.py,sha256=mR3Rc6oUOoPQyuGQ_jlh-Hkmd9jJ3UksSuLByTXZ3p4,17320 cmdlr/comicpath.py,sha256=lD7Vz7KWgh44pkN6VaKLdXXT5sdiEbERC-jfZa1jhy8,2921 cmdlr/downloader.py,sha256=vfgWqhcF7YSMMRHNRV97H9yE8ZfE-Sdwqd-4UIYu1SE,4190 cmdlr/info.py,sha256=SQWc_FSKD-P9EiuhXvWTRdr0ZpKfvO1h-J0v9duoVOk,1571 cmdlr/stringprocess.py,sha256=cRqqXwE-5p2HaP1vCeXKa5R4YXIpfse5abCXlCnXvsQ,2529 cmdlr/analyzers/__init__.py,sha256=7sh5SgqL2-7D413H_qGhKLI4yVmI_DQ0xfOxhRJVarg,1464 cmdlr/analyzers/cartoonmad.py,sha256=h9jUt36kZikuNaGQq5agysR8cQ1p8xNeQpx_C7iKQV0,5976 cmdlr/analyzers/eightcomic.py,sha256=xFBRdhnLu889CnGvKuVEPOC54CkWtFFZ8R--yUHwLUk,9010 cmdlr/analyzers/u17.py,sha256=Xxhhyla9rI2KaNI1bUuYUtitsADhLPcKh1gUdVIz0OY,5592 cmdlr-2.0.1.dist-info/DESCRIPTION.rst,sha256=OCTuuN6LcWulhHS3d5rfjdsQtW22n7HENFRh6jC6ego,10 cmdlr-2.0.1.dist-info/METADATA,sha256=r0vm25SQIET3Hbdp3tkmRUWK8ovk4dkwRX6-cX2R9IU,679 cmdlr-2.0.1.dist-info/RECORD,, cmdlr-2.0.1.dist-info/WHEEL,sha256=zX7PHtH_7K-lEzyK75et0UBa3Bj8egCBMXe1M4gc6SU,92 cmdlr-2.0.1.dist-info/entry_points.txt,sha256=Kf5SAuvIPfaLkzzVe9lMsu0oI6uhy38yP8RfTtIeVpE,107 cmdlr-2.0.1.dist-info/metadata.json,sha256=ICyuX_QyqzW5TW8LtGlBgV30DtghjQVGHIg5j__qTrE,1058 cmdlr-2.0.1.dist-info/top_level.txt,sha256=7GRR7s5spIFplbrwrXE2q38IFjLRkizfuFusrCA6cIU,6 PKbGCs2..cmdlr/comicdb.pyPK_GO{]## /cmdlr/info.pyPK:O^G;^^i5cmdlr/downloader.pyPK:O^G244Ecmdlr/cmdline.pyPK:O^Gs[I{cmdlr/__init__.pyPK9aG)ۖCCYcmdlr/comicdownloader.pyPK:O^Gs7cmdlr/analyzersmanager.pyPK:O^G6ZINNcmdlr/comicanalyzer.pyPKl5bGg qcmdlr/stringprocess.pyPK72bGe_1i i cmdlr/comicpath.pyPK:O^GJުXXcmdlr/analyzers/cartoonmad.pyPK:O^Gv4%cmdlr/analyzers/__init__.pyPK:O^G2#2#+cmdlr/analyzers/eightcomic.pyPK:O^GxOcmdlr/analyzers/u17.pyPKmG^- %ecmdlr-2.0.1.dist-info/DESCRIPTION.rstPKmGkk&iecmdlr-2.0.1.dist-info/entry_points.txtPKmG""#fcmdlr-2.0.1.dist-info/metadata.jsonPKmGUV#{jcmdlr-2.0.1.dist-info/top_level.txtPKmG}\\jcmdlr-2.0.1.dist-info/WHEELPKmG%ڧWkcmdlr-2.0.1.dist-info/METADATAPKmG_V]:ncmdlr-2.0.1.dist-info/RECORDPK u