├── histexport ├── __init__.py └── histexport.py ├── requirements.txt ├── LICENSE ├── setup.py ├── README.md └── .gitignore /histexport/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pandas 2 | openpyxl 3 | colorlog -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 darkArp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | # Read the long description from README.md 4 | with open("README.md", "r", encoding="utf-8") as f: 5 | long_description = f.read() 6 | 7 | # Define package metadata 8 | metadata = { 9 | 'name': 'histexport', 10 | 'version': "0.3.0", 11 | 'description': 'A Python utility to export Chromium-based browser history and downloads to various formats.', 12 | 'author': 'Mario Nascimento', 13 | 'author_email': 'mario@whitehathacking.tech', 14 | 'url': 'https://github.com/darkarp/histexport', 15 | 'license': 'MIT', 16 | 'keywords': ['history', 'browser', 'chromium', 'export', 'downloads', 'URLs'], 17 | 'classifiers': [ 18 | 'Development Status :: 3 - Alpha', 19 | 'Intended Audience :: Information Technology', 20 | 'Intended Audience :: Developers', 21 | 'Topic :: Security', 22 | 'License :: OSI Approved :: MIT License', 23 | 'Programming Language :: Python :: 3.7', 24 | 'Programming Language :: Python :: 3.8', 25 | 'Programming Language :: Python :: 3.9', 26 | 'Programming Language :: Python :: 3.10', 27 | 'Programming Language :: Python :: 3.11', 28 | ], 29 | } 30 | 31 | # Define package requirements 32 | requirements = [ 33 | 'pandas >= 1.0.0', 34 | 'openpyxl >= 3.0.0', 35 | 'colorlog >= 6.0.0' 36 | ] 37 | 38 | # Entry points for command line utility 39 | entry_points = { 40 | 'console_scripts': [ 41 | 'histexport=histexport.histexport:main', 42 | ], 43 | } 44 | 45 | # Setup 46 | setup( 47 | **metadata, 48 | packages=find_packages(exclude=["tests*"]), 49 | install_requires=requirements, 50 | long_description=long_description, 51 | long_description_content_type='text/markdown', 52 | entry_points=entry_points, 53 | python_requires='>=3.7', 54 | include_package_data=True, # Will include any non-python files specified in MANIFEST.in 55 | ) 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # History Exporter 2 | 3 | ![Build Status](https://img.shields.io/badge/build-passing-green) 4 | ![GitHub release](https://img.shields.io/github/release/darkarp/histexport.svg) 5 | ![License](https://img.shields.io/github/license/darkarp/histexport.svg) 6 | 7 | ## Table of Contents 8 | 9 | - [Introduction](#introduction) 10 | - [Prerequisites](#prerequisites) 11 | - [Installation](#installation) 12 | - [Usage](#usage) 13 | - [Command Line Interface](#command-line-interface) 14 | - [Data Extraction](#data-extraction) 15 | - [Output Formats](#output-formats) 16 | - [Logging](#logging) 17 | - [License](#license) 18 | 19 | ## Introduction 20 | 21 | HistoryExtractor is a Python utility aimed at exporting Chromium-based browser history and downloads data to various formats such as CSV, XLSX, and TXT. Designed with a focus on user flexibility, this tool provides customizable extraction options to suit different needs. 22 | 23 | ## Prerequisites 24 | 25 | - Python 3.7+ 26 | 27 | ## Installation 28 | 29 | ### Method 1: Using pip 30 | 31 | You can install `histexport` directly using pip: 32 | ``` 33 | pip install histexport 34 | ``` 35 | 36 | ### Method 2: From GitHub Repository 37 | 38 | 1. Clone the GitHub repository. 39 | ``` 40 | git clone https://github.com/darkarp/histexport.git 41 | ``` 42 | 2. Navigate to the project directory. 43 | ``` 44 | cd histexport 45 | ``` 46 | 3. Install the required Python packages. 47 | ``` 48 | pip install -e . 49 | ``` 50 | 51 | Either of these methods will install the required Python packages and make `histexport` available for use. 52 | 53 | ## Usage 54 | 55 | ### Command Line Interface 56 | 57 | 1. Basic extraction of URLs and Downloads in `txt`: 58 | ``` 59 | histexport -i path/to/history/history_file -o output_file 60 | ``` 61 | 62 | 2. Specify output directory and formats: 63 | ``` 64 | histexport -i path/to/history/history_file -o output_file -d path/to/output -f csv xlsx 65 | ``` 66 | 67 | 3. Enable logging (`-l`): 68 | ``` 69 | histexport -i path/to/history/history_file -o output_file -l 70 | ``` 71 | 72 | 4. Extract from a folder of SQLite files: 73 | ``` 74 | histexport -i path/to/history_folder -t folder -o output_file -d path/to/output -f csv xlsx -e urls downloads 75 | ``` 76 | 77 | #### Arguments 78 | 79 | - `-i`, `--input`: Path to the SQLite history file. (required) 80 | - `-t`, `--type`: Type of the input: file or folder. Default is file 81 | - `-o`, `--output`: Base name for output files. (required) 82 | - `-d`, `--dir`: Output directory. (optional, default is `./`) 83 | - `-f`, `--formats`: Output formats (csv, xlsx, txt). (optional, default is `txt`) 84 | - `-e`, `--extract`: Data to extract (urls, downloads). (optional, default is both) 85 | - `-l`, `--log`: Enable logging. (optional, default is disabled. 86 | - `-l 1`: CRITICAL 87 | - `-l 2`: ERROR 88 | - `-l 3`: WARNING 89 | - `-l 4`: INFO 90 | - `-l 5`: DEBUG 91 | - `-v`, `--version`: Show version. 92 | 93 | ## Data Extraction 94 | 95 | The tool allows extraction of: 96 | 97 | - URLs: Fetches `URL`, `Title`, `Visit_Count`, and `Last_Visit_Time`. 98 | - Downloads: Extracts `Target_Path`, `Start_Time`, `End_Time`, `Total_Bytes`, `Received_Bytes`, and `URL`. 99 | 100 | ## Output Formats 101 | 102 | You can export the data into: 103 | 104 | - CSV 105 | - XLSX (Excel) 106 | - TXT (Pretty printed text file) 107 | 108 | ## License 109 | 110 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE) file for details. 111 | 112 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor 2 | .vscode/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /histexport/histexport.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sqlite3 4 | import logging 5 | import argparse 6 | import sys 7 | import tempfile 8 | import colorlog 9 | import pandas as pd 10 | from time import sleep 11 | from queue import Queue 12 | from typing import List 13 | from threading import Lock 14 | from datetime import datetime, timedelta 15 | from concurrent.futures import ThreadPoolExecutor 16 | 17 | __version__ = "0.3.0" 18 | 19 | MAX_RETRIES = 5 20 | BACKOFF_FACTOR = 0.2 21 | FILE_LOCK = Lock() 22 | 23 | 24 | def init_logging(level: int) -> None: 25 | """Initialize logging. 26 | 27 | Args: 28 | enable_logging (bool): Flag to enable or disable logging. 29 | """ 30 | if level: 31 | logger = colorlog.getLogger() 32 | if level == 1: 33 | logger.setLevel(logging.CRITICAL) 34 | elif level == 2: 35 | logger.setLevel(logging.ERROR) 36 | elif level == 3: 37 | logger.setLevel(logging.WARNING) 38 | elif level == 4: 39 | logger.setLevel(logging.INFO) 40 | elif level == 5: 41 | logger.setLevel(logging.DEBUG) 42 | handler = colorlog.StreamHandler() 43 | handler.setFormatter(colorlog.ColoredFormatter( 44 | '%(log_color)s%(levelname)-8s%(reset)s %(message)s', 45 | log_colors={ 46 | 'DEBUG': 'cyan', 47 | 'INFO': 'green', 48 | 'WARNING': 'yellow', 49 | 'ERROR': 'red', 50 | 'CRITICAL': 'red,bg_white', 51 | } 52 | )) 53 | logger.addHandler(handler) 54 | logging.info(f"Logging is enabled at level {level}.") 55 | 56 | 57 | def dummy_query(conn: sqlite3.Connection) -> bool: 58 | """Run a dummy query to check for a database lock.""" 59 | cursor = conn.cursor() 60 | cursor.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='urls'") 61 | 62 | 63 | def connect_db(path_to_history: str, retries=MAX_RETRIES) -> tuple: 64 | """Connect to an SQLite database. 65 | 66 | Args: 67 | path_to_history (str): Path to SQLite database. 68 | 69 | Returns: 70 | tuple: SQLite connection object if successful and the path to the file. (None, None) otherwise. 71 | """ 72 | while retries > 0: 73 | try: 74 | with sqlite3.connect(path_to_history, isolation_level='IMMEDIATE', check_same_thread=False) as conn: 75 | dummy_query(conn) # Check for lock with a dummy query 76 | logging.info(f"Connected to SQLite database at {path_to_history}") 77 | return conn, path_to_history # return the path_to_history to identify if it's a temp db 78 | except sqlite3.OperationalError as e: 79 | if "database is locked" in str(e): 80 | temp_db_path = tempfile.mktemp(suffix=".sqlite") 81 | shutil.copy2(path_to_history, temp_db_path) 82 | sleep(BACKOFF_FACTOR * (MAX_RETRIES - retries + 1)) 83 | retries -= 1 84 | logging.info( 85 | f"Database `{path_to_history}` was locked. Created a temporary copy at {temp_db_path}. Try #: {MAX_RETRIES-retries}") 86 | path_to_history = temp_db_path 87 | else: 88 | logging.error(str(e)) 89 | return None, None 90 | 91 | 92 | def table_exists(conn: sqlite3.Connection, table_name: str) -> bool: 93 | """Check if a table exists in the SQLite database. 94 | 95 | Args: 96 | conn (sqlite3.Connection): SQLite connection object. 97 | table_name (str): Name of the table to check. 98 | 99 | Returns: 100 | bool: True if table exists, False otherwise. 101 | """ 102 | cursor = conn.cursor() 103 | cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,)) 104 | result = cursor.fetchone() 105 | return result is not None 106 | 107 | 108 | def fetch_and_write_data( 109 | conn: sqlite3.Connection, output_file_name: str, output_dir: str, output_base: str, formats: List[str], 110 | extract_types: List[str]) -> None: 111 | """Fetch data from the SQLite database and write it to specified output formats. 112 | 113 | Args: 114 | conn (sqlite3.Connection): SQLite connection object. 115 | output_dir (str): Directory where the output will be saved. 116 | output_base (str): Base name for the output files. 117 | formats (List[str]): List of output formats (csv, xlsx, txt). 118 | extract_types (List[str]): List of data types to extract (urls, downloads). 119 | """ 120 | cursor = conn.cursor() 121 | epoch_start = datetime(1601, 1, 1) 122 | successful_extractions = 0 123 | 124 | def convert_chrome_time(chrome_time): 125 | return epoch_start + timedelta(microseconds=chrome_time) 126 | 127 | def fetch_and_convert_data(query, columns, time_cols): 128 | cursor.execute(query) 129 | rows = cursor.fetchall() 130 | df = pd.DataFrame(rows, columns=columns) 131 | for time_col in time_cols: 132 | if time_col in df.columns: 133 | df[time_col] = df[time_col].apply(convert_chrome_time) 134 | return df 135 | 136 | query_dict = { 137 | 'urls': ("SELECT url, title, visit_count, last_visit_time FROM urls", ['URL', 'Title', 'Visit_Count', 'Last_Visit_Time'], ['Last_Visit_Time']), 138 | 'downloads': ("SELECT downloads.target_path, downloads.start_time, downloads.end_time, downloads.total_bytes, downloads.received_bytes, downloads_url_chains.url, downloads.tab_referrer_url, downloads.referrer FROM downloads INNER JOIN downloads_url_chains ON downloads.id=downloads_url_chains.id", 139 | ['Target_Path', 'Start_Time', 'End_Time', 'Total_Bytes', 'Received_Bytes', 'URL', 'Tab_Referrer_URL', 'Referrer'], ['Start_Time', 'End_Time']) 140 | } 141 | 142 | def _pretty_txt(df, file_name): 143 | longest_field_name = max(df.columns, key=len) 144 | field_name_length = len(longest_field_name) 145 | 146 | max_length = max( 147 | len(f"{field}: {value}") 148 | for _, row in df.iterrows() 149 | for field, value in zip(df.columns, row) 150 | ) 151 | 152 | with open(file_name, 'w', encoding='utf-8') as f: 153 | for _, row in df.iterrows(): 154 | for field, value in zip(df.columns, row): 155 | f.write(f"{field.ljust(field_name_length)}: {value}\n") 156 | f.write("=" * max_length + "\n") 157 | 158 | error = False 159 | for extract_type in extract_types: 160 | query, columns, time_cols = query_dict[extract_type] 161 | try: 162 | df = fetch_and_convert_data(query, columns, time_cols) 163 | for fmt in formats: 164 | output_file = os.path.normpath(os.path.join(output_dir, f"{output_file_name}_{extract_type}.{fmt}")) 165 | if fmt == 'csv': 166 | df.to_csv(output_file, index=False) 167 | elif fmt == 'xlsx': 168 | df.to_excel(output_file, index=False, engine='openpyxl') 169 | elif fmt == 'txt': 170 | _pretty_txt(df, output_file) 171 | logging.info(f"Data saved to {output_file}") 172 | except sqlite3.OperationalError as e: 173 | error = True 174 | if "no such table" in str(e): 175 | logging.warning(f"The table '{query.split(' ')[3]}' does not exist. Skipping extraction.") 176 | continue 177 | if not error: 178 | successful_extractions = 1 179 | return successful_extractions 180 | 181 | 182 | def is_sqlite3(filename: str) -> bool: 183 | """Check if a file is an SQLite3 database. 184 | 185 | Args: 186 | filename (str): File path to check. 187 | 188 | Returns: 189 | bool: True if the file is an SQLite3 database, False otherwise. 190 | """ 191 | if not os.access(filename, os.R_OK): # Check for read permission 192 | logging.error(f"Access denied for {filename}. Ensure you have read permissions.") 193 | return False 194 | try: 195 | with open(filename, 'rb') as f: 196 | header = f.read(16) 197 | return header == b'SQLite format 3\x00' 198 | except Exception as e: 199 | logging.error(f"Failed to determine if {filename} is an SQLite3 database: {str(e)}") 200 | return False 201 | 202 | 203 | def main(): 204 | """Main function to run the application. Parses command line arguments and 205 | orchestrates the extraction and writing of data. 206 | 207 | Returns: 208 | int: Exit code (0 for success, non-zero for failure). 209 | """ 210 | if '-v' in sys.argv or '--version' in sys.argv: 211 | print(f"HistExport version {__version__}") 212 | sys.exit(0) 213 | parser = argparse.ArgumentParser( 214 | description="Export Chromium-based browser and download history to various formats.", 215 | formatter_class=argparse.RawTextHelpFormatter, 216 | epilog="""Examples: 217 | 1) Basic extraction of URLs and Downloads in `txt`: 218 | histexport -i path/to/history/history_file -o output_file 219 | 2) Specify output directory and formats: 220 | histexport -i path/to/history/history_file -o output_file -d path/to/output -f csv xlsx 221 | 3) Enable logging (`-l`): 222 | histexport -i path/to/history/history_file -o output_file -l 1 223 | 4) Extract URLs and downloads from a folder of SQLite files: 224 | histexport -i path/to/history_folder -t folder -o output_file -d path/to/output -f csv xlsx -e urls downloads 225 | """) 226 | parser.add_argument('-i', '--input', required=True, 227 | help='Path to the SQLite history file.') 228 | parser.add_argument('-t', '--type', choices=['file', 'folder'], default='file', 229 | help='Type of the input: file or folder. Default is file') 230 | parser.add_argument('-o', '--output', required=True, 231 | help='Base name for the output files.') 232 | parser.add_argument('-d', '--dir', required=False, default='./', 233 | help='Output directory. Default is current directory') 234 | parser.add_argument( 235 | '-f', '--formats', nargs='+', choices=['csv', 'xlsx', 'txt'], default=['txt'], 236 | help='Output formats. Multiple formats can be specified. Default is txt') 237 | parser.add_argument('-e', '--extract', nargs='+', choices=['urls', 'downloads'], default=[ 238 | 'urls', 'downloads'], help='Types to extract: urls, downloads, or both. Default is both') 239 | parser.add_argument( 240 | '-l', '--log', type=int, choices=[1, 2, 3, 4, 5], 241 | default=0, 242 | help='Enable logging with debug level. 1=CRITICAL, 2=ERROR, 3=WARNING, 4=INFO, 5=DEBUG. Default is disabled') 243 | parser.add_argument('-v', '--version', action='store_true', 244 | help='Show the version of this script.') 245 | 246 | args = parser.parse_args() 247 | 248 | # Initialize logging if enabled 249 | init_logging(args.log) 250 | 251 | output_dir = os.path.normpath(args.dir) 252 | if not os.path.exists(output_dir): 253 | os.makedirs(output_dir) 254 | 255 | def _process_history_file(queue, output_file, output_dir, formats, extract_types): 256 | successful_conversions = 0 257 | while not queue.empty(): 258 | input_path = queue.get() 259 | logging.info(f"Processing {input_path}") 260 | try: 261 | input_path = os.path.normpath(input_path) 262 | conn, db_path = connect_db(input_path) 263 | if conn is not None: 264 | output_base = os.path.splitext(os.path.basename(input_path))[0] 265 | successful_conversions += fetch_and_write_data(conn, output_file, 266 | output_dir, output_base, formats, extract_types) 267 | if conn is not None: 268 | conn.close() 269 | if db_path != input_path: 270 | with FILE_LOCK: 271 | if os.access(db_path, os.W_OK): 272 | os.remove(db_path) 273 | except Exception as e: 274 | logging.error(f"An error occurred while processing {input_path}: {str(e)}") 275 | return successful_conversions 276 | 277 | exit_code = 0 # EXIT_SUCCESS 278 | successful_conversions = 0 # Initialize a counter for successful conversions 279 | 280 | try: 281 | queue = Queue() 282 | if args.type == 'folder': 283 | for filename in os.listdir(args.input): 284 | input_path = os.path.join(args.input, filename) 285 | if is_sqlite3(input_path): 286 | queue.put(input_path) 287 | 288 | with ThreadPoolExecutor(max_workers=10) as executor: 289 | futures = [ 290 | executor.submit(_process_history_file, queue, args.output, args.dir, args.formats, args.extract) 291 | for _ in range(min(10, queue.qsize()))] 292 | print(successful_conversions) 293 | successful_conversions = sum(future.result() for future in futures) 294 | else: 295 | queue.put(args.input) 296 | successful_conversions = _process_history_file(queue, args.output, args.dir, args.formats, args.extract) 297 | 298 | logging.info(f"Successfully converted {successful_conversions} file(s).") 299 | 300 | except Exception as e: 301 | logging.error(f"An error occurred: {str(e)}") 302 | exit_code = 2 303 | 304 | return exit_code 305 | 306 | 307 | if __name__ == "__main__": 308 | exit(main()) 309 | --------------------------------------------------------------------------------