Парсим выдачу DotA 2 API

DotA 2

Обновление материала! (12.02.2013)

В одной из предыдущих заметок я привел вольный перевод «документации» по этому API. К сожалению, само API сейчас часто бывает недоступно, потому что просто не выдерживает нагрузки (в нем еще нет ограничения на кол-во запросов за интервал времени). Надеюсь, что в скором времени это исправят. А пока разберем, как же грамотно спарсить и сохранить предоставляемые данные.

Чем парсить и где хранить?

Возьмем простую связку PHP+MySQL. Дешево и сердито.

Что парсить?

Парсить мы будет данные об проведенных матчах заданного игрока. Код будет рассчитан на то, что бы вытянуть данные обо всех его играх, слотах в этих играх и участниках игры.

Проектирование БД

Нам нужно как минимум 3 таблицы — для матчей, слотов в этих матчах и участниках (игроках). Так же нужно где-то хранить информацию (хотя бы id+название) героев и предметов, которые были использованы в играх. То есть, имеем еще две таблицы. Итог:

  • Матчи.
  • Слоты (10 игроков в каждом матче, а НЕ 6 мест под шмотки на герое!).
  • Игроки.
  • Герои.
  • Предметы.

На рисунке ниже приведена предложенная структура. Столбцы в таблицах выбраны на основе данных, которые возвращает API (имена идентичны полям в ответа API). Посмотрим на связи. Таблица slots содержит в себе ряд полей, которые являются внешними ключами ко всем остальным таблицам — в каждом слоте есть запись про матч, к которому этот слот относится (таблица matches); про игрока, который был в этом слоте (таблица users); про предметы, которые у него остались в конце игры (таблица items); ну и про героя, на котором играл участник (таблица heroes).

Dota 2 DB Schema

Кодим

Как пишется в документации к API, перед началом работы нам надо получить свой API_KEY. Не стоит забывать об этом.
Получили? Теперь можно и приступать. Для начала сделаем небольшой конфиг-файл config.php.

<?php
 
// db connection
define ('HOST', 'localhost');
define ('USER', 'root');
define ('PASSWORD', 'KronuS');
define ('DB', 'dota2');
define ('TABLE_PR', '');
 
// dota2 api key (you can get it here - http://steamcommunity.com/dev/apikey)
define ('API_KEY', '******************************');
// player's account_id you want to parse
define ('ACCOUNT_ID', '*************');
 
error_reporting('E_ALL');
 
set_time_limit(0);
 
require_once('class.db.php');
require_once('request.php');

Константа ACCOUNT_ID определяет игрока, игры которого парсятся.

На счет файла — class.db.php. В нем находится описание класса для работы с MySQL с использованием PDO. Написан он неким Abdul Rashid Gadhwalla и дополнен мной. Его код тут приводить не буду.

Файл request.php содержит описание функции выполнения запросов к серверу API.

<?php
 
function request($url, array $params) {
	$ch = curl_init();
	$d = '';
	foreach ($params as $key=>$value) {
		$d .= $key.'='.$value.'&';
	}
	$d = rtrim($d, '&');
	curl_setopt($ch, CURLOPT_URL, $url.'?'.$d);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
	curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
	curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
	$r = curl_exec($ch);
	curl_close($ch);
	return $r;
}

Обычный CURL-запрос. Стоит только сказать, что раз запросы идут по https, то надо позаботиться о возможной проблеме с сертификатами. В нашем случае проверка сертификатов просто отключается.

Исходя из структуры связей между таблицами в БД, нам необходимо перед началом парсинга данных об играх внести в БД информацию о героях и предметах (иначе получил ошибку MySQL о недопустимом значении и нарушении целостности данных).

Для начала вытянем всех героев в Д2 (heroes.php):

<?php
 
require_once ('config.php');
 
$db = db::obtain(HOST, USER, PASSWORD, DB, TABLE_PR);
$db->connect_pdo();
 
// GET HEROES NAMES
$r = request('http://api.steampowered.com/IEconDOTA2_570/GetHeroes/v0001/', array('language' => 'en_us', 'key' => API_KEY, 'format' => 'xml'));
 
$xml = new SimpleXMLElement($r);
 
