Wikiproyecto:Wikidemia/Cómo procesar los dumps con python

Este tutorial explica las herramientas básicas para procesar los dumps XML usando python.

Para entender este tutorial es necesario entender qué es un fichero XML. Aquí sólo se dará una brevísima introducción. También es necesario conocer algo de python. Aunque en un ejemplo se usa gnuplot, no es necesario saber gnuplot para seguirlo. Sin embargo resulta muy conveniente para hacer algo más complejo que lo que se hace aquí.

Para probar cosas es útil bajarse un fichero de una wiki pequeña, como zuwiki [1] (chwiki o cbk-zamwiki son parecidos al español y también son pequeñas). En este tutorial trabajaremos con el dump completo de zuwiki con historial (All pages with complete page edit history).

Estructura de un fichero XML editar

Un archivo XML tiene una estructura de pares etiquetas encerradas entre <>. La etiqueta de cierre lleva una barra junto al nombre de elemento (</identificador>). Las etiquetas pueden estar anidadas (<elemento1><elemento2></elemento2><elemento3></elemento3></elemento1>), formando una estructura de árbol.

Si, tras descargarlo y descomprimirlo, abrimos el dump de zuwiki con un editor de texto (se recomienda un editor apropiado para programar, como Notepad++, Vim, Emacs...) veremos, tras unas cuantas líneas, algo como:

  <page>
    <title>Ikhasi Elikhulu</title>
    <id>3</id>
    <revision>
      <id>4</id>
      <timestamp>2003-11-30T01:41:12Z</timestamp>
      <contributor>
        <ip>CPE0007e9885d15-CM000039d1c86a.cpe.net.cable.rogers.com</ip>
      </contributor>
      <comment>*</comment>
...

  </page>

Donde vemos que el elemento <page> define una página, y tiene subelementos: <title> (el título), <id> y una lista de revisiones, cada una con subelementos (la dirección o el nombre del editor, la hora, la id de edición, el comentario, el texto...).

Python y XML editar

Python tiene dos herramientas para parsear XML:

  • SAX va leyendo el documento línea a línea y permite asociar funciones con eventos (se abre una etiqueta, se cierra una etiqueta). Nos resultará más útil porque las bases de datos de wikipedia son muy grandes, y SAX no necesita cargar todo el documento en memoria.
  • DOM carga el XML entero en memoria y se puede acceder a él como un árbol. Carga todo el documento en memoria.

Procesado del dump con SAX editar

Vamos a empezar por un ejemplo sencillo: cargar el archivo e imprimir por pantalla el título de cada página y al final el número de páginas totales de esa wiki.

Para trabajar con SAX tendremos que crear un parser. Al parser le pasaremos una clase (handler o manejador) con una serie de funciones definidas. Estas funciones tienen unos nombres prefijados. Es decir, no les podemos poner el nombre que nosotros queramos, porque el parser llama a startElement (si existe) al encontrar una etiqueta de inicio. Podemos, eso sí, crear funciones nuevas que llamaremos nosotros mismos que tengan el nombre que queramos.

El parser llamará a las funciones de nuestro handler cuando suceda un evento. Veamos un ejemplo:

Ejemplo 1: Imprimir los títulos de todas las páginas de una wiki y el número de páginas editar

# -*- coding: utf-8 -*- 
from xml.sax import ContentHandler
from xml.sax import make_parser


class MuestraTitulos(ContentHandler):
		#Se llama al empezar a parsear
		def __init__(self):
				#Definimos las variables de nuestro parser
				#Número de páginas		
 				self.num = 0
				#Título de la página
				self.titulo = ""
				#Marca si estamos dentro de un elemento <title>
				self.enTitulo = False

		#Se llama al encontrar una etiqueta de inicio de elemento <elemento>
		def startElement(self, name, attrs):
				#Empieza una página
				if name == 'page':
						self.num = self.num + 1
				#Entramos en <title>
				if name == 'title':
						self.titulo = ""
						#Marcamos que estamos dentro de <title> para que lo sepa "characters"
						self.enTitulo = True
    
		def endElement(self, name):
				#Salimos del elemento título
				if name == 'title':
						self.enTitulo = False
				#Hemos acabado de parsear el dump
				if name == 'mediawiki':
						print "Número de páginas: "+repr(self.num)

		
		#Se llama cuando encuentra texto dentro de un elemento <elemento>texto</elemento>
		def characters(self, ch):
				#Si estamos dentro de <title></title> imprimimos el título
				if self.enTitulo:
						print ch

