mirror of
https://github.com/KnugiHK/WhatsApp-Chat-Exporter.git
synced 2026-07-07 22:55:09 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49ad409293 | |||
| ea1f8b4068 | |||
| 05184d4a27 | |||
| 3264fa44ec | |||
| 3950797b7b | |||
| 0b34f45dde | |||
| 01852b1d61 | |||
| df8a8197e9 | |||
| 0aec1075a7 | |||
| d48096a0a5 | |||
| 24bad20b4d | |||
| 9be38fce53 | |||
| 019edd5825 | |||
| 5caf9df045 | |||
| de693f9df1 | |||
| 46df657b9b | |||
| 713d7ec43b | |||
| 8e6271ba8d | |||
| 31a5f89a99 | |||
| 7268845986 | |||
| ae9f3a99d3 | |||
| b0abda301a | |||
| 26ae5cecdf | |||
| 3be2c0f1fc | |||
| 13b468d463 | |||
| cda80b86cc | |||
| 7ed5ed80f6 | |||
| a5a511ca2c |
@@ -1,79 +0,0 @@
|
||||
name: Compile standalone binary
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
|
||||
pip install .
|
||||
- name: Build binary with Nuitka
|
||||
run: |
|
||||
python -m nuitka --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --follow-imports Whatsapp_Chat_Exporter/__main__.py
|
||||
cp __main__.bin wtsexporter_linux_x64
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: binary-linux
|
||||
path: |
|
||||
./wtsexporter_linux_x64
|
||||
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
|
||||
pip install .
|
||||
- name: Build binary with Nuitka
|
||||
run: |
|
||||
python -m nuitka --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --assume-yes-for-downloads --follow-imports Whatsapp_Chat_Exporter\__main__.py
|
||||
copy __main__.exe wtsexporter_x64.exe
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: binary-windows
|
||||
path: |
|
||||
.\wtsexporter_x64.exe
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
|
||||
pip install .
|
||||
- name: Build binary with Nuitka
|
||||
run: |
|
||||
python -m nuitka --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --follow-imports Whatsapp_Chat_Exporter/__main__.py
|
||||
cp __main__.bin wtsexporter_macos_x64
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: binary-macos
|
||||
path: |
|
||||
./wtsexporter_macos_x64
|
||||
@@ -1,36 +0,0 @@
|
||||
# This workflow will upload a Python Package using Twine when a release is created
|
||||
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
- name: Publish package
|
||||
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 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
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,142 +0,0 @@
|
||||
# Whatsapp-Chat-Exporter
|
||||
[](https://pypi.org/project/whatsapp-chat-exporter/)
|
||||

|
||||
[](https://pypi.org/project/Whatsapp-Chat-Exporter/)
|
||||
|
||||
A customizable Android and iPhone Whatsapp database parser that will give you the history of your Whatsapp conversations in HTML and JSON. Inspired by [Telegram Chat Export Tool](https://telegram.org/blog/export-and-more).
|
||||
**If you plan to uninstall WhatsApp or delete your WhatsApp account, please make a backup of your WhatsApp database. You may want to use this exporter again on the same database in the future as the exporter develops**
|
||||
|
||||
# Usage
|
||||
**Usage in README may be removed in the future. Check the usage in [Wiki](https://github.com/KnugiHK/Whatsapp-Chat-Exporter/wiki)**.
|
||||
|
||||
**If you want to use the old release (< 0.5) of the exporter, please follow the [old usage guide](https://github.com/KnugiHK/Whatsapp-Chat-Exporter/wiki/Old-Usage#usage)**.
|
||||
|
||||
First, install the exporter by:
|
||||
```shell
|
||||
pip install whatsapp-chat-exporter
|
||||
pip install whatsapp-chat-exporter[android_backup] :; # Optional, if you want it to support decrypting Android WhatsApp backup.
|
||||
```
|
||||
Then, create a working directory in somewhere you want
|
||||
```shell
|
||||
mkdir working_wts
|
||||
cd working_wts
|
||||
```
|
||||
## Working with Android
|
||||
### Unencrypted WhatsApp database
|
||||
Extract the WhatsApp database with whatever means, one possible means is to use the [WhatsApp-Key-DB-Extractor](https://github.com/KnugiHK/WhatsApp-Key-DB-Extractor)
|
||||
|
||||
After you obtain your WhatsApp database, copy the WhatsApp database and media folder to the working directory. The database is called msgstore.db. If you also want the name of your contacts, get the contact database, which is called wa.db. And copy the WhatsApp (Media) directory from your phone directly.
|
||||
|
||||
And now, you should have something like this in the working directory.
|
||||
|
||||

|
||||
#### Extracting
|
||||
Simply invoke the following command from shell.
|
||||
```sh
|
||||
wtsexporter -a
|
||||
```
|
||||
|
||||
### Encrypted Android WhatsApp Backup
|
||||
In order to support the decryption, install pycryptodome if it is not installed
|
||||
```sh
|
||||
pip install pycryptodome # Or
|
||||
pip install whatsapp-chat-exporter["android_backup"] # install along with this software
|
||||
```
|
||||
### Crypt15 is now the easiest way to decrypt a backup. If you have the 32 bytes hex key generated when you enable End-to-End encrypted backup, you can use it to decrypt the backup. If you do not have the 32 bytes hex key, you can still use the key file extracted just like extacting key file for Crypt12 and Crypt14 to decrypt the backup.
|
||||
#### Crypt12 or Crypt14
|
||||
Place the decryption key file (key) and the encrypted WhatsApp Backup (msgstore.db.crypt14) in the working directory. If you also want the name of your contacts, get the contact database, which is called wa.db. And copy the WhatsApp (Media) directory from your phone directly.
|
||||
|
||||
And now, you should have something like this in the working directory.
|
||||
|
||||

|
||||
#### Extracting
|
||||
Simply invoke the following command from shell.
|
||||
```sh
|
||||
wtsexporter -a -k key -b msgstore.db.crypt14
|
||||
```
|
||||
|
||||
#### Crypt15 (End-to-End Encrypted Backup)
|
||||
To support Crypt15 backup, install javaobj-py3 if it is not installed
|
||||
```sh
|
||||
pip install javaobj-py3 # Or
|
||||
pip install whatsapp-chat-exporter["crypt15"] # install along with this software
|
||||
```
|
||||
Place the encrypted WhatsApp Backup (msgstore.db.crypt15) in the working directory. If you also want the name of your contacts, get the contact database, which is called wa.db. And copy the WhatsApp (Media) directory from your phone directly.
|
||||
If you do not have the 32 bytes hex key (64 hexdigits), place the decryption key file (encrypted_backup.key) extracted from Android. If you gave the 32 bytes hex key, simply put the key in the shell.
|
||||
|
||||
Now, you should have something like this in the working directory (if you do not have 32 bytes hex key).
|
||||
|
||||

|
||||
##### Extracting
|
||||
If you do not have 32 bytes hex key but have the key file available, simply invoke the following command from shell.
|
||||
```sh
|
||||
wtsexporter -a -k encrypted_backup.key -b msgstore.db.crypt15
|
||||
```
|
||||
If you have the 32 bytes hex key, simply put the hex key in the -k option and invoke the command from shell like this:
|
||||
```sh
|
||||
wtsexporter -a -k 432435053b5204b08e5c3823423399aa30ff061435ab89bc4e6713969cdaa5a8 -b msgstore.db.crypt15
|
||||
```
|
||||
|
||||
## Working with iPhone
|
||||
Do an iPhone Backup with iTunes first.
|
||||
### Encrypted iPhone Backup
|
||||
**If you are working on unencrypted iPhone backup, skip this**
|
||||
|
||||
If you want to work on an encrypted iPhone 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 git+https://github.com/KnugiHK/iphone_backup_decrypt
|
||||
```
|
||||
### Extracting
|
||||
Simply invoke the following command from shell, remember to replace the username and device id correspondingly in the command.
|
||||
```sh
|
||||
wtsexporter -i -b "C:\Users\[Username]\AppData\Roaming\Apple Computer\MobileSync\Backup\[device id]"
|
||||
```
|
||||
|
||||
## Results
|
||||
After extracting, you will get these:
|
||||
#### Private Message
|
||||

|
||||
|
||||
#### Group Message
|
||||

|
||||
|
||||
## More options
|
||||
Invoke the wtsexporter with --help option will show you all options available.
|
||||
```sh
|
||||
> wtsexporter --help
|
||||
usage: wtsexporter [options]
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
-a, --android Define the target as Android
|
||||
-i, --iphone, --ios Define the target as iPhone
|
||||
-w WA, --wa WA Path to contact database (default: wa.db/ContactsV2.sqlite)
|
||||
-m MEDIA, --media MEDIA
|
||||
Path to WhatsApp media folder (default: WhatsApp)
|
||||
-b BACKUP, --backup BACKUP
|
||||
Path to Android (must be used together with -k)/iPhone WhatsApp backup
|
||||
-o OUTPUT, --output OUTPUT
|
||||
Output to specific directory (default: result)
|
||||
-j [JSON], --json [JSON]
|
||||
Save the result to a single JSON file (default if present: result.json)
|
||||
-d DB, --db DB Path to database file (default: msgstore.db/7c7fba66680ef796b916b067077cc246adacf01d)
|
||||
-k KEY, --key KEY Path to key file
|
||||
-t TEMPLATE, --template TEMPLATE
|
||||
Path to custom HTML template
|
||||
-e, --embedded Embed media into HTML file (not yet implemented)
|
||||
-s, --showkey Show the HEX key used to decrypt the database
|
||||
-c, --move-media Move the media directory to output directory if the flag is set, otherwise copy it
|
||||
--offline OFFLINE Relative path to offline static files
|
||||
--size SIZE, --output-size SIZE
|
||||
Maximum size of a single output file in bytes, 0 for auto (not yet implemented)
|
||||
--no-html Do not output html files
|
||||
--check-update Check for updates
|
||||
```
|
||||
|
||||
# To do
|
||||
See [issues](https://github.com/KnugiHK/Whatsapp-Chat-Exporter/issues).
|
||||
|
||||
# Copyright
|
||||
This is a MIT licensed project.
|
||||
|
||||
The Telegram Desktop's export is the reference for whatsapp.html in this repo
|
||||
@@ -1 +0,0 @@
|
||||
__version__ = "0.9.1"
|
||||
@@ -1,287 +0,0 @@
|
||||
try:
|
||||
from .__init__ import __version__
|
||||
except ImportError:
|
||||
from Whatsapp_Chat_Exporter.__init__ import __version__
|
||||
from Whatsapp_Chat_Exporter import extract, extract_iphone
|
||||
from Whatsapp_Chat_Exporter import extract_iphone_media
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore
|
||||
from Whatsapp_Chat_Exporter.utility import Crypt, check_update
|
||||
from argparse import ArgumentParser
|
||||
import os
|
||||
import sqlite3
|
||||
import shutil
|
||||
import json
|
||||
import string
|
||||
from sys import exit
|
||||
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser(
|
||||
description = 'A customizable Android and iPhone WhatsApp database parser that '
|
||||
'will give you the history of your WhatsApp conversations inHTML '
|
||||
'and JSON. Android Backup Crypt12, Crypt14 and Crypt15 supported.',
|
||||
epilog = f'WhatsApp Chat Exporter: {__version__} Licensed with MIT'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-a',
|
||||
'--android',
|
||||
dest='android',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Define the target as Android")
|
||||
parser.add_argument(
|
||||
'-i',
|
||||
'--iphone',
|
||||
'--ios',
|
||||
dest='iphone',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Define the target as iPhone/iPad")
|
||||
parser.add_argument(
|
||||
"-w",
|
||||
"--wa",
|
||||
dest="wa",
|
||||
default=None,
|
||||
help="Path to contact database (default: wa.db/ContactsV2.sqlite)")
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--media",
|
||||
dest="media",
|
||||
default=None,
|
||||
help="Path to WhatsApp media folder (default: WhatsApp)")
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--backup",
|
||||
dest="backup",
|
||||
default=None,
|
||||
help="Path to Android (must be used together "
|
||||
"with -k)/iPhone WhatsApp backup")
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
dest="output",
|
||||
default="result",
|
||||
help="Output to specific directory (default: result)")
|
||||
parser.add_argument(
|
||||
'-j',
|
||||
'--json',
|
||||
dest='json',
|
||||
nargs='?',
|
||||
default=None,
|
||||
type=str,
|
||||
const="result.json",
|
||||
help="Save the result to a single JSON file (default if present: result.json)")
|
||||
parser.add_argument(
|
||||
'-d',
|
||||
'--db',
|
||||
dest='db',
|
||||
default=None,
|
||||
help="Path to database file (default: msgstore.db/"
|
||||
"7c7fba66680ef796b916b067077cc246adacf01d)")
|
||||
parser.add_argument(
|
||||
'-k',
|
||||
'--key',
|
||||
dest='key',
|
||||
default=None,
|
||||
help="Path to key file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--template",
|
||||
dest="template",
|
||||
default=None,
|
||||
help="Path to custom HTML template"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-e",
|
||||
"--embedded",
|
||||
dest="embedded",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Embed media into HTML file (not yet implemented)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--showkey",
|
||||
dest="showkey",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Show the HEX key used to decrypt the database"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--move-media",
|
||||
dest="move_media",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Move the media directory to output directory if the flag is set, otherwise copy it"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--offline",
|
||||
dest="offline",
|
||||
default=None,
|
||||
help="Relative path to offline static files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--size",
|
||||
"--output-size",
|
||||
dest="size",
|
||||
default=None,
|
||||
help="Maximum size of a single output file in bytes, 0 for auto (not yet implemented)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-html",
|
||||
dest="no_html",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Do not output html files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-update",
|
||||
dest="check_update",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Check for updates"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check for updates
|
||||
if args.check_update:
|
||||
exit(check_update())
|
||||
|
||||
# Sanity checks
|
||||
if args.android and args.iphone:
|
||||
print("You must define only one device type.")
|
||||
exit(1)
|
||||
if not args.android and not args.iphone:
|
||||
print("You must define the device type.")
|
||||
exit(1)
|
||||
if args.no_html and not args.json:
|
||||
print("You must either specify a JSON output file or enable HTML output.")
|
||||
exit(1)
|
||||
|
||||
data = {}
|
||||
|
||||
if args.android:
|
||||
contacts = extract.contacts
|
||||
messages = extract.messages
|
||||
media = extract.media
|
||||
vcard = extract.vcard
|
||||
create_html = extract.create_html
|
||||
if args.db is None:
|
||||
msg_db = "msgstore.db"
|
||||
else:
|
||||
msg_db = args.db
|
||||
if args.key is not None:
|
||||
if args.backup is None:
|
||||
print("You must specify the backup file with -b")
|
||||
exit(1)
|
||||
print("Decryption key specified, decrypting WhatsApp backup...")
|
||||
if "crypt12" in args.backup:
|
||||
crypt = Crypt.CRYPT12
|
||||
elif "crypt14" in args.backup:
|
||||
crypt = Crypt.CRYPT14
|
||||
elif "crypt15" in args.backup:
|
||||
crypt = Crypt.CRYPT15
|
||||
if os.path.isfile(args.key):
|
||||
key = open(args.key, "rb")
|
||||
elif all(char in string.hexdigits for char in args.key):
|
||||
key = bytes.fromhex(args.key)
|
||||
db = open(args.backup, "rb").read()
|
||||
error = extract.decrypt_backup(db, key, msg_db, crypt, args.showkey)
|
||||
if error != 0:
|
||||
if error == 1:
|
||||
print("Dependencies of decrypt_backup and/or extract_encrypted_key"
|
||||
" are not present. For details, see README.md.")
|
||||
exit(3)
|
||||
elif error == 2:
|
||||
print("Failed when decompressing the decrypted backup. "
|
||||
"Possibly incorrect offsets used in decryption.")
|
||||
exit(4)
|
||||
else:
|
||||
print("Unknown error occurred.", error)
|
||||
exit(5)
|
||||
if args.wa is None:
|
||||
contact_db = "wa.db"
|
||||
else:
|
||||
contact_db = args.wa
|
||||
if args.media is None:
|
||||
args.media = "WhatsApp"
|
||||
|
||||
if os.path.isfile(contact_db):
|
||||
with sqlite3.connect(contact_db) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
contacts(db, data)
|
||||
|
||||
elif args.iphone:
|
||||
import sys
|
||||
if "--iphone" in sys.argv:
|
||||
print("WARNING: The --iphone flag is deprecated and will be removed in the future. Use --ios instead.")
|
||||
messages = extract_iphone.messages
|
||||
media = extract_iphone.media
|
||||
vcard = extract_iphone.vcard
|
||||
create_html = extract_iphone.create_html
|
||||
if args.backup is not None:
|
||||
extract_iphone_media.extract_media(args.backup)
|
||||
if args.db is None:
|
||||
msg_db = "7c7fba66680ef796b916b067077cc246adacf01d"
|
||||
else:
|
||||
msg_db = args.db
|
||||
if args.wa is None:
|
||||
contact_db = "ContactsV2.sqlite"
|
||||
else:
|
||||
contact_db = args.wa
|
||||
if args.media is None:
|
||||
args.media = "Message"
|
||||
|
||||
if os.path.isfile(msg_db):
|
||||
with sqlite3.connect(msg_db) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
messages(db, data)
|
||||
media(db, data, args.media)
|
||||
vcard(db, data)
|
||||
if not args.no_html:
|
||||
create_html(
|
||||
data,
|
||||
args.output,
|
||||
args.template,
|
||||
args.embedded,
|
||||
args.offline,
|
||||
args.size
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"The message database does not exist. You may specify the path "
|
||||
"to database file with option -d or check your provided path."
|
||||
)
|
||||
exit(2)
|
||||
|
||||
if os.path.isdir(args.media):
|
||||
if os.path.isdir(f"{args.output}/{args.media}"):
|
||||
print("Media directory already exists in output directory. Skipping...")
|
||||
else:
|
||||
if not args.move_media:
|
||||
print("Copying media directory...")
|
||||
shutil.copytree(args.media, f"{args.output}/WhatsApp")
|
||||
else:
|
||||
try:
|
||||
shutil.move(args.media, f"{args.output}/")
|
||||
except PermissionError:
|
||||
print("Cannot remove original WhatsApp directory. "
|
||||
"Perhaps the directory is opened?")
|
||||
|
||||
if args.json:
|
||||
if isinstance(data[next(iter(data))], ChatStore):
|
||||
data = {jik: chat.to_json() for jik, chat in data.items()}
|
||||
with open(args.json, "w") as f:
|
||||
data = json.dumps(data)
|
||||
print(f"\nWriting JSON file...({int(len(data)/1024/1024)}MB)")
|
||||
f.write(data)
|
||||
else:
|
||||
print()
|
||||
|
||||
print("Everything is done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,53 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
|
||||
class ChatStore():
|
||||
def __init__(self, name=None):
|
||||
if name is not None and not isinstance(name, str):
|
||||
raise TypeError("Name must be a string or None")
|
||||
self.name = name
|
||||
self.messages = {}
|
||||
|
||||
def add_message(self, id, message):
|
||||
if not isinstance(message, Message):
|
||||
raise TypeError("Chat must be a Chat object")
|
||||
self.messages[id] = message
|
||||
|
||||
def delete_message(self, id):
|
||||
if id in self.messages:
|
||||
del self.messages[id]
|
||||
|
||||
def to_json(self):
|
||||
serialized_msgs = {id : msg.to_json() for id,msg in self.messages.items()}
|
||||
return {'name' : self.name, 'messages' : serialized_msgs}
|
||||
|
||||
class Message():
|
||||
def __init__(self, from_me: Union[bool,int], timestamp: int, time: str, key_id: int):
|
||||
self.from_me = bool(from_me)
|
||||
self.timestamp = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||
self.time = datetime.fromtimestamp(time/1000).strftime("%H:%M")
|
||||
self.media = False
|
||||
self.key_id = key_id
|
||||
self.meta = False
|
||||
self.data = None
|
||||
self.sender = None
|
||||
# Extra
|
||||
self.reply = None
|
||||
self.quoted_data = None
|
||||
self.caption = None
|
||||
|
||||
def to_json(self):
|
||||
return {
|
||||
'from_me' : self.from_me,
|
||||
'timestamp' : self.timestamp,
|
||||
'time' : self.time,
|
||||
'media' : self.media,
|
||||
'key_id' : self.key_id,
|
||||
'meta' : self.meta,
|
||||
'data' : self.data,
|
||||
'sender' : self.sender,
|
||||
'reply' : self.reply,
|
||||
'quoted_data' : self.quoted_data,
|
||||
'caption' : self.caption
|
||||
}
|
||||
@@ -1,631 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import jinja2
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
import io
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
from mimetypes import MimeTypes
|
||||
from hashlib import sha256
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||
from Whatsapp_Chat_Exporter.utility import sanitize_except, determine_day, Crypt
|
||||
from Whatsapp_Chat_Exporter.utility import brute_force_offset, CRYPT14_OFFSETS
|
||||
|
||||
try:
|
||||
import zlib
|
||||
from Crypto.Cipher import AES
|
||||
except ModuleNotFoundError:
|
||||
support_backup = False
|
||||
else:
|
||||
support_backup = True
|
||||
try:
|
||||
import javaobj
|
||||
except ModuleNotFoundError:
|
||||
support_crypt15 = False
|
||||
else:
|
||||
support_crypt15 = True
|
||||
|
||||
|
||||
def _generate_hmac_of_hmac(key_stream):
|
||||
key = hmac.new(
|
||||
hmac.new(
|
||||
b'\x00' * 32,
|
||||
key_stream,
|
||||
sha256
|
||||
).digest(),
|
||||
b"backup encryption\x01",
|
||||
sha256
|
||||
)
|
||||
return key.digest(), key_stream
|
||||
|
||||
|
||||
def _extract_encrypted_key(keyfile):
|
||||
key_stream = b""
|
||||
for byte in javaobj.loads(keyfile):
|
||||
key_stream += byte.to_bytes(1, "big", signed=True)
|
||||
|
||||
return _generate_hmac_of_hmac(key_stream)
|
||||
|
||||
|
||||
def decrypt_backup(database, key, output, crypt=Crypt.CRYPT14, show_crypt15=False):
|
||||
if not support_backup:
|
||||
return 1
|
||||
if isinstance(key, io.IOBase):
|
||||
key = key.read()
|
||||
if crypt is not Crypt.CRYPT15:
|
||||
t1 = key[30:62]
|
||||
if crypt is not Crypt.CRYPT15 and len(key) != 158:
|
||||
raise ValueError("The key file must be 158 bytes")
|
||||
# Determine the IV and database offsets
|
||||
if crypt == Crypt.CRYPT14:
|
||||
if len(database) < 191:
|
||||
raise ValueError("The crypt14 file must be at least 191 bytes")
|
||||
current_try = 0
|
||||
offsets = CRYPT14_OFFSETS[current_try]
|
||||
t2 = database[15:47]
|
||||
iv = database[offsets["iv"]:offsets["iv"] + 16]
|
||||
db_ciphertext = database[offsets["db"]:]
|
||||
elif crypt == Crypt.CRYPT12:
|
||||
if len(database) < 67:
|
||||
raise ValueError("The crypt12 file must be at least 67 bytes")
|
||||
t2 = database[3:35]
|
||||
iv = database[51:67]
|
||||
db_ciphertext = database[67:-20]
|
||||
elif crypt == Crypt.CRYPT15:
|
||||
if not support_crypt15:
|
||||
return 1
|
||||
if len(database) < 131:
|
||||
raise ValueError("The crypt15 file must be at least 131 bytes")
|
||||
t1 = t2 = None
|
||||
iv = database[8:24]
|
||||
db_offset = database[0] + 2 # Skip protobuf + protobuf size and backup type
|
||||
db_ciphertext = database[db_offset:]
|
||||
|
||||
if t1 != t2:
|
||||
raise ValueError("The signature of key file and backup file mismatch")
|
||||
|
||||
if crypt == Crypt.CRYPT15:
|
||||
if len(key) == 32:
|
||||
main_key, hex_key = _generate_hmac_of_hmac(key)
|
||||
else:
|
||||
main_key, hex_key = _extract_encrypted_key(key)
|
||||
if show_crypt15:
|
||||
hex_key = [hex_key.hex()[c:c+4] for c in range(0, len(hex_key.hex()), 4)]
|
||||
print("The HEX key of the crypt15 backup is: " + ' '.join(hex_key))
|
||||
else:
|
||||
main_key = key[126:]
|
||||
decompressed = False
|
||||
while not decompressed:
|
||||
cipher = AES.new(main_key, AES.MODE_GCM, iv)
|
||||
db_compressed = cipher.decrypt(db_ciphertext)
|
||||
try:
|
||||
db = zlib.decompress(db_compressed)
|
||||
except zlib.error:
|
||||
if crypt == Crypt.CRYPT14:
|
||||
current_try += 1
|
||||
if current_try < len(CRYPT14_OFFSETS):
|
||||
offsets = CRYPT14_OFFSETS[current_try]
|
||||
iv = database[offsets["iv"]:offsets["iv"] + 16]
|
||||
db_ciphertext = database[offsets["db"]:]
|
||||
continue
|
||||
else:
|
||||
print("Common offsets are not applicable to "
|
||||
"your backup. Trying to brute force it...")
|
||||
for start_iv, end_iv, start_db in brute_force_offset():
|
||||
iv = database[start_iv:end_iv]
|
||||
db_ciphertext = database[start_db:]
|
||||
cipher = AES.new(main_key, AES.MODE_GCM, iv)
|
||||
db_compressed = cipher.decrypt(db_ciphertext)
|
||||
try:
|
||||
db = zlib.decompress(db_compressed)
|
||||
except zlib.error:
|
||||
continue
|
||||
else:
|
||||
decompressed = True
|
||||
print(
|
||||
f"The offsets of your IV and database are {start_iv} and "
|
||||
f"{start_db}, respectively. To include your offsets in the "
|
||||
"program, please report it by creating an issue on GitHub: "
|
||||
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/issues/new"
|
||||
)
|
||||
break
|
||||
if not decompressed:
|
||||
return 2
|
||||
else:
|
||||
return 3
|
||||
else:
|
||||
decompressed = True
|
||||
if db[0:6].upper() == b"SQLITE":
|
||||
with open(output, "wb") as f:
|
||||
f.write(db)
|
||||
return 0
|
||||
else:
|
||||
raise ValueError("The plaintext is not a SQLite database. Did you use the key to encrypt something...")
|
||||
|
||||
|
||||
def contacts(db, data):
|
||||
# Get contacts
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM wa_contacts""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Gathering contacts...({total_row_number})")
|
||||
|
||||
c.execute("""SELECT jid, display_name FROM wa_contacts; """)
|
||||
row = c.fetchone()
|
||||
while row is not None:
|
||||
data[row["jid"]] = ChatStore(row["display_name"])
|
||||
row = c.fetchone()
|
||||
|
||||
|
||||
def messages(db, data):
|
||||
# Get message history
|
||||
c = db.cursor()
|
||||
try:
|
||||
c.execute("""SELECT count() FROM messages""")
|
||||
except sqlite3.OperationalError:
|
||||
c.execute("""SELECT count() FROM message""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Gathering messages...(0/{total_row_number})", end="\r")
|
||||
|
||||
phone_number_re = re.compile(r"[0-9]+@s.whatsapp.net")
|
||||
try:
|
||||
c.execute("""SELECT messages.key_remote_jid,
|
||||
messages._id,
|
||||
messages.key_from_me,
|
||||
messages.timestamp,
|
||||
messages.data,
|
||||
messages.status,
|
||||
messages.edit_version,
|
||||
messages.thumb_image,
|
||||
messages.remote_resource,
|
||||
messages.media_wa_type,
|
||||
messages.latitude,
|
||||
messages.longitude,
|
||||
messages_quotes.key_id as quoted,
|
||||
messages.key_id,
|
||||
messages_quotes.data as quoted_data,
|
||||
messages.media_caption
|
||||
FROM messages
|
||||
LEFT JOIN messages_quotes
|
||||
ON messages.quoted_row_id = messages_quotes._id
|
||||
WHERE messages.key_remote_jid <> '-1';"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
try:
|
||||
c.execute("""SELECT jid_global.raw_string as key_remote_jid,
|
||||
message._id,
|
||||
message.from_me as key_from_me,
|
||||
message.timestamp,
|
||||
message.text_data as data,
|
||||
message.status,
|
||||
message_future.version as edit_version,
|
||||
message_thumbnail.thumbnail as thumb_image,
|
||||
message_media.file_path as remote_resource,
|
||||
message_media.mime_type as media_wa_type,
|
||||
message_location.latitude,
|
||||
message_location.longitude,
|
||||
message_quoted.key_id as quoted,
|
||||
message.key_id,
|
||||
message_quoted.text_data as quoted_data,
|
||||
message.message_type,
|
||||
jid_group.raw_string as group_sender_jid,
|
||||
chat.subject as chat_subject
|
||||
FROM message
|
||||
LEFT JOIN message_quoted
|
||||
ON message_quoted.message_row_id = message._id
|
||||
LEFT JOIN message_location
|
||||
ON message_location.message_row_id = message._id
|
||||
LEFT JOIN message_media
|
||||
ON message_media.message_row_id = message._id
|
||||
LEFT JOIN message_thumbnail
|
||||
ON message_thumbnail.message_row_id = message._id
|
||||
LEFT JOIN message_future
|
||||
ON message_future.message_row_id = message._id
|
||||
LEFT JOIN chat
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid jid_global
|
||||
ON jid_global._id = chat.jid_row_id
|
||||
LEFT JOIN jid jid_group
|
||||
ON jid_group._id = message.sender_jid_row_id
|
||||
WHERE key_remote_jid <> '-1';"""
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
else:
|
||||
table_message = True
|
||||
else:
|
||||
table_message = False
|
||||
i = 0
|
||||
content = c.fetchone()
|
||||
while content is not None:
|
||||
if content["key_remote_jid"] not in data:
|
||||
data[content["key_remote_jid"]] = ChatStore()
|
||||
if content["key_remote_jid"] is None:
|
||||
continue # Not sure
|
||||
data[content["key_remote_jid"]].add_message(content["_id"], Message(
|
||||
from_me=content["key_from_me"],
|
||||
timestamp=content["timestamp"],
|
||||
time=content["timestamp"],
|
||||
key_id=content["key_id"],
|
||||
))
|
||||
if "-" in content["key_remote_jid"] and content["key_from_me"] == 0:
|
||||
name = None
|
||||
if table_message:
|
||||
if content["chat_subject"] is not None:
|
||||
_jid = content["group_sender_jid"]
|
||||
else:
|
||||
_jid = content["key_remote_jid"]
|
||||
if _jid in data:
|
||||
name = data[_jid].name
|
||||
fallback = _jid.split('@')[0] if "@" in _jid else None
|
||||
else:
|
||||
fallback = None
|
||||
else:
|
||||
if content["remote_resource"] in data:
|
||||
name = data[content["remote_resource"]].name
|
||||
if "@" in content["remote_resource"]:
|
||||
fallback = content["remote_resource"].split('@')[0]
|
||||
else:
|
||||
fallback = None
|
||||
else:
|
||||
fallback = None
|
||||
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].sender = name or fallback
|
||||
else:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].sender = None
|
||||
|
||||
if content["quoted"] is not None:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].reply = content["quoted"]
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].quoted_data = content["quoted_data"]
|
||||
else:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].reply = None
|
||||
|
||||
if not table_message and content["media_caption"] is not None:
|
||||
# Old schema
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].caption = content["media_caption"]
|
||||
elif table_message and content["message_type"] == 1 and content["data"] is not None:
|
||||
# New schema
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].caption = content["data"]
|
||||
else:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].caption = None
|
||||
|
||||
if content["status"] == 6: # 6 = Metadata, otherwise it's a message
|
||||
if (not table_message and "-" in content["key_remote_jid"]) or \
|
||||
(table_message and content["chat_subject"] is not None):
|
||||
# Is Group
|
||||
if content["data"] is not None:
|
||||
try:
|
||||
int(content["data"])
|
||||
except ValueError:
|
||||
msg = f"The group name changed to {content['data']}"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].data = msg
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
data[content["key_remote_jid"]].delete_message(content["_id"])
|
||||
else:
|
||||
thumb_image = content["thumb_image"]
|
||||
if thumb_image is not None:
|
||||
if b"\x00\x00\x01\x74\x00\x1A" in thumb_image:
|
||||
# Add user
|
||||
added = phone_number_re.search(
|
||||
thumb_image.decode("unicode_escape"))[0]
|
||||
if added in data:
|
||||
name_right = data[added].name
|
||||
else:
|
||||
name_right = added.split('@')[0]
|
||||
if content["remote_resource"] is not None:
|
||||
if content["remote_resource"] in data:
|
||||
name_left = data[content["remote_resource"]].name
|
||||
else:
|
||||
name_left = content["remote_resource"].split('@')[0]
|
||||
msg = f"{name_left} added {name_right or 'You'}"
|
||||
else:
|
||||
msg = f"Added {name_right or 'You'}"
|
||||
elif b"\xac\xed\x00\x05\x74\x00" in thumb_image:
|
||||
# Changed number
|
||||
original = content["remote_resource"].split('@')[0]
|
||||
changed = thumb_image[7:].decode().split('@')[0]
|
||||
msg = f"{original} changed to {changed}"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].data = msg
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
if content["data"] is None:
|
||||
data[content["key_remote_jid"]].delete_message(content["_id"])
|
||||
else:
|
||||
# Private chat
|
||||
if content["data"] is None and content["thumb_image"] is None:
|
||||
data[content["key_remote_jid"]].delete_message(content["_id"])
|
||||
|
||||
else:
|
||||
if content["key_from_me"] == 1:
|
||||
if content["status"] == 5 and content["edit_version"] == 7 or table_message and content["message_type"] == 15:
|
||||
msg = "Message deleted"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
if content["media_wa_type"] == "5":
|
||||
msg = f"Location shared: {content['latitude'], content['longitude']}"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
msg = content["data"]
|
||||
if msg is not None:
|
||||
if "\r\n" in msg:
|
||||
msg = msg.replace("\r\n", "<br>")
|
||||
if "\n" in msg:
|
||||
msg = msg.replace("\n", "<br>")
|
||||
else:
|
||||
if content["status"] == 0 and content["edit_version"] == 7 or table_message and content["message_type"] == 15:
|
||||
msg = "Message deleted"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
if content["media_wa_type"] == "5":
|
||||
msg = f"Location shared: {content['latitude'], content['longitude']}"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
msg = content["data"]
|
||||
if msg is not None:
|
||||
if "\r\n" in msg:
|
||||
msg = msg.replace("\r\n", "<br>")
|
||||
if "\n" in msg:
|
||||
msg = msg.replace("\n", "<br>")
|
||||
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].data = msg
|
||||
|
||||
i += 1
|
||||
if i % 1000 == 0:
|
||||
print(f"Gathering messages...({i}/{total_row_number})", end="\r")
|
||||
content = c.fetchone()
|
||||
print(f"Gathering messages...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def media(db, data, media_folder):
|
||||
# Get media
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM message_media""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"\nGathering media...(0/{total_row_number})", end="\r")
|
||||
i = 0
|
||||
try:
|
||||
c.execute("""SELECT messages.key_remote_jid,
|
||||
message_row_id,
|
||||
file_path,
|
||||
message_url,
|
||||
mime_type,
|
||||
media_key
|
||||
FROM message_media
|
||||
INNER JOIN messages
|
||||
ON message_media.message_row_id = messages._id
|
||||
ORDER BY messages.key_remote_jid ASC"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
c.execute("""SELECT jid.raw_string as key_remote_jid,
|
||||
message_row_id,
|
||||
file_path,
|
||||
message_url,
|
||||
mime_type,
|
||||
media_key
|
||||
FROM message_media
|
||||
INNER JOIN message
|
||||
ON message_media.message_row_id = message._id
|
||||
LEFT JOIN chat
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid
|
||||
ON jid._id = chat.jid_row_id
|
||||
ORDER BY jid.raw_string ASC"""
|
||||
)
|
||||
content = c.fetchone()
|
||||
mime = MimeTypes()
|
||||
while content is not None:
|
||||
file_path = f"{media_folder}/{content['file_path']}"
|
||||
data[content["key_remote_jid"]].messages[content["message_row_id"]].media = True
|
||||
if os.path.isfile(file_path):
|
||||
data[content["key_remote_jid"]].messages[content["message_row_id"]].data = file_path
|
||||
if content["mime_type"] is None:
|
||||
guess = mime.guess_type(file_path)[0]
|
||||
if guess is not None:
|
||||
data[content["key_remote_jid"]].messages[content["message_row_id"]].mime = guess
|
||||
else:
|
||||
data[content["key_remote_jid"]].messages[content["message_row_id"]].mime = "data/data"
|
||||
else:
|
||||
data[content["key_remote_jid"]].messages[content["message_row_id"]].mime = content["mime_type"]
|
||||
else:
|
||||
# if "https://mmg" in content[4]:
|
||||
# try:
|
||||
# r = requests.get(content[3])
|
||||
# if r.status_code != 200:
|
||||
# raise RuntimeError()
|
||||
# except:
|
||||
# data[content[0]]["messages"][content[1]]["data"] = "{The media is missing}"
|
||||
# data[content[0]]["messages"][content[1]]["media"] = True
|
||||
# data[content[0]]["messages"][content[1]]["mime"] = "media"
|
||||
# else:
|
||||
data[content["key_remote_jid"]].messages[content["message_row_id"]].data = "The media is missing"
|
||||
data[content["key_remote_jid"]].messages[content["message_row_id"]].mime = "media"
|
||||
data[content["key_remote_jid"]].messages[content["message_row_id"]].meta = True
|
||||
i += 1
|
||||
if i % 100 == 0:
|
||||
print(f"Gathering media...({i}/{total_row_number})", end="\r")
|
||||
content = c.fetchone()
|
||||
print(
|
||||
f"Gathering media...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def vcard(db, data):
|
||||
c = db.cursor()
|
||||
try:
|
||||
c.execute("""SELECT message_row_id,
|
||||
messages.key_remote_jid,
|
||||
vcard,
|
||||
messages.media_name
|
||||
FROM messages_vcards
|
||||
INNER JOIN messages
|
||||
ON messages_vcards.message_row_id = messages._id
|
||||
ORDER BY messages.key_remote_jid ASC;"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
c.execute("""SELECT message_row_id,
|
||||
jid.raw_string as key_remote_jid,
|
||||
vcard,
|
||||
message.text_data as media_name
|
||||
FROM message_vcard
|
||||
INNER JOIN message
|
||||
ON message_vcard.message_row_id = message._id
|
||||
LEFT JOIN chat
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid
|
||||
ON jid._id = chat.jid_row_id
|
||||
ORDER BY message.chat_row_id ASC;"""
|
||||
)
|
||||
|
||||
rows = c.fetchall()
|
||||
total_row_number = len(rows)
|
||||
print(f"\nGathering vCards...(0/{total_row_number})", end="\r")
|
||||
base = "WhatsApp/vCards"
|
||||
if not os.path.isdir(base):
|
||||
Path(base).mkdir(parents=True, exist_ok=True)
|
||||
for index, row in enumerate(rows):
|
||||
media_name = row["media_name"] if row["media_name"] is not None else ""
|
||||
file_name = "".join(x for x in media_name if x.isalnum())
|
||||
file_path = f"{base}/{file_name}.vcf"
|
||||
if not os.path.isfile(file_path):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(row["vcard"])
|
||||
data[row["key_remote_jid"]].messages[row["message_row_id"]].data = media_name + \
|
||||
"The vCard file cannot be displayed here, " \
|
||||
f"however it should be located at {file_path}"
|
||||
data[row["key_remote_jid"]].messages[row["message_row_id"]].mime = "text/x-vcard"
|
||||
data[row["key_remote_jid"]].messages[row["message_row_id"]].meta = True
|
||||
print(f"Gathering vCards...({index + 1}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def create_html(
|
||||
data,
|
||||
output_folder,
|
||||
template=None,
|
||||
embedded=False,
|
||||
offline_static=False,
|
||||
maximum_size=None
|
||||
):
|
||||
if template is None:
|
||||
template_dir = os.path.dirname(__file__)
|
||||
template_file = "whatsapp.html"
|
||||
else:
|
||||
template_dir = os.path.dirname(template)
|
||||
template_file = os.path.basename(template)
|
||||
templateLoader = jinja2.FileSystemLoader(searchpath=template_dir)
|
||||
templateEnv = jinja2.Environment(loader=templateLoader)
|
||||
templateEnv.globals.update(determine_day=determine_day)
|
||||
templateEnv.filters['sanitize_except'] = sanitize_except
|
||||
template = templateEnv.get_template(template_file)
|
||||
|
||||
total_row_number = len(data)
|
||||
print(f"\nCreating HTML...(0/{total_row_number})", end="\r")
|
||||
|
||||
if not os.path.isdir(output_folder):
|
||||
os.mkdir(output_folder)
|
||||
|
||||
w3css = "https://www.w3schools.com/w3css/4/w3.css"
|
||||
if offline_static:
|
||||
import urllib.request
|
||||
static_folder = os.path.join(output_folder, offline_static)
|
||||
if not os.path.isdir(static_folder):
|
||||
os.mkdir(static_folder)
|
||||
w3css_path = os.path.join(static_folder, "w3.css")
|
||||
if not os.path.isfile(w3css_path):
|
||||
with urllib.request.urlopen(w3css) as resp:
|
||||
with open(w3css_path, "wb") as f:
|
||||
f.write(resp.read())
|
||||
w3css = os.path.join(offline_static, "w3.css")
|
||||
|
||||
for current, contact in enumerate(data):
|
||||
if len(data[contact].messages) == 0:
|
||||
continue
|
||||
phone_number = contact.split('@')[0]
|
||||
if "-" in contact:
|
||||
file_name = ""
|
||||
else:
|
||||
file_name = phone_number
|
||||
|
||||
if data[contact].name is not None:
|
||||
if file_name != "":
|
||||
file_name += "-"
|
||||
file_name += data[contact].name.replace("/", "-")
|
||||
name = data[contact].name
|
||||
else:
|
||||
name = phone_number
|
||||
safe_file_name = ''
|
||||
safe_file_name = "".join(x for x in file_name if x.isalnum() or x in "- ")
|
||||
with open(f"{output_folder}/{safe_file_name}.html", "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
template.render(
|
||||
name=name,
|
||||
msgs=data[contact].messages.values(),
|
||||
my_avatar=None,
|
||||
their_avatar=f"WhatsApp/Avatars/{contact}.j",
|
||||
w3css=w3css
|
||||
)
|
||||
)
|
||||
if current % 10 == 0:
|
||||
print(f"Creating HTML...({current}/{total_row_number})", end="\r")
|
||||
|
||||
print(f"Creating HTML...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from optparse import OptionParser
|
||||
parser = OptionParser()
|
||||
parser.add_option(
|
||||
"-w",
|
||||
"--wa",
|
||||
dest="wa",
|
||||
default="wa.db",
|
||||
help="Path to contact database")
|
||||
parser.add_option(
|
||||
"-m",
|
||||
"--media",
|
||||
dest="media",
|
||||
default="WhatsApp",
|
||||
help="Path to WhatsApp media folder"
|
||||
)
|
||||
# parser.add_option(
|
||||
# "-t",
|
||||
# "--template",
|
||||
# dest="html",
|
||||
# default="wa.db",
|
||||
# help="Path to HTML template")
|
||||
(options, args) = parser.parse_args()
|
||||
msg_db = "msgstore.db"
|
||||
output_folder = "temp"
|
||||
contact_db = options.wa
|
||||
media_folder = options.media
|
||||
|
||||
if len(args) == 1:
|
||||
msg_db = args[0]
|
||||
elif len(args) == 2:
|
||||
msg_db = args[0]
|
||||
output_folder = args[1]
|
||||
|
||||
data = {}
|
||||
|
||||
if os.path.isfile(contact_db):
|
||||
with sqlite3.connect(contact_db) as db:
|
||||
contacts(db, data)
|
||||
if os.path.isfile(msg_db):
|
||||
with sqlite3.connect(msg_db) as db:
|
||||
messages(db, data)
|
||||
media(db, data, media_folder)
|
||||
vcard(db, data)
|
||||
create_html(data, output_folder)
|
||||
|
||||
if not os.path.isdir(f"{output_folder}/WhatsApp"):
|
||||
shutil.move(media_folder, f"{output_folder}/")
|
||||
|
||||
with open("result.json", "w") as f:
|
||||
data = json.dumps(data)
|
||||
print(f"\nWriting JSON file...({int(len(data)/1024/1024)}MB)")
|
||||
f.write(data)
|
||||
|
||||
print("Everything is done!")
|
||||
@@ -1,332 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import jinja2
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from mimetypes import MimeTypes
|
||||
from Whatsapp_Chat_Exporter.utility import sanitize_except, determine_day, APPLE_TIME
|
||||
|
||||
|
||||
def messages(db, data):
|
||||
c = db.cursor()
|
||||
# Get contacts
|
||||
c.execute("""SELECT count() FROM ZWACHATSESSION""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Gathering contacts...({total_row_number})")
|
||||
|
||||
c.execute("""SELECT ZCONTACTJID, ZPARTNERNAME FROM ZWACHATSESSION; """)
|
||||
row = c.fetchone()
|
||||
while row is not None:
|
||||
data[row[0]] = {"name": row[1], "messages": {}}
|
||||
row = c.fetchone()
|
||||
|
||||
# Get message history
|
||||
c.execute("""SELECT count() FROM ZWAMESSAGE""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Gathering messages...(0/{total_row_number})", end="\r")
|
||||
|
||||
c.execute("""SELECT COALESCE(ZFROMJID, ZTOJID),
|
||||
ZWAMESSAGE.Z_PK,
|
||||
ZISFROMME,
|
||||
ZMESSAGEDATE,
|
||||
ZTEXT,
|
||||
ZMESSAGETYPE,
|
||||
ZWAGROUPMEMBER.ZMEMBERJID
|
||||
FROM main.ZWAMESSAGE
|
||||
LEFT JOIN main.ZWAGROUPMEMBER
|
||||
ON main.ZWAMESSAGE.ZGROUPMEMBER = main.ZWAGROUPMEMBER.Z_PK;""")
|
||||
i = 0
|
||||
content = c.fetchone()
|
||||
while content is not None:
|
||||
if content[0] not in data:
|
||||
data[content[0]] = {"name": None, "messages": {}}
|
||||
ts = APPLE_TIME + content[3]
|
||||
data[content[0]]["messages"][content[1]] = {
|
||||
"from_me": bool(content[2]),
|
||||
"timestamp": ts,
|
||||
"time": datetime.fromtimestamp(ts).strftime("%H:%M"),
|
||||
"media": False,
|
||||
"reply": None,
|
||||
"caption": None,
|
||||
"meta": False,
|
||||
"data": None
|
||||
}
|
||||
if "-" in content[0] and content[2] == 0:
|
||||
name = None
|
||||
if content[6] is not None:
|
||||
if content[6] in data:
|
||||
name = data[content[6]]["name"]
|
||||
if "@" in content[6]:
|
||||
fallback = content[6].split('@')[0]
|
||||
else:
|
||||
fallback = None
|
||||
else:
|
||||
fallback = None
|
||||
data[content[0]]["messages"][content[1]]["sender"] = name or fallback
|
||||
else:
|
||||
data[content[0]]["messages"][content[1]]["sender"] = None
|
||||
if content[5] == 6:
|
||||
# Metadata
|
||||
if "-" in content[0]:
|
||||
# Group
|
||||
if content[4] is not None:
|
||||
# Chnaged name
|
||||
try:
|
||||
int(content[4])
|
||||
except ValueError:
|
||||
msg = f"The group name changed to {content[4]}"
|
||||
data[content[0]]["messages"][content[1]]["data"] = msg
|
||||
data[content[0]]["messages"][content[1]]["meta"] = True
|
||||
else:
|
||||
del data[content[0]]["messages"][content[1]]
|
||||
else:
|
||||
data[content[0]]["messages"][content[1]]["data"] = None
|
||||
else:
|
||||
data[content[0]]["messages"][content[1]]["data"] = None
|
||||
else:
|
||||
# real message
|
||||
if content[2] == 1:
|
||||
if content[5] == 14:
|
||||
msg = "Message deleted"
|
||||
data[content[0]]["messages"][content[1]]["meta"] = True
|
||||
else:
|
||||
msg = content[4]
|
||||
if msg is not None:
|
||||
if "\r\n" in msg:
|
||||
msg = msg.replace("\r\n", "<br>")
|
||||
if "\n" in msg:
|
||||
msg = msg.replace("\n", "<br>")
|
||||
else:
|
||||
if content[5] == 14:
|
||||
msg = "Message deleted"
|
||||
data[content[0]]["messages"][content[1]]["meta"] = True
|
||||
else:
|
||||
msg = content[4]
|
||||
if msg is not None:
|
||||
if "\r\n" in msg:
|
||||
msg = msg.replace("\r\n", "<br>")
|
||||
if "\n" in msg:
|
||||
msg = msg.replace("\n", "<br>")
|
||||
data[content[0]]["messages"][content[1]]["data"] = msg
|
||||
i += 1
|
||||
if i % 1000 == 0:
|
||||
print(f"Gathering messages...({i}/{total_row_number})", end="\r")
|
||||
content = c.fetchone()
|
||||
print(
|
||||
f"Gathering messages...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def media(db, data, media_folder):
|
||||
c = db.cursor()
|
||||
# Get media
|
||||
c.execute("""SELECT count() FROM ZWAMEDIAITEM""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"\nGathering media...(0/{total_row_number})", end="\r")
|
||||
i = 0
|
||||
c.execute("""SELECT COALESCE(ZWAMESSAGE.ZFROMJID, ZWAMESSAGE.ZTOJID) as _id,
|
||||
ZMESSAGE,
|
||||
ZMEDIALOCALPATH,
|
||||
ZMEDIAURL,
|
||||
ZVCARDSTRING,
|
||||
ZMEDIAKEY,
|
||||
ZTITLE
|
||||
FROM ZWAMEDIAITEM
|
||||
INNER JOIN ZWAMESSAGE
|
||||
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK
|
||||
WHERE ZMEDIALOCALPATH IS NOT NULL
|
||||
ORDER BY _id ASC""")
|
||||
content = c.fetchone()
|
||||
mime = MimeTypes()
|
||||
while content is not None:
|
||||
file_path = f"{media_folder}/{content[2]}"
|
||||
data[content[0]]["messages"][content[1]]["media"] = True
|
||||
|
||||
if os.path.isfile(file_path):
|
||||
data[content[0]]["messages"][content[1]]["data"] = file_path
|
||||
if content[4] is None:
|
||||
guess = mime.guess_type(file_path)[0]
|
||||
if guess is not None:
|
||||
data[content[0]]["messages"][content[1]]["mime"] = guess
|
||||
else:
|
||||
data[content[0]]["messages"][content[1]]["mime"] = "data/data"
|
||||
else:
|
||||
data[content[0]]["messages"][content[1]]["mime"] = content[4]
|
||||
else:
|
||||
# if "https://mmg" in content[4]:
|
||||
# try:
|
||||
# r = requests.get(content[3])
|
||||
# if r.status_code != 200:
|
||||
# raise RuntimeError()
|
||||
# except:
|
||||
# data[content[0]]["messages"][content[1]]["data"] = "{The media is missing}"
|
||||
# data[content[0]]["messages"][content[1]]["mime"] = "media"
|
||||
# else:
|
||||
data[content[0]]["messages"][content[1]]["data"] = "The media is missing"
|
||||
data[content[0]]["messages"][content[1]]["mime"] = "media"
|
||||
data[content[0]]["messages"][content[1]]["meta"] = True
|
||||
if content[6] is not None:
|
||||
data[content[0]]["messages"][content[1]]["caption"] = content[6]
|
||||
i += 1
|
||||
if i % 100 == 0:
|
||||
print(f"Gathering media...({i}/{total_row_number})", end="\r")
|
||||
content = c.fetchone()
|
||||
print(
|
||||
f"Gathering media...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def vcard(db, data):
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT DISTINCT ZWAVCARDMENTION.ZMEDIAITEM,
|
||||
ZWAMEDIAITEM.ZMESSAGE,
|
||||
COALESCE(ZWAMESSAGE.ZFROMJID,
|
||||
ZWAMESSAGE.ZTOJID) as _id,
|
||||
ZVCARDNAME,
|
||||
ZVCARDSTRING
|
||||
FROM ZWAVCARDMENTION
|
||||
INNER JOIN ZWAMEDIAITEM
|
||||
ON ZWAVCARDMENTION.ZMEDIAITEM = ZWAMEDIAITEM.Z_PK
|
||||
INNER JOIN ZWAMESSAGE
|
||||
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK""")
|
||||
rows = c.fetchall()
|
||||
total_row_number = len(rows)
|
||||
print(f"\nGathering vCards...(0/{total_row_number})", end="\r")
|
||||
base = "Message/vCards"
|
||||
if not os.path.isdir(base):
|
||||
Path(base).mkdir(parents=True, exist_ok=True)
|
||||
for index, row in enumerate(rows):
|
||||
file_name = "".join(x for x in row[3] if x.isalnum())
|
||||
file_path = f"{base}/{file_name[:200]}.vcf"
|
||||
if not os.path.isfile(file_path):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(row[4])
|
||||
data[row[2]]["messages"][row[1]]["data"] = row[3] + \
|
||||
"The vCard file cannot be displayed here, " \
|
||||
f"however it should be located at {file_path}"
|
||||
data[row[2]]["messages"][row[1]]["mime"] = "text/x-vcard"
|
||||
data[row[2]]["messages"][row[1]]["media"] = True
|
||||
data[row[2]]["messages"][row[1]]["meta"] = True
|
||||
print(f"Gathering vCards...({index + 1}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def create_html(data, output_folder, template=None, embedded=False, offline_static=False, maximum_size=None):
|
||||
if template is None:
|
||||
template_dir = os.path.dirname(__file__)
|
||||
template_file = "whatsapp.html"
|
||||
else:
|
||||
template_dir = os.path.dirname(template)
|
||||
template_file = os.path.basename(template)
|
||||
templateLoader = jinja2.FileSystemLoader(searchpath=template_dir)
|
||||
templateEnv = jinja2.Environment(loader=templateLoader)
|
||||
templateEnv.globals.update(determine_day=determine_day)
|
||||
templateEnv.filters['sanitize_except'] = sanitize_except
|
||||
template = templateEnv.get_template(template_file)
|
||||
|
||||
total_row_number = len(data)
|
||||
print(f"\nCreating HTML...(0/{total_row_number})", end="\r")
|
||||
|
||||
if not os.path.isdir(output_folder):
|
||||
os.mkdir(output_folder)
|
||||
|
||||
w3css = "https://www.w3schools.com/w3css/4/w3.css"
|
||||
if offline_static:
|
||||
import urllib.request
|
||||
static_folder = os.path.join(output_folder, offline_static)
|
||||
if not os.path.isdir(static_folder):
|
||||
os.mkdir(static_folder)
|
||||
w3css_path = os.path.join(static_folder, "w3.css")
|
||||
if not os.path.isfile(w3css_path):
|
||||
with urllib.request.urlopen(w3css) as resp:
|
||||
with open(w3css_path, "wb") as f: f.write(resp.read())
|
||||
w3css = os.path.join(offline_static, "w3.css")
|
||||
|
||||
for current, contact in enumerate(data):
|
||||
if len(data[contact]["messages"]) == 0:
|
||||
continue
|
||||
phone_number = contact.split('@')[0]
|
||||
if "-" in contact:
|
||||
file_name = ""
|
||||
else:
|
||||
file_name = phone_number
|
||||
|
||||
if data[contact]["name"] is not None:
|
||||
if file_name != "":
|
||||
file_name += "-"
|
||||
file_name += data[contact]["name"].replace("/", "-")
|
||||
name = data[contact]["name"]
|
||||
else:
|
||||
name = phone_number
|
||||
|
||||
safe_file_name = ''
|
||||
safe_file_name = "".join(x for x in file_name if x.isalnum() or x in "- ")
|
||||
with open(f"{output_folder}/{safe_file_name}.html", "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
template.render(
|
||||
name=name,
|
||||
msgs=data[contact]["messages"].values(),
|
||||
my_avatar=None,
|
||||
their_avatar=f"WhatsApp/Avatars/{contact}.j",
|
||||
w3css=w3css
|
||||
)
|
||||
)
|
||||
if current % 10 == 0:
|
||||
print(f"Creating HTML...({current}/{total_row_number})", end="\r")
|
||||
|
||||
print(f"Creating HTML...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from optparse import OptionParser
|
||||
parser = OptionParser()
|
||||
parser.add_option(
|
||||
"-w",
|
||||
"--wa",
|
||||
dest="wa",
|
||||
default="wa.db",
|
||||
help="Path to contact database")
|
||||
parser.add_option(
|
||||
"-m",
|
||||
"--media",
|
||||
dest="media",
|
||||
default="Message",
|
||||
help="Path to WhatsApp media folder"
|
||||
)
|
||||
# parser.add_option(
|
||||
# "-t",
|
||||
# "--template",
|
||||
# dest="html",
|
||||
# default="wa.db",
|
||||
# help="Path to HTML template")
|
||||
(options, args) = parser.parse_args()
|
||||
msg_db = "7c7fba66680ef796b916b067077cc246adacf01d"
|
||||
output_folder = "temp"
|
||||
contact_db = options.wa
|
||||
media_folder = options.media
|
||||
|
||||
if len(args) == 1:
|
||||
msg_db = args[0]
|
||||
elif len(args) == 2:
|
||||
msg_db = args[0]
|
||||
output_folder = args[1]
|
||||
|
||||
data = {}
|
||||
|
||||
if os.path.isfile(msg_db):
|
||||
with sqlite3.connect(msg_db) as db:
|
||||
messages(db, data)
|
||||
media(db, data, media_folder)
|
||||
vcard(db, data)
|
||||
create_html(data, output_folder)
|
||||
|
||||
if not os.path.isdir(f"{output_folder}/WhatsApp"):
|
||||
shutil.move(media_folder, f"{output_folder}/")
|
||||
|
||||
with open("result.json", "w") as f:
|
||||
data = json.dumps(data)
|
||||
print(f"\nWriting JSON file...({int(len(data)/1024/1024)}MB)")
|
||||
f.write(data)
|
||||
|
||||
print("Everything is done!")
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import os
|
||||
import getpass
|
||||
try:
|
||||
from iphone_backup_decrypt import EncryptedBackup, RelativePath
|
||||
except ModuleNotFoundError:
|
||||
support_encrypted = False
|
||||
else:
|
||||
support_encrypted = True
|
||||
|
||||
|
||||
def extract_encrypted(base_dir, password):
|
||||
backup = EncryptedBackup(backup_directory=base_dir, passphrase=password)
|
||||
print("Decrypting WhatsApp database...")
|
||||
backup.extract_file(relative_path=RelativePath.WHATSAPP_MESSAGES,
|
||||
output_filename="7c7fba66680ef796b916b067077cc246adacf01d")
|
||||
backup.extract_file(relative_path=RelativePath.WHATSAPP_CONTACTS,
|
||||
output_filename="ContactsV2.sqlite")
|
||||
data = backup.execute_sql("""SELECT count()
|
||||
FROM Files
|
||||
WHERE relativePath
|
||||
LIKE 'Message/Media/%'"""
|
||||
)
|
||||
total_row_number = data[0][0]
|
||||
print(f"Gathering media...(0/{total_row_number})", end="\r")
|
||||
data = backup.execute_sql("""SELECT fileID,
|
||||
relativePath,
|
||||
flags,
|
||||
file
|
||||
FROM Files
|
||||
WHERE relativePath
|
||||
LIKE 'Message/Media/%'"""
|
||||
)
|
||||
if not os.path.isdir("Message"):
|
||||
os.mkdir("Message")
|
||||
if not os.path.isdir("Message/Media"):
|
||||
os.mkdir("Message/Media")
|
||||
i = 0
|
||||
for row in data:
|
||||
destination = row[1]
|
||||
hashes = row[0]
|
||||
folder = hashes[:2]
|
||||
flags = row[2]
|
||||
file = row[3]
|
||||
if flags == 2:
|
||||
try:
|
||||
os.mkdir(destination)
|
||||
except FileExistsError:
|
||||
pass
|
||||
elif flags == 1:
|
||||
decrypted = backup.decrypt_inner_file(file_id=hashes, file_bplist=file)
|
||||
with open(destination, "wb") as f:
|
||||
f.write(decrypted)
|
||||
i += 1
|
||||
if i % 100 == 0:
|
||||
print(f"Gathering media...({i}/{total_row_number})", end="\r")
|
||||
print(f"Gathering media...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def is_encrypted(base_dir):
|
||||
with sqlite3.connect(f"{base_dir}/Manifest.db") as f:
|
||||
c = f.cursor()
|
||||
try:
|
||||
c.execute("""SELECT count()
|
||||
FROM Files
|
||||
""")
|
||||
except sqlite3.DatabaseError:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def extract_media(base_dir):
|
||||
if is_encrypted(base_dir):
|
||||
if not support_encrypted:
|
||||
print("You don't have the dependencies to handle encrypted backup.")
|
||||
print("Read more on how to deal with encrypted backup:")
|
||||
print("https://github.com/KnugiHK/Whatsapp-Chat-Exporter/blob/main/README.md#usage")
|
||||
return False
|
||||
password = getpass.getpass("Enter the password:")
|
||||
extract_encrypted(base_dir, password)
|
||||
else:
|
||||
wts_db = os.path.join(base_dir, "7c/7c7fba66680ef796b916b067077cc246adacf01d")
|
||||
if not os.path.isfile(wts_db):
|
||||
print("WhatsApp database not found.")
|
||||
exit()
|
||||
else:
|
||||
shutil.copyfile(wts_db, "7c7fba66680ef796b916b067077cc246adacf01d")
|
||||
with sqlite3.connect(f"{base_dir}/Manifest.db") as manifest:
|
||||
c = manifest.cursor()
|
||||
c.execute("""SELECT count()
|
||||
FROM Files
|
||||
WHERE relativePath
|
||||
LIKE 'Message/Media/%'""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Gathering media...(0/{total_row_number})", end="\r")
|
||||
c.execute("""SELECT fileID,
|
||||
relativePath,
|
||||
flags
|
||||
FROM Files
|
||||
WHERE relativePath
|
||||
LIKE 'Message/Media/%'""")
|
||||
row = c.fetchone()
|
||||
if not os.path.isdir("Message"):
|
||||
os.mkdir("Message")
|
||||
if not os.path.isdir("Message/Media"):
|
||||
os.mkdir("Message/Media")
|
||||
i = 0
|
||||
while row is not None:
|
||||
destination = row[1]
|
||||
hashes = row[0]
|
||||
folder = hashes[:2]
|
||||
flags = row[2]
|
||||
if flags == 2:
|
||||
try:
|
||||
os.mkdir(destination)
|
||||
except FileExistsError:
|
||||
pass
|
||||
elif flags == 1:
|
||||
shutil.copyfile(f"{base_dir}/{folder}/{hashes}", destination)
|
||||
i += 1
|
||||
if i % 100 == 0:
|
||||
print(f"Gathering media...({i}/{total_row_number})", end="\r")
|
||||
row = c.fetchone()
|
||||
print(f"Gathering media...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from optparse import OptionParser
|
||||
parser = OptionParser()
|
||||
(_, args) = parser.parse_args()
|
||||
base_dir = args[0]
|
||||
extract_media(base_dir)
|
||||
@@ -1,588 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import jinja2
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
import io
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
from bleach import clean as sanitize
|
||||
from markupsafe import Markup
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from mimetypes import MimeTypes
|
||||
from hashlib import sha256
|
||||
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||
|
||||
try:
|
||||
import zlib
|
||||
from Crypto.Cipher import AES
|
||||
except ModuleNotFoundError:
|
||||
support_backup = False
|
||||
else:
|
||||
support_backup = True
|
||||
try:
|
||||
import javaobj
|
||||
except ModuleNotFoundError:
|
||||
support_crypt15 = False
|
||||
else:
|
||||
support_crypt15 = True
|
||||
|
||||
|
||||
def sanitize_except(html):
|
||||
return Markup(sanitize(html, tags=["br"]))
|
||||
|
||||
|
||||
def determine_day(last, current):
|
||||
last = datetime.fromtimestamp(last).date()
|
||||
current = datetime.fromtimestamp(current).date()
|
||||
if last == current:
|
||||
return None
|
||||
else:
|
||||
return current
|
||||
|
||||
|
||||
CRYPT14_OFFSETS = (
|
||||
{"iv": 67, "db": 191},
|
||||
{"iv": 67, "db": 190},
|
||||
{"iv": 66, "db": 99},
|
||||
{"iv": 67, "db": 193}
|
||||
)
|
||||
|
||||
|
||||
class Crypt(Enum):
|
||||
CRYPT15 = 15
|
||||
CRYPT14 = 14
|
||||
CRYPT12 = 12
|
||||
|
||||
|
||||
def brute_force_offset():
|
||||
for iv in range(0, 200):
|
||||
for db in range(0, 200):
|
||||
yield iv, iv + 16, db
|
||||
|
||||
|
||||
def _generate_hmac_of_hmac(key_stream):
|
||||
key = hmac.new(
|
||||
hmac.new(
|
||||
b'\x00' * 32,
|
||||
key_stream,
|
||||
sha256
|
||||
).digest(),
|
||||
b"backup encryption\x01",
|
||||
sha256
|
||||
)
|
||||
return key.digest(), key_stream
|
||||
|
||||
|
||||
def _extract_encrypted_key(keyfile):
|
||||
key_stream = b""
|
||||
for byte in javaobj.loads(keyfile):
|
||||
key_stream += byte.to_bytes(1, "big", signed=True)
|
||||
|
||||
return _generate_hmac_of_hmac(key_stream)
|
||||
|
||||
|
||||
def decrypt_backup(database, key, output, crypt=Crypt.CRYPT14, show_crypt15=False):
|
||||
if not support_backup:
|
||||
return 1
|
||||
if isinstance(key, io.IOBase):
|
||||
key = key.read()
|
||||
if crypt is not Crypt.CRYPT15:
|
||||
t1 = key[30:62]
|
||||
if crypt is not Crypt.CRYPT15 and len(key) != 158:
|
||||
raise ValueError("The key file must be 158 bytes")
|
||||
if crypt == Crypt.CRYPT14:
|
||||
if len(database) < 191:
|
||||
raise ValueError("The crypt14 file must be at least 191 bytes")
|
||||
current_try = 0
|
||||
offsets = CRYPT14_OFFSETS[current_try]
|
||||
t2 = database[15:47]
|
||||
iv = database[offsets["iv"]:offsets["iv"] + 16]
|
||||
db_ciphertext = database[offsets["db"]:]
|
||||
elif crypt == Crypt.CRYPT12:
|
||||
if len(database) < 67:
|
||||
raise ValueError("The crypt12 file must be at least 67 bytes")
|
||||
t2 = database[3:35]
|
||||
iv = database[51:67]
|
||||
db_ciphertext = database[67:-20]
|
||||
elif crypt == Crypt.CRYPT15:
|
||||
if not support_crypt15:
|
||||
return 1
|
||||
if len(database) < 131:
|
||||
raise ValueError("The crypt15 file must be at least 131 bytes")
|
||||
t1 = t2 = None
|
||||
iv = database[8:24]
|
||||
db_offset = database[0] + 2 # Skip protobuf + protobuf size and backup type
|
||||
db_ciphertext = database[db_offset:]
|
||||
|
||||
if t1 != t2:
|
||||
raise ValueError("The signature of key file and backup file mismatch")
|
||||
|
||||
if crypt == Crypt.CRYPT15:
|
||||
if len(key) == 32:
|
||||
main_key, hex_key = _generate_hmac_of_hmac(key)
|
||||
else:
|
||||
main_key, hex_key = _extract_encrypted_key(key)
|
||||
if show_crypt15:
|
||||
hex_key = [hex_key.hex()[c:c+4] for c in range(0, len(hex_key.hex()), 4)]
|
||||
print("The HEX key of the crypt15 backup is: " + ' '.join(hex_key))
|
||||
else:
|
||||
main_key = key[126:]
|
||||
decompressed = False
|
||||
while not decompressed:
|
||||
cipher = AES.new(main_key, AES.MODE_GCM, iv)
|
||||
db_compressed = cipher.decrypt(db_ciphertext)
|
||||
try:
|
||||
db = zlib.decompress(db_compressed)
|
||||
except zlib.error:
|
||||
if crypt == Crypt.CRYPT14:
|
||||
current_try += 1
|
||||
if current_try < len(CRYPT14_OFFSETS):
|
||||
offsets = CRYPT14_OFFSETS[current_try]
|
||||
iv = database[offsets["iv"]:offsets["iv"] + 16]
|
||||
db_ciphertext = database[offsets["db"]:]
|
||||
continue
|
||||
else:
|
||||
print("Common offsets are not applicable to "
|
||||
"your backup. Trying to brute force it...")
|
||||
for start_iv, end_iv, start_db in brute_force_offset():
|
||||
iv = database[start_iv:end_iv]
|
||||
db_ciphertext = database[start_db:]
|
||||
cipher = AES.new(main_key, AES.MODE_GCM, iv)
|
||||
db_compressed = cipher.decrypt(db_ciphertext)
|
||||
try:
|
||||
db = zlib.decompress(db_compressed)
|
||||
except zlib.error:
|
||||
continue
|
||||
else:
|
||||
decompressed = True
|
||||
print(
|
||||
f"The offsets of your IV and database are {start_iv} and "
|
||||
f"{start_db}, respectively. To include your offsets in the "
|
||||
"program, please report it by creating an issue on GitHub: "
|
||||
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/issues/new"
|
||||
)
|
||||
break
|
||||
if not decompressed:
|
||||
return 2
|
||||
else:
|
||||
return 3
|
||||
else:
|
||||
decompressed = True
|
||||
if db[0:6].upper() == b"SQLITE":
|
||||
with open(output, "wb") as f:
|
||||
f.write(db)
|
||||
return 0
|
||||
else:
|
||||
raise ValueError("The plaintext is not a SQLite database. Did you use the key to encrypt something...")
|
||||
|
||||
|
||||
def contacts(db, data):
|
||||
# Get contacts
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM wa_contacts""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Gathering contacts...({total_row_number})")
|
||||
|
||||
c.execute("""SELECT jid, display_name FROM wa_contacts; """)
|
||||
row = c.fetchone()
|
||||
while row is not None:
|
||||
data[row["jid"]] = ChatStore(row["display_name"])
|
||||
row = c.fetchone()
|
||||
|
||||
|
||||
def messages(db, data):
|
||||
# Get message history
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM message""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Gathering messages...(0/{total_row_number})", end="\r")
|
||||
|
||||
phone_number_re = re.compile(r"[0-9]+@s.whatsapp.net")
|
||||
c.execute("""SELECT jid_global.raw_string as key_remote_jid,
|
||||
message._id,
|
||||
message.from_me as key_from_me,
|
||||
message.timestamp,
|
||||
message.text_data as data,
|
||||
message.status,
|
||||
message_future.version as edit_version,
|
||||
message_thumbnail.thumbnail as thumb_image,
|
||||
message_media.file_path as remote_resource,
|
||||
message_media.mime_type as media_wa_type,
|
||||
message_location.latitude,
|
||||
message_location.longitude,
|
||||
message_quoted.key_id as quoted,
|
||||
message.key_id,
|
||||
message_quoted.text_data as quoted_data,
|
||||
message.message_type,
|
||||
jid_group.raw_string as group_sender_jid,
|
||||
chat.subject as chat_subject
|
||||
FROM message
|
||||
LEFT JOIN message_quoted
|
||||
ON message_quoted.message_row_id = message._id
|
||||
LEFT JOIN message_location
|
||||
ON message_location.message_row_id = message._id
|
||||
LEFT JOIN message_media
|
||||
ON message_media.message_row_id = message._id
|
||||
LEFT JOIN message_thumbnail
|
||||
ON message_thumbnail.message_row_id = message._id
|
||||
LEFT JOIN message_future
|
||||
ON message_future.message_row_id = message._id
|
||||
LEFT JOIN chat
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid jid_global
|
||||
ON jid_global._id = chat.jid_row_id
|
||||
LEFT JOIN jid jid_group
|
||||
ON jid_group._id = message.sender_jid_row_id
|
||||
WHERE key_remote_jid <> '-1';""")
|
||||
i = 0
|
||||
content = c.fetchone()
|
||||
while content is not None:
|
||||
if content["key_remote_jid"] not in data:
|
||||
data[content["key_remote_jid"]] = ChatStore()
|
||||
if content["key_remote_jid"] is None:
|
||||
continue
|
||||
data[content["key_remote_jid"]].add_message(content["_id"], Message(
|
||||
from_me=content["key_from_me"],
|
||||
timestamp=content["timestamp"],
|
||||
time=content["timestamp"],
|
||||
key_id=content["key_id"],
|
||||
))
|
||||
if "-" in content["key_remote_jid"] and content["key_from_me"] == 0:
|
||||
name = None
|
||||
if content["chat_subject"] is not None:
|
||||
_jid = content["group_sender_jid"]
|
||||
else:
|
||||
_jid = content["key_remote_jid"]
|
||||
if _jid in data:
|
||||
name = data[_jid].name
|
||||
fallback = _jid.split('@')[0] if "@" in _jid else None
|
||||
else:
|
||||
fallback = None
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].sender = name or fallback
|
||||
else:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].sender = None
|
||||
|
||||
if content["quoted"] is not None:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].reply = content["quoted"]
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].quoted_data = content["quoted_data"]
|
||||
else:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].reply = None
|
||||
|
||||
if content["message_type"] == 1:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].caption = content["data"]
|
||||
else:
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].caption = None
|
||||
|
||||
if content["status"] == 6:
|
||||
if content["chat_subject"] is not None:
|
||||
# Is Group
|
||||
if content["data"] is not None:
|
||||
try:
|
||||
int(content["data"])
|
||||
except ValueError:
|
||||
msg = f"The group name changed to {content['data']}"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].data = msg
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
data[content["key_remote_jid"]].delete_message(content["_id"])
|
||||
else:
|
||||
thumb_image = content["thumb_image"]
|
||||
if thumb_image is not None:
|
||||
if b"\x00\x00\x01\x74\x00\x1A" in thumb_image:
|
||||
# Add user
|
||||
added = phone_number_re.search(
|
||||
thumb_image.decode("unicode_escape"))[0]
|
||||
if added in data:
|
||||
name_right = data[added]["name"]
|
||||
else:
|
||||
name_right = added.split('@')[0]
|
||||
if content["remote_resource"] is not None:
|
||||
if content["remote_resource"] in data:
|
||||
name_left = data[content["remote_resource"]]["name"]
|
||||
else:
|
||||
name_left = content["remote_resource"].split('@')[0]
|
||||
msg = f"{name_left} added {name_right or 'You'}"
|
||||
else:
|
||||
msg = f"Added {name_right or 'You'}"
|
||||
elif b"\xac\xed\x00\x05\x74\x00" in thumb_image:
|
||||
# Changed number
|
||||
original = content["remote_resource"].split('@')[0]
|
||||
changed = thumb_image[7:].decode().split('@')[0]
|
||||
msg = f"{original} changed to {changed}"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].data = msg
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
if content["data"] is None:
|
||||
data[content["key_remote_jid"]].delete_message(content["_id"])
|
||||
else:
|
||||
# Private chat
|
||||
if content["data"] is None and content["thumb_image"] is None:
|
||||
data[content["key_remote_jid"]].delete_message(content["_id"])
|
||||
|
||||
else:
|
||||
if content["key_from_me"] == 1:
|
||||
if content["status"] == 5 and content["edit_version"] == 7:
|
||||
msg = "Message deleted"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
if content["media_wa_type"] == "5":
|
||||
msg = f"Location shared: {content[10], content[11]}"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
msg = content["data"]
|
||||
if msg is not None:
|
||||
if "\r\n" in msg:
|
||||
msg = msg.replace("\r\n", "<br>")
|
||||
if "\n" in msg:
|
||||
msg = msg.replace("\n", "<br>")
|
||||
else:
|
||||
if content["status"] == 0 and content["edit_version"] == 7:
|
||||
msg = "Message deleted"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
if content["media_wa_type"] == "5":
|
||||
msg = f"Location shared: {content[10], content[11]}"
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].meta = True
|
||||
else:
|
||||
msg = content["data"]
|
||||
if msg is not None:
|
||||
if "\r\n" in msg:
|
||||
msg = msg.replace("\r\n", "<br>")
|
||||
if "\n" in msg:
|
||||
msg = msg.replace("\n", "<br>")
|
||||
|
||||
data[content["key_remote_jid"]].messages[content["_id"]].data = msg
|
||||
|
||||
i += 1
|
||||
if i % 1000 == 0:
|
||||
print(f"Gathering messages...({i}/{total_row_number})", end="\r")
|
||||
content = c.fetchone()
|
||||
print(f"Gathering messages...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def media(db, data, media_folder):
|
||||
# Get media
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM message_media""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"\nGathering media...(0/{total_row_number})", end="\r")
|
||||
i = 0
|
||||
c.execute("""SELECT jid.raw_string,
|
||||
message_row_id,
|
||||
file_path,
|
||||
message_url,
|
||||
mime_type,
|
||||
media_key
|
||||
FROM message_media
|
||||
INNER JOIN message
|
||||
ON message_media.message_row_id = message._id
|
||||
LEFT JOIN chat
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid
|
||||
ON jid._id = chat.jid_row_id
|
||||
ORDER BY jid.raw_string ASC""")
|
||||
content = c.fetchone()
|
||||
mime = MimeTypes()
|
||||
while content is not None:
|
||||
file_path = f"{media_folder}/{content['file_path']}"
|
||||
data[content["raw_string"]].messages[content["message_row_id"]].media = True
|
||||
if os.path.isfile(file_path):
|
||||
data[content["raw_string"]].messages[content["message_row_id"]].data = file_path
|
||||
if content["mime_type"] is None:
|
||||
guess = mime.guess_type(file_path)[0]
|
||||
if guess is not None:
|
||||
data[content["raw_string"]].messages[content["message_row_id"]].mime = guess
|
||||
else:
|
||||
data[content["raw_string"]].messages[content["message_row_id"]].mime = "data/data"
|
||||
else:
|
||||
data[content["raw_string"]].messages[content["message_row_id"]].mime = content["mime_type"]
|
||||
else:
|
||||
# if "https://mmg" in content["mime_type"]:
|
||||
# try:
|
||||
# r = requests.get(content["message_url"])
|
||||
# if r.status_code != 200:
|
||||
# raise RuntimeError()
|
||||
# except:
|
||||
# data[content["raw_string"]].messages[content["message_row_id"]].data = "{The media is missing}"
|
||||
# data[content["raw_string"]].messages[content["message_row_id"]].media = True
|
||||
# data[content["raw_string"]].messages[content["message_row_id"]].mime = "media"
|
||||
# else:
|
||||
data[content["raw_string"]].messages[content["message_row_id"]].data = "The media is missing"
|
||||
data[content["raw_string"]].messages[content["message_row_id"]].mime = "media"
|
||||
data[content["raw_string"]].messages[content["message_row_id"]].meta = True
|
||||
i += 1
|
||||
if i % 100 == 0:
|
||||
print(f"Gathering media...({i}/{total_row_number})", end="\r")
|
||||
content = c.fetchone()
|
||||
print(
|
||||
f"Gathering media...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def vcard(db, data):
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT message_row_id,
|
||||
jid.raw_string,
|
||||
vcard,
|
||||
message.text_data
|
||||
FROM message_vcard
|
||||
INNER JOIN message
|
||||
ON message_vcard.message_row_id = message._id
|
||||
LEFT JOIN chat
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid
|
||||
ON jid._id = chat.jid_row_id
|
||||
ORDER BY message.chat_row_id ASC;""")
|
||||
rows = c.fetchall()
|
||||
total_row_number = len(rows)
|
||||
print(f"\nGathering vCards...(0/{total_row_number})", end="\r")
|
||||
base = "WhatsApp/vCards"
|
||||
if not os.path.isdir(base):
|
||||
Path(base).mkdir(parents=True, exist_ok=True)
|
||||
for index, row in enumerate(rows):
|
||||
media_name = row["text_data"] if row["text_data"] else ""
|
||||
file_name = "".join(x for x in media_name if x.isalnum())
|
||||
file_path = f"{base}/{file_name}.vcf"
|
||||
if not os.path.isfile(file_path):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(row["vcard"])
|
||||
data[row["raw_string"]].messages[row["message_row_id"]].data = media_name + \
|
||||
"The vCard file cannot be displayed here, " \
|
||||
f"however it should be located at {file_path}"
|
||||
data[row["raw_string"]].messages[row["message_row_id"]].mime = "text/x-vcard"
|
||||
data[row["raw_string"]].messages[row["message_row_id"]].meta = True
|
||||
print(f"Gathering vCards...({index + 1}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def create_html(
|
||||
data,
|
||||
output_folder,
|
||||
template=None,
|
||||
embedded=False,
|
||||
offline_static=False,
|
||||
maximum_size=None
|
||||
):
|
||||
if template is None:
|
||||
template_dir = os.path.dirname(__file__)
|
||||
template_file = "whatsapp.html"
|
||||
else:
|
||||
template_dir = os.path.dirname(template)
|
||||
template_file = os.path.basename(template)
|
||||
templateLoader = jinja2.FileSystemLoader(searchpath=template_dir)
|
||||
templateEnv = jinja2.Environment(loader=templateLoader)
|
||||
templateEnv.globals.update(determine_day=determine_day)
|
||||
templateEnv.filters['sanitize_except'] = sanitize_except
|
||||
template = templateEnv.get_template(template_file)
|
||||
|
||||
total_row_number = len(data)
|
||||
print(f"\nCreating HTML...(0/{total_row_number})", end="\r")
|
||||
|
||||
if not os.path.isdir(output_folder):
|
||||
os.mkdir(output_folder)
|
||||
|
||||
w3css = "https://www.w3schools.com/w3css/4/w3.css"
|
||||
if offline_static:
|
||||
import urllib.request
|
||||
static_folder = os.path.join(output_folder, offline_static)
|
||||
if not os.path.isdir(static_folder):
|
||||
os.mkdir(static_folder)
|
||||
w3css_path = os.path.join(static_folder, "w3.css")
|
||||
if not os.path.isfile(w3css_path):
|
||||
with urllib.request.urlopen(w3css) as resp:
|
||||
with open(w3css_path, "wb") as f: f.write(resp.read())
|
||||
w3css = os.path.join(offline_static, "w3.css")
|
||||
|
||||
for current, contact in enumerate(data):
|
||||
if len(data[contact].messages) == 0:
|
||||
continue
|
||||
phone_number = contact.split('@')[0]
|
||||
if "-" in contact:
|
||||
file_name = ""
|
||||
else:
|
||||
file_name = phone_number
|
||||
|
||||
if data[contact].name is not None:
|
||||
if file_name != "":
|
||||
file_name += "-"
|
||||
file_name += data[contact].name.replace("/", "-")
|
||||
name = data[contact].name
|
||||
else:
|
||||
name = phone_number
|
||||
|
||||
safe_file_name = "".join(x for x in file_name if x.isalnum() or x in "- ")
|
||||
with open(f"{output_folder}/{safe_file_name}.html", "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
template.render(
|
||||
name=name,
|
||||
msgs=data[contact].messages.values(),
|
||||
my_avatar=None,
|
||||
their_avatar=f"WhatsApp/Avatars/{contact}.j",
|
||||
w3css=w3css
|
||||
)
|
||||
)
|
||||
if current % 10 == 0:
|
||||
print(f"Creating HTML...({current}/{total_row_number})", end="\r")
|
||||
|
||||
print(f"Creating HTML...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from optparse import OptionParser
|
||||
parser = OptionParser()
|
||||
parser.add_option(
|
||||
"-w",
|
||||
"--wa",
|
||||
dest="wa",
|
||||
default="wa.db",
|
||||
help="Path to contact database")
|
||||
parser.add_option(
|
||||
"-m",
|
||||
"--media",
|
||||
dest="media",
|
||||
default="WhatsApp",
|
||||
help="Path to WhatsApp media folder"
|
||||
)
|
||||
# parser.add_option(
|
||||
# "-t",
|
||||
# "--template",
|
||||
# dest="html",
|
||||
# default="wa.db",
|
||||
# help="Path to HTML template")
|
||||
(options, args) = parser.parse_args()
|
||||
msg_db = "msgstore.db"
|
||||
output_folder = "temp"
|
||||
contact_db = options.wa
|
||||
media_folder = options.media
|
||||
|
||||
if len(args) == 1:
|
||||
msg_db = args[0]
|
||||
elif len(args) == 2:
|
||||
msg_db = args[0]
|
||||
output_folder = args[1]
|
||||
|
||||
data = {}
|
||||
|
||||
if os.path.isfile(contact_db):
|
||||
with sqlite3.connect(contact_db) as db:
|
||||
contacts(db, data)
|
||||
if os.path.isfile(msg_db):
|
||||
with sqlite3.connect(msg_db) as db:
|
||||
messages(db, data)
|
||||
media(db, data, media_folder)
|
||||
vcard(db, data)
|
||||
create_html(data, output_folder)
|
||||
|
||||
if not os.path.isdir(f"{output_folder}/WhatsApp"):
|
||||
shutil.move(media_folder, f"{output_folder}/")
|
||||
|
||||
with open("result.json", "w") as f:
|
||||
data = json.dumps(data)
|
||||
print(f"\nWriting JSON file...({int(len(data)/1024/1024)}MB)")
|
||||
f.write(data)
|
||||
|
||||
print("Everything is done!")
|
||||
@@ -1,74 +0,0 @@
|
||||
from bleach import clean as sanitize
|
||||
from markupsafe import Markup
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
def sanitize_except(html):
|
||||
return Markup(sanitize(html, tags=["br"]))
|
||||
|
||||
|
||||
def determine_day(last, current):
|
||||
last = datetime.fromtimestamp(last).date()
|
||||
current = datetime.fromtimestamp(current).date()
|
||||
if last == current:
|
||||
return None
|
||||
else:
|
||||
return current
|
||||
|
||||
|
||||
# Android Specific
|
||||
|
||||
CRYPT14_OFFSETS = (
|
||||
{"iv": 67, "db": 191},
|
||||
{"iv": 67, "db": 190},
|
||||
{"iv": 66, "db": 99},
|
||||
{"iv": 67, "db": 193}
|
||||
)
|
||||
|
||||
|
||||
class Crypt(Enum):
|
||||
CRYPT15 = 15
|
||||
CRYPT14 = 14
|
||||
CRYPT12 = 12
|
||||
|
||||
|
||||
def brute_force_offset(max_iv=200, max_db=200):
|
||||
for iv in range(0, max_iv):
|
||||
for db in range(0, max_db):
|
||||
yield iv, iv + 16, db
|
||||
|
||||
def check_update():
|
||||
import urllib.request
|
||||
import json
|
||||
from sys import platform
|
||||
from .__init__ import __version__
|
||||
|
||||
package_url_json = "https://pypi.org/pypi/whatsapp-chat-exporter/json"
|
||||
try:
|
||||
raw = urllib.request.urlopen(package_url_json)
|
||||
except Exception:
|
||||
print("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(".")))
|
||||
current_version = tuple(map(int, __version__.split(".")))
|
||||
if current_version < latest_version:
|
||||
print("===============Update===============")
|
||||
print("A newer version of WhatsApp Chat Exporter is available.")
|
||||
print("Current version: " + __version__)
|
||||
print("Latest version: " + package_info["info"]["version"])
|
||||
if platform == "win32":
|
||||
print("Update with: pip install --upgrade whatsapp-chat-exporter")
|
||||
else:
|
||||
print("Update with: pip3 install --upgrade whatsapp-chat-exporter")
|
||||
print("====================================")
|
||||
else:
|
||||
print("You are using the latest version of WhatsApp Chat Exporter.")
|
||||
return 0
|
||||
|
||||
# iOS Specific
|
||||
|
||||
APPLE_TIME = datetime.timestamp(datetime(2001, 1, 1))
|
||||
@@ -1,173 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Whatsapp - {{ name }}</title>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="{{w3css}}">
|
||||
<style>
|
||||
html, body {
|
||||
font-size: 12px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
header {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
border-bottom: 2px solid #e3e6e7;
|
||||
font-size: 2em;
|
||||
font-weight: bolder;
|
||||
background-color: white;
|
||||
padding: 20px 0 20px 0;
|
||||
}
|
||||
footer {
|
||||
border-top: 2px solid #e3e6e7;
|
||||
font-size: 2em;
|
||||
padding: 20px 0 20px 0;
|
||||
}
|
||||
article {
|
||||
width:500px;
|
||||
margin:100px auto;
|
||||
z-index:10;
|
||||
font-size: 15px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
img, video {
|
||||
max-width:100%;
|
||||
}
|
||||
a.anchor {
|
||||
display: block;
|
||||
position: relative;
|
||||
top: -100px;
|
||||
visibility: hidden;
|
||||
}
|
||||
div.reply{
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="w3-center w3-top">Chat history with {{ name }}</header>
|
||||
<article class="w3-container">
|
||||
<div class="table" style="width:100%">
|
||||
{% set last = {'last': 946688461.001} %}
|
||||
{% for msg in msgs -%}
|
||||
<div class="w3-row" style="padding-bottom: 10px">
|
||||
<a class="anchor" id="{{ msg.key_id }}"></a>
|
||||
{% if determine_day(last.last, msg.timestamp) is not none %}
|
||||
<div class="w3-center" style="color:#70777c;padding: 10px 0 10px 0;">{{ determine_day(last.last, msg.timestamp) }}</div>
|
||||
{% if last.update({'last': msg.timestamp}) %}{% endif %}
|
||||
{% endif %}
|
||||
{% if msg.from_me == true %}
|
||||
<div class="w3-row">
|
||||
<div style="float: left; color:#70777c;">{{ msg.time }}</div>
|
||||
<div style="padding-left: 10px; text-align: right; color: #3892da;">You</div>
|
||||
</div>
|
||||
<div class="w3-row">
|
||||
<div class="w3-col m10 l10">
|
||||
<div style="text-align: right;">
|
||||
{% if msg.reply is not none %}
|
||||
<div class="reply">
|
||||
<span style="color: #70777a;">Replying to </span>
|
||||
<a href="#{{msg.reply}}" style="color: #168acc;">"{{ msg.quoted_data or 'media' }}"</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
||||
<div style="text-align: center;" class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar">
|
||||
<p>{{ msg.data or 'This message is not supported' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if msg.media == false %}
|
||||
{{ msg.data | sanitize_except() }}
|
||||
{% else %}
|
||||
{% if "image/" in msg.mime %}
|
||||
<a href="{{ msg.data }}"><img src="{{ msg.data }}" /></a>
|
||||
{% elif "audio/" in msg.mime %}
|
||||
<audio controls="controls" autobuffer="autobuffer">
|
||||
<source src="{{ msg.data }}" />
|
||||
</audio>
|
||||
{% elif "video/" in msg.mime %}
|
||||
<video controls="controls" autobuffer="autobuffer">
|
||||
<source src="{{ msg.data }}" />
|
||||
</video>
|
||||
{% elif "/" in msg.mime %}
|
||||
<div style="text-align: center;" class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar">
|
||||
<p>The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a></p>
|
||||
</div>
|
||||
{% else %}
|
||||
{% filter escape %}{{ msg.data }}{% endfilter %}
|
||||
{% endif %}
|
||||
{% if msg.caption is not none %}
|
||||
<br>
|
||||
{{ msg.caption }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w3-col m2 l2" style="padding-left: 10px"><img src="{{ my_avatar }}" onerror="this.style.display='none'"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w3-row">
|
||||
<div style="padding-right: 10px; float: left; color: #3892da;">
|
||||
{% if msg.sender is not none %}
|
||||
{{ msg.sender }}
|
||||
{% else %}
|
||||
{{ name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="text-align: right; color:#70777c;">{{ msg.time }}</div>
|
||||
</div>
|
||||
<div class="w3-row">
|
||||
<div class="w3-col m2 l2"><img src="{{ their_avatar }}" onerror="this.style.display='none'"></div>
|
||||
<div class="w3-col m10 l10">
|
||||
<div style="text-align: left;">
|
||||
{% if msg.reply is not none %}
|
||||
<div class="reply">
|
||||
<span style="color: #70777a;">Replying to </span>
|
||||
<a href="#{{msg.reply}}" style="color: #168acc;">"{{ msg.quoted_data or 'media' }}"</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
||||
<div style="text-align: center;" class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar">
|
||||
<p>{{ msg.data or 'This message is not supported' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if msg.media == false %}
|
||||
{{ msg.data | sanitize_except() }}
|
||||
{% else %}
|
||||
{% if "image/" in msg.mime %}
|
||||
<a href="{{ msg.data }}"><img src="{{ msg.data }}" /></a>
|
||||
{% elif "audio/" in msg.mime %}
|
||||
<audio controls preload="auto">
|
||||
<source src="{{ msg.data }}" />
|
||||
</audio>
|
||||
{% elif "video/" in msg.mime %}
|
||||
<video controls preload="auto">
|
||||
<source src="{{ msg.data }}" />
|
||||
</video>
|
||||
{% elif "/" in msg.mime %}
|
||||
<div style="text-align: center;" class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar">
|
||||
<p>The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a></p>
|
||||
</div>
|
||||
{% else %}
|
||||
{% filter escape %}{{ msg.data }}{% endfilter %}
|
||||
{% endif %}
|
||||
{% if msg.caption is not none %}
|
||||
<br>
|
||||
{{ msg.caption }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
<footer class="w3-center">
|
||||
End of history
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
destination = {
|
||||
"filter": "Filter",
|
||||
"date": "Filters#date-filters",
|
||||
"chat": "Filters#chat-filter",
|
||||
"osl": "Open-Source-Licenses",
|
||||
"iose2e": "iOS-Usage#encrypted-iosipados-backup",
|
||||
null: ""
|
||||
};
|
||||
const dest = new URLSearchParams(window.location.search).get('dest');
|
||||
window.location.href = `https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki/${destination[dest]}`;
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>If the redirection doesn't work, you can find the documentation at <a href="https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki">https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 116 KiB |
+710
@@ -0,0 +1,710 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="title" content="WhatsApp Chat Exporter">
|
||||
<meta name="description" content="Export your WhatsApp conversations from Android and iOS/iPadOS devices to HTML, JSON, or text formats. Supports encrypted backups (Crypt12, Crypt14, Crypt15) and customizable templates.">
|
||||
<meta name="keywords" content="WhatsApp, WhatsApp Chat Exporter, WhatsApp export tool, WhatsApp backup decryption, Crypt12, Crypt14, Crypt15, WhatsApp database parser, WhatsApp chat history, HTML export, JSON export, text export, customizable templates, media handling, vCard import, Python tool, open source, MIT license">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="author" content="KnugiHK">
|
||||
<meta name="license" content="MIT">
|
||||
<meta name="generator" content="Python">
|
||||
<title>WhatsApp Chat Exporter</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #128C7E;
|
||||
--secondary-color: #25D366;
|
||||
--dark-color: #075E54;
|
||||
--light-color: #DCF8C6;
|
||||
--text-color: #333;
|
||||
--light-text: #777;
|
||||
--code-bg: #f6f8fa;
|
||||
--border-color: #e1e4e8;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 60px 0 40px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.8rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.badges {
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: white;
|
||||
padding: 40px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inner-content {
|
||||
padding: 0 30px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--dark-color);
|
||||
margin: 30px 0 15px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--light-color);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--dark-color);
|
||||
margin: 25px 0 15px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
color: var(--dark-color);
|
||||
margin: 20px 0 10px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
p, ul, ol {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.alert {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #f0ad4e;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.alert--tip {
|
||||
border-color: var(--secondary-color);
|
||||
background-color: rgba(37, 211, 102, 0.1);
|
||||
}
|
||||
|
||||
.alert--note {
|
||||
border-color: #0088cc;
|
||||
background-color: rgba(0, 136, 204, 0.1);
|
||||
}
|
||||
.markdown-alert {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #f0ad4e;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.markdown-alert-note {
|
||||
border-color: #0088cc;
|
||||
background-color: rgba(0, 136, 204, 0.1);
|
||||
}
|
||||
|
||||
.markdown-alert-tip {
|
||||
border-color: var(--secondary-color);
|
||||
background-color: rgba(37, 211, 102, 0.1);
|
||||
}
|
||||
|
||||
.markdown-alert-important {
|
||||
border-color: #d9534f;
|
||||
background-color: rgba(217, 83, 79, 0.1);
|
||||
}
|
||||
|
||||
.markdown-alert-warning {
|
||||
border-color: #f0ad4e;
|
||||
background-color: rgba(240, 173, 78, 0.1);
|
||||
}
|
||||
|
||||
.markdown-alert-caution {
|
||||
border-color: #ff9800;
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
.markdown-alert p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-alert-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
pre {
|
||||
background-color: var(--code-bg);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
|
||||
font-size: 85%;
|
||||
background-color: var(--code-bg);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
margin: 20px 0;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: var(--dark-color);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 30px 0;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s ease;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--dark-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--light-color);
|
||||
color: var(--dark-color);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin: 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-of-contents {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 15px 25px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.table-of-contents h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.table-of-contents ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: var(--light-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.device-section {
|
||||
padding: 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
padding: 40px 0 30px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>WhatsApp Chat Exporter</h1>
|
||||
<div class="badges">
|
||||
<a href="https://pypi.org/project/whatsapp-chat-exporter/" class="badge"><img src="https://img.shields.io/pypi/v/whatsapp-chat-exporter?label=Latest%20in%20PyPI" alt="Latest in PyPI"></a>
|
||||
<a href="https://github.com/KnugiHK/WhatsApp-Chat-Exporter/blob/main/LICENSE" class="badge"><img src="https://img.shields.io/pypi/l/whatsapp-chat-exporter?color=427B93" alt="License MIT"></a>
|
||||
<a href="https://pypi.org/project/Whatsapp-Chat-Exporter/" class="badge"><img src="https://img.shields.io/pypi/pyversions/Whatsapp-Chat-Exporter" alt="Python"></a>
|
||||
<a href="https://matrix.to/#/#wtsexporter:matrix.org" class="badge"><img src="https://img.shields.io/matrix/wtsexporter:matrix.org.svg?label=Matrix%20Chat%20Room" alt="Matrix Chat Room"></a>
|
||||
</div>
|
||||
<p class="tagline">A customizable Android and iPhone Whatsapp database parser that will give you the history of your Whatsapp conversations in HTML and JSON</p>
|
||||
<div class="action-buttons">
|
||||
<a href="https://github.com/KnugiHK/WhatsApp-Chat-Exporter" class="btn"><i class="fab fa-github"></i> GitHub</a>
|
||||
<a href="https://pypi.org/project/whatsapp-chat-exporter/" class="btn btn-secondary"><i class="fab fa-python"></i> PyPI</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="inner-content">
|
||||
<section id="features">
|
||||
<h2>Key Features</h2>
|
||||
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-mobile-alt"></i></div>
|
||||
<h3 class="feature-title">Cross-Platform</h3>
|
||||
<p>Support for both Android and iOS/iPadOS WhatsApp databases</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-lock"></i></div>
|
||||
<h3 class="feature-title">Backup Decryption</h3>
|
||||
<p>Support for Crypt12, Crypt14, and Crypt15 (End-to-End) encrypted backups</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-file-export"></i></div>
|
||||
<h3 class="feature-title">Multiple Formats</h3>
|
||||
<p>Export your chats in HTML, JSON, and text formats</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-paint-brush"></i></div>
|
||||
<h3 class="feature-title">Customizable</h3>
|
||||
<p>Use custom HTML templates and styling for your chat exports</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-images"></i></div>
|
||||
<h3 class="feature-title">Media Support</h3>
|
||||
<p>Properly handles and organizes your media files in the exports</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon"><i class="fas fa-filter"></i></div>
|
||||
<h3 class="feature-title">Filtering Options</h3>
|
||||
<p>Filter chats by date, phone number, and more</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="readme-content">
|
||||
<h1>Whatsapp-Chat-Exporter</h1>
|
||||
<p>A customizable Android and iPhone Whatsapp database parser that will give you the history of your Whatsapp conversations in HTML and JSON. Inspired by <a href="https://telegram.org/blog/export-and-more">Telegram Chat Export Tool</a>. </p>
|
||||
<div class="markdown-alert markdown-alert-tip">
|
||||
<p class="markdown-alert-title"><svg class="octicon octicon-light-bulb mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>Tip</p>
|
||||
<p>If you plan to uninstall WhatsApp or delete your WhatsApp account, please make a backup of your WhatsApp database. You may want to use this exporter again on the same database in the future as the exporter develops.</p>
|
||||
</div>
|
||||
<p>If you would like to support this project, all you need to do is to contribute or share this project! If you think otherwise and want to make a donation, please refer to the <a href="https://blog.knugi.com/DONATE.html">Donation Guide</a>.</p>
|
||||
<p>To contribute, see the <a href="https://github.com/KnugiHK/WhatsApp-Chat-Exporter/blob/main/CONTRIBUTING.md">Contributing Guidelines</a>.</p>
|
||||
<h1>Usage</h1>
|
||||
<div class="markdown-alert markdown-alert-note">
|
||||
<p class="markdown-alert-title"><svg class="octicon octicon-info mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</p>
|
||||
<p>Usage in README may be removed in the future. Check the usage in <a href="https://github.com/KnugiHK/Whatsapp-Chat-Exporter/wiki">Wiki</a></p>
|
||||
<p>Click <a href="https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki/Android-Usage#crypt15-end-to-end-encrypted-backup">here</a> for the most trivia way for exporting from Android</p>
|
||||
</div>
|
||||
<p>First, install the exporter by:</p>
|
||||
<pre><code class="language-shell">pip install whatsapp-chat-exporter
|
||||
pip install whatsapp-chat-exporter[android_backup] :; # Optional, if you want it to support decrypting Android WhatsApp backup.
|
||||
</code></pre>
|
||||
<p>Then, create a working directory in somewhere you want</p>
|
||||
<pre><code class="language-shell">mkdir working_wts
|
||||
cd working_wts
|
||||
</code></pre>
|
||||
<div class="markdown-alert markdown-alert-tip">
|
||||
<p class="markdown-alert-title"><svg class="octicon octicon-light-bulb mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>Tip</p>
|
||||
<p>macOS users should grant <em>Full Disk Access</em> to Terminal in the <em>Security & Privacy</em> settings before using the exporter.</p>
|
||||
</div>
|
||||
<h2>Working with Android</h2>
|
||||
<h3>Unencrypted WhatsApp database</h3>
|
||||
<p>Extract the WhatsApp database with whatever means, one possible means is to use the <a href="https://github.com/KnugiHK/WhatsApp-Key-DB-Extractor">WhatsApp-Key-DB-Extractor</a>. Note that the extractor only works on Android 4.0 to 13.</p>
|
||||
<p>After you obtain your WhatsApp database, copy the WhatsApp database and media folder to the working directory. The database is called msgstore.db. If you also want the name of your contacts, get the contact database, which is called wa.db. And copy the WhatsApp (Media) directory from your phone directly.</p>
|
||||
<p>And now, you should have something like this in the working directory.</p>
|
||||
<p><img src="imgs/android_structure.png" alt="Android folder structure"></p>
|
||||
<h4>Extracting</h4>
|
||||
<p>Simply invoke the following command from shell.</p>
|
||||
<pre><code class="language-sh">wtsexporter -a
|
||||
</code></pre>
|
||||
<h4>Enriching Contact from vCard</h4>
|
||||
<p>The default WhatsApp contact database typically contained contact names extracted from your phone, which the exporter used to map your chats. However, in some reported cases, the database may have never been populated. In such case, you can export your contacts to a vCard file from your phone or a cloud provider like Google Contacts. Then, install the necessary dependency and run the following command from the shell:</p>
|
||||
<pre><code class="language-sh">pip install whatsapp-chat-exporter["vcards"]
|
||||
wtsexporter -a --enrich-from-vcards contacts.vcf --default-country-code 852
|
||||
</code></pre>
|
||||
<h3>Encrypted Android WhatsApp Backup</h3>
|
||||
<p>In order to support the decryption, install pycryptodome if it is not installed</p>
|
||||
<pre><code class="language-sh">pip install pycryptodome # Or
|
||||
pip install whatsapp-chat-exporter["android_backup"] # install along with this software
|
||||
</code></pre>
|
||||
<div class="markdown-alert markdown-alert-tip">
|
||||
<p class="markdown-alert-title"><svg class="octicon octicon-light-bulb mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>Tip</p>
|
||||
<p>Crypt15 is now the easiest way to decrypt a backup. If you have the 32 bytes hex key generated when you enable End-to-End encrypted backup, you can use it to decrypt the backup. If you do not have the 32 bytes hex key, you can still use the key file extracted just like extacting key file for Crypt12 and Crypt14 to decrypt the backup.</p>
|
||||
</div>
|
||||
<h4>Crypt12 or Crypt14</h4>
|
||||
<p>You will need the decryption key file from your phone. If you have root access, you can find it as <code>/data/data/com.whatsapp/files/key</code>. Otherwise, if you used WhatsApp-Key-DB-Extractor before, it will appear in the WhatsApp backup directory as <code>WhatsApp/Databases/.nomedia</code>.</p>
|
||||
<p>Place the decryption key file (key) and the encrypted WhatsApp Backup (msgstore.db.crypt14) in the working directory. If you also want the name of your contacts, get the contact database, which is called wa.db. And copy the WhatsApp (Media) directory from your phone directly.</p>
|
||||
<p>And now, you should have something like this in the working directory.</p>
|
||||
<p><img src="imgs/android_structure_backup.png" alt="Android folder structure with WhatsApp Backup"></p>
|
||||
<h4>Extracting</h4>
|
||||
<p>Simply invoke the following command from shell.</p>
|
||||
<pre><code class="language-sh">wtsexporter -a -k key -b msgstore.db.crypt14
|
||||
</code></pre>
|
||||
<h4>Crypt15 (End-to-End Encrypted Backup)</h4>
|
||||
<p>To support Crypt15 backup, install javaobj-py3 if it is not installed</p>
|
||||
<pre><code class="language-sh">pip install javaobj-py3 # Or
|
||||
pip install whatsapp-chat-exporter["crypt15"] # install along with this software
|
||||
</code></pre>
|
||||
<p>Before proceeding with this method, you must first create an end-to-end encrypted backup. For detailed instructions, refer to <a href="https://faq.whatsapp.com/490592613091019">WhatsApp's help center</a>.</p>
|
||||
<p>Once you have copied the backup files to your computer, place the encrypted WhatsApp backup file (msgstore.db.crypt15) into the working directory. If you also wish to include your contacts' names, obtain the contact database file, named wa.db. Additionally, copy the WhatsApp Media folder directly from your phone.</p>
|
||||
<p>If you do not have the 32 bytes hex key (64 hexdigits), place the decryption key file (encrypted_backup.key) extracted from Android. If you gave the 32 bytes hex key, simply put the key in the shell.</p>
|
||||
<p>Now, you should have something like this in the working directory (if you do not have 32 bytes hex key).</p>
|
||||
<p><img src="imgs/android_structure_backup_crypt15.png" alt="Android folder structure with WhatsApp Crypt15 Backup"></p>
|
||||
<h5>Extracting</h5>
|
||||
<p>If you do not have 32 bytes hex key but have the key file available, simply invoke the following command from shell.</p>
|
||||
<pre><code class="language-sh">wtsexporter -a -k encrypted_backup.key -b msgstore.db.crypt15
|
||||
</code></pre>
|
||||
<p>If you have the 32 bytes hex key, simply put the hex key in the -k option and invoke the command from shell like this:</p>
|
||||
<pre><code class="language-sh">wtsexporter -a -k 133735053b5204b08e5c3823423399aa30ff061435ab89bc4e6713969cda1337 -b msgstore.db.crypt15
|
||||
</code></pre>
|
||||
<h2>Working with iOS/iPadOS (iPhone or iPad)</h2>
|
||||
<p>Do an iPhone/iPad Backup with iTunes/Finder first.</p>
|
||||
<ul>
|
||||
<li>iPhone backup on Mac: <a href="https://support.apple.com/HT211229">https://support.apple.com/HT211229</a></li>
|
||||
<li>iPhone backup on Windows: <a href="https://support.apple.com/HT212156">https://support.apple.com/HT212156</a></li>
|
||||
<li>iPad backup: <a href="https://support.apple.com/guide/ipad/ipad9a74df05xx/ipados">https://support.apple.com/guide/ipad/ipad9a74df05xx/ipados</a></li>
|
||||
</ul>
|
||||
<h3>Encrypted iOS/iPadOS Backup</h3>
|
||||
<div class="markdown-alert markdown-alert-note">
|
||||
<p class="markdown-alert-title"><svg class="octicon octicon-info mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</p>
|
||||
<p>If you are working on unencrypted iOS/iPadOS backup, skip this.</p>
|
||||
</div>
|
||||
<p>If you want to work on an encrypted iOS/iPadOS Backup, you should install <code>iphone_backup_decrypt</code> from <a href="https://github.com/KnugiHK/iphone_backup_decrypt">KnugiHK/iphone_backup_decrypt</a> before you run the <code>ios_media_handler.py</code>.</p>
|
||||
<pre><code class="language-sh">pip install git+https://github.com/KnugiHK/iphone_backup_decrypt
|
||||
</code></pre>
|
||||
<div class="markdown-alert markdown-alert-note">
|
||||
<p class="markdown-alert-title"><svg class="octicon octicon-info mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</p>
|
||||
<p>You will need to disable the built-in end-to-end encryption for WhatsApp backups. See <a href="https://faq.whatsapp.com/490592613091019#turn-off-end-to-end-encrypted-backup">WhatsApp's FAQ</a> for how to do it.</p>
|
||||
</div>
|
||||
<h3>Extracting</h3>
|
||||
<p>To extract messages from iOS/iPadOS backups, run the following command in the shell, making sure to replace the username and device ID with the correct values. Keep in mind that there are at least two possible paths for the backups on Windows.</p>
|
||||
<h4>Windows</h4>
|
||||
<pre><code class="language-powershell"># Possible path one
|
||||
wtsexporter -i -b "C:\Users\[Username]\AppData\Roaming\Apple Computer\MobileSync\Backup\[device id]"
|
||||
|
||||
# Possible path two
|
||||
wtsexporter -i -b "C:\Users\[Username]\Apple\MobileSync\Backup\[device id]"
|
||||
</code></pre>
|
||||
<h4>Mac</h4>
|
||||
<pre><code class="language-sh">wtsexporter -i -b ~/Library/Application\ Support/MobileSync/Backup/[device id]
|
||||
</code></pre>
|
||||
<h2>Results</h2>
|
||||
<p>After extracting, you will get this:</p>
|
||||
<p><img src="imgs/pm.png" alt="Private Message"></p>
|
||||
<h2>Working with Business</h2>
|
||||
<p>If you are working with WhatsApp Business, add the <code>--business</code> flag to the command</p>
|
||||
<pre><code class="language-sh">wtsexporter -a --business ...other flags
|
||||
wtsexporter -i --business ...other flags
|
||||
</code></pre>
|
||||
<h2>More options</h2>
|
||||
<p>Invoke the wtsexporter with --help option will show you all options available.</p>
|
||||
<pre><code class="language-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]
|
||||
[--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]
|
||||
[--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.
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--debug Enable debug mode
|
||||
|
||||
Device Type:
|
||||
-a, --android Define the target as Android
|
||||
-i, --ios Define the target as iPhone/iPad
|
||||
-e, --exported EXPORTED
|
||||
Define the target as exported chat file and specify the path to the file
|
||||
|
||||
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.
|
||||
--call-db [CALL_DB_IOS]
|
||||
Path to call database (default: 1b432994e958845fffe8e2f190f26d1511534088) iOS only
|
||||
--wab, --wa-backup WAB
|
||||
Path to contact database in crypt15 format
|
||||
|
||||
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/)
|
||||
--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
|
||||
--no-reply Do not process replies (iOS only) (default: handle replies)
|
||||
|
||||
JSON Options:
|
||||
--avoid-encoding-json
|
||||
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)
|
||||
--per-chat Output the JSON file per chat
|
||||
--import Import JSON file and convert to HTML output
|
||||
|
||||
HTML Options:
|
||||
-t, --template TEMPLATE
|
||||
Path to custom HTML template
|
||||
--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
|
||||
|
||||
Media Handling:
|
||||
-c, --move-media Move the media directory to output directory if the flag is set, otherwise copy it
|
||||
--create-separated-media
|
||||
Create a copy of the media seperated per chat in <MEDIA>/separated/ directory
|
||||
|
||||
Filtering Options:
|
||||
--time-offset {-12 to 14}
|
||||
Offset in hours (-12 to 14) for time displayed in the output
|
||||
--date DATE The date filter in specific format (inclusive)
|
||||
--date-format FORMAT The date format for the date filter
|
||||
--include [phone number ...]
|
||||
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
|
||||
|
||||
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
|
||||
--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
|
||||
|
||||
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.
|
||||
--source-dir SOURCE_DIR
|
||||
Sets the source directory. Used for performing incremental merges.
|
||||
--target-dir TARGET_DIR
|
||||
Sets the target directory. Used for performing incremental merges.
|
||||
|
||||
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.
|
||||
--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.0 Licensed with MIT. See https://wts.knugi.dev/docs?dest=osl for all open
|
||||
source licenses.
|
||||
</code></pre>
|
||||
<h1>Verifying Build Integrity</h1>
|
||||
<p>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.</p>
|
||||
<div class="markdown-alert markdown-alert-note">
|
||||
<p class="markdown-alert-title"><svg class="octicon octicon-info mr-2" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note</p>
|
||||
<p>Requires version 0.13.0 or newer. Legacy binaries are unsupported.</p>
|
||||
</div>
|
||||
<h3>Using Bash (Linux/WSL/macOS)</h3>
|
||||
<pre><code class="language-bash">for file in wtsexporter*; do ; gh attestation verify "$file" -R KnugiHK/WhatsApp-Chat-Exporter; done
|
||||
</code></pre>
|
||||
<h3>Using PowerShell (Windows)</h3>
|
||||
<pre><code class="language-powershell">gci "wtsexporter*" | % { gh attestation verify $_.FullName -R KnugiHK/WhatsApp-Chat-Exporter }
|
||||
</code></pre>
|
||||
<h1>Python Support Policy</h1>
|
||||
<p>This project officially supports all non-EOL (End-of-Life) versions of Python. Once a Python version reaches EOL, it is dropped in the next release. See <a href="https://devguide.python.org/versions/">Python's EOL Schedule</a>.</p>
|
||||
<h1>Legal Stuff & Disclaimer</h1>
|
||||
<p>This is a MIT licensed project.</p>
|
||||
<p>The Telegram Desktop's export is the reference for whatsapp.html in this repo.</p>
|
||||
<p><code>bplist.py</code> was released by Vladimir "Farcaller" Pouzanov under MIT license.</p>
|
||||
<p>Please also refer to any files prefixed with <code>LICENSE</code> to obtain copies of the various licenses.</p>
|
||||
<p>WhatsApp Chat Exporter is not affiliated, associated, authorized, endorsed by, or in any way officially connected with the WhatsApp LLC, or any of its subsidiaries or its affiliates. The official WhatsApp LLC website can be found at <a href="https://www.whatsapp.com/">https://www.whatsapp.com/</a>.</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="action-buttons">
|
||||
<a href="https://github.com/KnugiHK/WhatsApp-Chat-Exporter" class="btn"><i class="fab fa-github"></i> View on GitHub</a>
|
||||
<a href="https://pypi.org/project/whatsapp-chat-exporter/" class="btn btn-secondary"><i class="fab fa-python"></i> PyPI Package</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2021-2026 WhatsApp Chat Exporter</p>
|
||||
<p>Licensed under MIT License</p>
|
||||
<p>
|
||||
<a href="https://github.com/KnugiHK/WhatsApp-Chat-Exporter" style="color: white; margin: 0 10px;"><i class="fab fa-github fa-lg"></i></a>
|
||||
<a href="https://matrix.to/#/#wtsexporter:matrix.org" style="color: white; margin: 0 10px;"><i class="fas fa-comments fa-lg"></i></a>
|
||||
</p>
|
||||
<p><small>Last updated: 5/18/2026</small></p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Simple script to handle smooth scrolling for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const targetId = this.getAttribute('href');
|
||||
const targetElement = document.querySelector(targetId);
|
||||
|
||||
if (targetElement) {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 20,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,63 +0,0 @@
|
||||
import setuptools
|
||||
from re import search
|
||||
|
||||
with open("README.md", "r") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
with open("Whatsapp_Chat_Exporter/__init__.py", encoding="utf8") as f:
|
||||
version = search(r'__version__ = "(.*?)"', f.read()).group(1)
|
||||
|
||||
setuptools.setup(
|
||||
name="whatsapp-chat-exporter",
|
||||
version=version,
|
||||
author="KnugiHK",
|
||||
author_email="hello@knugi.com",
|
||||
description="A Whatsapp database parser that will give you the "
|
||||
"history of your Whatsapp conversations in HTML and JSON.",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
license="MIT",
|
||||
keywords=[
|
||||
"android", "ios", "parsing", "history","iphone", "whatsapp", "message"
|
||||
"customizable", "android-backup", "crypt12", "whatsapp-chat-exporter",
|
||||
"whatsapp-export", "whatsapp-database", "whatsapp-database-parser",
|
||||
"whatsapp-conversations", "iphone-backup", "crypt14", "crypt15", "messages"
|
||||
],
|
||||
platforms=["any"],
|
||||
url="https://github.com/KnugiHK/Whatsapp-Chat-Exporter",
|
||||
packages=setuptools.find_packages(),
|
||||
package_data={
|
||||
'': ['whatsapp.html']
|
||||
},
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Topic :: Communications :: Chat",
|
||||
"Topic :: Utilities",
|
||||
"Topic :: Database"
|
||||
],
|
||||
python_requires='>=3.7',
|
||||
install_requires=[
|
||||
'jinja2',
|
||||
'bleach'
|
||||
],
|
||||
extras_require={
|
||||
'android_backup': ["pycryptodome", "javaobj-py3"],
|
||||
'crypt12': ["pycryptodome"],
|
||||
'crypt14': ["pycryptodome"],
|
||||
'crypt15': ["pycryptodome", "javaobj-py3"]
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"wtsexporter = Whatsapp_Chat_Exporter.__main__:main"
|
||||
]
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user