echo '<pre>';
foreach($xml->heroes->hero as $hero) {
	$data = array(
		'id' => $hero->id,
		'name' => $hero->name,
		'localized_name' => $hero->localized_name
	);
	$db->insert_pdo('heroes', $data);
}

Ничего хитрого — один запрос к API и все есть. С предметами все не так гладко. Запроса, что бы получить данные о предметах, в API еще нет. Но выход есть всегда. На просторах форума http://dev.dota2.com один добрый человек выложил JSON-файл, где собранные базовые данные о предметах (идентификатор-имя). Ссылка на файл — ссылка. Качаем этот файл, кладем в папочку с остальными скриптами. Создаем файл items.php с содержимым:

<?php
 
require_once ('config.php');
 
$db = db::obtain(HOST, USER, PASSWORD, DB, TABLE_PR);
$db->connect_pdo();
 
$xml = json_decode(file_get_contents('items.json'), true);
 
foreach ($xml['result']['items'] as $item) {
	echo $item['id'];
	$data = array(
		'id' => $item['id'],
		'name' => $item['name'],
	);
	$db->insert_pdo('items', $data);
}

Он и выполнит заполнение таблицы предметов данными. ВАЖНО! В эту таблицу руками необходимо внести запись {0, ’empty’}. Почему? Потому что далеко не всегда у игрока сложенные в инвентаре 6 предметов. И в таких ситуациях API возвращает 0 на месте идентификатора предмета.

На данный момент все готово к парсингу данных об играх. Начнем. Файл matches.php:

<?php
 
require_once('config.php');
 
libxml_use_internal_errors(true);
 
$db = db::obtain(HOST, USER, PASSWORD, DB, TABLE_PR);
$db->connect_pdo();
 
$last_match_id = $db->fetch_array_pdo('SELECT s.match_id FROM matches AS m, slots AS s WHERE s.match_id = m.match_id AND s.account_id = \''.ACCOUNT_ID.'\' ORDER BY match_id ASC LIMIT 1');
if (count($last_match_id) == 1) {
	$last_match_id = $last_match_id[0]['match_id'];
}
else {
	$last_match_id = null;
}
while (true) {
 
	echo $last_match_id."\n";
 
	if (!is_null($last_match_id)) {
		$r = request('https://api.steampowered.com/IDOTA2Match_570/GetMatchHistory/V001/', array('key'=>API_KEY, 'account_id' => ACCOUNT_ID, 'format' => 'xml', 'matches_requested' => 25, 'start_at_match_id' => ($last_match_id - 1)));
	}
	else {
		$r = request('https://api.steampowered.com/IDOTA2Match_570/GetMatchHistory/V001/', array('key'=>API_KEY, 'account_id' => ACCOUNT_ID, 'format' => 'xml', 'matches_requested' => 25));
	}
	try {
		$xml = new SimpleXMLElement($r);
	}
	catch(Exception $ex) {
		$errors = libxml_get_errors();
	    foreach ($errors as $error) {
	        //echo display_xml_error($error, $xml)."\n";
	    }
	    libxml_clear_errors();
	    die('API has gone away.');
	}
	print_r($xml);
	echo $xml->results_remaining.' left'."\n";
 
	if (isset($xml->matches)) {
		foreach ($xml->matches as $match) {
			foreach ($match as $m) {
				// users
				foreach ($m->players->player as $user) {
					$data = array();
					$data[] = $user->account_id;
					$r = $db->fetch_array_pdo('SELECT * FROM users WHERE account_id=?', $data);
					if (count($r) == 0) {
						$db->insert_pdo('users', array('account_id'=>$user->account_id));
					}
				}
				// match info
				$r = request('https://api.steampowered.com/IDOTA2Match_570/GetMatchDetails/V001/', array('match_id' => $m->match_id, 'key' => API_KEY, 'format' => 'xml'));
				$match_info = new SimpleXMLElement($r);
				$players = array();
				foreach($match_info->players->player as $key=>$player) {
					$players[] = $player;
				}
				$data = (array)$match_info;
				unset($data['players']);
				$data['starttime'] = date('Y-m-d H:i:s', $data['starttime']);
				//print_r($data);
				$check_match = $db->fetch_array_pdo('SELECT * FROM matches WHERE match_id=?', array($m->match_id));
				if (count($check_match) == 0) {
					$db->insert_pdo('matches', $data);
					echo $db->get_error();
				}
				else {
					// If match is in the DB, we have already got all other
					die ('Complete! Older matches have been parsed earlier.');
				}
				// slots info
				foreach ($players as $player) {
					$data = (array)$player;
					$data['match_id'] = $m->match_id;
					//print_r($data);
					$check_slot = $db->fetch_array_pdo('SELECT * FROM slots WHERE match_id=? AND account_id=?', array($m->match_id, $player->account_id));
					if (count($check_slot) == 0) {
						$db->insert_pdo('slots', $data);
						echo $db->get_error();
					}
				}
				$last_match_id = $m->match_id;
			}
		}
	}
	if ($xml->results_remaining == 0) break;
}
 
