This commit is contained in:
Evgeny Zinoviev 2021-02-05 00:10:23 +03:00
commit 1f919eaef0
9 changed files with 583 additions and 0 deletions

132
README.md Normal file
View File

@ -0,0 +1,132 @@
# vk-messages-post-archive
> **Дисклеймер:** информация актуальна на осень 2020 года. Может, во ВКонтакте уже всё поменялось и скрипт более
> не нужен, а может, поменялся формат отдаваемого архива и скрипт не сработает. Смотрите сами.
Во ВКонтакте существует [функция](https://vk.com/data_protection?section=rules&scroll_to_archive=1) выгрузки информации
в ZIP-архиве, в т.ч. всех сообщений. Сделана она как попало: история сообщений, полученная таким образом, содержит
только текст самих сообщений (там даже нет пересланных! сложно было положить, что ли?), ссылки на фото и нерабочие (!)
ссылки на документы. Но лучше так, чем никак.
Этот репозиторий содержит простенькие инструменты и инструкции, с помощью которых можно дополнительно выкачать все
фотографии из истории, а также получить и сохранить объекты сообщений с вложениями из API (рекомендуется как минимум
для получения рабочих ссылок на документы).
Это проще, чем выкачивать всё через API с нуля. Пусть ВКонтактик соберёт нам свой архив, там уже есть более-менее
нормальная навигация и оформление, а мы потом просто скачаем все, чего не хватает. Все-таки простых текстовых сообщений
без вложений должно быть на порядок больше.
# Системные требования
- bash
- PHP >= 7.1
- composer
- php-iconv
- php-curl
- наверное, неплохой идеей будет включить opcache для cli в `php.ini` (путь которого можно узнать через `php --ini`):
```
opcache.enable=1
opcache.enable_cli=1
opcache.file_cache=/tmp/php-opcache
```
- iconv
- wget
- sqlite3
- стандартные утилиты типа cat, find, grep, xargs и т.д.
# "Поехали", как говорил Юра
## Скачиваем фоточки
Склонили репозиторий, устанавливаем зависимости:
```
git clone https://github.com/gch1p/vk-messages-post-archive
cd vk-messages-post-archive
composer install
```
Перейти в папку с распакованным архивом от ВК:
```
cd path/to/Archive
```
Запускаем нехитрый парсер:
```
for f in $(find messages -type f -name "*.html"); do
cat "$f" | iconv -f windows-1251 | php path/to/get-attaches.php >> to-download.txt
done
```
В файле `to-download.txt` будут ID сообщений с аттачами вперемешку со ссылками на фотки.
Выкачиваем фотки. `-P8` означает, что будет параллельно запущено 8 процессов wget. Можете написать другое число.
```
cat to-download.txt | grep https | xargs -P8 wget -x -i
```
Заменяем ссылки (`<a>`) на изображения (`<img>`). Тут в `-P` разумно передать количество ядер.
```
find messages -type f -name "*.html" | xargs -I{} -P4 php path/to/replace-photos.php "{}"
```
## Достаём остальную информацию из API
Теперь давайте залезем в API. ВК недавно отключил сторонним приложениям доступ к сообщениям, но кого это остановит?
Скачайте [десктопный мессенджер](https://vk.com/messenger), авторизуйтесь в нём, потом закройте и зайдите в папку, где
он хранит конфиг (`~/.config/VK` на Linux, `~/Library/Application Support/VK` на маке, на винде по логике должно быть
`%APPDATA%\Roaming\VK`), там будет sqlite3 база `vk.db`. Зайдём в неё:
```
sqlite3 vk.db
```
Осмотримся.
```
sqlite> .tables
auth debug_api_fails recents users
communities friends settings
```
Хм, табличка `auth` выглядит многообещающе. Посмотрим структуру:
```
sqlite> .schema auth
CREATE TABLE auth (
id INTEGER PRIMARY KEY,
user_id INTEGER,
ts INTEGER,
access_token TEXT
, is_encrypted INTEGER);
```
О! То, что нужно. Нам нужен `access_token`.
```
sqlite> SELECT access_token FROM auth;
```
Скопируйте токен и можно выходить (`Ctrl+D`) отсюда.
Теперь откройте скрипт `common.php` и пропишите этот токен в константу `ACCESS_TOKEN`, а так же путь к папке
`Archive` в константу `ARCHIVE_DIR`.
После этого можно запустить скрипт `fetch-messages.php` для слива объектов сообщений. Они будут сохраняться в папку `api`
с именами `{dir}/{id}.txt`, где `{id}` это ID сообщения, а `{dir}` это остаток от деления ID сообщения на 100.
Метод [messages.getById](https://vk.com/dev.php?method=messages.getById) вконтактовского API может вернуть до 100
сообщений за 1 запрос, плюс нужно учитывать стандартное ограничение на 3 запроса в секунду, иначе нам прилетит капча.
`xargs` поможет запустить не более 3-х инстансов скрипта за раз и передать не более 100 идентификаторов в каждый, а
`sleep(1)` сделает сам скрипт в конце.
```
cat to-download.txt | grep -v https | xargs -n100 -P3 php path/to/fetch-messages.php
```
Теперь добавим возможность просмотра этих объектов на страничках истории.
```
find messages -type f -name "*.html" | xargs -I{} -P4 php path/to/insert-api-objects.php "{}"
```
## Скачиваем документы
```
php fetch-documents.php
```
Ну вот, вроде, и всё.

95
common.php Normal file
View File

@ -0,0 +1,95 @@
<?php
require_once __DIR__.'/vendor/autoload.php';
error_reporting(E_ALL);
ini_set('display_errors', 1);
define('FAKE_USER_AGENT', 'User-Agent: VKDesktopMessenger/5.0.1 (darwin; 19.6.0; x64)');
define('ACCESS_TOKEN', '');
define('ARCHIVE_DIR', '');
function fatalError(string $message) {
fprintf(STDERR, "error: ".$message."\n");
exit(1);
}
/**
* @param string $str
* @param callable|null $message_callback
* @param callable|null $photo_callback
* @return simplehtmldom\HtmlDocument
* @throws Exception
*/
function onEachMessageOrAttachment(string $str, ?callable $message_callback, ?callable $photo_callback) {
$doc = new simplehtmldom\HtmlDocument($str,
/* $lowercase */ true,
/* $forceTagsClosed */ true,
/* $target_charset */ simplehtmldom\DEFAULT_TARGET_CHARSET,
/* $stripRN */ false
);
if (!$doc)
throw new Exception('failed to parse html');
$nodes = $doc->find('.message');
if (!count($nodes))
throw new Exception('no message nodes found');
foreach ($nodes as $node) {
$kludges = $node->find('.kludges');
if (empty($kludges))
continue;
$attachments = $kludges[0]->find('.attachment');
if (empty($attachments))
continue;
$message_id = $node->getAttribute('data-id');
if (!is_null($message_callback))
$message_callback($doc, $message_id, $node);
foreach ($attachments as $attachment) {
$desc = $attachment->find('.attachment__description');
if (empty($desc) || $desc[0]->innertext != 'Фотография')
continue;
$link_node = $attachment->find('a.attachment__link');
if (!$link_node)
continue;
$href = $link_node[0]->href;
if (strpos($href, 'https://vk.com/im?sel') !== false)
continue;
if (!is_null($photo_callback))
$photo_callback($doc, $href, $link_node[0]);
}
}
return $doc;
}
function httpPost(string $url, array $fields = []): array {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $fields);
curl_setopt($ch, CURLOPT_USERAGENT, FAKE_USER_AGENT);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$code, $body];
}
function httpGet(string $url): array {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_USERAGENT, FAKE_USER_AGENT);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [$code, $body];
}

