Skip to content

Передача документов из Node.js в Sphinx

19/04/2010

Вообще то изначально Sphinx ориентирован на работу с MySQL. Возможность добавлять документы другими способами появилась позже. Нас интересует один из способов: xmlpipe2.

При этом варианте индексации Sphinx запускает указанную команду, подключается к её потоку вывода и ожидает потоковый XML с документами. Всё что нам нужно — получать документы из CouchDB, возможно как то их обрабатывать (обрабатывать HTML умеет и сам Sphinx, тут волноваться не о чем) и формировать XML в потоковом режиме. Не очень то сложно.

Но сначала надо удостовериться что Sphinx поддерживает тип источника xmlpipe2. Для этого Sphinx должен быть собран с поддержкой libexpat. Если нет, придётся его пересобрать. Ставим сам libexpat:

    apt-get install libexpat1-dev
    

Конфигурируем Sphinx:

    cd ~/sphinx/sphinx-0.9.9
    ./configure
    

В секции configuring Sphinx должна быть запись checking for libexpat... found.

Собираем, устанавливаем:

    make && make install
    

Формирование XML

Xmlpipe2 теперь должен работать. Нам нужен скрипт, который будет запрашивать документы из CouchDB по очереди, и отдавать их в виде XML на стандартный вывод.

Это можно организовать очень просто: сначала получаем список всех документов в базе, потом запрашиваем их по очереди. Обработчик, получающий документ, сразу оборачивает его в xml и отдаёт на stdout с помощью например sys.puts. После этого он запрашивает уже следующий документ с тем же обработчиком:

var process_document = function (docs) {
    if (docs.length > 0) {
        var document = docs.pop();

        db.openDoc(document.id,{
            'success': function(page) {
                sys.puts('<sphinx:document id="' + page._id + '">');

                sys.puts('<subject><![CDATA[[');
                sys.puts(page.title);
                sys.puts(']]></subject>');

                sys.puts('<content><![CDATA[[');
                sys.puts(page.text);
                sys.puts(']]></content>');

                sys.puts('<published>' + (new Date()).getTime().toString() + '</published>');

                sys.puts('</sphinx:document>');

                process_document(docs);
            },
            'error':function (e) {
                sys.puts('Error getting doc: ' + sys.inspect(e));
            }
        });

    } else {
        sys.puts('</sphinx:docset>');
    }
};
    

Секция else нужна чтобы выводить завершающие теги когда список документов опустеет. Здесь у нас текст и названия страниц обёрнуты в CDATA-секции, т.к. мы передаём там HTML, который вполне может оказаться неаккуратно сформированным. Здесь нас правда ждёт одна проблема: внутри этого документа могут встретиться другие CDATA-секции, а вложенные секции запрещены стандартом XML.

    <sphinx:document>
        <title>Document title</title>
        <content><![CDATA[
            <p>Some text paragraph</p>
            <script><![CDATA[
                window.alert('Gotcha!');
            ]]></script>
            <p>Some more text</p>
        ]]></content>
    </sphinx:document>
    

Надо как то их оттуда убирать. Вообще, по хорошему, оттуда надо убрать всё кроме непосредственно текста. Но это задача довольно непростая, так что пока мы просто заменим критические символы на соответствующие entities:

text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")

Это заменит символы <, >, &. Также теперь можно убрать секции CDATA:

