diff --git a/.gitignore b/.gitignore index 68bc17f..076249d 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,7 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +*.json +*.db +exported/ diff --git a/booksnew.py b/booksnew.py new file mode 100644 index 0000000..23de89e --- /dev/null +++ b/booksnew.py @@ -0,0 +1,29 @@ +from os.path import isdir, join +from zipfile import ZipFile + + +class BooksNew: + def __init__(self, path: str): + self._path = path + self._is_zip = False + self._contain_dir_name = False + if not isdir(path): + self._z = ZipFile(path) + self._is_zip = True + try: + self._z.getinfo("booksnew/") + self._contain_dir_name = True + except KeyError: + pass + + def get_chapter(self, book_id: int, chapter_id: int): + if self._is_zip: + if self._contain_dir_name: + path = f'booksnew/{book_id}/{chapter_id}.txt' + else: + path = f'{book_id}/{chapter_id}.txt' + return self._z.read(path).decode() + else: + with open(join(self._path, str(book_id), f"{chapter_id}.txt"), 'r', + encoding='UTF-8') as f: + return f.read() diff --git a/config.py b/config.py new file mode 100644 index 0000000..6765a50 --- /dev/null +++ b/config.py @@ -0,0 +1,74 @@ +from argparse import Namespace +from os.path import exists +import json +try: + from functools import cached_property +except Exception: + cached_property = property + + +class Config: + def __init__(self, cfg_path: str): + self._path = cfg_path + self._data = {} + self._args = Namespace() + if exists(cfg_path): + with open(cfg_path, 'r', encoding='UTF-8') as f: + self._data = json.load(f) + else: + self._data['db'] = 'cwm.db' + self._data['save_to_config'] = True + self._data['export_chapter_template'] = 'exported//.txt' # noqa: E501 + self.save() + + def add_args(self, args: Namespace): + self._args = args + + def get_arg(self, key: str, default): + x = getattr(self._args, key, None) + if x is not None: + if self.save_to_config: + self._data[key] = x + return x + if key in self._data: + return self._data[key] + else: + return default + + @cached_property + def booksnew(self): + return self.get_arg('booksnew', None) + + @cached_property + def chapter_id(self): + return getattr(self._args, 'cid', None) + + @cached_property + def cwmdb(self): + return self.get_arg('cwmdb', None) + + @cached_property + def db(self): + return self.get_arg('db', 'cwm.db') + + @cached_property + def export_chapter_template(self): + return self.get_arg('export_chapter_template', 'exported//.txt') # noqa: E501 + + def get_export_chapter(self, chapter): + temp = self.export_chapter_template + for k in chapter.keys(): + temp = temp.replace(f'<{k}>', str(chapter[k])) + return temp + + @cached_property + def key(self): + return self.get_arg('key', None) + + @cached_property + def save_to_config(self): + return getattr(self._args, 'save_to_config', True) + + def save(self): + with open(self._path, 'w', encoding='UTF-8') as f: + json.dump(self._data, f, ensure_ascii=False) diff --git a/crypto.py b/crypto.py new file mode 100644 index 0000000..9888e9c --- /dev/null +++ b/crypto.py @@ -0,0 +1,14 @@ +from Crypto.Cipher import AES +from hashlib import sha256 +from base64 import b64decode + + +def decrypt(encrypted: str | bytes, + key: str | bytes = 'zG2nSeEfSHfvTCHy5LCcqtBbQehKNLXn') -> bytes: + if isinstance(key, str): + key = key.encode() + ekey = sha256(key).digest() + iv = b'\0' * 16 + aes = AES.new(ekey, AES.MODE_CBC, iv) + data = aes.decrypt(b64decode(encrypted)) + return data[0:len(data) - ord(chr(data[len(data) - 1]))] diff --git a/db.py b/db.py new file mode 100644 index 0000000..c45e497 --- /dev/null +++ b/db.py @@ -0,0 +1,77 @@ +import sqlite3 +from semver import Version +from typing import Optional, Set, List + + +VERSION_TABLE = '''CREATE TABLE version ( +id TEXT, +version TEXT, +PRIMARY KEY (id) +);''' +KEY_TABLE = '''CREATE TABLE key ( +chapter_id INT, +user_id INT, +key TEXT, +PRIMARY KEY (chapter_id, user_id) +);''' + + +class CwmDb: + def __init__(self, db_path): + self._db = sqlite3.connect(db_path, check_same_thread=False) + self.version = Version(0, 0, 0, 0) + if not self.__check_database(): + self.__create_table() + + def __check_database(self): + self.__update_exists_tables() + v = self.__read_version() + if v is None: + return False + if v < self.version: + self.__update_exists_tables() + self.__write_version() + return True + + def __create_table(self): + if 'version' not in self._exist_tables: + self._db.execute(VERSION_TABLE) + self.__write_version() + if 'key' not in self._exist_tables: + self._db.execute(KEY_TABLE) + self._db.commit() + + def __write_version(self): + self._db.execute('INSERT OR REPLACE INTO version VALUES (?, ?);', [ + 'main', str(self.version)]) + + def __read_version(self) -> Optional[Version]: + if 'version' not in self._exist_tables: + return None + cur = self._db.execute( + 'SELECT version FROM version WHERE id = ?;', ['main']) + for i in cur: + try: + return Version.parse(i[0]) + except Exception: + return None + + def __update_exists_tables(self): + cur = self._db.execute('SELECT * FROM main.sqlite_master;') + self._exist_tables = {} + for i in cur: + if i[0] == 'table': + self._exist_tables[i[1]] = i + + def add_key(self, chapter_id: int, user_id: int, key: str): + self._db.execute('INSERT OR REPLACE INTO key VALUES (?, ?, ?);', [ + chapter_id, user_id, key]) + + def get_all_keys_as_origin(self) -> Set[str]: + cur = self._db.execute('SELECT chapter_id, user_id FROM key;') + return {f'{i[0]}{i[1]}' for i in cur} + + def get_key(self, chapter_id: int) -> List[str]: + cur = self._db.execute('SELECT key FROM key WHERE chapter_id = ?;', [ + chapter_id]) + return [i[0] for i in cur] diff --git a/export.py b/export.py new file mode 100644 index 0000000..b6ec35c --- /dev/null +++ b/export.py @@ -0,0 +1,48 @@ +from novelCiwei import NovelCiwei +from db import CwmDb +from config import Config +from key import import_keys +from booksnew import BooksNew +from crypto import decrypt +from os.path import dirname +from os import makedirs + + +key_imported = False + + +def get_key(db: CwmDb, cfg: Config, chapter_id: int): + keys = db.get_key(chapter_id) + if len(keys) == 0: + if key_imported: + raise ValueError('The key is not found.') + else: + import_keys(cfg.key, db) + keys = db.get_key(chapter_id) + if len(keys) == 0: + raise ValueError('The key is not found.') + return keys + + +def try_decrypt(db: CwmDb, cfg: Config, content, chapter_id: int): + keys = get_key(db, cfg, chapter_id) + for key in keys: + try: + return decrypt(content, key).decode() + except Exception: + pass + raise ValueError('Failed to decrypt the content.') + + +def export_chapter(ncw: NovelCiwei, db: CwmDb, cfg: Config, bn: BooksNew, + chapter_id: int): + chapter = ncw.get_chapter(chapter_id) + book_id = int(chapter['book_id']) + raw_content = bn.get_chapter(book_id, chapter_id) + content = try_decrypt(db, cfg, raw_content, chapter_id) + filename = cfg.get_export_chapter(chapter) + d = dirname(filename) + makedirs(d, exist_ok=True) + with open(filename, 'w', encoding='UTF-8') as f: + f.write(chapter['chapter_title'] + '\n') + f.write(content) diff --git a/key.py b/key.py new file mode 100644 index 0000000..30687a7 --- /dev/null +++ b/key.py @@ -0,0 +1,45 @@ +from os.path import isdir, join +from os import listdir +from db import CwmDb +from zipfile import ZipFile +from base64 import b64decode + + +def import_keys(key: str, db: CwmDb): + is_zip = False + file_list = [] + contain_dir_name = False + if isdir(key): + file_list = listdir(key) + else: + z = ZipFile(key) + is_zip = True + file_list = z.namelist() + if 'Y2hlcy8/' in file_list: + contain_dir_name = True + file_list = [i[8:] for i in file_list if i.startswith( + 'Y2hlcy8/') and i != 'Y2hlcy8/'] + try: + count = 0 + keys = db.get_all_keys_as_origin() + for i in file_list: + oid = b64decode(i).decode() + if oid in keys: + continue + cid = int(oid[0:9]) + uid = int(oid[9:]) + if is_zip: + if contain_dir_name: + path = 'Y2hlcy8/' + i + else: + path = i + content = z.read(path).decode() + else: + content = open(join(key, i), 'r', encoding='UTF-8').read() + db.add_key(cid, uid, content) + count += 1 + print(f'Imported {count} keys.') + finally: + db._db.commit() + if is_zip: + z.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..1a3b4d7 --- /dev/null +++ b/main.py @@ -0,0 +1,46 @@ +from argparse import ArgumentParser +from config import Config +from db import CwmDb +from novelCiwei import NovelCiwei +from booksnew import BooksNew + + +parser = ArgumentParser(description='A tool to export CiWeiMao novel cache.') +parser.add_argument('-c', '--config', help='The path of the config file.', default='config.json') # noqa: E501 +parser.add_argument('-d', '--db', help='The path of the database file.') +parser.add_argument('-k', '--key', help='The path to Y2hlcy8 key directory or zip file.') # noqa: E501 +parser.add_argument('--cwmdb', help='The path to NovelCiwei file.') +parser.add_argument('-b', '--booksnew', help='The path to booksnew directory or zip file.') # noqa: E501 +parser.add_argument('-C', '--cid', '--chapter-id', help='The chapter id.', type=int) # noqa: E501 +parser.add_argument('--ect', '--export-chapter-template', help='The template of the exported chapter. Available key: , eta.') # noqa: E501 +parser.add_argument('action', help='The action to do.', choices=['importkey', 'exportchapter']) # noqa: E501 + + +def main(args=None): + arg = parser.parse_intermixed_args(args) + cfg = Config(arg.config) + cfg.add_args(arg) + try: + db = CwmDb(cfg.db) + if arg.action == 'importkey': + if cfg.key is None: + raise ValueError('The key is not specified.') + from key import import_keys + import_keys(cfg.key, db) + elif arg.action == 'exportchapter': + if cfg.cwmdb is None: + raise ValueError('The cwmdb is not specified.') + ncw = NovelCiwei(cfg.cwmdb) + if cfg.booksnew is None: + raise ValueError('The booksnew is not specified.') + bn = BooksNew(cfg.booksnew) + if cfg.chapter_id is None: + raise ValueError('The chapter id is not specified.') + from export import export_chapter + export_chapter(ncw, db, cfg, bn, cfg.chapter_id) + finally: + cfg.save() + + +if __name__ == '__main__': + main() diff --git a/novelCiwei.py b/novelCiwei.py new file mode 100644 index 0000000..3a5c1a2 --- /dev/null +++ b/novelCiwei.py @@ -0,0 +1,20 @@ +import sqlite3 +import json + + +class NovelCiwei: + def __init__(self, db_path: str): + self._db = sqlite3.connect(db_path, check_same_thread=False) + + def get_book_in_shelf(self, book_id: int): + cur = self._db.execute( + 'SELECT book_info FROM shelf_book_info WHERE book_id = ?;', + [str(book_id)]) + for i in cur: + return json.loads(i) + + def get_chapter(self, chapter_id: int): + cur = self._db.execute( + 'SELECT * FROM catalog1 WHERE chapter_id = ?;', [str(chapter_id)]) + cur.row_factory = sqlite3.Row + return cur.fetchone() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e218f03 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyCryptodome +semver