echo 'Complete!';

Для начала из БД выбирается идентификатор последнего матча игрока (если есть). От этого зависит вид первого запроса к API. То есть, этот идентификатор просто установит смещение в запросе. А если в базе нет данных про матчи этого игрока, то будут запрашиваться все доступные матчи.

Дальше запускается псевдо бесконечный цикл, на каждом шаге которого выполняется запрос на 25 матчей и данных об этих матчах. Стоит сказать, что в ответе от API еще передается кол-во игр, которые нам осталось спарсить — это удобно использовать, как условие выхода из нашего псевдо бесконечного цикла.

Что бы не проходить по несколько раз по уже спарсенным играм, в коде есть проверка на наличие в БД с указанным ID. Если в ответе от API вернулся матч, который у нас уже есть, то мы считаем, что все, что было до него, мы уже тоже спарсили и завершаем скрипт. Ну и так же не забываем вставлять данные об игроках в соответствующую таблицу. Но тут стоит сказать, что через запросы к DotA2 API нельзя получить информации больше, чем его идентификатор — account_id. А связь между account_id и Steam_id пока не установлена (что бы на 100% можно было быть уверенным в точности данных). Вопрос о том, что бы через DotA2 API передавать ник игрока уже не раз поднимался, но пока безответно.

Вместо эпилога

Вышеизложенный код был проверен и протестирован. Данные парсятся довольно шустро (пока не ввели никаких ограничений). Единственное, что огорчает, — это то, что API часто бывает выключено и приходится угадывать время, когда есть к нему доступ. В последующем я расскажу, какую полезную статистику можно почерпнуть из полученных данных по одному игроку.

Архив с исходниками и дампом структуры БД — скачать. Так же в дамп включены данные из таблиц items и heroes, так что заново парсить их не придется.

, , , ,

11 комментариев
  1. Guest сказал(а):

    парсит только в таблицу users, с остальными таблицами какие то несоответствия.

  2. Guest сказал(а):

    Но вообще спасибо, статья помогла немного разобраться.

  3. KronuS сказал(а):

    На момент написания статьи API еще работало. А сейчас оно, увы, не работает. Как запустят в нормальном режиме, то весь код перепроверю и покажу, как красиво можно эту статистику выводить.

  4. Guest сказал(а):

    Уже работает, запустили пару дней назад.

  5. KronuS сказал(а):

    Спасибо за инфу. Наконец-то можно будет довести до конца разработку.

  6. Руслан сказал(а):

    Очень полезная информация, спасибо.
    Я вот чем заинтересовался, а если мне нужно считать данные последнего матча, точнее результат и только матча с РАНКЕД ЛИГИ( в Доте есть пабы( обычная лига ) и ранкед лига ), как это реализовать?

  7. KronuS сказал(а):

    Руслан, я не уверен, что через АПИ можно разделить матчи на «обычные» и «рейтинговые»

  8. Руслан сказал(а):

    Kronus, но ведь dotabuff.com делает это 🙁
    Надо бы порыться..
    В любом случае спасибо за ответ, давно не обновлял эту страницу 🙂

  9. Руслан сказал(а):

    Спасибо большущее 🙂

  10. Pyhon сказал(а):

    Как понимать значения полей для tower_status(dire/radiant)?
    Ответ желательно с примером 😉

  11. KronuS сказал(а):

    Python, по ссылке внизу https://wiki.teamfortress.com/wiki/WebAPI/GetMatchDetails есть пример и для вышек, и для бараков.

Оставить комментарий

Top ↑ | Main page | Back