#Creamos un parser y le decimos que el handler o manejador será nuestra clase
parser = make_parser()
handler=MuestraTitulos()
parser.setContentHandler(handler)
#Parseamos el fichero
parser.parse("zuwiki-todo.xml")

Como vemos, es necesario que tengamos variables que nos indiquen en qué punto del árbol nos encontramos, según nos interese. En este caso la variable enTitulo nos sirve para controlar cuándo estamos dentro de <title></title>, porque no tenemos otra manera de saberlo.

Ejemplo 2: Contar el número de ediciones por página y hacer un gráfico con gnuplot editar

Ahora vamos a hacer algo más complejo. Supongamos que queremos hacer un gráfico con el número de ediciones (etiqueta <revision>) por artículo (no cualquier página, solo artículos). Veremos que al principio del dump se listan los namespaces:

<namespaces>
      <namespace key="-2">Media</namespace>
      <namespace key="-1">Special</namespace>
      <namespace key="0" />
      <namespace key="1">Talk</namespace>
...
</namespaces>

Almacenaremos estos identificadores de nombre de espacio para descartar los títulos que empiecen por ellos. El código es.

# -*- coding: utf-8 -*- 
from xml.sax import ContentHandler
from xml.sax import make_parser
import re

class CuentaRevs(ContentHandler):
		#Se llama al empezar a parsear
		def __init__(self, ficheroSalida):
				#Definimos las variables de nuestro parser
				self.ficheroSalida = ficheroSalida
				#Número de revisiones	de la página actual
 				self.num = 0
				#Lista con contadores de revisiones
				self.listaNum = []
				#Lista de nombres de namespaces
				self.nombresEspacio = []
				#Lista de expresiones regulares para los nombres de espacio
				self.nombresEspacioRgx = []
				#Controlar si estamos en <namespace>
				self.enNamespace = 0
				#Controlar si estamos en <title>
				self.enTitulo = 0
				#¿La página actual es un artículo?
				self.esArt = False

		#Se llama al encontrar una etiqueta de inicio de elemento <elemento>
		def startElement(self, name, attrs):
				#Empieza una página
				if name == 'page':
						self.num = 0
				#Entramos en <title>
				if name == 'title':
						self.enTitulo = True
				#Entramos en <revision>, si es un artículo incrementamos el contador
				if name == 'revision':
						if self.esArt:
								self.num = self.num + 1
				#Entramos en <namespace>
				if name == 'namespace':
						self.enNamespace = True

		def endElement(self, name): 
				#salimos de <namespace>
				if name == 'namespace':
						self.enNamespace = False
				#salimos de <title>
				if name == 'title':
						self.enTitulo = False
				#Tenemos todos los nombres de espacio, compilamos las regexps
				if name == 'namespaces':
						for nombre in self.nombresEspacio:
								self.nombresEspacioRgx.append(re.compile(nombre))
								print "Compilando regexp para nombre de espacio "+nombre
				#Salimos de <page>, almacenamos el contador de revisiones
				if name == 'page':
						self.listaNum.append(self.num)
						self.num = 0
				#Hemos acabado de parsear el dump
				if name == 'mediawiki':
						#ordenamos la lista de contadores de revisión
						self.listaNum.sort(reverse = True)
						fileHandle = open ( self.ficheroSalida, 'w' )
						for num in self.listaNum:
								fileHandle.write(repr(num)+ "\n")
						fileHandle.close()

		def characters(self, ch):
				#Si estamos dentro de <namespace></namespace> nos guardamos el texto
				if self.enNamespace:
						self.nombresEspacio.append(ch)
				#Si estamos en un titulo nos interesa saber si es de un espacio de nombres distinto al de artículos
				if self.enTitulo:
						self.esArt = True
						#Si alguna regexp hace match con el título, estamos en un namespace que no nos interesa
						for reg in self.nombresEspacioRgx:
								if reg.match(ch):
										self.esArt = False

