aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEvgeny Zinoviev <me@ch1p.io>2021-02-05 00:10:23 +0300
committerEvgeny Zinoviev <me@ch1p.io>2021-02-05 00:13:21 +0300
commit1f919eaef022b4721ecb9f72a0ea703b2ac3e89a (patch)
tree53f75ef906c296b9da4598e128f677b4b926194b
initialHEADmaster
-rw-r--r--README.md132
-rw-r--r--common.php95
-rw-r--r--composer.json15
-rw-r--r--composer.lock96
-rw-r--r--fetch-documents.php99
-rw-r--r--fetch-messages.php38
-rw-r--r--get-attaches.php31
-rw-r--r--insert-api-objects.php44
-rw-r--r--replace-photos.php33
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