mirror of
https://github.com/KnugiHK/WhatsApp-Chat-Exporter.git
synced 2026-01-29 05:40:42 +00:00
Compare commits
16 Commits
1694ae7dd9
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bac2efe15a | ||
|
|
9a6ee3ce5f | ||
|
|
823a89e677 | ||
|
|
945b422f71 | ||
|
|
19008a80bc | ||
|
|
4e877987fb | ||
|
|
322b12a5a4 | ||
|
|
1560c49644 | ||
|
|
28ba97d72f | ||
|
|
eab98ba0d6 | ||
|
|
f920ca82b4 | ||
|
|
4eed3ca321 | ||
|
|
746e4e1ac5 | ||
|
|
e0aab06192 | ||
|
|
43b00d8b48 | ||
|
|
32c93159ac |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
pull_request:
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -18,14 +18,17 @@ jobs:
|
||||
include:
|
||||
- os: windows-latest
|
||||
python-version: "3.13"
|
||||
python_utf8: "1"
|
||||
- os: macos-latest
|
||||
python-version: "3.13"
|
||||
- os: windows-11-arm
|
||||
python-version: "3.13"
|
||||
python_utf8: "1"
|
||||
- os: macos-15-intel
|
||||
python-version: "3.13"
|
||||
- os: windows-latest
|
||||
python-version: "3.14"
|
||||
python_utf8: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -42,4 +45,6 @@ jobs:
|
||||
pip install .[all] pytest nuitka
|
||||
|
||||
- name: Run pytest
|
||||
env:
|
||||
PYTHONUTF8: ${{ matrix.python_utf8 || '0' }}
|
||||
run: pytest
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-2025 Knugi
|
||||
Copyright (c) 2021-2026 Knugi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
88
README.md
88
README.md
@@ -115,7 +115,7 @@ Do an iPhone/iPad Backup with iTunes/Finder first.
|
||||
|
||||
If you want to work on an encrypted iOS/iPadOS Backup, you should install iphone_backup_decrypt from [KnugiHK/iphone_backup_decrypt](https://github.com/KnugiHK/iphone_backup_decrypt) before you run the extract_iphone_media.py.
|
||||
```sh
|
||||
pip install whatsapp-chat-exporter["ios_backup"]
|
||||
pip install git+https://github.com/KnugiHK/iphone_backup_decrypt
|
||||
```
|
||||
> [!NOTE]
|
||||
> You will need to disable the built-in end-to-end encryption for WhatsApp backups. See [WhatsApp's FAQ](https://faq.whatsapp.com/490592613091019#turn-off-end-to-end-encrypted-backup) for how to do it.
|
||||
@@ -141,24 +141,33 @@ After extracting, you will get this:
|
||||

|
||||
|
||||
|
||||
## Working with Business
|
||||
If you are working with WhatsApp Business, add the `--business` flag to the command
|
||||
```sh
|
||||
wtsexporter -a --business ...other flags
|
||||
wtsexporter -i --business ...other flags
|
||||
```
|
||||
|
||||
## More options
|
||||
Invoke the wtsexporter with --help option will show you all options available.
|
||||
```sh
|
||||
> wtsexporter --help
|
||||
usage: wtsexporter [-h] [--debug] [-a] [-i] [-e EXPORTED] [-w WA] [-m MEDIA] [-b BACKUP] [-d DB] [-k [KEY]]
|
||||
[--call-db [CALL_DB_IOS]] [--wab WAB] [-o OUTPUT] [-j [JSON]] [--txt [TEXT_FORMAT]] [--no-html]
|
||||
[--size [SIZE]] [--no-reply] [--avoid-encoding-json] [--pretty-print-json [PRETTY_PRINT_JSON]]
|
||||
[--tg] [--per-chat] [--import] [-t TEMPLATE] [--offline OFFLINE] [--no-avatar] [--old-theme]
|
||||
[--headline HEADLINE] [-c] [--create-separated-media] [--time-offset {-12 to 14}] [--date DATE]
|
||||
usage: wtsexporter [-h] [--debug] [-a] [-i] [-e EXPORTED] [-w WA] [-m MEDIA] [-b BACKUP] [-d DB]
|
||||
[-k [KEY]] [--call-db [CALL_DB_IOS]] [--wab WAB] [-o OUTPUT] [-j [JSON]]
|
||||
[--txt [TEXT_FORMAT]] [--no-html] [--size [SIZE]] [--no-reply] [--avoid-encoding-json]
|
||||
[--pretty-print-json [PRETTY_PRINT_JSON]] [--tg] [--per-chat] [--import] [-t TEMPLATE]
|
||||
[--offline OFFLINE] [--no-avatar] [--old-theme] [--headline HEADLINE] [-c]
|
||||
[--create-separated-media] [--time-offset {-12 to 14}] [--date DATE]
|
||||
[--date-format FORMAT] [--include [phone number ...]] [--exclude [phone number ...]]
|
||||
[--dont-filter-empty] [--enrich-from-vcards ENRICH_FROM_VCARDS]
|
||||
[--default-country-code DEFAULT_COUNTRY_CODE] [--incremental-merge] [--source-dir SOURCE_DIR]
|
||||
[--target-dir TARGET_DIR] [-s] [--check-update] [--assume-first-as-me] [--business]
|
||||
[--decrypt-chunk-size DECRYPT_CHUNK_SIZE] [--max-bruteforce-worker MAX_BRUTEFORCE_WORKER]
|
||||
[--no-banner]
|
||||
[--default-country-code DEFAULT_COUNTRY_CODE] [--incremental-merge]
|
||||
[--source-dir SOURCE_DIR] [--target-dir TARGET_DIR] [-s] [--check-update]
|
||||
[--check-update-pre] [--assume-first-as-me] [--business]
|
||||
[--decrypt-chunk-size DECRYPT_CHUNK_SIZE]
|
||||
[--max-bruteforce-worker MAX_BRUTEFORCE_WORKER] [--no-banner] [--fix-dot-files]
|
||||
|
||||
A customizable Android and iOS/iPadOS WhatsApp database parser that will give you the history of your WhatsApp
|
||||
conversations in HTML and JSON. Android Backup Crypt12, Crypt14 and Crypt15 supported.
|
||||
A customizable Android and iOS/iPadOS WhatsApp database parser that will give you the history of your
|
||||
WhatsApp conversations in HTML and JSON. Android Backup Crypt12, Crypt14 and Crypt15 supported.
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
@@ -174,9 +183,10 @@ Input Files:
|
||||
-w, --wa WA Path to contact database (default: wa.db/ContactsV2.sqlite)
|
||||
-m, --media MEDIA Path to WhatsApp media folder (default: WhatsApp)
|
||||
-b, --backup BACKUP Path to Android (must be used together with -k)/iOS WhatsApp backup
|
||||
-d, --db DB Path to database file (default: msgstore.db/7c7fba66680ef796b916b067077cc246adacf01d)
|
||||
-k, --key [KEY] Path to key file. If this option is set for crypt15 backup but nothing is specified, you will
|
||||
be prompted to enter the key.
|
||||
-d, --db DB Path to database file (default:
|
||||
msgstore.db/7c7fba66680ef796b916b067077cc246adacf01d)
|
||||
-k, --key [KEY] Path to key file. If this option is set for crypt15 backup but nothing is
|
||||
specified, you will be prompted to enter the key.
|
||||
--call-db [CALL_DB_IOS]
|
||||
Path to call database (default: 1b432994e958845fffe8e2f190f26d1511534088) iOS only
|
||||
--wab, --wa-backup WAB
|
||||
@@ -185,8 +195,8 @@ Input Files:
|
||||
Output Options:
|
||||
-o, --output OUTPUT Output to specific directory (default: result)
|
||||
-j, --json [JSON] Save the result to a single JSON file (default if present: result.json)
|
||||
--txt [TEXT_FORMAT] Export chats in text format similar to what WhatsApp officially provided (default if present:
|
||||
result/)
|
||||
--txt [TEXT_FORMAT] Export chats in text format similar to what WhatsApp officially provided (default
|
||||
if present: result/)
|
||||
--no-html Do not output html files
|
||||
--size, --output-size, --split [SIZE]
|
||||
Maximum (rough) size of a single output file in bytes, 0 for auto
|
||||
@@ -197,7 +207,8 @@ JSON Options:
|
||||
Don't encode non-ascii characters in the output JSON files
|
||||
--pretty-print-json [PRETTY_PRINT_JSON]
|
||||
Pretty print the output JSON.
|
||||
--tg, --telegram Output the JSON in a format compatible with Telegram export (implies json-per-chat)
|
||||
--tg, --telegram Output the JSON in a format compatible with Telegram export (implies json-per-
|
||||
chat)
|
||||
--per-chat Output the JSON file per chat
|
||||
--import Import JSON file and convert to HTML output
|
||||
|
||||
@@ -207,7 +218,8 @@ HTML Options:
|
||||
--offline OFFLINE Relative path to offline static files
|
||||
--no-avatar Do not render avatar in HTML output
|
||||
--old-theme Use the old Telegram-alike theme
|
||||
--headline HEADLINE The custom headline for the HTML output. Use '??' as a placeholder for the chat name
|
||||
--headline HEADLINE The custom headline for the HTML output. Use '??' as a placeholder for the chat
|
||||
name
|
||||
|
||||
Media Handling:
|
||||
-c, --move-media Move the media directory to output directory if the flag is set, otherwise copy it
|
||||
@@ -223,24 +235,26 @@ Filtering Options:
|
||||
Include chats that match the supplied phone number
|
||||
--exclude [phone number ...]
|
||||
Exclude chats that match the supplied phone number
|
||||
--dont-filter-empty By default, the exporter will not render chats with no valid message. Setting this flag will
|
||||
cause the exporter to render those. This is useful if chat(s) are missing from the output
|
||||
--dont-filter-empty By default, the exporter will not render chats with no valid message. Setting this
|
||||
flag will cause the exporter to render those. This is useful if chat(s) are
|
||||
missing from the output
|
||||
|
||||
Contact Enrichment:
|
||||
--enrich-from-vcards ENRICH_FROM_VCARDS
|
||||
Path to an exported vcf file from Google contacts export. Add names missing from WhatsApp's
|
||||
default database
|
||||
Path to an exported vcf file from Google contacts export. Add names missing from
|
||||
WhatsApp's default database
|
||||
--default-country-code DEFAULT_COUNTRY_CODE
|
||||
Use with --enrich-from-vcards. When numbers in the vcf file does not have a country code, this
|
||||
will be used. 1 is for US, 66 for Thailand etc. Most likely use the number of your own country
|
||||
Use with --enrich-from-vcards. When numbers in the vcf file does not have a
|
||||
country code, this will be used. 1 is for US, 66 for Thailand etc. Most likely use
|
||||
the number of your own country
|
||||
|
||||
Incremental Merging:
|
||||
--incremental-merge Performs an incremental merge of two exports. Requires setting both --source-dir and --target-
|
||||
dir. The chats (JSON files only) and media from the source directory will be merged into the
|
||||
target directory. No chat messages or media will be deleted from the target directory; only
|
||||
new chat messages and media will be added to it. This enables chat messages and media to be
|
||||
deleted from the device to free up space, while ensuring they are preserved in the exported
|
||||
backups.
|
||||
--incremental-merge Performs an incremental merge of two exports. Requires setting both --source-dir
|
||||
and --target-dir. The chats (JSON files only) and media from the source directory
|
||||
will be merged into the target directory. No chat messages or media will be
|
||||
deleted from the target directory; only new chat messages and media will be added
|
||||
to it. This enables chat messages and media to be deleted from the device to free
|
||||
up space, while ensuring they are preserved in the exported backups.
|
||||
--source-dir SOURCE_DIR
|
||||
Sets the source directory. Used for performing incremental merges.
|
||||
--target-dir TARGET_DIR
|
||||
@@ -249,16 +263,20 @@ Incremental Merging:
|
||||
Miscellaneous:
|
||||
-s, --showkey Show the HEX key used to decrypt the database
|
||||
--check-update Check for updates (require Internet access)
|
||||
--check-update-pre Check for updates including pre-releases (require Internet access)
|
||||
--assume-first-as-me Assume the first message in a chat as sent by me (must be used together with -e)
|
||||
--business Use Whatsapp Business default files (iOS only)
|
||||
--decrypt-chunk-size DECRYPT_CHUNK_SIZE
|
||||
Specify the chunk size for decrypting iOS backup, which may affect the decryption speed.
|
||||
Specify the chunk size for decrypting iOS backup, which may affect the decryption
|
||||
speed.
|
||||
--max-bruteforce-worker MAX_BRUTEFORCE_WORKER
|
||||
Specify the maximum number of worker for bruteforce decryption.
|
||||
--no-banner Do not show the banner
|
||||
--fix-dot-files Fix files with a dot at the end of their name (allowing the outputs be stored in
|
||||
FAT filesystems)
|
||||
|
||||
WhatsApp Chat Exporter: 0.13.0rc2 Licensed with MIT. See https://wts.knugi.dev/docs?dest=osl for all open source
|
||||
licenses.
|
||||
WhatsApp Chat Exporter: 0.13.0 Licensed with MIT. See https://wts.knugi.dev/docs?dest=osl for all open
|
||||
source licenses.
|
||||
```
|
||||
|
||||
# Verifying Build Integrity
|
||||
@@ -266,7 +284,7 @@ licenses.
|
||||
To ensure that the binaries provided in the releases were built directly from this source code via GitHub Actions and have not been tampered with, GitHub Artifact Attestations is used. You can verify the authenticity of any pre-built binaries using the GitHub CLI.
|
||||
|
||||
> [!NOTE]
|
||||
> Requires version 0.13.0rc1 or newer. Legacy binaries are unsupported.
|
||||
> Requires version 0.13.0 or newer. Legacy binaries are unsupported.
|
||||
|
||||
### Using Bash (Linux/WSL/macOS)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import importlib.metadata
|
||||
from Whatsapp_Chat_Exporter import android_crypt, exported_handler, android_handler
|
||||
from Whatsapp_Chat_Exporter import ios_handler, ios_media_handler
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatCollection, ChatStore, Timing
|
||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, CLEAR_LINE, CURRENT_TZ_OFFSET, Crypt
|
||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, CURRENT_TZ_OFFSET, Crypt
|
||||
from Whatsapp_Chat_Exporter.utility import readable_to_bytes, safe_name, bytes_to_readable
|
||||
from Whatsapp_Chat_Exporter.utility import import_from_json, incremental_merge, check_update
|
||||
from Whatsapp_Chat_Exporter.utility import telegram_json_format, convert_time_unit, DbType
|
||||
@@ -26,7 +26,6 @@ from typing import Optional, List, Dict
|
||||
from Whatsapp_Chat_Exporter.vcards_contacts import ContactsFromVCards
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
__version__ = importlib.metadata.version("whatsapp_chat_exporter")
|
||||
WTSEXPORTER_BANNER = f"""========================================================================================================
|
||||
██╗ ██╗██╗ ██╗ █████╗ ████████╗███████╗ █████╗ ██████╗ ██████╗
|
||||
@@ -275,6 +274,10 @@ def setup_argument_parser() -> ArgumentParser:
|
||||
"--check-update", dest="check_update", default=False, action='store_true',
|
||||
help="Check for updates (require Internet access)"
|
||||
)
|
||||
misc_group.add_argument(
|
||||
"--check-update-pre", dest="check_update_pre", default=False, action='store_true',
|
||||
help="Check for updates including pre-releases (require Internet access)"
|
||||
)
|
||||
misc_group.add_argument(
|
||||
"--assume-first-as-me", dest="assume_first_as_me", default=False, action='store_true',
|
||||
help="Assume the first message in a chat as sent by me (must be used together with -e)"
|
||||
@@ -440,10 +443,10 @@ def setup_contact_store(args) -> Optional['ContactsFromVCards']:
|
||||
def decrypt_android_backup(args) -> int:
|
||||
"""Decrypt Android backup files and return error code."""
|
||||
if args.key is None or args.backup is None:
|
||||
logger.error(f"You must specify the backup file with -b and a key with -k{CLEAR_LINE}")
|
||||
logging.error(f"You must specify the backup file with -b and a key with -k")
|
||||
return 1
|
||||
|
||||
logger.info(f"Decryption key specified, decrypting WhatsApp backup...{CLEAR_LINE}")
|
||||
logging.info(f"Decryption key specified, decrypting WhatsApp backup...")
|
||||
|
||||
# Determine crypt type
|
||||
if "crypt12" in args.backup:
|
||||
@@ -453,8 +456,8 @@ def decrypt_android_backup(args) -> int:
|
||||
elif "crypt15" in args.backup:
|
||||
crypt = Crypt.CRYPT15
|
||||
else:
|
||||
logger.error(
|
||||
f"Unknown backup format. The backup file must be crypt12, crypt14 or crypt15.{CLEAR_LINE}")
|
||||
logging.error(
|
||||
f"Unknown backup format. The backup file must be crypt12, crypt14 or crypt15.")
|
||||
return 1
|
||||
|
||||
# Get key
|
||||
@@ -506,15 +509,15 @@ def decrypt_android_backup(args) -> int:
|
||||
def handle_decrypt_error(error: int) -> None:
|
||||
"""Handle decryption errors with appropriate messages."""
|
||||
if error == 1:
|
||||
logger.error("Dependencies of decrypt_backup and/or extract_encrypted_key"
|
||||
" are not present. For details, see README.md.\n")
|
||||
logging.error("Dependencies of decrypt_backup and/or extract_encrypted_key"
|
||||
" are not present. For details, see README.md.")
|
||||
exit(3)
|
||||
elif error == 2:
|
||||
logger.error("Failed when decompressing the decrypted backup. "
|
||||
"Possibly incorrect offsets used in decryption.\n")
|
||||
logging.error("Failed when decompressing the decrypted backup. "
|
||||
"Possibly incorrect offsets used in decryption.")
|
||||
exit(4)
|
||||
else:
|
||||
logger.error("Unknown error occurred.\n")
|
||||
logging.error("Unknown error occurred.")
|
||||
exit(5)
|
||||
|
||||
|
||||
@@ -537,9 +540,9 @@ def process_messages(args, data: ChatCollection) -> None:
|
||||
msg_db = args.db if args.db else "msgstore.db" if args.android else args.identifiers.MESSAGE
|
||||
|
||||
if not os.path.isfile(msg_db):
|
||||
logger.error(
|
||||
logging.error(
|
||||
"The message database does not exist. You may specify the path "
|
||||
"to database file with option -d or check your provided path.\n"
|
||||
"to database file with option -d or check your provided path."
|
||||
)
|
||||
exit(6)
|
||||
|
||||
@@ -596,21 +599,21 @@ def handle_media_directory(args) -> None:
|
||||
media_path = os.path.join(args.output, args.media)
|
||||
|
||||
if os.path.isdir(media_path):
|
||||
logger.info(
|
||||
f"WhatsApp directory already exists in output directory. Skipping...{CLEAR_LINE}")
|
||||
logging.info(
|
||||
f"WhatsApp directory already exists in output directory. Skipping...")
|
||||
else:
|
||||
if args.move_media:
|
||||
try:
|
||||
logger.info(f"Moving media directory...\r")
|
||||
logging.info(f"Moving media directory...", extra={"clear": True})
|
||||
shutil.move(args.media, f"{args.output}/")
|
||||
logger.info(f"Media directory has been moved to the output directory{CLEAR_LINE}")
|
||||
logging.info(f"Media directory has been moved to the output directory")
|
||||
except PermissionError:
|
||||
logger.warning("Cannot remove original WhatsApp directory. "
|
||||
"Perhaps the directory is opened?\n")
|
||||
logging.warning("Cannot remove original WhatsApp directory. "
|
||||
"Perhaps the directory is opened?")
|
||||
else:
|
||||
logger.info(f"Copying media directory...\r")
|
||||
logging.info(f"Copying media directory...", extra={"clear": True})
|
||||
shutil.copytree(args.media, media_path)
|
||||
logger.info(f"Media directory has been copied to the output directory{CLEAR_LINE}")
|
||||
logging.info(f"Media directory has been copied to the output directory")
|
||||
|
||||
|
||||
def create_output_files(args, data: ChatCollection) -> None:
|
||||
@@ -631,7 +634,7 @@ def create_output_files(args, data: ChatCollection) -> None:
|
||||
|
||||
# Create text files if requested
|
||||
if args.text_format:
|
||||
logger.info(f"Writing text file...{CLEAR_LINE}")
|
||||
logging.info(f"Writing text file...")
|
||||
android_handler.create_txt(data, args.text_format)
|
||||
|
||||
# Create JSON files if requested
|
||||
@@ -661,9 +664,9 @@ def export_single_json(args, data: Dict) -> None:
|
||||
ensure_ascii=not args.avoid_encoding_json,
|
||||
indent=args.pretty_print_json
|
||||
)
|
||||
logger.info(f"Writing JSON file...\r")
|
||||
logging.info(f"Writing JSON file...", extra={"clear": True})
|
||||
f.write(json_data)
|
||||
logger.info(f"JSON file saved...({bytes_to_readable(len(json_data))}){CLEAR_LINE}")
|
||||
logging.info(f"JSON file saved...({bytes_to_readable(len(json_data))})")
|
||||
|
||||
|
||||
def export_multiple_json(args, data: Dict) -> None:
|
||||
@@ -697,7 +700,7 @@ def export_multiple_json(args, data: Dict) -> None:
|
||||
f.write(file_content)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Generated {total} JSON files in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Generated {total} JSON files in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
def process_exported_chat(args, data: ChatCollection) -> None:
|
||||
@@ -722,16 +725,36 @@ def process_exported_chat(args, data: ChatCollection) -> None:
|
||||
shutil.copy(file, args.output)
|
||||
|
||||
|
||||
class ClearLineFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
is_clear = getattr(record, 'clear', False)
|
||||
if is_clear:
|
||||
record.line_end = "\r"
|
||||
record.prefix = "\x1b[K"
|
||||
else:
|
||||
record.line_end = "\n"
|
||||
record.prefix = ""
|
||||
return True
|
||||
|
||||
|
||||
def setup_logging(level):
|
||||
log_handler_stdout = logging.StreamHandler()
|
||||
log_handler_stdout.terminator = ""
|
||||
log_handler_stdout.addFilter(ClearLineFilter())
|
||||
log_handler_stdout.set_name("console")
|
||||
|
||||
handlers = [log_handler_stdout]
|
||||
|
||||
if level == logging.DEBUG:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
handlers.append(logging.FileHandler(f"wtsexpoter-debug-{timestamp}.log", mode="w"))
|
||||
log_handler_file = logging.FileHandler(f"wtsexpoter-debug-{timestamp}.log", mode="w")
|
||||
log_handler_file.terminator = ""
|
||||
log_handler_file.addFilter(ClearLineFilter())
|
||||
handlers.append(log_handler_file)
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="[%(levelname)s] %(message)s",
|
||||
format="[%(levelname)s] %(message)s%(line_end)s",
|
||||
handlers=handlers
|
||||
)
|
||||
|
||||
@@ -742,23 +765,29 @@ def main():
|
||||
parser = setup_argument_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check for updates
|
||||
if args.check_update:
|
||||
exit(check_update())
|
||||
|
||||
# Validate arguments
|
||||
validate_args(parser, args)
|
||||
|
||||
# Print banner if not suppressed
|
||||
if not args.no_banner:
|
||||
# Note: This may raise UnicodeEncodeError on Windows if the terminal
|
||||
# doesn't support UTF-8 (e.g., Legacy CMD). Use a modern terminal
|
||||
# or set PYTHONUTF8=1 in your environment.
|
||||
print(WTSEXPORTER_BANNER)
|
||||
|
||||
if args.debug:
|
||||
setup_logging(logging.DEBUG)
|
||||
logger.debug("Debug mode enabled.\n")
|
||||
logging.debug("Debug mode enabled.")
|
||||
for handler in logging.getLogger().handlers:
|
||||
if handler.name == "console":
|
||||
handler.setLevel(logging.INFO)
|
||||
else:
|
||||
setup_logging(logging.INFO)
|
||||
|
||||
# Check for updates
|
||||
if args.check_update or args.check_update_pre:
|
||||
exit(check_update(args.check_update_pre))
|
||||
|
||||
# Validate arguments
|
||||
validate_args(parser, args)
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
|
||||
@@ -821,8 +850,8 @@ def main():
|
||||
ios_media_handler.extract_media(
|
||||
args.backup, identifiers, args.decrypt_chunk_size)
|
||||
else:
|
||||
logger.info(
|
||||
f"WhatsApp directory already exists, skipping WhatsApp file extraction.{CLEAR_LINE}")
|
||||
logging.info(
|
||||
f"WhatsApp directory already exists, skipping WhatsApp file extraction.")
|
||||
|
||||
# Set default DB paths if not provided
|
||||
if args.db is None:
|
||||
@@ -838,7 +867,7 @@ def main():
|
||||
args.pretty_print_json,
|
||||
args.avoid_encoding_json
|
||||
)
|
||||
logger.info(f"Incremental merge completed successfully.{CLEAR_LINE}")
|
||||
logging.info(f"Incremental merge completed successfully.")
|
||||
else:
|
||||
# Process contacts
|
||||
process_contacts(args, data)
|
||||
@@ -856,7 +885,7 @@ def main():
|
||||
# Handle media directory
|
||||
handle_media_directory(args)
|
||||
|
||||
logger.info("Everything is done!")
|
||||
logging.info("Everything is done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,7 +7,7 @@ from tqdm import tqdm
|
||||
from typing import Tuple, Union
|
||||
from hashlib import sha256
|
||||
from functools import partial
|
||||
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, CRYPT14_OFFSETS, Crypt, DbType
|
||||
from Whatsapp_Chat_Exporter.utility import CRYPT14_OFFSETS, Crypt, DbType
|
||||
|
||||
try:
|
||||
import zlib
|
||||
@@ -25,7 +25,6 @@ else:
|
||||
support_crypt15 = True
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DecryptionError(Exception):
|
||||
@@ -126,7 +125,7 @@ def _decrypt_database(db_ciphertext: bytes, main_key: bytes, iv: bytes) -> bytes
|
||||
raise ValueError("Decryption/Authentication failed. Ensure you are using the correct key.")
|
||||
|
||||
if len(db_compressed) < 2 or db_compressed[0] != 0x78:
|
||||
logger.debug(f"Data passes GCM but is not Zlib. Header: {db_compressed[:2].hex()}")
|
||||
logging.debug(f"Data passes GCM but is not Zlib. Header: {db_compressed[:2].hex()}")
|
||||
raise ValueError(
|
||||
"Key is correct, but decrypted data is not a valid compressed stream. "
|
||||
"Is this even a valid WhatsApp database backup?"
|
||||
@@ -171,12 +170,12 @@ def _decrypt_crypt14(database: bytes, main_key: bytes, max_worker: int = 10) ->
|
||||
except (zlib.error, ValueError):
|
||||
continue
|
||||
else:
|
||||
logger.debug(
|
||||
f"Decryption successful with known offsets: IV {iv}, DB {db}{CLEAR_LINE}"
|
||||
logging.debug(
|
||||
f"Decryption successful with known offsets: IV {iv}, DB {db}"
|
||||
)
|
||||
return decrypted_db # Successful decryption
|
||||
|
||||
logger.info(f"Common offsets failed. Will attempt to brute-force{CLEAR_LINE}")
|
||||
logging.info(f"Common offsets failed. Will attempt to brute-force")
|
||||
offset_max = 200
|
||||
workers = max_worker
|
||||
check_offset = partial(_attempt_decrypt_task, database=database, main_key=main_key)
|
||||
@@ -195,20 +194,20 @@ def _decrypt_crypt14(database: bytes, main_key: bytes, max_worker: int = 10) ->
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
logger.info(
|
||||
f"The offsets of your IV and database are {start_iv} and {start_db}, respectively.{CLEAR_LINE}"
|
||||
logging.info(
|
||||
f"The offsets of your IV and database are {start_iv} and {start_db}, respectively."
|
||||
)
|
||||
logger.info(
|
||||
f"To include your offsets in the expoter, please report it in the discussion thread on GitHub:{CLEAR_LINE}"
|
||||
logging.info(
|
||||
f"To include your offsets in the expoter, please report it in the discussion thread on GitHub:"
|
||||
)
|
||||
logger.info(f"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/discussions/47{CLEAR_LINE}")
|
||||
logging.info(f"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/discussions/47")
|
||||
return result
|
||||
|
||||
except KeyboardInterrupt:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
print("\n")
|
||||
logging.info("")
|
||||
raise KeyboardInterrupt(
|
||||
f"Brute force interrupted by user (Ctrl+C). Shutting down gracefully...{CLEAR_LINE}"
|
||||
f"Brute force interrupted by user (Ctrl+C). Shutting down gracefully..."
|
||||
)
|
||||
|
||||
finally:
|
||||
@@ -346,7 +345,7 @@ def decrypt_backup(
|
||||
main_key, hex_key = _derive_main_enc_key(key)
|
||||
if show_crypt15:
|
||||
hex_key_str = ' '.join([hex_key.hex()[c:c+4] for c in range(0, len(hex_key.hex()), 4)])
|
||||
logger.info(f"The HEX key of the crypt15 backup is: {hex_key_str}{CLEAR_LINE}")
|
||||
logging.info(f"The HEX key of the crypt15 backup is: {hex_key_str}")
|
||||
else:
|
||||
main_key = key[126:]
|
||||
|
||||
|
||||
@@ -11,13 +11,12 @@ from markupsafe import escape as htmle
|
||||
from base64 import b64decode, b64encode
|
||||
from datetime import datetime
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, MAX_SIZE, ROW_SIZE, JidType, Device, get_jid_map_join
|
||||
from Whatsapp_Chat_Exporter.utility import MAX_SIZE, ROW_SIZE, JidType, Device, get_jid_map_join
|
||||
from Whatsapp_Chat_Exporter.utility import rendering, get_file_name, setup_template, get_cond_for_empty
|
||||
from Whatsapp_Chat_Exporter.utility import get_status_location, convert_time_unit, get_jid_map_selection
|
||||
from Whatsapp_Chat_Exporter.utility import get_chat_condition, safe_name, bytes_to_readable, determine_metadata
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def contacts(db, data, enrich_from_vcards):
|
||||
@@ -38,14 +37,14 @@ def contacts(db, data, enrich_from_vcards):
|
||||
|
||||
if total_row_number == 0:
|
||||
if enrich_from_vcards is not None:
|
||||
logger.info(
|
||||
"No contacts profiles found in the default database, contacts will be imported from the specified vCard file.\n")
|
||||
logging.info(
|
||||
"No contacts profiles found in the default database, contacts will be imported from the specified vCard file.")
|
||||
else:
|
||||
logger.warning(
|
||||
"No contacts profiles found in the default database, consider using --enrich-from-vcards for adopting names from exported contacts from Google\n")
|
||||
logging.warning(
|
||||
"No contacts profiles found in the default database, consider using --enrich-from-vcards for adopting names from exported contacts from Google")
|
||||
return False
|
||||
else:
|
||||
logger.info(f"Processed {total_row_number} contacts\n")
|
||||
logging.info(f"Processed {total_row_number} contacts")
|
||||
|
||||
c.execute("SELECT jid, COALESCE(display_name, wa_name) as display_name, status FROM wa_contacts;")
|
||||
|
||||
@@ -56,7 +55,7 @@ def contacts(db, data, enrich_from_vcards):
|
||||
current_chat.status = row["status"]
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} contacts in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} contacts in {convert_time_unit(total_time)}")
|
||||
|
||||
return True
|
||||
|
||||
@@ -81,7 +80,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
||||
content_cursor = _get_messages_cursor_legacy(c, filter_empty, filter_date, filter_chat)
|
||||
table_message = False
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.debug(f'Got sql error "{e}" in _get_message_cursor_legacy trying fallback.\n')
|
||||
logging.debug(f'Got sql error "{e}" in _get_message_cursor_legacy trying fallback.\n')
|
||||
try:
|
||||
content_cursor = _get_messages_cursor_new(
|
||||
c,
|
||||
@@ -101,7 +100,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
_get_reactions(db, data)
|
||||
logger.info(f"Processed {total_row_number} messages in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} messages in {convert_time_unit(total_time)}")
|
||||
|
||||
# Helper functions for message processing
|
||||
|
||||
@@ -127,7 +126,7 @@ def _get_message_count(cursor, filter_empty, filter_date, filter_chat, jid_map_e
|
||||
{include_filter}
|
||||
{exclude_filter}""")
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.debug(f'Got sql error "{e}" in _get_message_count trying fallback.\n')
|
||||
logging.debug(f'Got sql error "{e}" in _get_message_count trying fallback.\n')
|
||||
|
||||
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")
|
||||
date_filter = f'AND timestamp {filter_date}' if filter_date is not None else ''
|
||||
@@ -143,6 +142,8 @@ def _get_message_count(cursor, filter_empty, filter_date, filter_chat, jid_map_e
|
||||
FROM message
|
||||
LEFT JOIN chat
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid
|
||||
ON jid._id = chat.jid_row_id
|
||||
INNER JOIN jid jid_global
|
||||
ON jid_global._id = chat.jid_row_id
|
||||
LEFT JOIN jid jid_group
|
||||
@@ -315,8 +316,8 @@ def _fetch_row_safely(cursor):
|
||||
except sqlite3.OperationalError as e:
|
||||
# Not sure how often this might happen, but this check should reduce the overhead
|
||||
# if DEBUG flag is not set.
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f'Got sql error "{e}" in _fetch_row_safely ignoring row.\n')
|
||||
if logging.isEnabledFor(logging.DEBUG):
|
||||
logging.debug(f'Got sql error "{e}" in _fetch_row_safely ignoring row.\n')
|
||||
continue
|
||||
|
||||
|
||||
@@ -518,7 +519,7 @@ def _get_reactions(db, data):
|
||||
if c.fetchone()[0] == 0:
|
||||
return
|
||||
|
||||
logger.info("Processing reactions...\r")
|
||||
logging.info("Processing reactions...", extra={"clear": True})
|
||||
|
||||
c.execute("""
|
||||
SELECT
|
||||
@@ -539,7 +540,7 @@ def _get_reactions(db, data):
|
||||
ON chat.jid_row_id = chat_jid._id
|
||||
""")
|
||||
except sqlite3.OperationalError:
|
||||
logger.warning(f"Could not fetch reactions (schema might be too old or incompatible){CLEAR_LINE}")
|
||||
logging.warning(f"Could not fetch reactions (schema might be too old or incompatible)")
|
||||
return
|
||||
|
||||
rows = c.fetchall()
|
||||
@@ -574,7 +575,7 @@ def _get_reactions(db, data):
|
||||
message.reactions[sender_name] = reaction
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} reactions in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} reactions in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separate_media=True, fix_dot_files=False):
|
||||
@@ -595,7 +596,7 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
||||
try:
|
||||
content_cursor = _get_media_cursor_legacy(c, filter_empty, filter_date, filter_chat)
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.debug(f'Got sql error "{e}" in _get_media_cursor_legacy trying fallback.\n')
|
||||
logging.debug(f'Got sql error "{e}" in _get_media_cursor_legacy trying fallback.\n')
|
||||
content_cursor = _get_media_cursor_new(c, filter_empty, filter_date, filter_chat)
|
||||
|
||||
content = content_cursor.fetchone()
|
||||
@@ -609,7 +610,7 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
||||
_process_single_media(data, content, media_folder, mime, separate_media, fix_dot_files)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} media in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} media in {convert_time_unit(total_time)}")
|
||||
|
||||
# Helper functions for media processing
|
||||
|
||||
@@ -637,7 +638,7 @@ def _get_media_count(cursor, filter_empty, filter_date, filter_chat):
|
||||
{include_filter}
|
||||
{exclude_filter}""")
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.debug(f'Got sql error "{e}" in _get_media_count trying fallback.\n')
|
||||
logging.debug(f'Got sql error "{e}" in _get_media_count trying fallback.\n')
|
||||
empty_filter = get_cond_for_empty(filter_empty, "jid.raw_string", "broadcast")
|
||||
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
||||
include_filter = get_chat_condition(
|
||||
@@ -814,7 +815,7 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
||||
try:
|
||||
rows = _execute_vcard_query_modern(c, filter_date, filter_chat, filter_empty)
|
||||
except sqlite3.OperationalError as e:
|
||||
logger.debug(f'Got sql error "{e}" in _execute_vcard_query_modern trying fallback.\n')
|
||||
logging.debug(f'Got sql error "{e}" in _execute_vcard_query_modern trying fallback.\n')
|
||||
rows = _execute_vcard_query_legacy(c, filter_date, filter_chat, filter_empty)
|
||||
|
||||
total_row_number = len(rows)
|
||||
@@ -828,7 +829,7 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
||||
_process_vcard_row(row, path, data)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} vCards in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} vCards in {convert_time_unit(total_time)}")
|
||||
|
||||
def _execute_vcard_query_modern(c, filter_date, filter_chat, filter_empty):
|
||||
"""Execute vCard query for modern WhatsApp database schema."""
|
||||
@@ -935,7 +936,7 @@ def calls(db, data, timezone_offset, filter_chat):
|
||||
if total_row_number == 0:
|
||||
return
|
||||
|
||||
logger.info(f"Processing calls...({total_row_number})\r")
|
||||
logging.info(f"Processing calls...({total_row_number})", extra={"clear": True})
|
||||
|
||||
# Fetch call data
|
||||
calls_data = _fetch_calls_data(c, filter_chat)
|
||||
@@ -952,7 +953,7 @@ def calls(db, data, timezone_offset, filter_chat):
|
||||
|
||||
# Add the calls chat to the data
|
||||
data.add_chat("000000000000000", chat)
|
||||
logger.info(f"Processed {total_row_number} calls in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} calls in {convert_time_unit(total_time)}")
|
||||
|
||||
def _get_calls_count(c, filter_chat):
|
||||
"""Get the count of call records that match the filter."""
|
||||
@@ -1128,7 +1129,7 @@ def create_html(
|
||||
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Generated {total_row_number} chats in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Generated {total_row_number} chats in {convert_time_unit(total_time)}")
|
||||
|
||||
def _generate_single_chat(current_chat, safe_file_name, name, contact, output_folder, template, w3css, headline):
|
||||
"""Generate a single HTML file for a chat."""
|
||||
|
||||
@@ -6,10 +6,9 @@ from datetime import datetime
|
||||
from mimetypes import MimeTypes
|
||||
from tqdm import tqdm
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, Device, convert_time_unit
|
||||
from Whatsapp_Chat_Exporter.utility import Device, convert_time_unit
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def messages(path, data, assume_first_as_me=False):
|
||||
@@ -43,7 +42,7 @@ def messages(path, data, assume_first_as_me=False):
|
||||
)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} messages & media in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} messages & media in {convert_time_unit(total_time)}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@ from pathlib import Path
|
||||
from mimetypes import MimeTypes
|
||||
from markupsafe import escape as htmle
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, CLEAR_LINE, get_chat_condition, Device
|
||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, get_chat_condition, Device
|
||||
from Whatsapp_Chat_Exporter.utility import bytes_to_readable, convert_time_unit, safe_name
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def contacts(db, data):
|
||||
@@ -21,7 +20,7 @@ def contacts(db, data):
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
logger.info(f"Pre-processing contacts...({total_row_number})\r")
|
||||
logging.info(f"Pre-processing contacts...({total_row_number})", extra={"clear": True})
|
||||
|
||||
c.execute("""SELECT ZWHATSAPPID, ZABOUTTEXT FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
|
||||
with tqdm(total=total_row_number, desc="Processing contacts", unit="contact", leave=False) as pbar:
|
||||
@@ -35,7 +34,7 @@ def contacts(db, data):
|
||||
data.add_chat(zwhatsapp_id, current_chat)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Pre-processed {total_row_number} contacts in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Pre-processed {total_row_number} contacts in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
def process_contact_avatars(current_chat, media_folder, contact_id):
|
||||
@@ -132,7 +131,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
||||
process_contact_avatars(current_chat, media_folder, contact_id)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} contacts in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} contacts in {convert_time_unit(total_time)}")
|
||||
|
||||
# Get message count
|
||||
message_count_query = f"""
|
||||
@@ -149,7 +148,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
||||
"""
|
||||
c.execute(message_count_query)
|
||||
total_row_number = c.fetchone()[0]
|
||||
logger.info(f"Processing messages...(0/{total_row_number})\r")
|
||||
logging.info(f"Processing messages...(0/{total_row_number})", extra={"clear": True})
|
||||
|
||||
# Fetch messages
|
||||
messages_query = f"""
|
||||
@@ -226,7 +225,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
||||
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} messages in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} messages in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
def process_message_data(message, content, is_group_message, data, message_map, no_reply):
|
||||
@@ -340,7 +339,7 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
||||
"""
|
||||
c.execute(media_count_query)
|
||||
total_row_number = c.fetchone()[0]
|
||||
logger.info(f"Processing media...(0/{total_row_number})\r")
|
||||
logging.info(f"Processing media...(0/{total_row_number})", extra={"clear": True})
|
||||
|
||||
# Fetch media items
|
||||
media_query = f"""
|
||||
@@ -373,7 +372,7 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
||||
process_media_item(content, data, media_folder, mime, separate_media, fix_dot_files)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} media in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} media in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
def process_media_item(content, data, media_folder, mime, separate_media, fix_dot_files=False):
|
||||
@@ -462,7 +461,7 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
||||
c.execute(vcard_query)
|
||||
contents = c.fetchall()
|
||||
total_row_number = len(contents)
|
||||
logger.info(f"Processing vCards...(0/{total_row_number})\r")
|
||||
logging.info(f"Processing vCards...(0/{total_row_number})", extra={"clear": True})
|
||||
|
||||
# Create vCards directory
|
||||
path = f'{media_folder}/Message/vCards'
|
||||
@@ -474,7 +473,7 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
||||
process_vcard_item(content, path, data)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Processed {total_row_number} vCards in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} vCards in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
def process_vcard_item(content, path, data):
|
||||
@@ -566,7 +565,7 @@ def calls(db, data, timezone_offset, filter_chat):
|
||||
|
||||
# Add calls chat to data
|
||||
data.add_chat("000000000000000", chat)
|
||||
logger.info(f"Processed {total_row_number} calls in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Processed {total_row_number} calls in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
def process_call_record(content, chat, data, timezone_offset):
|
||||
|
||||
@@ -8,7 +8,7 @@ import getpass
|
||||
from sys import exit, platform as osname
|
||||
import sys
|
||||
from tqdm import tqdm
|
||||
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, WhatsAppIdentifier, convert_time_unit
|
||||
from Whatsapp_Chat_Exporter.utility import WhatsAppIdentifier, convert_time_unit
|
||||
from Whatsapp_Chat_Exporter.bplist import BPListReader
|
||||
try:
|
||||
from iphone_backup_decrypt import EncryptedBackup, RelativePath
|
||||
@@ -18,7 +18,6 @@ else:
|
||||
support_encrypted = True
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackupExtractor:
|
||||
@@ -60,7 +59,7 @@ class BackupExtractor:
|
||||
return False
|
||||
except sqlite3.DatabaseError as e:
|
||||
if str(e) == "authorization denied" and osname == "darwin":
|
||||
logger.error(
|
||||
logging.error(
|
||||
"You don't have permission to access the backup database. Please"
|
||||
"check your permissions or try moving the backup to somewhere else."
|
||||
)
|
||||
@@ -73,13 +72,13 @@ class BackupExtractor:
|
||||
Handles the extraction of data from an encrypted iOS backup.
|
||||
"""
|
||||
if not support_encrypted:
|
||||
logger.error("You don't have the dependencies to handle encrypted backup."
|
||||
logging.error("You don't have the dependencies to handle encrypted backup."
|
||||
"Read more on how to deal with encrypted backup:"
|
||||
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/blob/main/README.md#usage"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Encryption detected on the backup!{CLEAR_LINE}")
|
||||
logging.info(f"Encryption detected on the backup!")
|
||||
password = getpass.getpass("Enter the password for the backup:")
|
||||
sys.stdout.write("\033[F\033[K")
|
||||
sys.stdout.flush()
|
||||
@@ -93,7 +92,7 @@ class BackupExtractor:
|
||||
Args:
|
||||
password (str): The password for the encrypted backup.
|
||||
"""
|
||||
logger.info(f"Trying to open the iOS backup...{CLEAR_LINE}")
|
||||
logging.info(f"Trying to open the iOS backup...")
|
||||
self.backup = EncryptedBackup(
|
||||
backup_directory=self.base_dir,
|
||||
passphrase=password,
|
||||
@@ -101,8 +100,8 @@ class BackupExtractor:
|
||||
check_same_thread=False,
|
||||
decrypt_chunk_size=self.decrypt_chunk_size,
|
||||
)
|
||||
logger.info(f"iOS backup is opened successfully{CLEAR_LINE}")
|
||||
logger.info("Decrypting WhatsApp database...\r")
|
||||
logging.info(f"iOS backup is opened successfully")
|
||||
logging.info("Decrypting WhatsApp database...", extra={"clear": True})
|
||||
try:
|
||||
self.backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_MESSAGES,
|
||||
@@ -120,17 +119,17 @@ class BackupExtractor:
|
||||
output_filename=self.identifiers.CALL,
|
||||
)
|
||||
except ValueError:
|
||||
logger.error("Failed to decrypt backup: incorrect password?")
|
||||
logging.error("Failed to decrypt backup: incorrect password?")
|
||||
exit(7)
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
logging.error(
|
||||
"Essential WhatsApp files are missing from the iOS backup. "
|
||||
"Perhapse you enabled end-to-end encryption for the backup? "
|
||||
"See https://wts.knugi.dev/docs.html?dest=iose2e"
|
||||
)
|
||||
exit(6)
|
||||
else:
|
||||
logger.info(f"WhatsApp database decrypted successfully{CLEAR_LINE}")
|
||||
logging.info(f"WhatsApp database decrypted successfully")
|
||||
|
||||
def _extract_decrypted_files(self):
|
||||
"""Extract all WhatsApp files after decryption"""
|
||||
@@ -150,7 +149,7 @@ class BackupExtractor:
|
||||
)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
pbar.close()
|
||||
logger.info(f"All required files are decrypted and extracted in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"All required files are decrypted and extracted in {convert_time_unit(total_time)}")
|
||||
|
||||
def _extract_unencrypted_backup(self):
|
||||
"""
|
||||
@@ -169,10 +168,10 @@ class BackupExtractor:
|
||||
|
||||
if not os.path.isfile(wts_db_path):
|
||||
if self.identifiers is WhatsAppIdentifier:
|
||||
logger.error("WhatsApp database not found.")
|
||||
logging.error("WhatsApp database not found.")
|
||||
else:
|
||||
logger.error("WhatsApp Business database not found.")
|
||||
logger.error(
|
||||
logging.error("WhatsApp Business database not found.")
|
||||
logging.error(
|
||||
"Essential WhatsApp files are missing from the iOS backup. "
|
||||
"Perhapse you enabled end-to-end encryption for the backup? "
|
||||
"See https://wts.knugi.dev/docs.html?dest=iose2e"
|
||||
@@ -182,12 +181,12 @@ class BackupExtractor:
|
||||
shutil.copyfile(wts_db_path, self.identifiers.MESSAGE)
|
||||
|
||||
if not os.path.isfile(contact_db_path):
|
||||
logger.warning(f"Contact database not found. Skipping...{CLEAR_LINE}")
|
||||
logging.warning(f"Contact database not found. Skipping...")
|
||||
else:
|
||||
shutil.copyfile(contact_db_path, self.identifiers.CONTACT)
|
||||
|
||||
if not os.path.isfile(call_db_path):
|
||||
logger.warning(f"Call database not found. Skipping...{CLEAR_LINE}")
|
||||
logging.warning(f"Call database not found. Skipping...")
|
||||
else:
|
||||
shutil.copyfile(call_db_path, self.identifiers.CALL)
|
||||
|
||||
@@ -236,7 +235,7 @@ class BackupExtractor:
|
||||
os.utime(destination, (modification, modification))
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Extracted {total_row_number} WhatsApp files in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Extracted {total_row_number} WhatsApp files in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
def extract_media(base_dir, identifiers, decrypt_chunk_size):
|
||||
|
||||
@@ -30,9 +30,7 @@ except ImportError:
|
||||
MAX_SIZE = 4 * 1024 * 1024 # Default 4MB
|
||||
ROW_SIZE = 0x3D0
|
||||
CURRENT_TZ_OFFSET = datetime.now().astimezone().utcoffset().seconds / 3600
|
||||
CLEAR_LINE = "\x1b[K\n"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def convert_time_unit(time_second: int) -> str:
|
||||
@@ -159,39 +157,40 @@ def determine_day(last: int, current: int) -> Optional[datetime.date]:
|
||||
return current
|
||||
|
||||
|
||||
def check_update():
|
||||
def check_update(include_beta: bool = False) -> int:
|
||||
import urllib.request
|
||||
import json
|
||||
import importlib
|
||||
from sys import platform
|
||||
from packaging import version
|
||||
|
||||
PACKAGE_JSON = "https://pypi.org/pypi/whatsapp-chat-exporter/json"
|
||||
try:
|
||||
raw = urllib.request.urlopen(PACKAGE_JSON)
|
||||
except Exception:
|
||||
logger.error("Failed to check for updates.")
|
||||
logging.error("Failed to check for updates.")
|
||||
return 1
|
||||
else:
|
||||
with raw:
|
||||
package_info = json.load(raw)
|
||||
latest_version = tuple(
|
||||
map(int, package_info["info"]["version"].split(".")))
|
||||
__version__ = importlib.metadata.version("whatsapp_chat_exporter")
|
||||
current_version = tuple(map(int, __version__.split(".")))
|
||||
if include_beta:
|
||||
all_versions = [version.parse(v) for v in package_info["releases"].keys()]
|
||||
latest_version = max(all_versions, key=lambda v: (v.release, v.pre))
|
||||
else:
|
||||
latest_version = version.parse(package_info["info"]["version"])
|
||||
current_version = version.parse(importlib.metadata.version("whatsapp_chat_exporter"))
|
||||
if current_version < latest_version:
|
||||
logger.info(
|
||||
logging.info(
|
||||
"===============Update===============\n"
|
||||
"A newer version of WhatsApp Chat Exporter is available.\n"
|
||||
f"Current version: {__version__}\n"
|
||||
f"Latest version: {package_info['info']['version']}\n"
|
||||
f"Current version: {current_version}\n"
|
||||
f"Latest version: {latest_version}"
|
||||
)
|
||||
if platform == "win32":
|
||||
logger.info("Update with: pip install --upgrade whatsapp-chat-exporter\n")
|
||||
else:
|
||||
logger.info("Update with: pip3 install --upgrade whatsapp-chat-exporter\n")
|
||||
logger.info("====================================\n")
|
||||
pip_cmd = "pip" if platform == "win32" else "pip3"
|
||||
logging.info(f"Update with: {pip_cmd} install --upgrade whatsapp-chat-exporter {'--pre' if include_beta else ''}")
|
||||
logging.info("====================================")
|
||||
else:
|
||||
logger.info("You are using the latest version of WhatsApp Chat Exporter.\n")
|
||||
logging.info("You are using the latest version of WhatsApp Chat Exporter.")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -254,7 +253,7 @@ def import_from_json(json_file: str, data: ChatCollection):
|
||||
data.add_chat(jid, chat)
|
||||
pbar.update(1)
|
||||
total_time = pbar.format_dict['elapsed']
|
||||
logger.info(f"Imported {total_row_number} chats from JSON in {convert_time_unit(total_time)}{CLEAR_LINE}")
|
||||
logging.info(f"Imported {total_row_number} chats from JSON in {convert_time_unit(total_time)}")
|
||||
|
||||
|
||||
class IncrementalMerger:
|
||||
@@ -284,10 +283,10 @@ class IncrementalMerger:
|
||||
"""
|
||||
json_files = [f for f in os.listdir(source_dir) if f.endswith('.json')]
|
||||
if not json_files:
|
||||
logger.error("No JSON files found in the source directory.")
|
||||
logging.error("No JSON files found in the source directory.")
|
||||
raise SystemExit(1)
|
||||
|
||||
logger.info("JSON files found:", json_files)
|
||||
logging.debug("JSON files found:", json_files)
|
||||
return json_files
|
||||
|
||||
def _copy_new_file(self, source_path: str, target_path: str, target_dir: str, json_file: str) -> None:
|
||||
@@ -299,7 +298,7 @@ class IncrementalMerger:
|
||||
target_dir: Target directory path.
|
||||
json_file: Name of the JSON file.
|
||||
"""
|
||||
logger.info(f"Copying '{json_file}' to target directory...")
|
||||
logging.info(f"Copying '{json_file}' to target directory...")
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
shutil.copy2(source_path, target_path)
|
||||
|
||||
@@ -389,7 +388,7 @@ class IncrementalMerger:
|
||||
target_path: Path to target file.
|
||||
json_file: Name of the JSON file.
|
||||
"""
|
||||
logger.info(f"Merging '{json_file}' with existing file in target directory...")
|
||||
logging.info(f"Merging '{json_file}' with existing file in target directory...", extra={"clear": True})
|
||||
|
||||
source_data = self._load_chat_data(source_path)
|
||||
target_data = self._load_chat_data(target_path)
|
||||
@@ -401,10 +400,10 @@ class IncrementalMerger:
|
||||
merged_data = self._serialize_chats(merged_chats)
|
||||
|
||||
if self._has_changes(merged_data, target_data):
|
||||
logger.info(f"Changes detected in '{json_file}', updating target file...")
|
||||
logging.info(f"Changes detected in '{json_file}', updating target file...")
|
||||
self._save_merged_data(target_path, merged_data)
|
||||
else:
|
||||
logger.info(f"No changes detected in '{json_file}', skipping update.")
|
||||
logging.info(f"No changes detected in '{json_file}', skipping update.")
|
||||
|
||||
def _should_copy_media_file(self, source_file: str, target_file: str) -> bool:
|
||||
"""Check if media file should be copied.
|
||||
@@ -429,7 +428,7 @@ class IncrementalMerger:
|
||||
source_media_path = os.path.join(source_dir, media_dir)
|
||||
target_media_path = os.path.join(target_dir, media_dir)
|
||||
|
||||
logger.info(f"Merging media directories. Source: {source_media_path}, target: {target_media_path}")
|
||||
logging.info(f"Merging media directories. Source: {source_media_path}, target: {target_media_path}")
|
||||
|
||||
if not os.path.exists(source_media_path):
|
||||
return
|
||||
@@ -444,7 +443,7 @@ class IncrementalMerger:
|
||||
target_file = os.path.join(target_root, file)
|
||||
|
||||
if self._should_copy_media_file(source_file, target_file):
|
||||
logger.info(f"Copying '{source_file}' to '{target_file}'...")
|
||||
logging.debug(f"Copying '{source_file}' to '{target_file}'...")
|
||||
shutil.copy2(source_file, target_file)
|
||||
|
||||
def merge(self, source_dir: str, target_dir: str, media_dir: str) -> None:
|
||||
@@ -457,6 +456,7 @@ class IncrementalMerger:
|
||||
"""
|
||||
json_files = self._get_json_files(source_dir)
|
||||
|
||||
logging.info("Starting incremental merge process...")
|
||||
for json_file in json_files:
|
||||
source_path = os.path.join(source_dir, json_file)
|
||||
target_path = os.path.join(target_dir, json_file)
|
||||
@@ -893,7 +893,7 @@ def get_chat_type(chat_id: str) -> str:
|
||||
return "status_broadcast"
|
||||
elif chat_id.endswith("@broadcast"):
|
||||
return "broadcast_channel"
|
||||
logger.warning(f"Unknown chat type for {chat_id}, defaulting to private_group{CLEAR_LINE}")
|
||||
logging.warning(f"Unknown chat type for {chat_id}, defaulting to private_group")
|
||||
return "private_group"
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ import re
|
||||
import quopri
|
||||
from typing import List, TypedDict
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore
|
||||
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, Device
|
||||
from Whatsapp_Chat_Exporter.utility import Device
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExportedContactNumbers(TypedDict):
|
||||
@@ -45,9 +44,9 @@ def decode_quoted_printable(value: str, charset: str) -> str:
|
||||
return bytes_val.decode(charset, errors="replace")
|
||||
except Exception:
|
||||
# Fallback: return the original value if decoding fails
|
||||
logger.warning(
|
||||
logging.warning(
|
||||
f"Failed to decode quoted-printable value: {value}, "
|
||||
f"charset: {charset}. Please report this issue.{CLEAR_LINE}"
|
||||
f"charset: {charset}. Please report this issue."
|
||||
)
|
||||
return value
|
||||
|
||||
@@ -176,7 +175,7 @@ def read_vcards_file(vcf_file_path, default_country_code: str):
|
||||
if contact := process_vcard_entry(vcard):
|
||||
contacts.append(contact)
|
||||
|
||||
logger.info(f"Imported {len(contacts)} contacts/vcards{CLEAR_LINE}")
|
||||
logging.info(f"Imported {len(contacts)} contacts/vcards")
|
||||
return map_number_to_name(contacts, default_country_code)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "whatsapp-chat-exporter"
|
||||
version = "0.13.0rc2"
|
||||
version = "0.13.0"
|
||||
description = "A Whatsapp database parser that provides history of your Whatsapp conversations in HTML and JSON. Android, iOS, iPadOS, Crypt12, Crypt14, Crypt15 supported."
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
@@ -42,13 +42,12 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
android_backup = ["pycryptodome", "javaobj-py3"]
|
||||
ios_backup = ["iphone_backup_decrypt @ git+https://github.com/KnugiHK/iphone_backup_decrypt"]
|
||||
crypt12 = ["pycryptodome"]
|
||||
crypt14 = ["pycryptodome"]
|
||||
crypt15 = ["pycryptodome", "javaobj-py3"]
|
||||
all = ["pycryptodome", "javaobj-py3", "iphone_backup_decrypt @ git+https://github.com/KnugiHK/iphone_backup_decrypt"]
|
||||
everything = ["pycryptodome", "javaobj-py3", "iphone_backup_decrypt @ git+https://github.com/KnugiHK/iphone_backup_decrypt"]
|
||||
backup = ["pycryptodome", "javaobj-py3", "iphone_backup_decrypt @ git+https://github.com/KnugiHK/iphone_backup_decrypt"]
|
||||
all = ["pycryptodome", "javaobj-py3"]
|
||||
everything = ["pycryptodome", "javaobj-py3"]
|
||||
backup = ["pycryptodome", "javaobj-py3"]
|
||||
|
||||
[project.scripts]
|
||||
wtsexporter = "Whatsapp_Chat_Exporter.__main__:main"
|
||||
|
||||
Reference in New Issue
Block a user