суббота, 7 января 2012 г.

    Парсинг результатов выдачи с Yandex.XML на Java

    Часть 1. Лирическое отступление.

    Если вы не хотите читать "многабукв", а хотите сразу увидеть пример кода - переходите ко второй части этой статьи.

    Года 4 назад я впервые окунулся в мир SEO. Это было для меня ново и интересно, а уж как завораживали финансовые "стриптизы" популярных сеошников - голова кругом шла :)
    С тех пор много воды утекло. Честно говоря, каких-то особых высот в этом деле я не достиг, но полезные знания сохранились. Помимо знаний сохранилось и несколько сайтов, которые я время от времени то забрасываю, то всячески стараюсь оживить.

    Сейчас, будучи старшим программистом в довольно крупной организации, регулярно выделять время на это хобби не получается. Поэтому всё чаще возникают идеи автоматизации некоторой работы. Вот подобной простенькой задачкой я и решил заняться после отходняка от новогодних праздников :)

    Плох тот сеошник, который не знает позиции своего сайта по основному набору ключевых слов. С одной стороны это легко решается приобретением какого-нибудь специализированного софта, а-ля Yazzle или ему подобных. С другой стороны регулярный контроль позиций можно осуществлять с помощью сервиса SeoBudget за сущие копейки (рефссылка), что я и делал до поры до времени. Есть и третий вариант - на просторах интернета можно найти бесплатные поделки, которые может быть выглядят похуже, умеют поменьше и рекламируются поскромнее, но при этом ничуть не хуже старших собратьев умеют проверять позиции сайта в выдаче по ключевым словам.
    Но нормальные герои всегда идут в обход, вот и я по исконно русской программерской традиции решил написать свой софт для снятия позиций.

    Начать решил с Яндекса, ибо с ним у меня всегда отношения складываются очень напряженно. За полчасика накидал парсер поисковой выдачи, запустил всю эту красоту и... словил бан от Яши - мои запросы справедливо были расценены, как роботские. Я посмеялся над своей наивностью, пригрозил Яндексу вторым раундом сражения, когда я напишу парсер с человекоподобным поведением, антикапчей, блэк джеком и балеринами и пошел писать парсер выдачи через сервис Яндекс.XML.

    Прежде чем перейти непосредственно к примеру взаимодействия с этим сервисом, хочу сказать пару слов об этом самом сервисе. Итак, Яндекс.XML - это по сути интерфейс взаимодействия программ/сервисов/сайтов пользователя с поисковой базой Яндекса. Сам Яша делает акцент на том, что лучше этот сервис использовать для прикручивания удобной формочки поиска на вашем сайте. При этом он не даёт расслабляться наглым халявщикам вроде меня и ограничивает использование сервиса 1000-ю запросами в день с заранее зарегистрированного IP-адреса. Более того, чтобы получить разрешение на эти 1000 запросов в день, нужно вбить какой-нибудь номер сотового телефона, отличный от того, к которому привязан ваш почтовый ящик, на него придет СМСка с кодом подтверждения и только тогда наступит счастье.

    В общем мороки много, выгода сомнительна - с тысячью запросами в день особо не разгуляешься, но халява есть халява, к тому же для простой проверки своих позиций в топе этого будет достаточно.
    Забегая далеко вперёд, я скажу, что всё-таки нашел не бесплатный, но довольно дешёвый способ анализировать выдачу Яндекса (примерно 1 копейка за запрос против 3 копеек у SeoBudget), и буду двигаться в этом направлении, но это уже тема для совсем другой статьи.

    Часть 2. Переходим к конкретике.

    Пора перейти к коду.
    Волка ноги кормят, а меня - язык Java, поэтому и пример будет на Java. К тому же беглый поиск примеров взаимодействия с Yandex.XML на java результатов не дал: кругом один php, и чуть-чуть perl-а.

    Слово "XML" в названии сервиса как бы намекает, что работать нам придется именно с xml форматом данных. Причем в этом формате мы будем не только получать ответ, но и посылать запрос.

    Начнем с запроса.
    Его можно отослать двумя способами: GET и POST. В обоих случаях запрос посылается на адрес
    http://xmlsearch.yandex.ru/xmlsearch?user=your_user&key=your_key
    , где:
        - your_user - ваш логин для доступа к аккаунту яндекса;
        - your_key - сгенерированный для вас яндексом ключ.

    Все эти параметры можно посмотреть на странице настроек сервиса Yandex.XML в поле "Ваш адрес для совершения запроса".

    Дальше. Возможные параметры запроса яндекс описывает тут.
    Меня заинтересовали только два:
        - query - сам текст запроса;
        - page - номер страницы поисковой выдачи. Отсчет начинается с 0, т.е. page=1 - это вторая страница поисковой выдачи.

    Рассмотрим GET запрос.
    Собственно чтобы отослать GET запрос, надо в конец адреса доклеить через амперсанд (&) интересующие нас параметры:
    http://xmlsearch.yandex.ru/xmlsearch?user=your_user&key=your_key&query=your_query&page=0
    Простенький класс, который вытягивает InputStream ответа по нашему GET запросу и пример его использования в main методе:
    package com.blogspot.omskblog.yaxml;
    
    import java.io.IOException;
    import java.io.InputStream;
    import java.io.InputStreamReader;
    import java.io.LineNumberReader;
    import java.net.URL;
    import java.net.URLEncoder;
    
    public class YaSearcher {
    
        private static final String YA_URL = "http://xmlsearch.yandex.ru/xmlsearch?";
        private static final String ENC = "UTF-8";
        private static final String AND = "&";
    
        private String user;
        private String key;
    
        /**
         * Constructor
         * @param user your Yandex username
         * @param key key generated by Yandex.XML
         */
        public YaSearcher(final String user, final String key) {
            this.user = user;
            this.key = key;
        }
    
        /**
         * Retrieve Yandex.XML response stream via GET request
         * @param query search query
         * @param pageNumber number of search page
         * @return Yandex.XML response stream
         * @throws IOException input/output exception
         */
        public InputStream retrieveResponseViaGetRequest(
            final String query,
            final int pageNumber
        ) throws IOException {
    
            final StringBuilder address = new StringBuilder(YA_URL);
            address.append("user=").append(user).append(AND)
                .append("key=").append(key).append(AND)
                .append("query=").append(URLEncoder.encode(query, ENC)).append(AND)
                .append("page=").append(pageNumber);
            final URL url = new URL(address.toString());
            return url.openStream();
        }
    
        /**
         * Example: print response to System.out
         */
        public static void main(String[] args) throws IOException {
    
            final String user = "your_user";
            final String key = "your_key";
            final String query = "Привет, мир!";
            final int page = 0;
    
            LineNumberReader lineReader = null;
            try {
                final YaSearcher searcher = new YaSearcher(user, key);
                lineReader = new LineNumberReader(
                    new InputStreamReader(
                        searcher.retrieveResponseViaGetRequest(query, page)
                    )
                );
                String line;
                while ((line = lineReader.readLine()) != null) {
                    System.out.println(line);
                }
            } finally {
                if (lineReader != null) {
                    lineReader.close();
                }
            }
        }
    }
    
    * URLEncoder перекодирует запрос в шестнадцатиричный формат, чтобы не возникало проблем с небезопасными символами в URL: пробелами, угловыми и фигурными скобками и т.п.

    Переходим к POST запросу.
    POST запрос нужно отсылать на тот же адрес (с указанными параметрами user и key), а вот поисковую фразу и номер страницы надо указывать в теле запроса, причем в xml формате:
    <?xml version="1.0" encoding="UTF-8"?>  
    <request>  
        <query>Привет, мир!</query>
        <page>0</page> 
    </request>
    

    Особо решил ничего не выдумывать, xml запрос склеил как строку. Добавляем в уже написанный класс новый метод (main метод для испытаний остаётся таким же, только retrieveResponseViaGetRequest заменить на retrieveResponseViaPostRequest):
        /**
         * Retrieve Yandex.XML response stream via POST request
         * @param query search query
         * @param pageNumber number of search page
         * @return Yandex.XML response stream
         * @throws IOException input/output exception
         */
        public InputStream retrieveResponseViaPostRequest(
            final String query,
            final int pageNumber
        ) throws IOException {
    
            OutputStreamWriter writer = null;
            try {
                final StringBuilder address = new StringBuilder(YA_URL);
                address.append("user=").append(user).append(AND).append("key=").append(key);
                final StringBuilder data = new StringBuilder();
                data.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?><request>")
                    .append("<query>").append(query).append("</query>")
                    .append("<page>").append(pageNumber).append("</page></request>");
    
                final URL url = new URL(address.toString());
                final URLConnection conn = url.openConnection();
                conn.setDoOutput(true);
    
                writer = new OutputStreamWriter(conn.getOutputStream());
                writer.write(data.toString());
                writer.flush();
    
                return conn.getInputStream();
            } finally {
                if (writer != null) {
                    writer.close();
                }
            }
        }
    
    Тут с экранированием дело обстоит посложнее. С одной стороны нам не страшны пробелы, xml их понимает корректно. С другой стороны специфичные для xml формата символы <, >, &, \, ', " не экранируются стандартными средствами java (во всяком случае я такого не знаю), и, следовательно, нашим кодом не будут поддерживаться. Решить эту проблему можно либо написав собственный обработчик этих символов (что мне, честно говоря, лень), либо подключив библиотеку commons-lang-3.0 и вызывать оттуда метод
    StringEscapeUtils.escapeXml(query)

    На этом тему с запросами можно считать закрытой. Какой метод выбрать - каждый решает сам. Причем отталкиваться стоит от задачи и контекста. В простейшем случае я бы выбрал GET запросы, так как там и кода меньше и экранирование прикрутилось с полпинка.

    Разбор результатов поиска.
    И вот мы на финишной прямой. Осталось разобрать xml ответ от сервиса и привести все данные в удобоваримый вид.

    Формат ответа Яндекс описывает тут. Опять же для нашей мини-задачки вся эта информация избыточна, поэтому выберем только самое интересное:
        - url - URL страницы в поиске;
        - domain - домен сайта. Может пригодиться, например, для определения какая конкретная страница сайта в поиске идет выше по заданному ключу;
        - title - заголовок страницы;
        - headline - описание страницы, берётся из мета-тэга description;
        - passages - примеры содержащих ключевую фразу предложений с найденной страницы.

    Пожалуй, хватит. Парсить результат буду в класс:
    package com.blogspot.omskblog.yaxml;
    
    import java.util.ArrayList;
    import java.util.List;
    
    /**
     * Representation of Yandex SERP page
     */
    public class YaPage {
    
        public static final int ITEMS_PER_PAGE = 10;
    
        private String keyword;
        private int pageNumber;
        private List<YaItem> yaItems = new ArrayList<YaItem>();
    
        /**
         * Constructor
         * @param keyword keyword for searching
         * @param pageNumber number of page
         */
        public YaPage(final String keyword, final int pageNumber) {
            this.keyword = keyword;
            this.pageNumber = pageNumber;
        }
    
        public List<YaItem> getYaItems() {
            return yaItems;
        }
    
        /**
         * Add one SERP item to collection (page)
         * @param item one SERP item
         */
        public void addYaItem(final YaItem item) {
    
            final int position = (pageNumber * ITEMS_PER_PAGE) + yaItems.size() + 1;
            item.setPosition(position);
            yaItems.add(item);
        }
    }
    
    , где каждый YaItem - это:
    package com.blogspot.omskblog.yaxml;
    
    /**
     * Representation of one item in Yandex SERP
     */
    public class YaItem {
    
        private int position;
        private String url;
        private String domain;
        private String title;
        private String description = "";
        private String passages = "";
    
        /**
         * Constructor
         * @param url url of current item
         */
        public YaItem(final String url) {
            this.url = url;
        }
    
        /* Тут набор getter-ов для приватных полей класса... */
    
        public void setPosition(final int position) {
            this.position = position;
        }
    
        public void setDomain(final String domain) {
            this.domain = domain;
        }
    
        public void setTitle(final String title) {
            this.title = title;
        }
    
        public void setDescription(final String description) {
            this.description = description;
        }
    
        public void addPassage(final String passage) {
            passages += passage;
        }
    
        @Override
        public String toString() {
            return "YaItem{" +
                "position=" + position +
                ", url='" + url + '\'' +
                ", domain='" + domain + '\'' +
                ", title='" + title + '\'' +
                ", description='" + description + '\'' +
                ", passages='" + passages + '\'' +
                '}';
        }
    }
    

    Для парсинга я нарисовал простенький handler для SAX parser-а:
    package com.blogspot.omskblog.yaxml;
    
    import org.xml.sax.Attributes;
    import org.xml.sax.SAXException;
    import org.xml.sax.helpers.DefaultHandler;
    
    import java.io.CharArrayWriter;
    
    /**
     * Handler for Yandex.XML's response parsing
     */
    public class YaHandler extends DefaultHandler {
    
        private static final String IGNORE_TAG = "hlword";
    
        private final CharArrayWriter buffer = new CharArrayWriter();
        private YaItem currentItem;
        private YaPage yaPage;
    
        /**
         * Constructor
         * @param yaPage yandex page that will be filled with SERP items
         */
        public YaHandler(final YaPage yaPage) {
            this.yaPage = yaPage;
        }
    
        @Override
        public void startElement(
            final String uri,
            final String localName,
            final String qName,
            final Attributes attr
        ) throws SAXException {
            super.startElement(uri, localName, qName, attr);
            if (!IGNORE_TAG.equals(qName)) {
                buffer.reset();
            }
        }
    
        @Override
        public void endElement(
            final String uri,
            final String localName,
            final String qName
        ) throws SAXException {
    
            super.endElement(uri, localName, qName);
            if ("error".equals(qName)) {
                throw new IllegalArgumentException("Bad request: " + buffer.toString());
            } else if ("url".equals(qName)) {
                currentItem = new YaItem(buffer.toString());
            } else if ("domain".equals(qName) && currentItem != null) {
                currentItem.setDomain(buffer.toString());
            } else if ("title".equals(qName) && currentItem != null) {
                currentItem.setTitle(clearFromTags(buffer.toString()));
            } else if ("headline".equals(qName) && currentItem != null) {
                currentItem.setDescription(clearFromTags(buffer.toString()));
            } else if ("passage".equals(qName) && currentItem != null) {
                currentItem.addPassage(clearFromTags(buffer.toString()));
            } else if ("group".equals(qName) && currentItem != null) {
                yaPage.addYaItem(currentItem);
            }
        }
    
        @Override
        public void characters(final char[] chars, final int start, final int length)
        throws SAXException {
            super.characters(chars, start, length);
            buffer.write(chars, start, length);
        }
    
        /**
         * Clear text from unwanted tags
         * @param text text to clear
         * @return cleared text
         */
        private String clearFromTags(final String text) {
            return text.replaceAll("<" + IGNORE_TAG +">", "")
                .replaceAll("</" + IGNORE_TAG + ">", "");
        }
    }
    

    В ранее написанный класс YaSearcher добавляем метод для получения распарсенного результата:
        /**
         * Load parsed yandex page from Yandex.XML service
         * @param query query for searching
         * @param pageNumber number of page
         * @return parsed result of searching 
         * @throws IOException input/output exception
         * @throws SAXException parsing exception
         */
        public YaPage loadYaPage(final String query, final int pageNumber)
        throws IOException, SAXException {
    
            final YaPage result = new YaPage(query, pageNumber);
            final XMLReader xmlReader = XMLReaderFactory.createXMLReader();
            xmlReader.setContentHandler(new YaHandler(result));
            xmlReader.parse(
                new InputSource(
                    this.retrieveResponseViaGetRequest(query, pageNumber)
                )
            );
            return result;
        }
    
    И переписываем метод main для наглядной иллюстрации работы парсера:
        /**
         * Example: print parsed response to System.out
         */
        public static void main(String[] args) throws IOException, SAXException {
            
            final String user = "your_user";
            final String key = "your_key";
            final String query = "Hello";
            final int page = 0;
    
            final YaSearcher searcher = new YaSearcher(user, key);
            final YaPage result = searcher.loadYaPage(query, page);
            for (YaItem item : result.getYaItems()) {
                System.out.println(item);
            }
        }
    
    Вуаля!

    Часть 3. Заключение.

    В общем чего я хотел этим примером показать. Не так страшен черт как его малюют. Я постарался максимально подробно рассказать что и как делать, чтобы даже начинающие программисты без труда разобрались в примере. Ничего революционного здесь нет: HTTP запрос и парсинг XML файла. Прикрутите считывание своих ключевиков и сайтов из файла, запись результатов в файл и жмакайте батник, запускающий программку, каждый день или каждый АП. И будет вам бесплатный сбор статистики по позициям.

    Удачи!


    15 коммент.:

    pric комментирует...

    Не могу составиь запрос для яндекс картинок, пробую

    http://xmlsearch.yandex.ru/xmlsearch?serverurl=seo-cook.ru&stype=image&user=zael55&key=xxxxx

    выдает количество проиндексированых страниц

    Артемий комментирует...

    А Ya.XML разве сейчас поддерживает поиск картинок? В документации нет никаких упоминаний о такой возможности.

    Alexx комментирует...

    Полезная штука, спасибо. А не знаете как быть с русскоязычными url? никак не могу понять как исправить кодировку, чтобы они нормально отображались.

    Артемий комментирует...

    Alexx,
    Я не очень оперативен в плане ответов :)

    Но если ещё актуально, то решить вашу проблему можно следующим образом:
    Кириллические домены отображаются в специальной кодировке - punycode. Характерной чертой пуникода является префикс "xn--".

    Чтобы расшифровать пуникод, я использую статический метод IDN.toUnicode(str) (import java.net.IDN;).
    В моем примере дешифровать надо и url и domain.
    Но если домен расшифруется без проблем, то с URL-ом возникнут сложности - расшифруется только средняя часть.
    Если заглянуть в исходники метода, то там какой-то бардак с проверкой вышеупомянутого префикса "xn--".
    А у нас в примере URL начинается с "http://".
    Вдобавок URL заканчивается как минимум на "/", а как максимум - идет длинный хвост адреса, который не является пуникодом и не подлежит расшифровке.
    Поэтому чтобы расшифровать домен из УРЛа, надо его сначала оттуда выдрать.

    Как-то так:

    private static final String URL_PREFIX = "http://";

    public YaItem(final String url) {
        final int endOfDomain = url.indexOf("/", URL_PREFIX.length());
        final String domain = url.substring(URL_PREFIX.length(), endOfDomain);
        this.url = url.replace(domain, IDN.toUnicode(domain));
    }

    pavel комментирует...

    Судя по последнему ответу, домены на кирилице плохо поддаются индексации Яндексом?

    Женщина комментирует...

    Да уж, материальчик не для средних умов ))

    я пользовалась вордстатом от Яндекс, классная штука

    Комиксы комментирует...

    мнфа интересная, только сложная, хотя я парщу с сайтов конкурентов

    SEO комментирует...

    О, парсить я люблю )

    Online film комментирует...

    Как па мне то это очень для меня сложно. Я пробовал такое сделать. И я думаю что стоит просто нанять программиста и показать это.

    Статусы комментирует...

    Спасибо за статью. А почему вы так редко обновляете блог? Только 2 раза за 3 года.

    Артемий комментирует...

    На самом деле у меня было много постов в блоге. Но в один прекрасный день я пересмотрел свои взгляды на жизнь и безжалостно пустил под нож все слабенькие посты.
    Хочу наполнять блог только качественными статьями. Хоть и времени на них сейчас катастрофически не хватает.

    Леша комментирует...

    Спасибо за статью Очень полезна и проста в изложении!

    Анонимный комментирует...

    по парсеру яндекс.xml на заказ для собственной crm если не затруднит, откликнитесь на boss@v12limited.com

    спасибо

    Владимир комментирует...

    а вот это уже интересно. Я сеошник, но всегда любил программистов вот за такие штучки)) спасибо

    seoonly.ru комментирует...

    Скрипты еще рабочие?