#Creamos un parser y le decimos que el handler o manejador será nuestra clase
parser = make_parser()
handler=CuentaRevs("revisiones.dat")
parser.setContentHandler(handler)
#Parseamos el fichero
parser.parse("zuwiki-todo.xml")

Este código escribe en disco el fichero revisiones.dat, que contiene algo como:

251
128
105
99
88
83
82
81
80
...

Que podemos mostrar con gnuplot:

$gnuplot
gnuplot>plot "revisiones.dat" with lines
 
Gráfica de número de ediciones por artículo de zuwiki

Que muestra algo parecido a la imagen de la derecha.

Sobre gnuplot hay muchos tutoriales en Internet, se pueden hacer cosas mucho más complejas y vistosas, esto solo es el ejemplo más sencillo.

Ejemplo 3: Obtener los artículos de otra wiki que no tienen interwiki hacia es.wikipedia editar

En este ejemplo haremos una consulta SQL. Hay un tutorial sobre dumps SQL en Wikiproyecto:Wikidemia/Cómo procesar los dumps SQL, así que aquí sólo haremos unos pocos comentarios.

Para obtener los artículos de otra wiki que no tienen interwiki hacia es.wikipedia necesitaremos dos dumps. "Wiki interlanguage link records", que es un SQL que contiene la información de interwikis, y, dado que éste último no trabaja con títulos de artículos que tienen interwiki sino con su id, necesitaremos un fichero que nos relaciones las id con los títulos. El dump que relaciona los dos es el de stubs (también otros, como el que contiene el texto actual de las páginas, pero este es más grande): "http://download.wikimedia.org/cbk_zamwiki/20090402/cbk_zamwiki-20090402-stub-articles.xml.gz". En este tutorial usaremos los dumps de la wiki cbk-zam, que es pequeña y la lengua se parece al español.

Empecemos: Creamos una tabla sql para cbk-wiki e importamos el dump, según se detalla en el tutorial de sql:

mysql> CREATE DATABASE cbkzamwiki;
Query OK, 1 row affected (0.17 sec)
mysql> USE cbkzamwiki
Database changed
mysql> source cbk_zamwiki-20090402-langlinks.sql


Vemos qué estructura tiene:

mysql> SELECT * FROM `langlinks` LIMIT 0 , 30;
+---------+---------+------------------------+
| ll_from | ll_lang | ll_title |
+---------+---------+------------------------+
| 46 | aa | MediaWiki:Common.css | 
| 47 | aa | MediaWiki:Monobook.css | 
| 49 | aa | MediaWiki:Monobook.js | 
| 2975 | aa | User:Adolfobs93 | 
| 2548 | aa | User:Alexbot |

los campos son: "ll_from", que es el artículo desde el que se hace el interwiki, "ll_lang", que es el código de lengua de la wiki destino, y "ll_title", que es el título en la wiki destino.

¿Qué queremos obtener de esta base de datos? Saber qué artículos tienen interwiki a la es.wikipedia. La consulta sql será:

mysql>SELECT DISTINCT `ll_from` FROM langlinks WHERE `ll_from` NOT IN ( SELECT `ll_from` FROM `langlinks` WHERE ll_lang = 'es' );
+---------+
| ll_from |
+---------+
| 52 | 
| 2380 | 
| 2565 | 
| 2560 | 
| 2654 | 
| 2579 | 
| 2580 | 
| 2607 | 
| 2483 | 
| 2608 | 
+---------+
10 rows in set (0.00 sec)

Como ejemplos, aunque no las vamos a usar, esta consulta obtiene los artículos que tienen algún interwiki pero no tienen interwiki a es.wikipedia:

mysql>SELECT DISTINCT `ll_from` FROM langlinks WHERE `ll_from` NOT IN ( SELECT `ll_from` FROM `langlinks` WHERE ll_lang = 'es' );

Y esta otra filtra los que tengan ":" en el nombre para eliminar otros espacios de nombres:

mysql>SELECT * FROM langlinks WHERE ll_from NOT IN (SELECT ll_from FROM langlinks WHERE ll_lang='es') AND ll_title NOT LIKE "%:%" GROUP BY ll_from;

Pero no las usaremos porque de todas formas nos faltarán los artículos que no tengan interwiki alguno, que no podemos obtener de esta tabla sql, y ya que tenemos que usar python para relacionar las id de artículo con los títulos, para qué complicarnos la vida con el sql.


