mirror of
https://github.com/KnugiHK/WhatsApp-Chat-Exporter.git
synced 2026-01-29 22:00:43 +00:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde3c18498 | ||
|
|
af3307825a | ||
|
|
9b34f7ea6d | ||
|
|
280a1186d8 | ||
|
|
30cff71e76 | ||
|
|
05d21e3e5a | ||
|
|
fb88c83ac4 | ||
|
|
ffb6aef96e | ||
|
|
77c5a3e20c | ||
|
|
7b0965ac1d | ||
|
|
d72b41da11 | ||
|
|
fed14ceb29 | ||
|
|
3e6fdaa126 | ||
|
|
04000c78e2 | ||
|
|
75c429fe22 | ||
|
|
9608fa387d | ||
|
|
fc9c76c34c | ||
|
|
87b1fcc038 | ||
|
|
fe88f1b837 | ||
|
|
af3d31f773 | ||
|
|
df67a549c0 | ||
|
|
884ccc4cc0 | ||
|
|
484910cf5c | ||
|
|
a83c8eb17f | ||
|
|
8ffa8cfcac | ||
|
|
8fcd50d21b | ||
|
|
f91c527676 | ||
|
|
f35bf24a5e | ||
|
|
e2684845b8 | ||
|
|
df3333f948 | ||
|
|
bd4ccbb8ac | ||
|
|
fb5a1c3e1f | ||
|
|
1760dea0f5 | ||
|
|
4fcb4df0a4 | ||
|
|
13904ea4d8 | ||
|
|
8069882dc5 | ||
|
|
d95b075ac0 | ||
|
|
ea01a727cf | ||
|
|
b2f679d975 | ||
|
|
0cf113561a | ||
|
|
80bdc4414a | ||
|
|
09e5e1a756 | ||
|
|
6e37061e71 | ||
|
|
b301dd22d0 | ||
|
|
5b97d6013a | ||
|
|
8f304f1c48 | ||
|
|
7bb2fb2420 | ||
|
|
83fefe585b | ||
|
|
4886587065 | ||
|
|
0423fdabda | ||
|
|
823ed663e7 | ||
|
|
be469aed93 | ||
|
|
b34045a59f | ||
|
|
3461ce3735 | ||
|
|
b0942d695b | ||
|
|
5449646a1b | ||
|
|
6370b81299 | ||
|
|
c69d053049 | ||
|
|
b01d81ddec | ||
|
|
7e2800d89a | ||
|
|
33763b5f41 | ||
|
|
f080e2d4ea | ||
|
|
00f666a3c0 | ||
|
|
2ca064d111 | ||
|
|
3b54ca9d28 | ||
|
|
03312da6ee | ||
|
|
c7e8a603c7 | ||
|
|
574b0393d8 | ||
|
|
baa79a7b74 | ||
|
|
d57ff29e71 | ||
|
|
2d4d934a91 | ||
|
|
9741cab078 | ||
|
|
1e7687f8e8 | ||
|
|
524b3a4034 | ||
|
|
1ab4b24fa0 | ||
|
|
8d003b217c | ||
|
|
d754e6c279 | ||
|
|
0eebbcff21 | ||
|
|
a569fb0875 | ||
|
|
6e8e0d7f59 | ||
|
|
c0a511adb3 | ||
|
|
e84640de1c | ||
|
|
20199ed794 | ||
|
|
f4e610a953 | ||
|
|
99a3a4bcd0 | ||
|
|
dedfce8feb | ||
|
|
54e0b43888 | ||
|
|
d5ea843286 | ||
|
|
b01fe0ab4a | ||
|
|
a7ccc3be66 | ||
|
|
07b1cf6a8a | ||
|
|
2b49ac2e41 | ||
|
|
2466e2542a | ||
|
|
af70f6f6f9 | ||
|
|
48c3fa965f | ||
|
|
472c18448c | ||
|
|
810d8c7c8b | ||
|
|
f80be81ee6 | ||
|
|
0fcaa946e6 | ||
|
|
1e7953e5fe | ||
|
|
481656fdeb | ||
|
|
3d155fb48f | ||
|
|
f659a8c171 | ||
|
|
3ffb63ed28 | ||
|
|
94956913e8 | ||
|
|
7b5a7419f1 | ||
|
|
d5cef051d3 | ||
|
|
f81f31d667 | ||
|
|
8c617b721f | ||
|
|
0d626519ec | ||
|
|
f39d448aa6 | ||
|
|
2dc433df7c | ||
|
|
75a8a2e8c5 | ||
|
|
3847836ed6 | ||
|
|
c27f5ee41c | ||
|
|
e6c43e7e35 | ||
|
|
c2fa18778f | ||
|
|
150180fdff | ||
|
|
86ea938323 | ||
|
|
7da71e84fe | ||
|
|
efd5ed80b2 | ||
|
|
efea1d6165 | ||
|
|
3082c83bc4 | ||
|
|
fc50415afd | ||
|
|
be4adadbd8 | ||
|
|
8eb05424fd | ||
|
|
380289d1c1 | ||
|
|
91ff882d15 | ||
|
|
5aad65fff7 | ||
|
|
decea88028 | ||
|
|
a08f44e6ed | ||
|
|
dbd1802dd6 | ||
|
|
b9f123fbea | ||
|
|
2944d00ca2 | ||
|
|
448ba892cc | ||
|
|
a5cb46e095 | ||
|
|
ee4e95c75f | ||
|
|
f488894942 | ||
|
|
269a59c1e2 | ||
|
|
d8b434e169 | ||
|
|
326b99d860 | ||
|
|
bd2f063cc0 | ||
|
|
736292538b | ||
|
|
d772efe779 |
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
# Must have
|
||||
- WhatsApp version: [WhatsApp version]
|
||||
- OS: [Android/iOS] - [version]
|
||||
- Platform: [Linux/Windows/MacOS]
|
||||
- Branch and version: [main/dev] - [exporter version]
|
||||
|
||||
If it is an error yield by Python, please also provide the trackback
|
||||
```
|
||||
[trackback here]
|
||||
```
|
||||
|
||||
# Nice to have
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
36
.github/workflows/compile-binary.yml
vendored
36
.github/workflows/compile-binary.yml
vendored
@@ -12,22 +12,22 @@ jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.12'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.3
|
||||
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
|
||||
python -m nuitka --no-deployment-flag=self-execution --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
|
||||
sha256sum wtsexporter_linux_x64
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-linux
|
||||
path: |
|
||||
@@ -36,22 +36,22 @@ jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.12'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.3
|
||||
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
|
||||
python -m nuitka --no-deployment-flag=self-execution --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
|
||||
Get-FileHash wtsexporter_x64.exe
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-windows
|
||||
path: |
|
||||
@@ -60,22 +60,22 @@ jobs:
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.12'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka
|
||||
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.3
|
||||
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
|
||||
python -m nuitka --no-deployment-flag=self-execution --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
|
||||
shasum -a 256 wtsexporter_macos_x64
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-macos
|
||||
path: |
|
||||
|
||||
11
.github/workflows/python-publish.yml
vendored
11
.github/workflows/python-publish.yml
vendored
@@ -11,6 +11,10 @@ name: Upload Python Package
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -18,9 +22,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
@@ -31,6 +35,3 @@ jobs:
|
||||
run: python -m build
|
||||
- name: Publish package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -127,3 +127,21 @@ dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# Nuitka
|
||||
*.build/
|
||||
*.dist/
|
||||
*.onefile-build/
|
||||
*.exe
|
||||
__main__
|
||||
|
||||
|
||||
# Dev time intermidiates & temp files
|
||||
result/
|
||||
WhatsApp/
|
||||
/*.db
|
||||
/*.db-*
|
||||
/myout
|
||||
/msgstore.db
|
||||
/myout-json
|
||||
.vscode/
|
||||
36
LICENSE.django
Normal file
36
LICENSE.django
Normal file
@@ -0,0 +1,36 @@
|
||||
The Whatsapp Chat Exporter is licensed under the MIT license. For more information,
|
||||
refer to the file LICENSE.
|
||||
|
||||
Whatsapp Chat Exporter incorporates code from Django, governed by the three-clause
|
||||
BSD license—a permissive open-source license. The copyright and license details are
|
||||
provided below to adhere to Django's terms.
|
||||
|
||||
------
|
||||
|
||||
Copyright (c) Django Software Foundation and individual contributors.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of Django nor the names of its contributors may be used
|
||||
to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
81
README.md
81
README.md
@@ -4,12 +4,17 @@
|
||||
[](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**
|
||||
> [!TIP]
|
||||
> 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.
|
||||
|
||||
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 [Donation Guide](https://blog.knugi.com/DONATE.html).
|
||||
|
||||
# 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)**.
|
||||
> [!NOTE]
|
||||
> 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
|
||||
@@ -35,6 +40,13 @@ Simply invoke the following command from shell.
|
||||
```sh
|
||||
wtsexporter -a
|
||||
```
|
||||
#### Enriching Contact from vCard
|
||||
Usually, the default WhatsApp contact database extracted from your phone will contains the contact names and the exporter will use it to map your chats. However, some reported cases showed that the database could has never been populated.
|
||||
In this 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:
|
||||
```sh
|
||||
pip install whatsapp-chat-exporter["vcards"]
|
||||
wtsexporter -a --enrich-from-vcard contacts.vcf --default-country-code 852
|
||||
```
|
||||
|
||||
### Encrypted Android WhatsApp Backup
|
||||
In order to support the decryption, install pycryptodome if it is not installed
|
||||
@@ -42,7 +54,10 @@ In order to support the decryption, install pycryptodome if it is not installed
|
||||
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.
|
||||
|
||||
> [!TIP]
|
||||
> 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
|
||||
You will need the decryption key file from your phone. If you have root access, you can find it as `/data/data/com.whatsapp/files/key`. Otherwise, if you used WhatsApp-Key-DB-Extractor before, it will appear in the WhatsApp backup directory as `WhatsApp/Databases/.nomedia`.
|
||||
|
||||
@@ -80,12 +95,13 @@ wtsexporter -a -k 432435053b5204b08e5c3823423399aa30ff061435ab89bc4e6713969cdaa5
|
||||
```
|
||||
|
||||
## Working with iOS/iPadOS (iPhone or iPad)
|
||||
Do an iPhone/iPad Backup with iTunes first.
|
||||
Do an iPhone/iPad Backup with iTunes/Finder first.
|
||||
* iPhone backup on Mac: https://support.apple.com/HT211229
|
||||
* iPhone backup on Windows: https://support.apple.com/HT212156
|
||||
* iPad backup: https://support.apple.com/guide/ipad/ipad9a74df05xx/ipados
|
||||
### Encrypted iOS/iPadOS Backup
|
||||
**If you are working on unencrypted iOS/iPadOS backup, skip this**
|
||||
> [!NOTE]
|
||||
> If you are working on unencrypted iOS/iPadOS backup, skip this.
|
||||
|
||||
If you want to work on an encrypted iOS/iPadOS Backup, you should install iphone_backup_decrypt from [KnugiHK/iphone_backup_decrypt](https://github.com/KnugiHK/iphone_backup_decrypt) before you run the extract_iphone_media.py.
|
||||
```sh
|
||||
@@ -99,7 +115,7 @@ wtsexporter -i -b "C:\Users\[Username]\AppData\Roaming\Apple Computer\MobileSync
|
||||
```
|
||||
#### Mac
|
||||
```sh
|
||||
wtsexporter -i -b "~/Library/Application Support/MobileSync/Backup/[device id]"
|
||||
wtsexporter -i -b ~/Library/Application\ Support/MobileSync/Backup/[device id]
|
||||
```
|
||||
|
||||
## Results
|
||||
@@ -110,30 +126,36 @@ After extracting, you will get these:
|
||||
#### Group Message
|
||||

|
||||
|
||||
*The above screenshots were taken longgggggggggggggg ago. I am going to update them when possible.*
|
||||
|
||||
## More options
|
||||
Invoke the wtsexporter with --help option will show you all options available.
|
||||
```sh
|
||||
> wtsexporter --help
|
||||
usage: wtsexporter [-h] [-a] [-i] [-e EXPORTED] [-w WA] [-m MEDIA] [-b BACKUP] [-o OUTPUT] [-j [JSON]] [-d DB] [-k KEY] [-t TEMPLATE] [-s] [-c] [--offline OFFLINE] [--size [SIZE]]
|
||||
[--no-html] [--check-update] [--assume-first-as-me]
|
||||
usage: wtsexporter [-h] [-a] [-i] [-e EXPORTED] [-w WA] [-m MEDIA] [-b BACKUP] [-o OUTPUT] [-j [JSON]] [--avoid-encoding-json] [--pretty-print-json [PRETTY_PRINT_JSON]] [-d DB] [-k KEY] [-t TEMPLATE] [-s]
|
||||
[-c] [--offline OFFLINE] [--size [SIZE]] [--no-html] [--check-update] [--assume-first-as-me] [--no-avatar] [--import] [--business] [--wab WAB] [--time-offset {-12 to 14}] [--date DATE]
|
||||
[--date-format FORMAT] [--include [phone number ...]] [--exclude [phone number ...]] [--dont-filter-empty] [--per-chat] [--create-separated-media] [--decrypt-chunk-size DECRYPT_CHUNK_SIZE]
|
||||
[--enrich-from-vcards ENRICH_FROM_VCARDS] [--default-country-code DEFAULT_CONTRY_CODE] [--txt [TEXT_FORMAT]]
|
||||
|
||||
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
|
||||
-a, --android Define the target as Android
|
||||
-i, --iphone, --ios Define the target as iPhone/iPad
|
||||
-i, --ios, --iphone Define the target as iPhone/iPad
|
||||
-e EXPORTED, --exported EXPORTED
|
||||
Define the target as exported chat file and specify the path to the file
|
||||
-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
|
||||
Path to Android (must be used together with -k)/iOS 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)
|
||||
--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.
|
||||
-d DB, --db DB Path to database file (default: msgstore.db/7c7fba66680ef796b916b067077cc246adacf01d)
|
||||
-k KEY, --key KEY Path to key file
|
||||
-t TEMPLATE, --template TEMPLATE
|
||||
@@ -146,8 +168,33 @@ options:
|
||||
--no-html Do not output html files
|
||||
--check-update Check for updates (require Internet access)
|
||||
--assume-first-as-me Assume the first message in a chat as sent by me (must be used together with -e)
|
||||
--no-avatar Do not render avatar in HTML output
|
||||
--import Import JSON file and convert to HTML output
|
||||
--business Use Whatsapp Business default files (iOS only)
|
||||
--wab WAB, --wa-backup WAB
|
||||
Path to contact database in crypt15 format
|
||||
--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.
|
||||
--per-chat Output the JSON file per chat
|
||||
--create-separated-media
|
||||
Create a copy of the media seperated per chat in <MEDIA>/separated/ directory
|
||||
--decrypt-chunk-size DECRYPT_CHUNK_SIZE
|
||||
Specify the chunk size for decrypting iOS backup, which may affect the decryption speed.
|
||||
--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_CONTRY_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
|
||||
--txt [TEXT_FORMAT] Export chats in text format similar to what WhatsApp officially provided (default if present: result/)
|
||||
|
||||
WhatsApp Chat Exporter: 0.9.5 Licensed with MIT
|
||||
WhatsApp Chat Exporter: 0.10.5 Licensed with MIT. See https://wts.knugi.dev/docs?dest=osl for all open source licenses.
|
||||
```
|
||||
|
||||
# To do
|
||||
@@ -156,6 +203,10 @@ 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
|
||||
The Telegram Desktop's export is the reference for whatsapp.html in this repo.
|
||||
|
||||
`bplist.py` was released by Vladimir "Farcaller" Pouzanov under MIT license.
|
||||
|
||||
Please also refer to any files prefixed with `LICENSE` to obtain copies of the various licenses.
|
||||
|
||||
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 https://www.whatsapp.com/.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
__version__ = "0.9.5"
|
||||
__version__ = "0.10.5"
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import io
|
||||
import os
|
||||
import sqlite3
|
||||
import shutil
|
||||
import json
|
||||
import string
|
||||
import glob
|
||||
from Whatsapp_Chat_Exporter import extract_exported, extract_iphone
|
||||
from Whatsapp_Chat_Exporter import extract, extract_iphone_media
|
||||
try:
|
||||
import vobject
|
||||
except ModuleNotFoundError:
|
||||
vcards_deps_installed = False
|
||||
else:
|
||||
from Whatsapp_Chat_Exporter.vcards_contacts import ContactsFromVCards
|
||||
vcards_deps_installed = True
|
||||
from Whatsapp_Chat_Exporter import exported_handler, android_handler
|
||||
from Whatsapp_Chat_Exporter import ios_handler, ios_media_handler
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore
|
||||
from Whatsapp_Chat_Exporter.utility import Crypt, check_update, import_from_json
|
||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, Crypt, DbType, chat_is_empty, readable_to_bytes
|
||||
from Whatsapp_Chat_Exporter.utility import check_update, import_from_json, sanitize_filename, bytes_to_readable
|
||||
from argparse import ArgumentParser, SUPPRESS
|
||||
from datetime import datetime
|
||||
from sys import exit
|
||||
try:
|
||||
from .__init__ import __version__
|
||||
@@ -20,10 +30,11 @@ except ImportError:
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser(
|
||||
description = 'A customizable Android and iPhone WhatsApp database parser that '
|
||||
'will give you the history of your WhatsApp conversations inHTML '
|
||||
description = '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.',
|
||||
epilog = f'WhatsApp Chat Exporter: {__version__} Licensed with MIT'
|
||||
epilog = f'WhatsApp Chat Exporter: {__version__} Licensed with MIT. See '
|
||||
'https://wts.knugi.dev/docs?dest=osl for all open source licenses.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-a',
|
||||
@@ -34,9 +45,9 @@ def main():
|
||||
help="Define the target as Android")
|
||||
parser.add_argument(
|
||||
'-i',
|
||||
'--iphone',
|
||||
'--ios',
|
||||
dest='iphone',
|
||||
'--iphone',
|
||||
dest='ios',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Define the target as iPhone/iPad")
|
||||
@@ -65,7 +76,7 @@ def main():
|
||||
dest="backup",
|
||||
default=None,
|
||||
help="Path to Android (must be used together "
|
||||
"with -k)/iPhone WhatsApp backup")
|
||||
"with -k)/iOS WhatsApp backup")
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
@@ -81,6 +92,20 @@ def main():
|
||||
type=str,
|
||||
const="result.json",
|
||||
help="Save the result to a single JSON file (default if present: result.json)")
|
||||
parser.add_argument(
|
||||
'--avoid-encoding-json',
|
||||
dest='avoid_encoding_json',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Don't encode non-ascii characters in the output JSON files")
|
||||
parser.add_argument(
|
||||
'--pretty-print-json',
|
||||
dest='pretty_print_json',
|
||||
default=None,
|
||||
nargs='?',
|
||||
const=2,
|
||||
type=int,
|
||||
help="Pretty print the output JSON.")
|
||||
parser.add_argument(
|
||||
'-d',
|
||||
'--db',
|
||||
@@ -137,7 +162,6 @@ def main():
|
||||
"--split",
|
||||
dest="size",
|
||||
nargs='?',
|
||||
type=int,
|
||||
const=0,
|
||||
default=None,
|
||||
help="Maximum (rough) size of a single output file in bytes, 0 for auto"
|
||||
@@ -177,6 +201,106 @@ def main():
|
||||
action='store_true',
|
||||
help="Import JSON file and convert to HTML output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--business",
|
||||
dest="business",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Use Whatsapp Business default files (iOS only)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wab",
|
||||
"--wa-backup",
|
||||
dest="wab",
|
||||
default=None,
|
||||
help="Path to contact database in crypt15 format"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--time-offset",
|
||||
dest="timezone_offset",
|
||||
default=0,
|
||||
type=int,
|
||||
choices=range(-12, 15),
|
||||
metavar="{-12 to 14}",
|
||||
help="Offset in hours (-12 to 14) for time displayed in the output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
dest="filter_date",
|
||||
default=None,
|
||||
metavar="DATE",
|
||||
help="The date filter in specific format (inclusive)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date-format",
|
||||
dest="filter_date_format",
|
||||
default="%Y-%m-%d %H:%M",
|
||||
metavar="FORMAT",
|
||||
help="The date format for the date filter"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include",
|
||||
dest="filter_chat_include",
|
||||
nargs='*',
|
||||
metavar="phone number",
|
||||
help="Include chats that match the supplied phone number"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude",
|
||||
dest="filter_chat_exclude",
|
||||
nargs='*',
|
||||
metavar="phone number",
|
||||
help="Exclude chats that match the supplied phone number"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dont-filter-empty",
|
||||
dest="filter_empty",
|
||||
default=True,
|
||||
action='store_false',
|
||||
help="By default, the exporter will not render chats with no valid message. Setting this flag will cause the exporter to render those."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--per-chat",
|
||||
dest="json_per_chat",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Output the JSON file per chat"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create-separated-media",
|
||||
dest="separate_media",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Create a copy of the media seperated per chat in <MEDIA>/separated/ directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--decrypt-chunk-size",
|
||||
dest="decrypt_chunk_size",
|
||||
default=1 * 1024 * 1024,
|
||||
type=int,
|
||||
help="Specify the chunk size for decrypting iOS backup, which may affect the decryption speed."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enrich-from-vcards",
|
||||
dest="enrich_from_vcards",
|
||||
default=None,
|
||||
help="Path to an exported vcf file from Google contacts export. Add names missing from WhatsApp's default database"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--default-country-code",
|
||||
dest="default_contry_code",
|
||||
default=None,
|
||||
help="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"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--txt",
|
||||
dest="text_format",
|
||||
nargs='?',
|
||||
default=None,
|
||||
type=str,
|
||||
const="result",
|
||||
help="Export chats in text format similar to what WhatsApp officially provided (default if present: result/)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check for updates
|
||||
@@ -184,34 +308,97 @@ def main():
|
||||
exit(check_update())
|
||||
|
||||
# Sanity checks
|
||||
if args.android and args.iphone and args.exported and args.import_json:
|
||||
print("You must define only one device type.")
|
||||
exit(1)
|
||||
if not args.android and not args.iphone and not args.exported and not args.import_json:
|
||||
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)
|
||||
if args.import_json and (args.android or args.iphone or args.exported or args.no_html):
|
||||
print("You can only use --import with -j and without --no-html.")
|
||||
exit(1)
|
||||
if args.android and args.ios and args.exported and args.import_json:
|
||||
parser.error("You must define only one device type.")
|
||||
if not args.android and not args.ios and not args.exported and not args.import_json:
|
||||
parser.error("You must define the device type.")
|
||||
if args.no_html and not args.json and not args.text_format:
|
||||
parser.error("You must either specify a JSON output file, text file output directory or enable HTML output.")
|
||||
if args.import_json and (args.android or args.ios or args.exported or args.no_html):
|
||||
parser.error("You can only use --import with -j and without --no-html, -a, -i, -e.")
|
||||
elif args.import_json and not os.path.isfile(args.json):
|
||||
print("JSON file not found.")
|
||||
exit(1)
|
||||
parser.error("JSON file not found.")
|
||||
if args.android and args.business:
|
||||
parser.error("WhatsApp Business is only available on iOS for now.")
|
||||
if args.json_per_chat and (
|
||||
(args.json[-5:] != ".json" and os.path.isfile(args.json)) or \
|
||||
(args.json[-5:] == ".json" and os.path.isfile(args.json[:-5]))
|
||||
):
|
||||
parser.error("When --per-chat is enabled, the destination of --json must be a directory.")
|
||||
if args.enrich_from_vcards is not None and args.default_contry_code is None:
|
||||
parser.error("When --enrich-from-vcards is provided, you must also set --default-country-code")
|
||||
if args.size is not None and not isinstance(args.size, int) and not args.size.isnumeric():
|
||||
try:
|
||||
args.size = readable_to_bytes(args.size)
|
||||
except ValueError:
|
||||
parser.error("The value for --split must be ended in pure bytes or with a proper unit (e.g., 1048576 or 1MB)")
|
||||
if args.filter_date is not None:
|
||||
if " - " in args.filter_date:
|
||||
start, end = args.filter_date.split(" - ")
|
||||
start = int(datetime.strptime(start, args.filter_date_format).timestamp())
|
||||
end = int(datetime.strptime(end, args.filter_date_format).timestamp())
|
||||
if start < 1009843200 or end < 1009843200:
|
||||
parser.error("WhatsApp was first released in 2009...")
|
||||
if start > end:
|
||||
parser.error("The start date cannot be a moment after the end date.")
|
||||
if args.android:
|
||||
args.filter_date = f"BETWEEN {start}000 AND {end}000"
|
||||
elif args.ios:
|
||||
args.filter_date = f"BETWEEN {start - APPLE_TIME} AND {end - APPLE_TIME}"
|
||||
else:
|
||||
_timestamp = int(datetime.strptime(args.filter_date[2:], args.filter_date_format).timestamp())
|
||||
if _timestamp < 1009843200:
|
||||
parser.error("WhatsApp was first released in 2009...")
|
||||
if args.filter_date[:2] == "> ":
|
||||
if args.android:
|
||||
args.filter_date = f">= {_timestamp}000"
|
||||
elif args.ios:
|
||||
args.filter_date = f">= {_timestamp - APPLE_TIME}"
|
||||
elif args.filter_date[:2] == "< ":
|
||||
if args.android:
|
||||
args.filter_date = f"<= {_timestamp}000"
|
||||
elif args.ios:
|
||||
args.filter_date = f"<= {_timestamp - APPLE_TIME}"
|
||||
else:
|
||||
parser.error("Unsupported date format. See https://wts.knugi.dev/docs?dest=date")
|
||||
if args.filter_chat_include is not None and args.filter_chat_exclude is not None:
|
||||
parser.error("Chat inclusion and exclusion filters cannot be used together.")
|
||||
if args.filter_chat_include is not None:
|
||||
for chat in args.filter_chat_include:
|
||||
if not chat.isnumeric():
|
||||
parser.error("Enter a phone number in the chat filter. See https://wts.knugi.dev/docs?dest=chat")
|
||||
if args.filter_chat_exclude is not None:
|
||||
for chat in args.filter_chat_exclude:
|
||||
if not chat.isnumeric():
|
||||
parser.error("Enter a phone number in the chat filter. See https://wts.knugi.dev/docs?dest=chat")
|
||||
filter_chat = (args.filter_chat_include, args.filter_chat_exclude)
|
||||
|
||||
data = {}
|
||||
|
||||
if args.enrich_from_vcards is not None:
|
||||
if not vcards_deps_installed:
|
||||
parser.error(
|
||||
"You don't have the dependency to enrich contacts with vCard.\n"
|
||||
"Read more on how to deal with enriching contacts:\n"
|
||||
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/blob/main/README.md#usage"
|
||||
)
|
||||
contact_store = ContactsFromVCards()
|
||||
contact_store.load_vcf_file(args.enrich_from_vcards, args.default_contry_code)
|
||||
|
||||
if args.android:
|
||||
contacts = extract.contacts
|
||||
messages = extract.messages
|
||||
media = extract.media
|
||||
vcard = extract.vcard
|
||||
create_html = extract.create_html
|
||||
contacts = android_handler.contacts
|
||||
messages = android_handler.messages
|
||||
media = android_handler.media
|
||||
vcard = android_handler.vcard
|
||||
create_html = android_handler.create_html
|
||||
if args.db is None:
|
||||
msg_db = "msgstore.db"
|
||||
else:
|
||||
msg_db = args.db
|
||||
if args.wa is None:
|
||||
contact_db = "wa.db"
|
||||
else:
|
||||
contact_db = args.wa
|
||||
if args.key is not None:
|
||||
if args.backup is None:
|
||||
print("You must specify the backup file with -b")
|
||||
@@ -228,7 +415,20 @@ def main():
|
||||
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 args.wab:
|
||||
wab = open(args.wab, "rb").read()
|
||||
error_wa = android_handler.decrypt_backup(wab, key, contact_db, crypt, args.showkey, DbType.CONTACT)
|
||||
if isinstance(key, io.IOBase):
|
||||
key.seek(0)
|
||||
else:
|
||||
error_wa = 0
|
||||
error_message = android_handler.decrypt_backup(db, key, msg_db, crypt, args.showkey, DbType.MESSAGE)
|
||||
if error_wa != 0:
|
||||
error = error_wa
|
||||
elif error_message != 0:
|
||||
error = error_message
|
||||
else:
|
||||
error = 0
|
||||
if error != 0:
|
||||
if error == 1:
|
||||
print("Dependencies of decrypt_backup and/or extract_encrypted_key"
|
||||
@@ -241,10 +441,6 @@ def main():
|
||||
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"
|
||||
|
||||
@@ -252,27 +448,31 @@ def main():
|
||||
with sqlite3.connect(contact_db) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
contacts(db, data)
|
||||
elif args.iphone:
|
||||
elif args.ios:
|
||||
import sys
|
||||
if "--iphone" in sys.argv:
|
||||
print(
|
||||
"WARNING: The --iphone flag is deprecated and will"
|
||||
"be removed in the future. Use --ios instead."
|
||||
)
|
||||
contacts = extract_iphone.contacts
|
||||
messages = extract_iphone.messages
|
||||
media = extract_iphone.media
|
||||
vcard = extract_iphone.vcard
|
||||
create_html = extract.create_html
|
||||
contacts = ios_handler.contacts
|
||||
messages = ios_handler.messages
|
||||
media = ios_handler.media
|
||||
vcard = ios_handler.vcard
|
||||
create_html = android_handler.create_html
|
||||
if args.business:
|
||||
from Whatsapp_Chat_Exporter.utility import WhatsAppBusinessIdentifier as identifiers
|
||||
else:
|
||||
from Whatsapp_Chat_Exporter.utility import WhatsAppIdentifier as identifiers
|
||||
if args.media is None:
|
||||
args.media = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
|
||||
args.media = identifiers.DOMAIN
|
||||
if args.backup is not None:
|
||||
if not os.path.isdir(args.media):
|
||||
extract_iphone_media.extract_media(args.backup)
|
||||
ios_media_handler.extract_media(args.backup, identifiers, args.decrypt_chunk_size)
|
||||
else:
|
||||
print("WhatsApp directory already exists, skipping WhatsApp file extraction.")
|
||||
if args.db is None:
|
||||
msg_db = "7c7fba66680ef796b916b067077cc246adacf01d"
|
||||
msg_db = identifiers.MESSAGE
|
||||
else:
|
||||
msg_db = args.db
|
||||
if args.wa is None:
|
||||
@@ -288,12 +488,15 @@ def main():
|
||||
if os.path.isfile(msg_db):
|
||||
with sqlite3.connect(msg_db) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
messages(db, data, args.media)
|
||||
media(db, data, args.media)
|
||||
vcard(db, data)
|
||||
messages(db, data, args.media, args.timezone_offset, args.filter_date, filter_chat)
|
||||
media(db, data, args.media, args.filter_date, filter_chat, args.separate_media)
|
||||
vcard(db, data, args.media, args.filter_date, filter_chat)
|
||||
if args.android:
|
||||
extract.calls(db, data)
|
||||
android_handler.calls(db, data, args.timezone_offset, filter_chat)
|
||||
if not args.no_html:
|
||||
if args.enrich_from_vcards is not None and not contact_store.is_empty():
|
||||
contact_store.enrich_from_vcards(data)
|
||||
|
||||
create_html(
|
||||
data,
|
||||
args.output,
|
||||
@@ -301,14 +504,15 @@ def main():
|
||||
args.embedded,
|
||||
args.offline,
|
||||
args.size,
|
||||
args.no_avatar
|
||||
args.no_avatar,
|
||||
args.filter_empty
|
||||
)
|
||||
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)
|
||||
exit(6)
|
||||
|
||||
if os.path.isdir(args.media):
|
||||
media_path = os.path.join(args.output, args.media)
|
||||
@@ -328,36 +532,72 @@ def main():
|
||||
print("\nCannot remove original WhatsApp directory. "
|
||||
"Perhaps the directory is opened?", end="\n")
|
||||
elif args.exported:
|
||||
extract_exported.messages(args.exported, data, args.assume_first_as_me)
|
||||
exported_handler.messages(args.exported, data, args.assume_first_as_me)
|
||||
if not args.no_html:
|
||||
extract.create_html(
|
||||
android_handler.create_html(
|
||||
data,
|
||||
args.output,
|
||||
args.template,
|
||||
args.embedded,
|
||||
args.offline,
|
||||
args.size
|
||||
args.size,
|
||||
args.no_avatar,
|
||||
args.filter_empty
|
||||
)
|
||||
for file in glob.glob(r'*.*'):
|
||||
shutil.copy(file, args.output)
|
||||
elif args.import_json:
|
||||
import_from_json(args.json, data)
|
||||
extract.create_html(
|
||||
android_handler.create_html(
|
||||
data,
|
||||
args.output,
|
||||
args.template,
|
||||
args.embedded,
|
||||
args.offline,
|
||||
args.size
|
||||
args.size,
|
||||
args.no_avatar,
|
||||
args.filter_empty
|
||||
)
|
||||
|
||||
if args.text_format:
|
||||
print("Writing text file...")
|
||||
android_handler.create_txt(data, args.text_format)
|
||||
|
||||
if args.json and not args.import_json:
|
||||
if args.filter_empty:
|
||||
data = {k: v for k, v in data.items() if not chat_is_empty(v)}
|
||||
|
||||
if args.enrich_from_vcards is not None and not contact_store.is_empty():
|
||||
contact_store.enrich_from_vcards(data)
|
||||
|
||||
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)
|
||||
|
||||
if not args.json_per_chat:
|
||||
with open(args.json, "w") as f:
|
||||
data = json.dumps(
|
||||
data,
|
||||
ensure_ascii=not args.avoid_encoding_json,
|
||||
indent=args.pretty_print_json
|
||||
)
|
||||
print(f"\nWriting JSON file...({bytes_to_readable(len(data))})")
|
||||
f.write(data)
|
||||
else:
|
||||
if args.json[-5:] == ".json":
|
||||
args.json = args.json[:-5]
|
||||
total = len(data.keys())
|
||||
if not os.path.isdir(args.json):
|
||||
os.mkdir(args.json)
|
||||
for index, jik in enumerate(data.keys()):
|
||||
if data[jik]["name"] is not None:
|
||||
contact = data[jik]["name"].replace('/', '')
|
||||
else:
|
||||
contact = jik.replace('+', '')
|
||||
with open(f"{args.json}/{sanitize_filename(contact)}.json", "w") as f:
|
||||
file_content_to_write = json.dumps({jik: data[jik]}, ensure_ascii=not args.avoid_encoding_json, indent=2 if args.pretty_print_json else None)
|
||||
f.write(file_content_to_write)
|
||||
print(f"Writing JSON file...({index + 1}/{total})", end="\r")
|
||||
print()
|
||||
else:
|
||||
print()
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import jinja2
|
||||
import os
|
||||
import shutil
|
||||
import re
|
||||
import io
|
||||
import hmac
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from mimetypes import MimeTypes
|
||||
from markupsafe import escape as htmle
|
||||
from hashlib import sha256
|
||||
from base64 import b64decode, b64encode
|
||||
from datetime import datetime
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||
from Whatsapp_Chat_Exporter.utility import MAX_SIZE, ROW_SIZE, determine_metadata, get_status_location
|
||||
from Whatsapp_Chat_Exporter.utility import rendering, Crypt, Device, get_file_name, setup_template
|
||||
from Whatsapp_Chat_Exporter.utility import brute_force_offset, CRYPT14_OFFSETS
|
||||
from Whatsapp_Chat_Exporter.utility import MAX_SIZE, ROW_SIZE, DbType, convert_time_unit, determine_metadata
|
||||
from Whatsapp_Chat_Exporter.utility import rendering, Crypt, Device, get_file_name, setup_template, JidType
|
||||
from Whatsapp_Chat_Exporter.utility import brute_force_offset, CRYPT14_OFFSETS, get_status_location
|
||||
from Whatsapp_Chat_Exporter.utility import get_chat_condition, slugify, bytes_to_readable, chat_is_empty
|
||||
|
||||
try:
|
||||
import zlib
|
||||
@@ -53,7 +53,7 @@ def _extract_encrypted_key(keyfile):
|
||||
return _generate_hmac_of_hmac(key_stream)
|
||||
|
||||
|
||||
def decrypt_backup(database, key, output, crypt=Crypt.CRYPT14, show_crypt15=False):
|
||||
def decrypt_backup(database, key, output, crypt=Crypt.CRYPT14, show_crypt15=False, db_type=DbType.MESSAGE):
|
||||
if not support_backup:
|
||||
return 1
|
||||
if isinstance(key, io.IOBase):
|
||||
@@ -83,8 +83,12 @@ def decrypt_backup(database, key, output, crypt=Crypt.CRYPT14, show_crypt15=Fals
|
||||
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
|
||||
if db_type == DbType.MESSAGE:
|
||||
iv = database[8:24]
|
||||
db_offset = database[0] + 2 # Skip protobuf + protobuf size and backup type
|
||||
elif db_type == DbType.CONTACT:
|
||||
iv = database[7:23]
|
||||
db_offset = database[0] + 1 # Skip protobuf + protobuf size
|
||||
db_ciphertext = database[db_offset:]
|
||||
|
||||
if t1 != t2:
|
||||
@@ -154,7 +158,11 @@ def contacts(db, data):
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM wa_contacts""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Processing contacts...({total_row_number})")
|
||||
if total_row_number == 0:
|
||||
print("No contacts profiles found in the default database, consider using --enrich-from-vcards for adopting names from exported contacts from Google")
|
||||
return False
|
||||
else:
|
||||
print(f"Processing contacts...({total_row_number})")
|
||||
|
||||
c.execute("""SELECT jid, COALESCE(display_name, wa_name) as display_name, status FROM wa_contacts; """)
|
||||
row = c.fetchone()
|
||||
@@ -165,19 +173,37 @@ def contacts(db, data):
|
||||
row = c.fetchone()
|
||||
|
||||
|
||||
def messages(db, data, media_folder):
|
||||
def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat):
|
||||
# Get message history
|
||||
c = db.cursor()
|
||||
try:
|
||||
c.execute("""SELECT count() FROM messages""")
|
||||
c.execute(f"""SELECT count()
|
||||
FROM messages
|
||||
INNER JOIN jid
|
||||
ON messages.key_remote_jid = jid.raw_string
|
||||
WHERE 1=1
|
||||
{f'AND timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "messages.remote_resource"], "jid", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "messages.remote_resource"], "jid", "android")}""")
|
||||
|
||||
except sqlite3.OperationalError:
|
||||
c.execute("""SELECT count() FROM message""")
|
||||
c.execute(f"""SELECT count()
|
||||
FROM message
|
||||
LEFT JOIN chat
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid
|
||||
ON jid._id = chat.jid_row_id
|
||||
LEFT JOIN jid jid_group
|
||||
ON jid_group._id = message.sender_jid_row_id
|
||||
WHERE 1=1
|
||||
{f'AND timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")}""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Processing 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,
|
||||
c.execute(f"""SELECT messages.key_remote_jid,
|
||||
messages._id,
|
||||
messages.key_from_me,
|
||||
messages.timestamp,
|
||||
@@ -193,12 +219,18 @@ def messages(db, data, media_folder):
|
||||
messages.key_id,
|
||||
messages_quotes.data as quoted_data,
|
||||
messages.media_caption,
|
||||
missed_call_logs.video_call,
|
||||
missed_call_logs.video_call,
|
||||
chat.subject as chat_subject,
|
||||
message_system.action_type,
|
||||
message_system_group.is_me_joined,
|
||||
jid_old.raw_string as old_jid,
|
||||
jid_new.raw_string as new_jid
|
||||
jid_new.raw_string as new_jid,
|
||||
jid_global.type as jid_type,
|
||||
group_concat(receipt_user.receipt_timestamp) as receipt_timestamp,
|
||||
group_concat(messages.received_timestamp) as received_timestamp,
|
||||
group_concat(receipt_user.read_timestamp) as read_timestamp,
|
||||
group_concat(receipt_user.played_timestamp) as played_timestamp,
|
||||
group_concat(messages.read_device_timestamp) as read_device_timestamp
|
||||
FROM messages
|
||||
LEFT JOIN messages_quotes
|
||||
ON messages.quoted_row_id = messages_quotes._id
|
||||
@@ -218,11 +250,18 @@ def messages(db, data, media_folder):
|
||||
ON jid_old._id = message_system_number_change.old_jid_row_id
|
||||
LEFT JOIN jid jid_new
|
||||
ON jid_new._id = message_system_number_change.new_jid_row_id
|
||||
WHERE messages.key_remote_jid <> '-1';"""
|
||||
LEFT JOIN receipt_user
|
||||
ON receipt_user.message_row_id = messages._id
|
||||
WHERE messages.key_remote_jid <> '-1'
|
||||
{f'AND messages.timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "messages.remote_resource"], "jid_global", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "messages.remote_resource"], "jid_global", "android")}
|
||||
GROUP BY messages._id
|
||||
ORDER BY messages.timestamp ASC;"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
try:
|
||||
c.execute("""SELECT jid_global.raw_string as key_remote_jid,
|
||||
c.execute(f"""SELECT jid_global.raw_string as key_remote_jid,
|
||||
message._id,
|
||||
message.from_me as key_from_me,
|
||||
message.timestamp,
|
||||
@@ -244,7 +283,12 @@ def messages(db, data, media_folder):
|
||||
message_system.action_type,
|
||||
message_system_group.is_me_joined,
|
||||
jid_old.raw_string as old_jid,
|
||||
jid_new.raw_string as new_jid
|
||||
jid_new.raw_string as new_jid,
|
||||
jid_global.type as jid_type,
|
||||
group_concat(receipt_user.receipt_timestamp) as receipt_timestamp,
|
||||
group_concat(message.received_timestamp) as received_timestamp,
|
||||
group_concat(receipt_user.read_timestamp) as read_timestamp,
|
||||
group_concat(receipt_user.played_timestamp) as played_timestamp
|
||||
FROM message
|
||||
LEFT JOIN message_quoted
|
||||
ON message_quoted.message_row_id = message._id
|
||||
@@ -274,7 +318,13 @@ def messages(db, data, media_folder):
|
||||
ON jid_old._id = message_system_number_change.old_jid_row_id
|
||||
LEFT JOIN jid jid_new
|
||||
ON jid_new._id = message_system_number_change.new_jid_row_id
|
||||
WHERE key_remote_jid <> '-1';"""
|
||||
LEFT JOIN receipt_user
|
||||
ON receipt_user.message_row_id = message._id
|
||||
WHERE key_remote_jid <> '-1'
|
||||
{f'AND message.timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid_global", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid_global", "android")}
|
||||
GROUP BY message._id;"""
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
@@ -304,6 +354,7 @@ def messages(db, data, media_folder):
|
||||
timestamp=content["timestamp"],
|
||||
time=content["timestamp"],
|
||||
key_id=content["key_id"],
|
||||
timezone_offset=timezone_offset
|
||||
)
|
||||
if isinstance(content["data"], bytes):
|
||||
message.data = ("The message is binary data and its base64 is "
|
||||
@@ -316,7 +367,7 @@ def messages(db, data, media_folder):
|
||||
i += 1
|
||||
content = c.fetchone()
|
||||
continue
|
||||
if "-" in content["key_remote_jid"] and content["key_from_me"] == 0:
|
||||
if content["jid_type"] == JidType.GROUP and content["key_from_me"] == 0:
|
||||
name = fallback = None
|
||||
if table_message:
|
||||
if content["sender_jid_row_id"] > 0:
|
||||
@@ -390,8 +441,7 @@ def messages(db, data, media_folder):
|
||||
message.data = None
|
||||
else:
|
||||
# Real message
|
||||
if content["media_wa_type"] == 20: # Sticker is a message
|
||||
message.sticker = True
|
||||
message.sticker = content["media_wa_type"] == 20 # Sticker is a message
|
||||
if content["key_from_me"] == 1:
|
||||
if content["status"] == 5 and content["edit_version"] == 7 or table_message and content["media_wa_type"] == 15:
|
||||
msg = "Message deleted"
|
||||
@@ -404,9 +454,9 @@ def messages(db, data, media_folder):
|
||||
msg = content["data"]
|
||||
if msg is not None:
|
||||
if "\r\n" in msg:
|
||||
msg = msg.replace("\r\n", "<br>")
|
||||
msg = msg.replace("\r\n", " <br>")
|
||||
if "\n" in msg:
|
||||
msg = msg.replace("\n", "<br>")
|
||||
msg = msg.replace("\n", " <br>")
|
||||
else:
|
||||
if content["status"] == 0 and content["edit_version"] == 7 or table_message and content["media_wa_type"] == 15:
|
||||
msg = "Message deleted"
|
||||
@@ -419,9 +469,9 @@ def messages(db, data, media_folder):
|
||||
msg = content["data"]
|
||||
if msg is not None:
|
||||
if "\r\n" in msg:
|
||||
msg = msg.replace("\r\n", "<br>")
|
||||
msg = msg.replace("\r\n", " <br>")
|
||||
if "\n" in msg:
|
||||
msg = msg.replace("\n", "<br>")
|
||||
msg = msg.replace("\n", " <br>")
|
||||
message.data = msg
|
||||
|
||||
data[content["key_remote_jid"]].add_message(content["_id"], message)
|
||||
@@ -438,15 +488,40 @@ def messages(db, data, media_folder):
|
||||
print(f"Processing messages...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def media(db, data, media_folder):
|
||||
def media(db, data, media_folder, filter_date, filter_chat, separate_media=True):
|
||||
# Get media
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM message_media""")
|
||||
try:
|
||||
c.execute(f"""SELECT count()
|
||||
FROM message_media
|
||||
INNER JOIN messages
|
||||
ON message_media.message_row_id = messages._id
|
||||
INNER JOIN jid
|
||||
ON messages.key_remote_jid = jid.raw_string
|
||||
WHERE 1=1
|
||||
{f'AND messages.timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}""")
|
||||
except sqlite3.OperationalError:
|
||||
c.execute(f"""SELECT count()
|
||||
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
|
||||
LEFT JOIN jid jid_group
|
||||
ON jid_group._id = message.sender_jid_row_id
|
||||
WHERE 1=1
|
||||
{f'AND message.timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")}""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"\nProcessing media...(0/{total_row_number})", end="\r")
|
||||
i = 0
|
||||
try:
|
||||
c.execute("""SELECT messages.key_remote_jid,
|
||||
c.execute(f"""SELECT messages.key_remote_jid,
|
||||
message_row_id,
|
||||
file_path,
|
||||
message_url,
|
||||
@@ -459,10 +534,16 @@ def media(db, data, media_folder):
|
||||
ON message_media.message_row_id = messages._id
|
||||
LEFT JOIN media_hash_thumbnail
|
||||
ON message_media.file_hash = media_hash_thumbnail.media_hash
|
||||
INNER JOIN jid
|
||||
ON messages.key_remote_jid = jid.raw_string
|
||||
WHERE jid.type <> 7
|
||||
{f'AND messages.timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
|
||||
ORDER BY messages.key_remote_jid ASC"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
c.execute("""SELECT jid.raw_string as key_remote_jid,
|
||||
c.execute(f"""SELECT jid.raw_string as key_remote_jid,
|
||||
message_row_id,
|
||||
file_path,
|
||||
message_url,
|
||||
@@ -471,14 +552,20 @@ def media(db, data, media_folder):
|
||||
file_hash,
|
||||
thumbnail
|
||||
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
|
||||
LEFT JOIN media_hash_thumbnail
|
||||
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
|
||||
LEFT JOIN media_hash_thumbnail
|
||||
ON message_media.file_hash = media_hash_thumbnail.media_hash
|
||||
LEFT JOIN jid jid_group
|
||||
ON jid_group._id = message.sender_jid_row_id
|
||||
WHERE jid.type <> 7
|
||||
{f'AND message.timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")}
|
||||
ORDER BY jid.raw_string ASC"""
|
||||
)
|
||||
content = c.fetchone()
|
||||
@@ -499,18 +586,16 @@ def media(db, data, media_folder):
|
||||
message.mime = "application/octet-stream"
|
||||
else:
|
||||
message.mime = content["mime_type"]
|
||||
if separate_media:
|
||||
chat_display_name = slugify(data[content["key_remote_jid"]].name or message.sender \
|
||||
or content["key_remote_jid"].split('@')[0], True)
|
||||
current_filename = file_path.split("/")[-1]
|
||||
new_folder = os.path.join(media_folder, "separated", chat_display_name)
|
||||
Path(new_folder).mkdir(parents=True, exist_ok=True)
|
||||
new_path = os.path.join(new_folder, current_filename)
|
||||
shutil.copy2(file_path, new_path)
|
||||
message.data = new_path
|
||||
else:
|
||||
if False: # Block execution
|
||||
try:
|
||||
r = requests.get(content["message_url"])
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError()
|
||||
except:
|
||||
message.data = "The media is missing"
|
||||
message.mime = "media"
|
||||
message.meta = True
|
||||
else:
|
||||
...
|
||||
message.data = "The media is missing"
|
||||
message.mime = "media"
|
||||
message.meta = True
|
||||
@@ -528,20 +613,26 @@ def media(db, data, media_folder):
|
||||
f"Processing media...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def vcard(db, data):
|
||||
def vcard(db, data, media_folder, filter_date, filter_chat):
|
||||
c = db.cursor()
|
||||
try:
|
||||
c.execute("""SELECT message_row_id,
|
||||
c.execute(f"""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
|
||||
INNER JOIN jid
|
||||
ON messages.key_remote_jid = jid.raw_string
|
||||
WHERE 1=1
|
||||
{f'AND messages.timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")}
|
||||
ORDER BY messages.key_remote_jid ASC;"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
c.execute("""SELECT message_row_id,
|
||||
c.execute(f"""SELECT message_row_id,
|
||||
jid.raw_string as key_remote_jid,
|
||||
vcard,
|
||||
message.text_data as media_name
|
||||
@@ -552,40 +643,54 @@ def vcard(db, data):
|
||||
ON chat._id = message.chat_row_id
|
||||
INNER JOIN jid
|
||||
ON jid._id = chat.jid_row_id
|
||||
LEFT JOIN jid jid_group
|
||||
ON jid_group._id = message.sender_jid_row_id
|
||||
WHERE 1=1
|
||||
{f'AND message.timestamp {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")}
|
||||
{get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")}
|
||||
ORDER BY message.chat_row_id ASC;"""
|
||||
)
|
||||
|
||||
rows = c.fetchall()
|
||||
total_row_number = len(rows)
|
||||
print(f"\nProcessing vCards...(0/{total_row_number})", end="\r")
|
||||
base = "WhatsApp/vCards"
|
||||
if not os.path.isdir(base):
|
||||
Path(base).mkdir(parents=True, exist_ok=True)
|
||||
path = f"{media_folder}/vCards"
|
||||
if not os.path.isdir(path):
|
||||
Path(path).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 ""
|
||||
media_name = row["media_name"] if row["media_name"] is not None else "Undefined vCard File"
|
||||
file_name = "".join(x for x in media_name if x.isalnum())
|
||||
file_name = file_name.encode('utf-8')[:230].decode('utf-8', 'ignore')
|
||||
file_path = os.path.join(base, f"{file_name}.vcf")
|
||||
file_path = os.path.join(path, f"{file_name}.vcf")
|
||||
if not os.path.isfile(file_path):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(row["vcard"])
|
||||
message = data[row["key_remote_jid"]].messages[row["message_row_id"]]
|
||||
message.data = media_name + \
|
||||
"The vCard file cannot be displayed here, " \
|
||||
f"however it should be located at {file_path}"
|
||||
message.data = "This media include the following vCard file(s):<br>" \
|
||||
f'<a href="{htmle(file_path)}">{htmle(media_name)}</a>'
|
||||
message.mime = "text/x-vcard"
|
||||
message.meta = True
|
||||
message.safe = True
|
||||
print(f"Processing vCards...({index + 1}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def calls(db, data):
|
||||
def calls(db, data, timezone_offset, filter_chat):
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT count() FROM call_log""")
|
||||
c.execute(f"""SELECT count()
|
||||
FROM call_log
|
||||
INNER JOIN jid
|
||||
ON call_log.jid_row_id = jid._id
|
||||
LEFT JOIN chat
|
||||
ON call_log.jid_row_id = chat.jid_row_id
|
||||
WHERE 1=1
|
||||
{get_chat_condition(filter_chat[0], True, ["jid.raw_string"])}
|
||||
{get_chat_condition(filter_chat[1], False, ["jid.raw_string"])}""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
if total_row_number == 0:
|
||||
return
|
||||
print(f"\nProcessing calls...({total_row_number})", end="\r")
|
||||
c.execute("""SELECT call_log._id,
|
||||
c.execute(f"""SELECT call_log._id,
|
||||
jid.raw_string,
|
||||
from_me,
|
||||
call_id,
|
||||
@@ -593,10 +698,16 @@ def calls(db, data):
|
||||
video_call,
|
||||
duration,
|
||||
call_result,
|
||||
bytes_transferred
|
||||
bytes_transferred,
|
||||
chat.subject as chat_subject
|
||||
FROM call_log
|
||||
INNER JOIN jid
|
||||
ON call_log.jid_row_id = jid._id"""
|
||||
ON call_log.jid_row_id = jid._id
|
||||
LEFT JOIN chat
|
||||
ON call_log.jid_row_id = chat.jid_row_id
|
||||
WHERE 1=1
|
||||
{get_chat_condition(filter_chat[0], True, ["jid.raw_string"])}
|
||||
{get_chat_condition(filter_chat[1], False, ["jid.raw_string"])}"""
|
||||
)
|
||||
chat = ChatStore(Device.ANDROID, "WhatsApp Calls")
|
||||
content = c.fetchone()
|
||||
@@ -606,28 +717,36 @@ def calls(db, data):
|
||||
timestamp=content["timestamp"],
|
||||
time=content["timestamp"],
|
||||
key_id=content["call_id"],
|
||||
timezone_offset=timezone_offset
|
||||
)
|
||||
_jid = content["raw_string"]
|
||||
if _jid in data:
|
||||
name = data[_jid].name
|
||||
fallback = _jid.split('@')[0] if "@" in _jid else None
|
||||
call.sender = name or fallback
|
||||
|
||||
name = data[_jid].name if _jid in data else content["chat_subject"] or None
|
||||
if _jid is not None and "@" in _jid:
|
||||
fallback = _jid.split('@')[0]
|
||||
else:
|
||||
fallback = None
|
||||
call.sender = name or fallback
|
||||
call.meta = True
|
||||
call.data = (
|
||||
f"A {'video' if content['video_call'] else 'voice'} "
|
||||
f"call {'to' if call.from_me else 'from'} "
|
||||
f"{name or fallback} was "
|
||||
f"{call.sender} was "
|
||||
)
|
||||
if content['call_result'] == 2:
|
||||
if content['call_result'] in (0, 4, 7):
|
||||
call.data += "cancelled." if call.from_me else "missed."
|
||||
elif content['call_result'] == 2:
|
||||
call.data += "not answered." if call.from_me else "missed."
|
||||
elif content['call_result'] == 3:
|
||||
call.data += "unavailable."
|
||||
elif content['call_result'] == 5:
|
||||
call_time = convert_time_unit(content['duration'])
|
||||
call_bytes = bytes_to_readable(content['bytes_transferred'])
|
||||
call.data += (
|
||||
f"initiated and lasted for {content['duration']} second(s) "
|
||||
f"with {content['bytes_transferred']} bytes transferred."
|
||||
f"initiated and lasted for {call_time} "
|
||||
f"with {call_bytes} data transferred."
|
||||
)
|
||||
else:
|
||||
call.data += "in an unknown state."
|
||||
chat.add_message(content["_id"], call)
|
||||
content = c.fetchone()
|
||||
data["000000000000000"] = chat
|
||||
@@ -640,7 +759,8 @@ def create_html(
|
||||
embedded=False,
|
||||
offline_static=False,
|
||||
maximum_size=None,
|
||||
no_avatar=False
|
||||
no_avatar=False,
|
||||
filter_empty=True
|
||||
):
|
||||
template = setup_template(template, no_avatar)
|
||||
|
||||
@@ -654,7 +774,7 @@ def create_html(
|
||||
|
||||
for current, contact in enumerate(data):
|
||||
chat = data[contact]
|
||||
if len(chat.messages) == 0:
|
||||
if filter_empty and chat_is_empty(chat):
|
||||
continue
|
||||
safe_file_name, name = get_file_name(contact, chat)
|
||||
|
||||
@@ -686,6 +806,7 @@ def create_html(
|
||||
current_size = 0
|
||||
current_page += 1
|
||||
else:
|
||||
render_box.append(message)
|
||||
if message.key_id == last_msg:
|
||||
if current_page == 1:
|
||||
output_file_name = f"{output_folder}/{safe_file_name}.html"
|
||||
@@ -701,8 +822,6 @@ def create_html(
|
||||
False,
|
||||
chat
|
||||
)
|
||||
else:
|
||||
render_box.append(message)
|
||||
else:
|
||||
output_file_name = f"{output_folder}/{safe_file_name}.html"
|
||||
rendering(
|
||||
@@ -719,3 +838,38 @@ def create_html(
|
||||
print(f"Generating chats...({current}/{total_row_number})", end="\r")
|
||||
|
||||
print(f"Generating chats...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def create_txt(data, output):
|
||||
os.makedirs(output, exist_ok=True)
|
||||
for jik, chat in data.items():
|
||||
if chat.name is not None:
|
||||
contact = chat.name.replace('/', '')
|
||||
else:
|
||||
contact = jik.replace('+', '')
|
||||
output_file = os.path.join(output, f"{contact}.txt")
|
||||
with open(output_file, "w", encoding="utf8") as f:
|
||||
for message in chat.messages.values():
|
||||
date = datetime.fromtimestamp(message.timestamp).date()
|
||||
if message.meta and message.mime != "media":
|
||||
continue # Skip any metadata in text format
|
||||
if message.from_me:
|
||||
name = "You"
|
||||
else:
|
||||
name = message.sender if message.sender else contact
|
||||
prefix = f"[{date} {message.time}] {name}: "
|
||||
prefix_length = len(prefix)
|
||||
if message.media and ("/" in message.mime or message.mime == "media"):
|
||||
if message.data == "The media is missing":
|
||||
message_text = "<The media is missing>"
|
||||
else:
|
||||
message_text = f"<media file in {message.data}>"
|
||||
else:
|
||||
if message.data is None:
|
||||
message_text = ""
|
||||
else:
|
||||
message_text = message.data.replace('<br>', f'\n{" " * prefix_length}')
|
||||
if message.caption is not None:
|
||||
message_text += "\n" + ' ' * len(prefix) + message.caption.replace('<br>', f'\n{" " * prefix_length}')
|
||||
f.write(f"{prefix}{message_text}\n")
|
||||
|
||||
292
Whatsapp_Chat_Exporter/bplist.py
Normal file
292
Whatsapp_Chat_Exporter/bplist.py
Normal file
@@ -0,0 +1,292 @@
|
||||
#################################################################################
|
||||
# Copyright (C) 2009-2011 Vladimir "Farcaller" Pouzanov <farcaller@gmail.com> #
|
||||
# #
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy #
|
||||
# of this software and associated documentation files (the "Software"), to deal #
|
||||
# in the Software without restriction, including without limitation the rights #
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell #
|
||||
# copies of the Software, and to permit persons to whom the Software is #
|
||||
# furnished to do so, subject to the following conditions: #
|
||||
# #
|
||||
# The above copyright notice and this permission notice shall be included in #
|
||||
# all copies or substantial portions of the Software. #
|
||||
# #
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR #
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, #
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE #
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER #
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, #
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN #
|
||||
# THE SOFTWARE. #
|
||||
#################################################################################
|
||||
|
||||
import struct
|
||||
import codecs
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class BPListWriter(object):
|
||||
def __init__(self, objects):
|
||||
self.bplist = ""
|
||||
self.objects = objects
|
||||
|
||||
def binary(self):
|
||||
'''binary -> string
|
||||
|
||||
Generates bplist
|
||||
'''
|
||||
self.data = 'bplist00'
|
||||
|
||||
# TODO: flatten objects and count max length size
|
||||
|
||||
# TODO: write objects and save offsets
|
||||
|
||||
# TODO: write offsets
|
||||
|
||||
# TODO: write metadata
|
||||
|
||||
return self.data
|
||||
|
||||
def write(self, filename):
|
||||
'''
|
||||
|
||||
Writes bplist to file
|
||||
'''
|
||||
if self.bplist != "":
|
||||
pass
|
||||
# TODO: save self.bplist to file
|
||||
else:
|
||||
raise Exception('BPlist not yet generated')
|
||||
|
||||
class BPListReader(object):
|
||||
def __init__(self, s):
|
||||
self.data = s
|
||||
self.objects = []
|
||||
self.resolved = {}
|
||||
|
||||
def __unpackIntStruct(self, sz, s):
|
||||
'''__unpackIntStruct(size, string) -> int
|
||||
|
||||
Unpacks the integer of given size (1, 2 or 4 bytes) from string
|
||||
'''
|
||||
if sz == 1:
|
||||
ot = '!B'
|
||||
elif sz == 2:
|
||||
ot = '!H'
|
||||
elif sz == 4:
|
||||
ot = '!I'
|
||||
elif sz == 8:
|
||||
ot = '!Q'
|
||||
else:
|
||||
raise Exception('int unpack size '+str(sz)+' unsupported')
|
||||
return struct.unpack(ot, s)[0]
|
||||
|
||||
def __unpackInt(self, offset):
|
||||
'''__unpackInt(offset) -> int
|
||||
|
||||
Unpacks int field from plist at given offset
|
||||
'''
|
||||
return self.__unpackIntMeta(offset)[1]
|
||||
|
||||
def __unpackIntMeta(self, offset):
|
||||
'''__unpackIntMeta(offset) -> (size, int)
|
||||
|
||||
Unpacks int field from plist at given offset and returns its size and value
|
||||
'''
|
||||
obj_header = self.data[offset]
|
||||
obj_type, obj_info = (obj_header & 0xF0), (obj_header & 0x0F)
|
||||
int_sz = 2**obj_info
|
||||
return int_sz, self.__unpackIntStruct(int_sz, self.data[offset+1:offset+1+int_sz])
|
||||
|
||||
def __resolveIntSize(self, obj_info, offset):
|
||||
'''__resolveIntSize(obj_info, offset) -> (count, offset)
|
||||
|
||||
Calculates count of objref* array entries and returns count and offset to first element
|
||||
'''
|
||||
if obj_info == 0x0F:
|
||||
ofs, obj_count = self.__unpackIntMeta(offset+1)
|
||||
objref = offset+2+ofs
|
||||
else:
|
||||
obj_count = obj_info
|
||||
objref = offset+1
|
||||
return obj_count, objref
|
||||
|
||||
def __unpackFloatStruct(self, sz, s):
|
||||
'''__unpackFloatStruct(size, string) -> float
|
||||
|
||||
Unpacks the float of given size (4 or 8 bytes) from string
|
||||
'''
|
||||
if sz == 4:
|
||||
ot = '!f'
|
||||
elif sz == 8:
|
||||
ot = '!d'
|
||||
else:
|
||||
raise Exception('float unpack size '+str(sz)+' unsupported')
|
||||
return struct.unpack(ot, s)[0]
|
||||
|
||||
def __unpackFloat(self, offset):
|
||||
'''__unpackFloat(offset) -> float
|
||||
|
||||
Unpacks float field from plist at given offset
|
||||
'''
|
||||
obj_header = self.data[offset]
|
||||
obj_type, obj_info = (obj_header & 0xF0), (obj_header & 0x0F)
|
||||
int_sz = 2**obj_info
|
||||
return int_sz, self.__unpackFloatStruct(int_sz, self.data[offset+1:offset+1+int_sz])
|
||||
|
||||
def __unpackDate(self, offset):
|
||||
td = int(struct.unpack(">d", self.data[offset+1:offset+9])[0])
|
||||
return datetime(year=2001,month=1,day=1) + timedelta(seconds=td)
|
||||
|
||||
def __unpackItem(self, offset):
|
||||
'''__unpackItem(offset)
|
||||
|
||||
Unpacks and returns an item from plist
|
||||
'''
|
||||
obj_header = self.data[offset]
|
||||
obj_type, obj_info = (obj_header & 0xF0), (obj_header & 0x0F)
|
||||
if obj_type == 0x00:
|
||||
if obj_info == 0x00: # null 0000 0000
|
||||
return None
|
||||
elif obj_info == 0x08: # bool 0000 1000 // false
|
||||
return False
|
||||
elif obj_info == 0x09: # bool 0000 1001 // true
|
||||
return True
|
||||
elif obj_info == 0x0F: # fill 0000 1111 // fill byte
|
||||
raise Exception("0x0F Not Implemented") # this is really pad byte, FIXME
|
||||
else:
|
||||
raise Exception('unpack item type '+str(obj_header)+' at '+str(offset)+ 'failed')
|
||||
elif obj_type == 0x10: # int 0001 nnnn ... // # of bytes is 2^nnnn, big-endian bytes
|
||||
return self.__unpackInt(offset)
|
||||
elif obj_type == 0x20: # real 0010 nnnn ... // # of bytes is 2^nnnn, big-endian bytes
|
||||
return self.__unpackFloat(offset)
|
||||
elif obj_type == 0x30: # date 0011 0011 ... // 8 byte float follows, big-endian bytes
|
||||
return self.__unpackDate(offset)
|
||||
elif obj_type == 0x40: # data 0100 nnnn [int] ... // nnnn is number of bytes unless 1111 then int count follows, followed by bytes
|
||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||
return self.data[objref:objref+obj_count] # XXX: we return data as str
|
||||
elif obj_type == 0x50: # string 0101 nnnn [int] ... // ASCII string, nnnn is # of chars, else 1111 then int count, then bytes
|
||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||
return self.data[objref:objref+obj_count]
|
||||
elif obj_type == 0x60: # string 0110 nnnn [int] ... // Unicode string, nnnn is # of chars, else 1111 then int count, then big-endian 2-byte uint16_t
|
||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||
return self.data[objref:objref+obj_count*2].decode('utf-16be')
|
||||
elif obj_type == 0x80: # uid 1000 nnnn ... // nnnn+1 is # of bytes
|
||||
# FIXME: Accept as a string for now
|
||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||
return self.data[objref:objref+obj_count]
|
||||
elif obj_type == 0xA0: # array 1010 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
|
||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||
arr = []
|
||||
for i in range(obj_count):
|
||||
arr.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
||||
return arr
|
||||
elif obj_type == 0xC0: # set 1100 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
|
||||
# XXX: not serializable via apple implementation
|
||||
raise Exception("0xC0 Not Implemented") # FIXME: implement
|
||||
elif obj_type == 0xD0: # dict 1101 nnnn [int] keyref* objref* // nnnn is count, unless '1111', then int count follows
|
||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||
keys = []
|
||||
for i in range(obj_count):
|
||||
keys.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
||||
values = []
|
||||
objref += obj_count*self.object_ref_size
|
||||
for i in range(obj_count):
|
||||
values.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
||||
dic = {}
|
||||
for i in range(obj_count):
|
||||
dic[keys[i]] = values[i]
|
||||
return dic
|
||||
else:
|
||||
raise Exception('don\'t know how to unpack obj type '+hex(obj_type)+' at '+str(offset))
|
||||
|
||||
def __resolveObject(self, idx):
|
||||
try:
|
||||
return self.resolved[idx]
|
||||
except KeyError:
|
||||
obj = self.objects[idx]
|
||||
if type(obj) == list:
|
||||
newArr = []
|
||||
for i in obj:
|
||||
newArr.append(self.__resolveObject(i))
|
||||
self.resolved[idx] = newArr
|
||||
return newArr
|
||||
if type(obj) == dict:
|
||||
newDic = {}
|
||||
for k,v in obj.items():
|
||||
key_resolved = self.__resolveObject(k)
|
||||
if isinstance(key_resolved, str):
|
||||
rk = key_resolved
|
||||
else:
|
||||
rk = codecs.decode(key_resolved, "utf-8")
|
||||
rv = self.__resolveObject(v)
|
||||
newDic[rk] = rv
|
||||
self.resolved[idx] = newDic
|
||||
return newDic
|
||||
else:
|
||||
self.resolved[idx] = obj
|
||||
return obj
|
||||
|
||||
def parse(self):
|
||||
# read header
|
||||
if self.data[:8] != b'bplist00':
|
||||
raise Exception('Bad magic')
|
||||
|
||||
# read trailer
|
||||
self.offset_size, self.object_ref_size, self.number_of_objects, self.top_object, self.table_offset = struct.unpack('!6xBB4xI4xI4xI', self.data[-32:])
|
||||
#print "** plist offset_size:",self.offset_size,"objref_size:",self.object_ref_size,"num_objs:",self.number_of_objects,"top:",self.top_object,"table_ofs:",self.table_offset
|
||||
|
||||
# read offset table
|
||||
self.offset_table = self.data[self.table_offset:-32]
|
||||
self.offsets = []
|
||||
ot = self.offset_table
|
||||
for i in range(self.number_of_objects):
|
||||
offset_entry = ot[:self.offset_size]
|
||||
ot = ot[self.offset_size:]
|
||||
self.offsets.append(self.__unpackIntStruct(self.offset_size, offset_entry))
|
||||
#print "** plist offsets:",self.offsets
|
||||
|
||||
# read object table
|
||||
self.objects = []
|
||||
k = 0
|
||||
for i in self.offsets:
|
||||
obj = self.__unpackItem(i)
|
||||
#print "** plist unpacked",k,type(obj),obj,"at",i
|
||||
k += 1
|
||||
self.objects.append(obj)
|
||||
|
||||
# rebuild object tree
|
||||
#for i in range(len(self.objects)):
|
||||
# self.__resolveObject(i)
|
||||
|
||||
# return root object
|
||||
return self.__resolveObject(self.top_object)
|
||||
|
||||
@classmethod
|
||||
def plistWithString(cls, s):
|
||||
parser = cls(s)
|
||||
return parser.parse()
|
||||
|
||||
# helpers for testing
|
||||
def plist(obj):
|
||||
from Foundation import NSPropertyListSerialization, NSPropertyListBinaryFormat_v1_0
|
||||
b = NSPropertyListSerialization.dataWithPropertyList_format_options_error_(obj, NSPropertyListBinaryFormat_v1_0, 0, None)
|
||||
return str(b.bytes())
|
||||
|
||||
def unplist(s):
|
||||
from Foundation import NSData, NSPropertyListSerialization
|
||||
d = NSData.dataWithBytes_length_(s, len(s))
|
||||
return NSPropertyListSerialization.propertyListWithData_options_format_error_(d, 0, None, None)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
file_path = sys.argv[1]
|
||||
|
||||
with open(file_path, "rb") as fp:
|
||||
data = fp.read()
|
||||
|
||||
out = BPListReader(data).parse()
|
||||
|
||||
with open(file_path + ".json", "w") as fp:
|
||||
json.dump(out, indent=4)
|
||||
@@ -1,10 +1,19 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import datetime, tzinfo, timedelta
|
||||
from typing import Union
|
||||
|
||||
|
||||
class TimeZone(tzinfo):
|
||||
def __init__(self, offset):
|
||||
self.offset = offset
|
||||
def utcoffset(self, dt):
|
||||
return timedelta(hours=self.offset)
|
||||
def dst(self, dt):
|
||||
return timedelta(0)
|
||||
|
||||
|
||||
class ChatStore():
|
||||
def __init__(self, type, name=None, media=None):
|
||||
if name is not None and not isinstance(name, str):
|
||||
@@ -25,6 +34,7 @@ class ChatStore():
|
||||
self.their_avatar = None
|
||||
self.their_avatar_thumb = None
|
||||
self.status = None
|
||||
self.media_base = ""
|
||||
|
||||
def add_message(self, id, message):
|
||||
if not isinstance(message, Message):
|
||||
@@ -55,15 +65,15 @@ class ChatStore():
|
||||
|
||||
|
||||
class Message():
|
||||
def __init__(self, from_me: Union[bool,int], timestamp: int, time: Union[int,float,str], key_id: int):
|
||||
def __init__(self, from_me: Union[bool,int], timestamp: int, time: Union[int,float,str], key_id: int, timezone_offset: int = 0):
|
||||
self.from_me = bool(from_me)
|
||||
self.timestamp = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||
if isinstance(time, int) or isinstance(time, float):
|
||||
self.time = datetime.fromtimestamp(time/1000).strftime("%H:%M")
|
||||
self.time = datetime.fromtimestamp(self.timestamp, TimeZone(timezone_offset)).strftime("%H:%M")
|
||||
elif isinstance(time, str):
|
||||
self.time = time
|
||||
else:
|
||||
raise TypeError("Time must be a string or integer")
|
||||
raise TypeError("Time must be a string or number")
|
||||
self.media = False
|
||||
self.key_id = key_id
|
||||
self.meta = False
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from glob import glob
|
||||
from pathlib import Path
|
||||
from mimetypes import MimeTypes
|
||||
from markupsafe import escape as htmle
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, Device
|
||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, Device, get_chat_condition, slugify
|
||||
|
||||
|
||||
def contacts(db, data):
|
||||
@@ -18,26 +20,50 @@ def contacts(db, data):
|
||||
content = c.fetchone()
|
||||
while content is not None:
|
||||
if not content["ZWHATSAPPID"].endswith("@s.whatsapp.net"):
|
||||
_id = content["ZWHATSAPPID"] + "@s.whatsapp.net"
|
||||
data[_id] = ChatStore(Device.IOS)
|
||||
data[_id].status = content["ZABOUTTEXT"]
|
||||
ZWHATSAPPID = content["ZWHATSAPPID"] + "@s.whatsapp.net"
|
||||
data[ZWHATSAPPID] = ChatStore(Device.IOS)
|
||||
data[ZWHATSAPPID].status = content["ZABOUTTEXT"]
|
||||
content = c.fetchone()
|
||||
|
||||
|
||||
def messages(db, data, media_folder):
|
||||
def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat):
|
||||
c = db.cursor()
|
||||
# Get contacts
|
||||
c.execute("""SELECT count() FROM ZWACHATSESSION""")
|
||||
c.execute(
|
||||
f"""SELECT count()
|
||||
FROM (SELECT DISTINCT ZCONTACTJID,
|
||||
ZPARTNERNAME,
|
||||
ZWAPROFILEPUSHNAME.ZPUSHNAME
|
||||
FROM ZWACHATSESSION
|
||||
INNER JOIN ZWAMESSAGE
|
||||
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
|
||||
LEFT JOIN ZWAPROFILEPUSHNAME
|
||||
ON ZWACHATSESSION.ZCONTACTJID = ZWAPROFILEPUSHNAME.ZJID
|
||||
LEFT JOIN ZWAGROUPMEMBER
|
||||
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
|
||||
WHERE 1=1
|
||||
{get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
{get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
GROUP BY ZCONTACTJID);"""
|
||||
)
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Processing contacts...({total_row_number})")
|
||||
|
||||
c.execute(
|
||||
"""SELECT ZCONTACTJID,
|
||||
f"""SELECT DISTINCT ZCONTACTJID,
|
||||
ZPARTNERNAME,
|
||||
ZPUSHNAME
|
||||
FROM ZWACHATSESSION
|
||||
ZWAPROFILEPUSHNAME.ZPUSHNAME
|
||||
FROM ZWACHATSESSION
|
||||
INNER JOIN ZWAMESSAGE
|
||||
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
|
||||
LEFT JOIN ZWAPROFILEPUSHNAME
|
||||
ON ZWACHATSESSION.ZCONTACTJID = ZWAPROFILEPUSHNAME.ZJID;"""
|
||||
ON ZWACHATSESSION.ZCONTACTJID = ZWAPROFILEPUSHNAME.ZJID
|
||||
LEFT JOIN ZWAGROUPMEMBER
|
||||
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
|
||||
WHERE 1=1
|
||||
{get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
{get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
GROUP BY ZCONTACTJID;"""
|
||||
)
|
||||
content = c.fetchone()
|
||||
while content is not None:
|
||||
@@ -65,11 +91,19 @@ def messages(db, data, media_folder):
|
||||
content = c.fetchone()
|
||||
|
||||
# Get message history
|
||||
c.execute("""SELECT count() FROM ZWAMESSAGE""")
|
||||
c.execute(f"""SELECT count()
|
||||
FROM ZWAMESSAGE
|
||||
INNER JOIN ZWACHATSESSION
|
||||
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
|
||||
LEFT JOIN ZWAGROUPMEMBER
|
||||
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
|
||||
WHERE 1=1
|
||||
{f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
{get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"Processing messages...(0/{total_row_number})", end="\r")
|
||||
|
||||
c.execute("""SELECT COALESCE(ZFROMJID, ZTOJID) as _id,
|
||||
c.execute(f"""SELECT ZCONTACTJID,
|
||||
ZWAMESSAGE.Z_PK,
|
||||
ZISFROMME,
|
||||
ZMESSAGEDATE,
|
||||
@@ -77,38 +111,48 @@ def messages(db, data, media_folder):
|
||||
ZMESSAGETYPE,
|
||||
ZWAGROUPMEMBER.ZMEMBERJID,
|
||||
ZMETADATA,
|
||||
ZSTANZAID
|
||||
ZSTANZAID,
|
||||
ZGROUPINFO
|
||||
FROM ZWAMESSAGE
|
||||
LEFT JOIN ZWAGROUPMEMBER
|
||||
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
|
||||
LEFT JOIN ZWAMEDIAITEM
|
||||
ON ZWAMESSAGE.Z_PK = ZWAMEDIAITEM.ZMESSAGE;""")
|
||||
ON ZWAMESSAGE.Z_PK = ZWAMEDIAITEM.ZMESSAGE
|
||||
INNER JOIN ZWACHATSESSION
|
||||
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
|
||||
WHERE 1=1
|
||||
{f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
{get_chat_condition(filter_chat[1], False, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
ORDER BY ZMESSAGEDATE ASC;""")
|
||||
i = 0
|
||||
content = c.fetchone()
|
||||
while content is not None:
|
||||
_id = content["_id"]
|
||||
ZCONTACTJID = content["ZCONTACTJID"]
|
||||
Z_PK = content["Z_PK"]
|
||||
if _id not in data:
|
||||
data[_id] = ChatStore(Device.IOS)
|
||||
path = f'{media_folder}/Media/Profile/{_id.split("@")[0]}'
|
||||
is_group_message = content["ZGROUPINFO"] is not None
|
||||
if ZCONTACTJID not in data:
|
||||
data[ZCONTACTJID] = ChatStore(Device.IOS)
|
||||
path = f'{media_folder}/Media/Profile/{ZCONTACTJID.split("@")[0]}'
|
||||
avatars = glob(f"{path}*")
|
||||
if 0 < len(avatars) <= 1:
|
||||
data[_id].their_avatar = avatars[0]
|
||||
data[ZCONTACTJID].their_avatar = avatars[0]
|
||||
else:
|
||||
for avatar in avatars:
|
||||
if avatar.endswith(".thumb"):
|
||||
data[_id].their_avatar_thumb = avatar
|
||||
data[ZCONTACTJID].their_avatar_thumb = avatar
|
||||
elif avatar.endswith(".jpg"):
|
||||
data[_id].their_avatar = avatar
|
||||
data[ZCONTACTJID].their_avatar = avatar
|
||||
ts = APPLE_TIME + content["ZMESSAGEDATE"]
|
||||
message = Message(
|
||||
from_me=content["ZISFROMME"],
|
||||
timestamp=ts,
|
||||
time=ts, # TODO: Could be bug
|
||||
key_id=content["ZSTANZAID"][:17],
|
||||
timezone_offset=timezone_offset
|
||||
)
|
||||
invalid = False
|
||||
if "-" in _id and content["ZISFROMME"] == 0:
|
||||
if is_group_message and content["ZISFROMME"] == 0:
|
||||
name = None
|
||||
if content["ZMEMBERJID"] is not None:
|
||||
if content["ZMEMBERJID"] in data:
|
||||
@@ -124,7 +168,7 @@ def messages(db, data, media_folder):
|
||||
message.sender = None
|
||||
if content["ZMESSAGETYPE"] == 6:
|
||||
# Metadata
|
||||
if "-" in _id:
|
||||
if is_group_message:
|
||||
# Group
|
||||
if content["ZTEXT"] is not None:
|
||||
# Chnaged name
|
||||
@@ -173,7 +217,7 @@ def messages(db, data, media_folder):
|
||||
msg = msg.replace("\n", "<br>")
|
||||
message.data = msg
|
||||
if not invalid:
|
||||
data[_id].add_message(Z_PK, message)
|
||||
data[ZCONTACTJID].add_message(Z_PK, message)
|
||||
i += 1
|
||||
if i % 1000 == 0:
|
||||
print(f"Processing messages...({i}/{total_row_number})", end="\r")
|
||||
@@ -182,14 +226,26 @@ def messages(db, data, media_folder):
|
||||
f"Processing messages...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def media(db, data, media_folder):
|
||||
def media(db, data, media_folder, filter_date, filter_chat, separate_media=False):
|
||||
c = db.cursor()
|
||||
# Get media
|
||||
c.execute("""SELECT count() FROM ZWAMEDIAITEM""")
|
||||
c.execute(f"""SELECT count()
|
||||
FROM ZWAMEDIAITEM
|
||||
INNER JOIN ZWAMESSAGE
|
||||
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK
|
||||
INNER JOIN ZWACHATSESSION
|
||||
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
|
||||
LEFT JOIN ZWAGROUPMEMBER
|
||||
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
|
||||
WHERE 1=1
|
||||
{f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID","ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
{get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
""")
|
||||
total_row_number = c.fetchone()[0]
|
||||
print(f"\nProcessing media...(0/{total_row_number})", end="\r")
|
||||
i = 0
|
||||
c.execute("""SELECT COALESCE(ZWAMESSAGE.ZFROMJID, ZWAMESSAGE.ZTOJID) as _id,
|
||||
c.execute(f"""SELECT ZCONTACTJID,
|
||||
ZMESSAGE,
|
||||
ZMEDIALOCALPATH,
|
||||
ZMEDIAURL,
|
||||
@@ -199,18 +255,27 @@ def media(db, data, media_folder):
|
||||
FROM ZWAMEDIAITEM
|
||||
INNER JOIN ZWAMESSAGE
|
||||
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK
|
||||
INNER JOIN ZWACHATSESSION
|
||||
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
|
||||
LEFT JOIN ZWAGROUPMEMBER
|
||||
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
|
||||
WHERE ZMEDIALOCALPATH IS NOT NULL
|
||||
ORDER BY _id ASC""")
|
||||
{f'AND ZWAMESSAGE.ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
{get_chat_condition(filter_chat[1], False, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
ORDER BY ZCONTACTJID ASC""")
|
||||
content = c.fetchone()
|
||||
mime = MimeTypes()
|
||||
while content is not None:
|
||||
file_path = f"{media_folder}/Message/{content['ZMEDIALOCALPATH']}"
|
||||
_id = content["_id"]
|
||||
ZMESSAGE = content["ZMESSAGE"]
|
||||
message = data[_id].messages[ZMESSAGE]
|
||||
contact = data[content["ZCONTACTJID"]]
|
||||
message = contact.messages[ZMESSAGE]
|
||||
message.media = True
|
||||
if contact.media_base == "":
|
||||
contact.media_base = media_folder + "/"
|
||||
if os.path.isfile(file_path):
|
||||
message.data = file_path
|
||||
message.data = '/'.join(file_path.split("/")[1:])
|
||||
if content["ZVCARDSTRING"] is None:
|
||||
guess = mime.guess_type(file_path)[0]
|
||||
if guess is not None:
|
||||
@@ -219,18 +284,16 @@ def media(db, data, media_folder):
|
||||
message.mime = "application/octet-stream"
|
||||
else:
|
||||
message.mime = content["ZVCARDSTRING"]
|
||||
if separate_media:
|
||||
chat_display_name = slugify(contact.name or message.sender \
|
||||
or content["ZCONTACTJID"].split('@')[0], True)
|
||||
current_filename = file_path.split("/")[-1]
|
||||
new_folder = os.path.join(media_folder, "separated", chat_display_name)
|
||||
Path(new_folder).mkdir(parents=True, exist_ok=True)
|
||||
new_path = os.path.join(new_folder, current_filename)
|
||||
shutil.copy2(file_path, new_path)
|
||||
message.data = new_path
|
||||
else:
|
||||
if False: # Block execution
|
||||
try:
|
||||
r = requests.get(content["ZMEDIAURL"])
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError()
|
||||
except:
|
||||
message.data = "The media is missing"
|
||||
message.mime = "media"
|
||||
message.meta = True
|
||||
else:
|
||||
...
|
||||
message.data = "The media is missing"
|
||||
message.mime = "media"
|
||||
message.meta = True
|
||||
@@ -244,37 +307,57 @@ def media(db, data, media_folder):
|
||||
f"Processing media...({total_row_number}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def vcard(db, data):
|
||||
def vcard(db, data, media_folder, filter_date, filter_chat):
|
||||
c = db.cursor()
|
||||
c.execute("""SELECT DISTINCT ZWAVCARDMENTION.ZMEDIAITEM,
|
||||
c.execute(f"""SELECT DISTINCT ZWAVCARDMENTION.ZMEDIAITEM,
|
||||
ZWAMEDIAITEM.ZMESSAGE,
|
||||
COALESCE(ZWAMESSAGE.ZFROMJID,
|
||||
ZWAMESSAGE.ZTOJID) as _id,
|
||||
ZCONTACTJID,
|
||||
ZVCARDNAME,
|
||||
ZVCARDSTRING
|
||||
FROM ZWAVCARDMENTION
|
||||
INNER JOIN ZWAMEDIAITEM
|
||||
ON ZWAVCARDMENTION.ZMEDIAITEM = ZWAMEDIAITEM.Z_PK
|
||||
INNER JOIN ZWAMESSAGE
|
||||
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK""")
|
||||
ON ZWAMEDIAITEM.ZMESSAGE = ZWAMESSAGE.Z_PK
|
||||
INNER JOIN ZWACHATSESSION
|
||||
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
|
||||
LEFT JOIN ZWAGROUPMEMBER
|
||||
ON ZWAMESSAGE.ZGROUPMEMBER = ZWAGROUPMEMBER.Z_PK
|
||||
WHERE 1=1
|
||||
{f'AND ZWAMESSAGE.ZMESSAGEDATE {filter_date}' if filter_date is not None else ''}
|
||||
{get_chat_condition(filter_chat[0], True, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")}
|
||||
{get_chat_condition(filter_chat[1], False, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")};""")
|
||||
contents = c.fetchall()
|
||||
total_row_number = len(contents)
|
||||
print(f"\nProcessing vCards...(0/{total_row_number})", end="\r")
|
||||
base = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared/Message/vCards"
|
||||
if not os.path.isdir(base):
|
||||
Path(base).mkdir(parents=True, exist_ok=True)
|
||||
path = f'{media_folder}/Message/vCards'
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for index, content in enumerate(contents):
|
||||
file_name = "".join(x for x in content["ZVCARDNAME"] if x.isalnum())
|
||||
file_name = file_name.encode('utf-8')[:230].decode('utf-8', 'ignore')
|
||||
file_path = os.path.join(base, f"{file_name}.vcf")
|
||||
if not os.path.isfile(file_path):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content["ZVCARDSTRING"])
|
||||
message = data[content["_id"]].messages[content["ZMESSAGE"]]
|
||||
message.data = content["ZVCARDNAME"] + \
|
||||
"The vCard file cannot be displayed here, " \
|
||||
f"however it should be located at {file_path}"
|
||||
file_paths = []
|
||||
vcard_names = content["ZVCARDNAME"].split("_$!<Name-Separator>!$_")
|
||||
vcard_strings = content["ZVCARDSTRING"].split("_$!<VCard-Separator>!$_")
|
||||
|
||||
# If this is a list of contacts
|
||||
if len(vcard_names) > len(vcard_strings):
|
||||
vcard_names.pop(0) # Dismiss the first element, which is the group name
|
||||
|
||||
for name, vcard_string in zip(vcard_names, vcard_strings):
|
||||
file_name = "".join(x for x in name if x.isalnum())
|
||||
file_name = file_name.encode('utf-8')[:230].decode('utf-8', 'ignore')
|
||||
file_path = os.path.join(path, f"{file_name}.vcf")
|
||||
file_paths.append(file_path)
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(vcard_string)
|
||||
|
||||
vcard_summary = "This media include the following vCard file(s):<br>"
|
||||
vcard_summary += " | ".join([f'<a href="{htmle(fp)}">{htmle(name)}</a>' for name, fp in zip(vcard_names, file_paths)])
|
||||
message = data[content["ZCONTACTJID"]].messages[content["ZMESSAGE"]]
|
||||
message.data = vcard_summary
|
||||
message.mime = "text/x-vcard"
|
||||
message.media = True
|
||||
message.meta = True
|
||||
message.safe = True
|
||||
print(f"Processing vCards...({index + 1}/{total_row_number})", end="\r")
|
||||
@@ -3,46 +3,59 @@
|
||||
import shutil
|
||||
import sqlite3
|
||||
import os
|
||||
import time
|
||||
import getpass
|
||||
import threading
|
||||
from Whatsapp_Chat_Exporter.utility import WhatsAppIdentifier
|
||||
from Whatsapp_Chat_Exporter.bplist import BPListReader
|
||||
try:
|
||||
from iphone_backup_decrypt import EncryptedBackup, RelativePath
|
||||
from iphone_backup_decrypt import FailedToDecryptError, Domain
|
||||
except ModuleNotFoundError:
|
||||
support_encrypted = False
|
||||
else:
|
||||
support_encrypted = True
|
||||
|
||||
|
||||
def extract_encrypted(base_dir, password):
|
||||
backup = EncryptedBackup(backup_directory=base_dir, passphrase=password, cleanup=False, check_same_thread=False)
|
||||
print("Decrypting WhatsApp database...")
|
||||
try:
|
||||
backup.extract_file(relative_path=RelativePath.WHATSAPP_MESSAGES,
|
||||
output_filename="7c7fba66680ef796b916b067077cc246adacf01d")
|
||||
backup.extract_file(relative_path=RelativePath.WHATSAPP_CONTACTS,
|
||||
output_filename="b8548dc30aa1030df0ce18ef08b882cf7ab5212f")
|
||||
except FailedToDecryptError:
|
||||
print("Failed to decrypt backup: incorrect password?")
|
||||
exit()
|
||||
extract_thread = threading.Thread(
|
||||
target=backup.extract_files_by_domain,
|
||||
args=(Domain.WHATSAPP, Domain.WHATSAPP)
|
||||
def extract_encrypted(base_dir, password, identifiers, decrypt_chunk_size):
|
||||
print("Trying to decrypt the iOS backup...", end="")
|
||||
backup = EncryptedBackup(
|
||||
backup_directory=base_dir,
|
||||
passphrase=password,
|
||||
cleanup=False,
|
||||
check_same_thread=False,
|
||||
decrypt_chunk_size=decrypt_chunk_size
|
||||
)
|
||||
extract_thread.daemon = True
|
||||
extract_thread.start()
|
||||
dot = 0
|
||||
while extract_thread.is_alive():
|
||||
print(f"Decrypting and extracting files{'.' * dot}{' ' * (3 - dot)}", end="\r")
|
||||
if dot < 3:
|
||||
dot += 1
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
dot = 0
|
||||
time.sleep(0.4)
|
||||
print(f"All required files decrypted and extracted.", end="\n")
|
||||
extract_thread.handled = True
|
||||
print("Done\nDecrypting WhatsApp database...", end="")
|
||||
try:
|
||||
backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_MESSAGES,
|
||||
domain_like=identifiers.DOMAIN,
|
||||
output_filename=identifiers.MESSAGE
|
||||
)
|
||||
backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_CONTACTS,
|
||||
domain_like=identifiers.DOMAIN,
|
||||
output_filename=identifiers.CONTACT
|
||||
)
|
||||
except ValueError:
|
||||
print("Failed to decrypt backup: incorrect password?")
|
||||
exit(7)
|
||||
except FileNotFoundError:
|
||||
print("Essential WhatsApp files are missing from the iOS backup.")
|
||||
exit(6)
|
||||
else:
|
||||
print("Done")
|
||||
|
||||
def extract_progress_handler(file_id, domain, relative_path, n, total_files):
|
||||
if n % 100 == 0:
|
||||
print(f"Decrypting and extracting files...({n}/{total_files})", end="\r")
|
||||
return True
|
||||
|
||||
backup.extract_files(
|
||||
domain_like=identifiers.DOMAIN,
|
||||
output_folder=identifiers.DOMAIN,
|
||||
preserve_folders=True,
|
||||
filter_callback=extract_progress_handler
|
||||
)
|
||||
print(f"All required files are decrypted and extracted. ", end="\n")
|
||||
return backup
|
||||
|
||||
|
||||
@@ -61,7 +74,7 @@ def is_encrypted(base_dir):
|
||||
return False
|
||||
|
||||
|
||||
def extract_media(base_dir):
|
||||
def extract_media(base_dir, identifiers, decrypt_chunk_size):
|
||||
if is_encrypted(base_dir):
|
||||
if not support_encrypted:
|
||||
print("You don't have the dependencies to handle encrypted backup.")
|
||||
@@ -70,21 +83,23 @@ def extract_media(base_dir):
|
||||
return False
|
||||
print("Encryption detected on the backup!")
|
||||
password = getpass.getpass("Enter the password for the backup:")
|
||||
extract_encrypted(base_dir, password)
|
||||
extract_encrypted(base_dir, password, identifiers, decrypt_chunk_size)
|
||||
else:
|
||||
wts_db = os.path.join(base_dir, "7c/7c7fba66680ef796b916b067077cc246adacf01d")
|
||||
contact_db = os.path.join(base_dir, "b8/b8548dc30aa1030df0ce18ef08b882cf7ab5212f")
|
||||
wts_db = os.path.join(base_dir, identifiers.MESSAGE[:2], identifiers.MESSAGE)
|
||||
contact_db = os.path.join(base_dir, identifiers.CONTACT[:2], identifiers.CONTACT)
|
||||
if not os.path.isfile(wts_db):
|
||||
print("WhatsApp database not found.")
|
||||
if identifiers is WhatsAppIdentifier:
|
||||
print("WhatsApp database not found.")
|
||||
else:
|
||||
print("WhatsApp Business database not found.")
|
||||
exit()
|
||||
else:
|
||||
shutil.copyfile(wts_db, "7c7fba66680ef796b916b067077cc246adacf01d")
|
||||
shutil.copyfile(wts_db, identifiers.MESSAGE)
|
||||
if not os.path.isfile(contact_db):
|
||||
print("Contact database not found.")
|
||||
exit()
|
||||
print("Contact database not found. Skipping...")
|
||||
else:
|
||||
shutil.copyfile(contact_db, "b8548dc30aa1030df0ce18ef08b882cf7ab5212f")
|
||||
_wts_id = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
|
||||
shutil.copyfile(contact_db, identifiers.CONTACT)
|
||||
_wts_id = identifiers.DOMAIN
|
||||
with sqlite3.connect(os.path.join(base_dir, "Manifest.db")) as manifest:
|
||||
manifest.row_factory = sqlite3.Row
|
||||
c = manifest.cursor()
|
||||
@@ -98,6 +113,7 @@ def extract_media(base_dir):
|
||||
c.execute(f"""SELECT fileID,
|
||||
relativePath,
|
||||
flags,
|
||||
file AS metadata,
|
||||
ROW_NUMBER() OVER(ORDER BY relativePath) AS _index
|
||||
FROM Files
|
||||
WHERE domain = '{_wts_id}'
|
||||
@@ -120,6 +136,10 @@ def extract_media(base_dir):
|
||||
pass
|
||||
elif flags == 1:
|
||||
shutil.copyfile(os.path.join(base_dir, folder, hashes), destination)
|
||||
metadata = BPListReader(row["metadata"]).parse()
|
||||
creation = metadata["$objects"][1]["Birth"]
|
||||
modification = metadata["$objects"][1]["LastModified"]
|
||||
os.utime(destination, (modification, modification))
|
||||
if row["_index"] % 100 == 0:
|
||||
print(f"Extracting WhatsApp files...({row['_index']}/{total_row_number})", end="\r")
|
||||
row = c.fetchone()
|
||||
@@ -1,24 +1,83 @@
|
||||
import jinja2
|
||||
import json
|
||||
import os
|
||||
import unicodedata
|
||||
import re
|
||||
import math
|
||||
from bleach import clean as sanitize
|
||||
from markupsafe import Markup
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from enum import IntEnum
|
||||
from Whatsapp_Chat_Exporter.data_model import ChatStore
|
||||
try:
|
||||
from enum import StrEnum
|
||||
from enum import StrEnum, IntEnum
|
||||
except ImportError:
|
||||
# < Python 3.11
|
||||
from enum import Enum
|
||||
class StrEnum(str, Enum):
|
||||
pass
|
||||
|
||||
class IntEnum(int, Enum):
|
||||
pass
|
||||
|
||||
MAX_SIZE = 4 * 1024 * 1024 # Default 4MB
|
||||
ROW_SIZE = 0x3D0
|
||||
|
||||
|
||||
def convert_time_unit(time_second: int):
|
||||
time = str(timedelta(seconds=time_second))
|
||||
if "day" not in time:
|
||||
if time_second < 1:
|
||||
time = "less than a second"
|
||||
elif time_second == 1:
|
||||
time = "a second"
|
||||
elif time_second < 60:
|
||||
time = time[5:][1 if time_second < 10 else 0:] + " seconds"
|
||||
elif time_second == 60:
|
||||
time = "a minute"
|
||||
elif time_second < 3600:
|
||||
time = time[2:] + " minutes"
|
||||
elif time_second == 3600:
|
||||
time = "an hour"
|
||||
else:
|
||||
time += " hour"
|
||||
return time
|
||||
|
||||
|
||||
def bytes_to_readable(size_bytes: int):
|
||||
"""From https://stackoverflow.com/a/14822210/9478891
|
||||
Authors: james-sapam & other contributors
|
||||
Licensed under CC BY-SA 3.0
|
||||
See git commit logs for changes, if any.
|
||||
"""
|
||||
if size_bytes == 0:
|
||||
return "0B"
|
||||
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||
i = int(math.floor(math.log(size_bytes, 1024)))
|
||||
p = math.pow(1024, i)
|
||||
s = round(size_bytes / p, 2)
|
||||
return "%s %s" % (s, size_name[i])
|
||||
|
||||
|
||||
def readable_to_bytes(size_str: str):
|
||||
SIZE_UNITS = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024**2,
|
||||
'GB': 1024**3,
|
||||
'TB': 1024**4,
|
||||
'PB': 1024**5,
|
||||
'EB': 1024**6,
|
||||
'ZB': 1024**7,
|
||||
'YB': 1024**8
|
||||
}
|
||||
size_str = size_str.upper().strip()
|
||||
number, unit = size_str[:-2].strip(), size_str[-2:].strip()
|
||||
if unit not in SIZE_UNITS or not number.isnumeric():
|
||||
raise ValueError("Invalid input for size_str. Example: 1024GB")
|
||||
return int(number) * SIZE_UNITS[unit]
|
||||
|
||||
|
||||
def sanitize_except(html):
|
||||
return Markup(sanitize(html, tags=["br"]))
|
||||
|
||||
@@ -89,6 +148,7 @@ def rendering(
|
||||
w3css=w3css,
|
||||
next=next,
|
||||
status=chat.status,
|
||||
media_base=chat.media_base
|
||||
)
|
||||
)
|
||||
|
||||
@@ -134,11 +194,15 @@ def import_from_json(json_file, data):
|
||||
print(f"Importing chats from JSON...({index + 1}/{total_row_number})", end="\r")
|
||||
|
||||
|
||||
def sanitize_filename(file_name: str):
|
||||
return "".join(x for x in file_name if x.isalnum() or x in "- ")
|
||||
|
||||
|
||||
def get_file_name(contact: str, chat: ChatStore):
|
||||
if "@" not in contact and contact not in ("000000000000000", "000000000000001", "ExportedChat"):
|
||||
raise ValueError("Unexpected contact format: " + contact)
|
||||
phone_number = contact.split('@')[0]
|
||||
if "-" in contact:
|
||||
if "-" in contact and chat.name is not None:
|
||||
file_name = ""
|
||||
else:
|
||||
file_name = phone_number
|
||||
@@ -146,12 +210,44 @@ def get_file_name(contact: str, chat: ChatStore):
|
||||
if chat.name is not None:
|
||||
if file_name != "":
|
||||
file_name += "-"
|
||||
file_name += chat.name.replace("/", "-")
|
||||
file_name += chat.name.replace("/", "-").replace("\\", "-")
|
||||
name = chat.name
|
||||
else:
|
||||
name = phone_number
|
||||
|
||||
return "".join(x for x in file_name if x.isalnum() or x in "- "), name
|
||||
return sanitize_filename(file_name), name
|
||||
|
||||
|
||||
def get_chat_condition(filter, include, columns, jid=None, platform=None):
|
||||
if filter is not None:
|
||||
conditions = []
|
||||
if len(columns) < 2 and jid is not None:
|
||||
raise ValueError("There must be at least two elements in argument columns if jid is not None")
|
||||
if jid is not None:
|
||||
if platform == "android":
|
||||
is_group = f"{jid}.type == 1"
|
||||
elif platform == "ios":
|
||||
is_group = f"{jid} IS NOT NULL"
|
||||
else:
|
||||
raise ValueError("Only android and ios are supported for argument platform if jid is not None")
|
||||
for index, chat in enumerate(filter):
|
||||
if include:
|
||||
conditions.append(f"{' OR' if index > 0 else ''} {columns[0]} LIKE '%{chat}%'")
|
||||
if len(columns) > 1:
|
||||
conditions.append(f" OR ({columns[1]} LIKE '%{chat}%' AND {is_group})")
|
||||
else:
|
||||
conditions.append(f"{' AND' if index > 0 else ''} {columns[0]} NOT LIKE '%{chat}%'")
|
||||
if len(columns) > 1:
|
||||
conditions.append(f" AND ({columns[1]} NOT LIKE '%{chat}%' AND {is_group})")
|
||||
return f"AND ({' '.join(conditions)})"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def _is_message_empty(message):
|
||||
return (message.data is None or message.data == "") and not message.media
|
||||
|
||||
def chat_is_empty(chat: ChatStore):
|
||||
return len(chat.messages) == 0 or all(_is_message_empty(message) for message in chat.messages.values())
|
||||
|
||||
|
||||
# Android Specific
|
||||
@@ -170,6 +266,11 @@ class Crypt(IntEnum):
|
||||
CRYPT12 = 12
|
||||
|
||||
|
||||
class DbType(StrEnum):
|
||||
MESSAGE = "message"
|
||||
CONTACT = "contact"
|
||||
|
||||
|
||||
def brute_force_offset(max_iv=200, max_db=200):
|
||||
for iv in range(0, max_iv):
|
||||
for db in range(0, max_db):
|
||||
@@ -290,3 +391,38 @@ def setup_template(template, no_avatar):
|
||||
|
||||
# iOS Specific
|
||||
APPLE_TIME = datetime.timestamp(datetime(2001, 1, 1))
|
||||
|
||||
|
||||
def slugify(value, allow_unicode=False):
|
||||
"""
|
||||
Taken from https://github.com/django/django/blob/master/django/utils/text.py
|
||||
Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated
|
||||
dashes to single dashes. Remove characters that aren't alphanumerics,
|
||||
underscores, or hyphens. Convert to lowercase. Also strip leading and
|
||||
trailing whitespace, dashes, and underscores.
|
||||
"""
|
||||
value = str(value)
|
||||
if allow_unicode:
|
||||
value = unicodedata.normalize('NFKC', value)
|
||||
else:
|
||||
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
|
||||
value = re.sub(r'[^\w\s-]', '', value.lower())
|
||||
return re.sub(r'[-\s]+', '-', value).strip('-_')
|
||||
|
||||
|
||||
class WhatsAppIdentifier(StrEnum):
|
||||
MESSAGE = "7c7fba66680ef796b916b067077cc246adacf01d"
|
||||
CONTACT = "b8548dc30aa1030df0ce18ef08b882cf7ab5212f"
|
||||
DOMAIN = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
|
||||
|
||||
|
||||
class WhatsAppBusinessIdentifier(StrEnum):
|
||||
MESSAGE = "724bd3b98b18518b455a87c1f3ac3a0d189c4466"
|
||||
CONTACT = "d7246a707f51ddf8b17ee2dddabd9e0a4da5c552"
|
||||
DOMAIN = "AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared"
|
||||
|
||||
class JidType(IntEnum):
|
||||
PM = 0
|
||||
GROUP = 1
|
||||
SYSTEM_BROADCAST = 5
|
||||
STATUS = 11
|
||||
|
||||
82
Whatsapp_Chat_Exporter/vcards_contacts.py
Normal file
82
Whatsapp_Chat_Exporter/vcards_contacts.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import vobject
|
||||
from typing import List, TypedDict
|
||||
|
||||
|
||||
class ExportedContactNumbers(TypedDict):
|
||||
full_name: str
|
||||
numbers: List[str]
|
||||
|
||||
|
||||
class ContactsFromVCards:
|
||||
def __init__(self) -> None:
|
||||
self.contact_mapping = []
|
||||
|
||||
def is_empty(self):
|
||||
return self.contact_mapping == []
|
||||
|
||||
def load_vcf_file(self, vcf_file_path: str, default_country_code: str):
|
||||
self.contact_mapping = read_vcards_file(vcf_file_path, default_country_code)
|
||||
|
||||
def enrich_from_vcards(self, chats):
|
||||
for number, name in self.contact_mapping:
|
||||
# short number must be a bad contact, lets skip it
|
||||
if len(number) <= 5:
|
||||
continue
|
||||
|
||||
for chat in filter_chats_by_prefix(chats, number).values():
|
||||
if not hasattr(chat, 'name') or (hasattr(chat, 'name') and chat.name is None):
|
||||
setattr(chat, 'name', name)
|
||||
|
||||
|
||||
def read_vcards_file(vcf_file_path, default_country_code: str):
|
||||
contacts = []
|
||||
with open(vcf_file_path, mode="r", encoding="utf-8") as f:
|
||||
reader = vobject.readComponents(f)
|
||||
for row in reader:
|
||||
if hasattr(row, 'fn'):
|
||||
name = str(row.fn.value)
|
||||
elif hasattr(row, 'n'):
|
||||
name = str(row.n.value)
|
||||
else:
|
||||
name = None
|
||||
if not hasattr(row, 'tel') or name is None:
|
||||
continue
|
||||
contact: ExportedContactNumbers = {
|
||||
"full_name": name,
|
||||
"numbers": list(map(lambda tel: tel.value, row.tel_list)),
|
||||
}
|
||||
contacts.append(contact)
|
||||
|
||||
return map_number_to_name(contacts, default_country_code)
|
||||
|
||||
|
||||
def filter_chats_by_prefix(chats, prefix: str):
|
||||
return {k: v for k, v in chats.items() if k.startswith(prefix)}
|
||||
|
||||
|
||||
def map_number_to_name(contacts, default_country_code: str):
|
||||
mapping = []
|
||||
for contact in contacts:
|
||||
for index, num in enumerate(contact['numbers']):
|
||||
normalized = normalize_number(num, default_country_code)
|
||||
if len(contact['numbers']) > 1:
|
||||
name = f"{contact['full_name']} ({index+1})"
|
||||
else:
|
||||
name = contact['full_name']
|
||||
mapping.append((normalized, name))
|
||||
return mapping
|
||||
|
||||
|
||||
def normalize_number(number: str, country_code: str):
|
||||
# Clean the number
|
||||
number = ''.join(c for c in number if c.isdigit() or c == "+")
|
||||
|
||||
# A number that starts with a + or 00 means it already have a country code
|
||||
for starting_char in ('+', "00"):
|
||||
if number.startswith(starting_char):
|
||||
return number[len(starting_char):]
|
||||
|
||||
# leading zero should be removed
|
||||
if starting_char == '0':
|
||||
number = number[1:]
|
||||
return country_code + number # fall back
|
||||
20
Whatsapp_Chat_Exporter/vcards_contacts_test.py
Normal file
20
Whatsapp_Chat_Exporter/vcards_contacts_test.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# from contacts_names_from_vcards import readVCardsFile
|
||||
|
||||
from Whatsapp_Chat_Exporter.vcards_contacts import normalize_number, read_vcards_file
|
||||
|
||||
|
||||
def test_readVCardsFile():
|
||||
assert len(read_vcards_file("contacts.vcf", "973")) > 0
|
||||
|
||||
def test_create_number_to_name_dicts():
|
||||
pass
|
||||
|
||||
def test_fuzzy_match_numbers():
|
||||
pass
|
||||
|
||||
def test_normalize_number():
|
||||
assert normalize_number('0531234567', '1') == '1531234567'
|
||||
assert normalize_number('001531234567', '2') == '1531234567'
|
||||
assert normalize_number('+1531234567', '34') == '1531234567'
|
||||
assert normalize_number('053(123)4567', '34') == '34531234567'
|
||||
assert normalize_number('0531-234-567', '58') == '58531234567'
|
||||
@@ -87,6 +87,7 @@
|
||||
max-height: 100px !important;
|
||||
}
|
||||
</style>
|
||||
<base href="{{ media_base }}" target="_blank">
|
||||
</head>
|
||||
<body>
|
||||
<header class="w3-center w3-top">
|
||||
@@ -137,13 +138,18 @@
|
||||
<p>{{ msg.data or 'Not supported WhatsApp internal message' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if msg.caption is not none %}
|
||||
<div class="w3-container">
|
||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if msg.media == false %}
|
||||
{{ msg.data | sanitize_except() }}
|
||||
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
|
||||
{% else %}
|
||||
{% if "image/" in msg.mime %}
|
||||
<a href="{{ msg.data }}">
|
||||
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} />
|
||||
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
|
||||
</a>
|
||||
{% elif "audio/" in msg.mime %}
|
||||
<audio controls="controls" autobuffer="autobuffer">
|
||||
@@ -161,17 +167,18 @@
|
||||
{% filter escape %}{{ msg.data }}{% endfilter %}
|
||||
{% endif %}
|
||||
{% if msg.caption is not none %}
|
||||
<br>
|
||||
{{ msg.caption }}
|
||||
<div class="w3-container">
|
||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not no_avatar and my_avatar is not none %}
|
||||
<div class="w3-col m2 l2 pad-left-10">
|
||||
<a href="{{ my_avatar }}">
|
||||
<img src="{{ my_avatar }}" onerror="this.style.display='none'" class="avatar">
|
||||
<img src="{{ my_avatar }}" onerror="this.style.display='none'" class="avatar" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -191,9 +198,9 @@
|
||||
{% if not no_avatar %}
|
||||
<div class="w3-col m2 l2">
|
||||
{% if their_avatar is not none %}
|
||||
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar"></a>
|
||||
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar" loading="lazy"></a>
|
||||
{% else %}
|
||||
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar">
|
||||
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar" loading="lazy">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="w3-col m10 l10">
|
||||
@@ -221,13 +228,18 @@
|
||||
<p>{{ msg.data or 'Not supported WhatsApp internal message' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if msg.caption is not none %}
|
||||
<div class="w3-container">
|
||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if msg.media == false %}
|
||||
{{ msg.data | sanitize_except() }}
|
||||
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
|
||||
{% else %}
|
||||
{% if "image/" in msg.mime %}
|
||||
<a href="{{ msg.data }}">
|
||||
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} />
|
||||
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
|
||||
</a>
|
||||
{% elif "audio/" in msg.mime %}
|
||||
<audio controls="controls" autobuffer="autobuffer">
|
||||
@@ -245,8 +257,9 @@
|
||||
{% filter escape %}{{ msg.data }}{% endfilter %}
|
||||
{% endif %}
|
||||
{% if msg.caption is not none %}
|
||||
<br>
|
||||
{{ msg.caption }}
|
||||
<div class="w3-container">
|
||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
20
docs.html
Normal file
20
docs.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0; url='https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki'" />
|
||||
<script type="text/javascript">
|
||||
destination = {
|
||||
"filter": "Filter",
|
||||
"date": "Filters#date-filters",
|
||||
"chat": "Filters#chat-filter",
|
||||
"osl": "Open-Source-Licenses"
|
||||
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>
|
||||
BIN
imgs/group.png
BIN
imgs/group.png
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 15 KiB |
BIN
imgs/pm.png
BIN
imgs/pm.png
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 126 KiB |
48
scripts/bruteforce_crypt15.py
Normal file
48
scripts/bruteforce_crypt15.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import hmac
|
||||
import javaobj
|
||||
import zlib
|
||||
from Crypto.Cipher import AES
|
||||
from hashlib import sha256
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
key = open("encrypted_backup.key", "rb").read()
|
||||
database = open("wa.db.crypt15", "rb").read()
|
||||
main_key, hex_key = _extract_encrypted_key(key)
|
||||
for i in range(100):
|
||||
iv = database[i:i+16]
|
||||
for j in range(100):
|
||||
cipher = AES.new(main_key, AES.MODE_GCM, iv)
|
||||
db_ciphertext = database[j:]
|
||||
db_compressed = cipher.decrypt(db_ciphertext)
|
||||
try:
|
||||
db = zlib.decompress(db_compressed)
|
||||
except zlib.error:
|
||||
...
|
||||
else:
|
||||
if db[0:6] == b"SQLite":
|
||||
print(f"Found!\nIV: {i}\nOffset: {j}")
|
||||
print(db_compressed[:10])
|
||||
exit()
|
||||
|
||||
print("Not found! Try to increase maximum search.")
|
||||
7
setup.py
7
setup.py
@@ -55,9 +55,10 @@ setuptools.setup(
|
||||
'crypt12': ["pycryptodome"],
|
||||
'crypt14': ["pycryptodome"],
|
||||
'crypt15': ["pycryptodome", "javaobj-py3"],
|
||||
'all': ["pycryptodome", "javaobj-py3"],
|
||||
'everything': ["pycryptodome", "javaobj-py3"],
|
||||
'backup': ["pycryptodome", "javaobj-py3"]
|
||||
'all': ["pycryptodome", "javaobj-py3", "vobject"],
|
||||
'everything': ["pycryptodome", "javaobj-py3", "vobject"],
|
||||
'backup': ["pycryptodome", "javaobj-py3"],
|
||||
'vcards': ["vobject", "pycryptodome", "javaobj-py3"],
|
||||
},
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
|
||||
Reference in New Issue
Block a user