15
composer.json Normal file
View File

@ -0,0 +1,15 @@
{
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"simplehtmldom/simplehtmldom": "^2.0@RC",
"ext-curl": "*",
"ext-json": "*",
"ext-iconv": "*"
},
"config": {
"platform": {
"php": "7.1"
}
}
}

96
composer.lock generated Normal file
View File

@ -0,0 +1,96 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6e4d59c1ab45b11a7233ab1e77f9f0df",
"packages": [
{
"name": "simplehtmldom/simplehtmldom",
"version": "2.0-RC2",
"source": {
"type": "git",
"url": "https://github.com/simplehtmldom/simplehtmldom.git",
"reference": "3c87726400e59d8e1bc4709cfe82353abeb0f4d1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/simplehtmldom/simplehtmldom/zipball/3c87726400e59d8e1bc4709cfe82353abeb0f4d1",
"reference": "3c87726400e59d8e1bc4709cfe82353abeb0f4d1",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": ">=5.6"
},
"require-dev": {
"phpunit/phpunit": "^6 || ^7"
},
"suggest": {
"ext-curl": "Needed to support cURL downloads in class HtmlWeb",
"ext-mbstring": "Allows better decoding for multi-byte documents",
"ext-openssl": "Allows loading HTTPS pages when using cURL"
},
"type": "library",
"autoload": {
"classmap": [
"./"
],
"exclude-from-classmap": [
"/example/",
"/manual/",
"/testcase/",
"/tests/",
"simple_html_dom.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "S.C. Chen",
"role": "Developer"
},
{
"name": "John Schlick",
"role": "Developer"
},
{
"name": "logmanoriginal",
"role": "Developer"
}
],
"description": "A fast, simple and reliable HTML document parser for PHP.",
"homepage": "https://simplehtmldom.sourceforge.io/",
"keywords": [
"Simple",
"dom",
"html",
"parser",
"php",
"simplehtmldom"
],
"support": {
"issues": "https://sourceforge.net/p/simplehtmldom/bugs/",
"rss": "https://sourceforge.net/p/simplehtmldom/news/feed.rss",
"source": "https://sourceforge.net/p/simplehtmldom/repository/",
"wiki": "https://simplehtmldom.sourceforge.io/docs/"
},
"time": "2019-11-09T15:42:50+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"simplehtmldom/simplehtmldom": 5
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.0.0"
}

99
fetch-documents.php Normal file
View File

@ -0,0 +1,99 @@
<?php
require_once __DIR__.'/common.php';
ini_set('memory_limit', '3072M');
function findAllAttachments(array $obj): array {
$list = [];
if (!empty($obj['attachments'])) {
foreach ($obj['attachments'] as $attachment) {
$list[] = $attachment;
if ($attachment['type'] == 'wall' || $attachment['type'] == 'wall_reply') {
$list = array_merge($list, findAllAttachments($attachment));
}
}
$list = array_merge($list, $obj['attachments']);
}
if (!empty($obj['fwd_messages'])) {
foreach ($obj['fwd_messages'] as $fwd_message) {
$list = array_merge($list, findAllAttachments($fwd_message));
}
}
$list = array_filter($list, function($attachment) {
static $ids = [];
$type = $attachment['type'];
if (!isset($attachment[$type]))
// weird
return false;
$attach = $attachment[$type];
$id = $type;
if (isset($attach['owner_id']))
$id .= $attach['owner_id'].'_';
if (isset($attach['id']))
$id .= isset($attach['id']);
if (isset($ids[$id]))
return false;
$ids[$id] = true;
return true;
});
return $list;
}
$api_dir = ARCHIVE_DIR.'/messages/api';
foreach (scandir($api_dir) as $n) {
if ($n == '.' || $n == '..')
continue;
foreach (scandir($api_dir.'/'.$n) as $file) {
if (!preg_match('/^\d+\.txt$/', $file))
continue;
$obj = json_decode(file_get_contents($api_dir.'/'.$n.'/'.$file), true);
$attachments = findAllAttachments($obj);
$docs = array_filter($attachments, function($a) {
return $a['type'] == 'doc';
});
if (empty($docs))
continue;
foreach ($docs as $doc) {
$doc = $doc['doc']; // seriously?!
$doc_id = $doc['owner_id'].'_'.$doc['id'];
$doc_dir = ARCHIVE_DIR.'/messages/docs/'.$doc_id;
if (!file_exists($doc_dir)) {
if (!mkdir($doc_dir, 0755, true))
fatalError("failed to mkdir({$doc_dir})");
}
// TODO sanitize filename
$doc_file = $doc_dir.'/'.$doc['title'];
if (file_exists($doc_file)) {
if (filesize($doc_file) == 56655)
unlink($doc_file);
else {
echo "$doc_id already exists\n";
continue;
}
}
list($code, $body) = httpGet($doc['url']);
if ($code != 200) {
fprintf(STDERR, "failed to download {$doc_id} ({$doc['url']})\n");
rmdir($doc_dir);
continue;
}
file_put_contents($doc_file, $body);
echo "$doc_id saved, ".filesize($doc_file)." bytes\n";
unset($body);
}
}
}

38
fetch-messages.php Normal file
View File

@ -0,0 +1,38 @@
<?php
require_once __DIR__.'/common.php';
$message_ids = array_slice($argv, 1);
if (empty($message_ids))
fatalError('no message ids');
$url = 'https://api.vk.com/method/messages.getById';
$fields = [
'message_ids' => implode(',', $message_ids),
'access_token' => ACCESS_TOKEN,
'v' => '5.109'
];
list($code, $body) = httpPost($url, $fields);
if ($code != 200)
fatalError('api returned '.$code);
$response = json_decode($body, true);
if (!empty($response['error']))
fatalError('api error: '.$response['error']['error_msg']);
foreach ($response['response']['items'] as $item) {
$id = (int)$item['id'];
$dir_n = $id % 100;
$cur_dir = ARCHIVE_DIR.'/messages/api/'.$dir_n;
if (!file_exists($cur_dir)) {
if (!mkdir($cur_dir, 0755, true))
fatalError('failed to mkdir('.$cur_dir.')');
}
file_put_contents($cur_dir.'/'.$id.'.txt', json_encode($item, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT));
}
sleep(1);

31
get-attaches.php Normal file
View File

@ -0,0 +1,31 @@
<?php
require_once __DIR__.'/common.php';
$str = file_get_contents('php://stdin');
if (!$str)
fatalError("no input");
$message_ids = [];
$photo_urls = [];
$on_message = function($doc, $message_id) {
global $message_ids;
$message_ids[] = $message_id;
};
$on_photo = function($doc, $href, $link_node) {
global $photo_urls;
$photo_urls[] = $href;
};
try {
onEachMessageOrAttachment($str, $on_message, $on_photo);
} catch (Exception $e) {
fatalError($e->getMessage());
}
if (!empty($message_ids))
echo implode("\n", $message_ids)."\n";
if (!empty($photo_urls))
echo implode("\n", $photo_urls)."\n";

44
insert-api-objects.php Normal file
View File

@ -0,0 +1,44 @@
<?php
require_once __DIR__.'/common.php';
$file = $argv[1] ?? '';
if (!$file)
fatalError("no file provided");
$str = file_get_contents($file);
$str = iconv('windows-1251', 'utf-8//IGNORE', $str);
$is_modified = false;
try {
$doc = onEachMessageOrAttachment($str, function (simplehtmldom\HtmlDocument $doc, int $id, simplehtmldom\HtmlNode $node) {
global $is_modified;
$file = ARCHIVE_DIR.'/messages/api/'.($id % 100).'/'.$id.'.txt';
if (!file_exists($file))
return;
$obj = file_get_contents($file);
$a = $doc->createElement('a');
$a->setAttribute('href', 'javascript:void(0)');
$a->setAttribute('onclick', "this.nextSibling.style.display = (this.nextSibling.style.display === 'none' ? 'block' : 'none')");
$a->appendChild($doc->createTextNode('Показать/скрыть объект API'));
$div = $doc->createElement('div');
$div->setAttribute('style', 'display: none; font-size: 11px; font-family: monospace; background-color: #edeef0; padding: 10px; white-space: pre; overflow: auto;');
$div->appendChild($doc->createTextNode($obj));
$node->appendChild($doc->createElement('br'));
$node->appendChild($a);
$node->appendChild($div);
$is_modified = true;
}, null);
} catch (Exception $e) {
fatalError($e->getMessage());
}
if ($is_modified)
file_put_contents($file, iconv('utf-8', 'windows-1251//IGNORE', $doc->outertext));

33
replace-photos.php Normal file
View File

@ -0,0 +1,33 @@
<?php
require_once __DIR__.'/common.php';
$file = $argv[1] ?? '';
if (!$file)
fatalError("no file provided");
$str = file_get_contents($file);
$str = iconv('windows-1251', 'utf-8//IGNORE', $str);
try {
$doc = onEachMessageOrAttachment($str,
null,
function (simplehtmldom\HtmlDocument $doc, string $href, simplehtmldom\HtmlNode $link_node) {
$local_href = '../../'.preg_replace('#^https?://#', '', $href);
/** @var simplehtmldom\HtmlNode $parent */
$parent = $link_node->parent();
$link_node->remove();
$img = $doc->createElement('img');
$img->setAttribute('src', $local_href);
$img->setAttribute('alt', $href);
$parent->appendChild($doc->createElement('br'));
$parent->appendChild($img);
});
} catch (Exception $e) {
fatalError($e->getMessage());
}
file_put_contents($file, iconv('utf-8', 'windows-1251//IGNORE', $doc->outertext));