var process_document = function (docs) {
    if (docs.length > 0) {
        var document = docs.pop();

        db.openDoc(document.id,{
            'success': function(page) {
                sys.puts('<sphinx:document id="' + page._id + '">');

                sys.puts('<subject>');
                sys.puts(page.title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"));
                sys.puts('</subject>');

                sys.puts('<content>');
                sys.puts(page.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"));
                sys.puts('</content>');

                sys.puts('<published>' + (new Date()).getTime().toString() + '</published>');

                sys.puts('</sphinx:document>');

                process_document(docs);
            },
            'error':function (e) {
                sys.puts('Error getting doc: ' + sys.inspect(e));
            }
        });

    } else {
        sys.puts('</sphinx:docset>');
    }
};
    

В остальном скрипт очень прост. Подключиться к CouchDB, получить id всех записей, перебрать их:

var couch = require('./node-couch').CouchDB,
    libxml = require('./libxmljs'),
    settings = require('./settings'),
    sys = require('sys');

var db = couch.db(settings.couchbase, settings.couchhost);

db.allDocs({
    'success': function(docs) {

        sys.puts('<' + '?xml version="1.0" encoding="utf-8"?>');
        sys.puts('<sphinx:docset>');
        sys.puts('<sphinx:schema>');

        sys.puts(' <sphinx:field name="subject" />');
        sys.puts(' <sphinx:field name="content" />');
        sys.puts(' <sphinx:field name="url" />');
        sys.puts(' <sphinx:field name="published" type="timestamp" />');

        sys.puts('</sphinx:schema>');

        process_document(docs.rows);

    },
    'error': function(errorResponse) {
        sys.puts('Error getting all docs: ' + JSON.stringify(errorResponse.reason));
    }
});
    

Можно запустить получившийся файл и полюбоваться на пролетающую по экрану простыню символов:

    node xmlpipe2.js
    

Sphinx

Теперь надо настроить Sphinx на индексирование этого источника, примерно так (sphinx.conf):

source spider
    {
        type = xmlpipe2
        xmlpipe_command = node /mnt/hgfs/node/spider/xmlpipe2.js # Скрипт-источник
    }

index searchengine
    {
        source spider

        # Здесь много дополнительных настроек, которые можно взять из шаблонного конфига

        charset_type = utf-8

        html_strip = 1

        html_remove_elements = style, script
    }
    

В источнике надо обязательно задать charset_type = utf-8, иначе индексация работать не будет.

Всё, теперь можно попытаться запустить индексатор:

    indexer searchengine
    

Здесь пошли ошибки. После копания стало ясно, что документы, судя по всему, слишком велики и затрагивают какой то баг в String.replace — не все символы заменялись на соответствующие последовательности. Да, похоже такой способ экранирования HTML-последовательностей не годится.

Ну что ж, можно пойти другим путём — выкинуть всё лишнее из страниц заранее.

Подготовка страниц при сборке

Нам есть смысл вырезать лишний хлам из страниц прямо на этапе их сбора пауком. В libxmljs есть для этого все инструменты. Но сначала вынесем парсинг HTML в отдельную функцию, чтобы не делать одно и то же несколько раз:

var parsePage = function(string) {
    try {
        var parsed = libxml.parseHtmlString(string);
    } catch(e) {
        sys.puts('Cannot parse: ' + string);
        return [];
    }

    return parsed;
};

    

Теперь с распарсенной страницей можно делать всё что угодно. Получать ссылки:

var getLinks = function(parsed_html) {

    var links = parsed_html.find('//a');
    var destinations = [];
    for (link in links) {
        var attr = links[link].attr('href');
        if (attr && attr.value) {
            var url_parts = url.parse(attr.value());

            if (!url_parts.hostname || url_parts.hostname.indexOf(settings.targethost) > -1) {
                destinations.push(url_parts.pathname);
            } else {
                // sys.puts('Found outbound link to ' + url_parts.hostname);
            }

        }
    }

    return destinations;
}
    

.., получать заголовок страницы:

var pageTitle = function(parsed_html) {

    var title = parsed_html.get('//head/title');

    return title.text();
}
    

.., и главное — получать чистый текст страницы без лишних элементов вроде script и style:

var cleanPage = function(parsed_html) {

    var scripts = parsed_html.find('//script');
    for (script in scripts) {
        scripts[script].remove();
    }

    var styles = parsed_html.find('//style');
    for (style in styles) {
        styles[style].remove();
    }

    var body = parsed_html.get('/html/body');

    if (body && body.text) {
        body = body.text();
    } else {
        sys.puts('Body is empty?');
        body = '';
    }

    return body;
}
    

Соответственно, теперь при получении документа алгоритм действий будет таков:

  • Распарсить страницу
  • Получить список исходящих ссылок
  • Получить title страницы
  • Получить очищенный текст

Пробуем запустить индексатор — уже лучше, он ругается на неправильные идентификаторы документов:

ERROR: index 'searchengine': source 'spider': attribute 'id' required in <sphinx:document> (line=9, pos=0, docid=0).

Заглянув в документацию мы видим, что ID документа в Sphinx должен быть 32-хбитным целым числом (если сборка 32-хбитная). Огромные ID, которые назначает документам CouchDB, тут не годятся — придётся назначать цифровой ID при сохранении документа:

var doc_id = 1;
var save_page = function (URL, title, text) {

    db.saveDoc({'url' : URL, 'title' : title, 'text' : text, 'doc_id': doc_id});

    doc_id++;
}

Это конечно не очень удобный хак (особенно если спайдера придётся перезапускать), но нам пока хватит. Теперь этот же doc_id передаём Сфинксу при индексации документа. Кстати, не стоит начинать нумерацию с нуля: Sphinx этого не оценит и скажет что id у такого документа нет.

После такого добавления мне пришлось ещё раз очистить базу паука и заново пройтись по сайту. Набралось 144 документа.

Теперь можно попробовать проиндексировать наш источник ещё раз. Запускаем индексатор, указываем название индекса, и…

debian:/mnt/hgfs/node/spider# indexer searchengine
Sphinx 0.9.9-release (r2117)
Copyright (c) 2001-2009, Andrew Aksyonoff

using config file '/usr/local/etc/sphinx.conf'...
indexing index 'searchengine'...
deprecation warning: process.mixin will be removed from node-core future releases.
WARNING: source 'spider': both embedded and configured schemas found; using embedded (line=3,
 pos=0, docid=0)
collected 144 docs, 0.9 MB
sorted 0.1 Mhits, 100.0% done
total 144 docs, 858757 bytes
total 2.134 sec, 402404 bytes/sec, 67.47 docs/sec
total 1 reads, 0.002 sec, 425.9 kb/call avg, 2.0 msec/call avg
total 5 writes, 0.006 sec, 210.2 kb/call avg, 1.2 msec/call avg

Ура, наши документы проиндексировались! 🙂 Теперь можно с помощью limestone организовать по ним поиск. Но это — уже в следующий раз.

Создание поискового сервера

Ссылки по теме

Advertisements
2 комментария
  1. Евгений permalink

    Спасибо, отличная статья! Правда, не работал поиск в кириллице. (node.js v0.4.3, limestone v0.1.1) В Buffer.makeWriter().push.lstring — направильно определялась длина буфера, если в строке присутствовали кириллические символы

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s

%d такие блоггеры, как: