27 сентября 2014

Преобразование XML в CSV

Недавно я нашёл интересный файл в исходниках пакета iso-codes. Вернее, даже несколько файлов. В файле iso_639.xml (просмотреть) есть двух- и трёхбуквенные коды языков и их полное наименование. А в файле iso_3166.xml (просмотреть) — двух- и трёхбуквенные коды стран и территорий и их полное наименование. Вот кусочек одного файла:

 <iso_639_entry
  iso_639_2B_code="afr"
  iso_639_2T_code="afr"
  iso_639_1_code="af"
  name="Afrikaans" />
 <iso_639_entry
  iso_639_2B_code="ain"
  iso_639_2T_code="ain"
  name="Ainu" />

Я пишу в основном на shell для busybox. И я не работал с форматом XML. Гораздо проще было бы обрабатывать простой текстовый файл, в котором то же самое было бы записано вот так:

|af|afr|afr|Afrikaans|
|  |ain|ain|Ainu|

Тогда при помощи grep можно было бы выбрать из файла строку с нужным языком, дальше при помощи cut в этой строке выделить наименование языка. А уже потом, при необходимости, можно это наименование перевести на другой язык, используя переводы из этого же пакета iso-codes. Вот так:

LANGUAGE_CODE="af"

LANGUAGE=$(grep -F "|$LANGUAGE_CODE|" /path/to/iso_639.tab | cut -d'|' -f5)

echo "$LANGUAGE_CODE : $LANGUAGE : $(gettext -d iso_639 $LANGUAGE)"

Дело осталось за малым — нужно сделать эти tab-файлы. Можно, конечно, использовать текстовый редактор и при помощи поиска с заменой, а потом еще и при помощи напильника выполнить задачу. Но это отнимает много времени, и всё придётся повторять при выходе новой версии пакета iso-codes. Нужно это как-то автоматизировать.

Первым делом в голову почему-то пришло название — xmllint. Я повозился с ним, погуглил, но так и не смог добиться цели. Максимум пользы от xmllint было в том, что если ему на вход подать xml-файл, то он его «облагородит»:

xmllint /path/to/iso_639.xml
 <iso_639_entry iso_639_2B_code="afr" iso_639_2T_code="afr" iso_639_1_code="af" name="Afrikaans" />
 <iso_639_entry iso_639_2B_code="ain" iso_639_2T_code="ain" name="Ainu" />

Так, уже лучше. Это уже почти то, что нужно — одно определение теперь занимает одну строку. Что ж, вот несложный скрипт в качестве «напильника»:

#!/bin/sh

TEMP=$(mktemp)
xmllint $1 | grep '<iso_639_entry' > $TEMP

    while read line; do
        iso_639_2B_code="$(echo $line | sed 's|.* iso_639_2B_code=\"\([^"]*\)\".*|\1|')"
        iso_639_2T_code="$(echo $line | sed 's|.* iso_639_2T_code=\"\([^"]*\)\".*|\1|')"
        iso_639_1_code="$(echo $line | grep iso_639_1_code | sed 's|.* iso_639_1_code=\"\([^"]*\)\".*|\1|')"
        name="$(echo $line | sed 's|.* name=\"\([^"]*\)\".*|\1|')"
        common_name="$(echo $line | grep common_name | sed 's|.* common_name=\"\([^"]*\)\".*|\1|')"

        [ "x$iso_639_1_code" == "x" ] && iso_639_1_code="  "

        echo "|$iso_639_1_code|$iso_639_2B_code|$iso_639_2T_code|$name|$common_name"

    done < $TEMP

Скрипт работает, выполняет своё дело. Но какой же он медленный!

real 0m 49.83s
user 0m 44.09s
sys 0m 12.40s

Да к тому же и процессор нагружает около 100%.

Писать на shell легко, но программы получаются далеко не эффективными и это особенно заметно на таких вот циклах. Я буду продолжать писать на shell, но этот кусочек я хотел бы переделать.

Я погуглил ещё на тему преобразования и трансформации XML и в конце-концов вышел на хороший пример, который пришлось только немного переделать под мой файл.

На сцену выходит утилита xsltproc. Она умеет преобразовывать файл XML в соответствии с правилами, заложенными в указанном файле XSL. Некоторая аналогия с информацией, находящейся в HTML и правилами в CSS. Да, как-то много лет назад я возился с XML и XSL но, конечно же, подробности забылись и тогда я не сделал каких-то особенных успехов. Получится ли сейчас? Да, к счастью, задача оказалась простой. Вот файл iso_639.xsl:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
 xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
 <xsl:output method="text" />
 <xsl:template match="/">
  <xsl:for-each select="//iso_639_entries/*">
   <xsl:text>|</xsl:text>
   <xsl:if test="not(@iso_639_1_code)">
    <xsl:text>  </xsl:text>
   </xsl:if>
   <xsl:value-of select="@iso_639_1_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@iso_639_2B_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@iso_639_2T_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@name" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@common_name" />
   <xsl:text>&#10;</xsl:text>
  </xsl:for-each>
 </xsl:template>
</xsl:stylesheet>

Здесь для каждой записи выводятся через разделители «|» параметры iso_639_1_code, iso_639_2B_code, iso_639_2T_code, name, common_name. Причём iso_639_1_code и common_name могут отсутствовать. Я «для красоты» сделал так, если отсутствует iso_639_1_code (двухбуквенный код), то выводить два пробела. Так табличка выглядит ровной при просмотре глазами. Запускается преобразование командой:

xsltproc iso_639.xsl iso_639.xml > iso_639.tab

Вот его время работы:

real 0m 0.03s
user 0m 0.03s
sys 0m 0.00s

Чёрт! Впечатляет!

Почти так же был написан и файл iso_3166.xsl. Отличие только в том, что теперь файл XML состоит из двух частей. В первой части — действующие коды, а во второй — исторические, как например, СССР. Для исторических кодов применяется немного другой формат, например указано, когда этот СССР развалился и код перестал действовать.

Вот, собственно, и сам файл iso_3166.xsl:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
 xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
 <xsl:output method="text" />
 <xsl:template match="/">
  <xsl:for-each select="//iso_3166_entries/iso_3166_entry">
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@alpha_2_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@alpha_3_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@numeric_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@name" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@official_name" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@common_name" />
   <xsl:text>|</xsl:text>
   <xsl:text>&#10;</xsl:text>
  </xsl:for-each>

  <xsl:text>&#10;&#10;</xsl:text>

  <xsl:for-each select="//iso_3166_entries/iso_3166_3_entry">
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@alpha_4_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@alpha_3_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@numeric_code" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@date_withdrawn" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@names" />
   <xsl:text>|</xsl:text>
   <xsl:value-of select="@comment" />
   <xsl:text>|</xsl:text>
   <xsl:text>&#10;</xsl:text>
  </xsl:for-each>

 </xsl:template>
</xsl:stylesheet>

Ну вот и всё. Если мне когда-нибудь понадобится преобразовать XML в CSV, я буду знать что делать.