El código en python comentado es el siguiente:

# -*- coding: utf-8 -*- 
import MySQLdb
from xml.sax import ContentHandler
from xml.sax import make_parser
import re

db=MySQLdb.connect(host='localhost',user='root',passwd='mipass',db='cbkzamwiki')

#Consulta sql con python y MySQLdb
cursor=db.cursor()
sql="""SELECT `ll_from` FROM `langlinks` WHERE ll_lang = 'es'"""
cursor.execute(sql)
resultado=cursor.fetchall()

#diccionario a rellenar con parejas id->titulo
dictIdTitulo = {}

class Getnoiws(ContentHandler):
		#Se llama al empezar a parsear
		def __init__(self):
				#Lista de nombres de namespaces
				self.nombresEspacio = []
				#Lista de expresiones regulares para los nombres de espacio
				self.nombresEspacioRgx = []
				#Controlar si estamos en <namespace>
				self.enNamespace = False
				#Controlar si estamos en <title>
				self.enTitulo = False
				#Controlar si estamos en <id>
				self.enId = False
				#Controlar si estamos en <revision>
				self.enRevision = False
				#¿La página actual es un artículo?
				self.esArt = False
				#Último título
				self.ultimoTitulo = ""

		#Se llama al encontrar una etiqueta de inicio de elemento <elemento>
		def startElement(self, name, attrs):
				#Empieza una página
				if name == 'page':
						self.num = 0
				#Entramos en <title>
				if name == 'title':
						self.enTitulo = True
				#Entramos en <id>
				if name == 'id':
						self.enId = True
				#Entramos en <namespace>
				if name == 'namespace':
						self.enNamespace = True
				#Entramos en <revision>
				if name == 'revision':
						self.enRevision = True

		def endElement(self, name): 
				#salimos de <namespace>
				if name == 'namespace':
						self.enNamespace = False
				#salimos de <title>
				if name == 'title':
						self.enTitulo = False
				if name == 'id':
						self.enId = False
				#Tenemos todos los nombres de espacio, compilamos las regexps
				if name == 'namespaces':
						for nombre in self.nombresEspacio:
								self.nombresEspacioRgx.append(re.compile(nombre))
								print "Compilando regexp para nombre de espacio "+nombre
				#Salimos de <revision>
				if name == 'revision':
						self.enRevision = False

		def characters(self, ch):
				#Si estamos dentro de <namespace></namespace> nos guardamos el texto
				if self.enNamespace:
						self.nombresEspacio.append(ch)
				#Si estamos en un titulo nos interesa saber si es de un espacio de nombres distinto al de artículos
				if self.enTitulo:
						self.esArt = True
						#Si alguna regexp hace match con el título, estamos en un namespace que no nos interesa
						for reg in self.nombresEspacioRgx:
								if reg.match(ch):
										self.esArt = False
						#si estamos en un artículo, nos guardamos su título para cuando entremos en id				
						if self.esArt:
							self.ultimoTitulo = ch

				#si estamos en una id de artículo, añadimos [id]->titulo al diccionario
				#comprobamos que no estamos en <revision>, porque también hay <id> en revision que no nos interesan
				if self.enId and not self.enRevision:
						if self.esArt:
								dictIdTitulo[ch]=self.ultimoTitulo



#Creamos un parser y le decimos que el handler o manejador será nuestra clase
parser = make_parser()
handler=Getnoiws()
parser.setContentHandler(handler)
#Parseamos el fichero
parser.parse("cbk-zamwiki-stubs.xml")

#recorremos el resultado de la consulta sql borrando de nuestra lista de títulos
#los que tienen interwiki
for id in resultado:
		#convertimos la id en unicode, porque así la tenemos en el diccionario
		#ponemos id[0] porque resultado en realidad es una tabla.
		#Aunque en este caso solo tiene una columna, tenemos que decir que 
		#queremos esa primera columna
		uid = unicode(id[0])
		#puede que el diccionario no tenga la página porque no es un artículo
		if dictIdTitulo.has_key(uid):
			del dictIdTitulo[uid]

#obtenemos lo que ha quedado
for id in dictIdTitulo:
		print "[["+dictIdTitulo[id]+"]]"

Enlaces externos editar