diff options
author | Evgeny Zinoviev <me@ch1p.io> | 2021-02-05 00:10:23 +0300 |
---|---|---|
committer | Evgeny Zinoviev <me@ch1p.io> | 2021-02-05 00:13:21 +0300 |
commit | 1f919eaef022b4721ecb9f72a0ea703b2ac3e89a (patch) | |
tree | 53f75ef906c296b9da4598e128f677b4b926194b |
-rw-r--r-- | README.md | 132 | ||||
-rw-r--r-- | common.php | 95 | ||||
-rw-r--r-- | composer.json | 15 | ||||
-rw-r--r-- | composer.lock | 96 | ||||
-rw-r--r-- | fetch-documents.php | 99 | ||||
-rw-r--r-- | fetch-messages.php | 38 | ||||
-rw-r--r-- | get-attaches.php | 31 | ||||
-rw-r--r-- | insert-api-objects.php | 44 | ||||
-rw-r--r-- | replace-photos.php | 33 |
9 files changed, 583 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..d65d533 --- /dev/null +++ b/README.md @@ -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 +``` + +Ну вот, вроде, и всё.
\ No newline at end of file diff --git a/common.php b/common.php new file mode 100644 index 0000000..e355eba --- /dev/null +++ b/common.php @@ -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]; +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e2ca2c9 --- /dev/null +++ b/composer.json @@ -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" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..89e9a04 --- /dev/null +++ b/composer.lock @@ -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" +} diff --git a/fetch-documents.php b/fetch-documents.php new file mode 100644 index 0000000..315f175 --- /dev/null +++ b/fetch-documents.php @@ -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); + } + } +}
\ No newline at end of file diff --git a/fetch-messages.php b/fetch-messages.php new file mode 100644 index 0000000..b968fbb --- /dev/null +++ b/fetch-messages.php @@ -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);
\ No newline at end of file diff --git a/get-attaches.php b/get-attaches.php new file mode 100644 index 0000000..36c12ee --- /dev/null +++ b/get-attaches.php @@ -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";
\ No newline at end of file diff --git a/insert-api-objects.php b/insert-api-objects.php new file mode 100644 index 0000000..f2d3823 --- /dev/null +++ b/insert-api-objects.php @@ -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));
\ No newline at end of file diff --git a/replace-photos.php b/replace-photos.php new file mode 100644 index 0000000..9715932 --- /dev/null +++ b/replace-photos.php @@ -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));
\ No newline at end of file |