Source code for textacy.datasets.wikimedia

"""
Wikimedia articles
------------------

All articles for a given Wikimedia project, specified by language and version.

Records include the following key fields (plus a few others):

    - ``text``: Plain text content of the wiki page -- no wiki markup!
    - ``title``: Title of the wiki page.
    - ``wiki_links``: A list of other wiki pages linked to from this page.
    - ``ext_links``: A list of external URLs linked to from this page.
    - ``categories``: A list of categories to which this wiki page belongs.
    - ``dt_created``: Date on which the wiki page was first created.
    - ``page_id``: Unique identifier of the wiki page, usable in Wikimedia APIs.

Datasets are generated by the Wikimedia Foundation for a variety of projects,
such as Wikipedia and Wikinews. The source files are meant for search indexes,
so they're dumped in Elasticsearch bulk insert format -- basically, a compressed
JSON file with one record per line. For more information, refer to
https://meta.wikimedia.org/wiki/Data_dumps.
"""
import datetime
import itertools
import logging
import os
import pathlib
import re
import urllib.parse
from typing import Iterable, Optional, Set, Union

import requests
from cytoolz import itertoolz

from .. import constants, types, utils
from .. import io as tio
from .base import Dataset

LOGGER = logging.getLogger(__name__)

METAS = {
    "wikipedia": {
        "site_url": "https://en.wikipedia.org/wiki/Main_Page",
        "description": (
            "All pages for a given language- and version-specific "
            "Wikipedia site snapshot."
        ),
    },
    "wikinews": {
        "site_url": "https://en.wikinews.org/wiki/Main_Page",
        "description": (
            "All pages for a given language- and version-specific "
            "Wikinews site snapshot."
        ),
    },
}
# NOTE: let's use a mirror rather than the official wikimedia host
# see: https://meta.wikimedia.org/wiki/Mirroring_Wikimedia_project_XML_dumps
# DOWNLOAD_ROOT = "https://dumps.wikimedia.org/other/cirrussearch/"
DOWNLOAD_ROOT = "https://dumps.wikimedia.your.org/other/cirrussearch/"


def _is_bad_category_en(cat: str) -> bool:
    return (
        cat == "All stub articles"
        or cat.startswith("Disambiguation pages")
        or re.search(
            r"^(?:All )?(?:Wikipedia )?(?:[Aa]rticles?|[Pp]ages)", cat, flags=re.UNICODE
        )
        is not None
    )


is_bad_category_funcs = {
    "wiki": {
        "de": lambda cat: cat.startswith("Wikipedia:"),
        "en": _is_bad_category_en,
        "nl": lambda cat: cat.startswith("Wikipedia:"),
    },
    "wikinews": {
        "de": lambda cat: cat in {"Artikelstatus: Fertig", "Veröffentlicht"},
        "en": lambda cat: cat in {"Archived", "Published", "AutoArchived", "No publish"},
        "es": lambda cat: cat in {"Archivado", "Artículos publicados"},
        "fr": lambda cat: cat in {"Article archivé", "Article publié"},
        "it": lambda cat: cat in {"Pubblicati"},
        "nl": lambda cat: cat in {"Gepubliceerd"},
        "pt": lambda cat: cat in {"Arquivado", "Publicado"},
    },
}

_bad_wiki_link_starts = {
    "wiki": {
        "de": ("Wikipedia:", "Hilfe:"),
        "el": ("Βοήθεια:",),
        "en": ("Wikipedia:", "Help:"),
        "es": ("Wikipedia:", "Ayuda:"),
        "fr": ("Wikipédia:", "Aide:"),
        "it": ("Wikipedia:", "Aiuto:"),
        "nl": ("Wikipedia:",),
        "pt": ("Wikipédia:", "Ajuda:"),
    },
    "wikinews": {
        "de": ("Wikinews:",),
        "el": ("Βικινέα",),
        "en": ("Wikinews:", "Template:", "User:"),
        "es": ("Wikinoticias:",),
        "fr": ("Wikinews:",),
        "it": ("Wikinotizie:",),
        "nl": ("Wikinieuws:",),
        "pt": ("Wikinotícias:",),
    },
}


[docs]class Wikimedia(Dataset): """ Base class for project-specific Wikimedia datasets. See: * :class:`Wikipedia` * :class:`Wikinews` """ def __init__( self, name, meta, project, data_dir, lang="en", version="current", namespace=0, ): super().__init__(name, meta=meta) self.lang = lang self.version = version self.project = project self.namespace = int(namespace) self._filestub = os.path.join( f"{self.lang}{self.project}", f"{self.version}", f"{self.lang}{self.project}-{self.version}-cirrussearch-content.json.gz", ) self.data_dir = utils.to_path(data_dir).resolve() self._filepath = self.data_dir.joinpath(self._filestub) @property def filepath(self) -> Optional[str]: """ str: Full path on disk for the Wikimedia CirrusSearch db dump corresponding to the ``project``, ``lang``, and ``version``. """ if self._filepath.is_file(): return str(self._filepath) else: return None
[docs] def download(self, *, force: bool = False) -> None: """ Download the Wikimedia CirrusSearch db dump corresponding to the given ``project``, ``lang``, and ``version`` as a compressed JSON file, and save it to disk under the ``data_dir`` directory. Args: force: If True, download the dataset, even if it already exists on disk under ``data_dir``. Note: Some datasets are quite large (e.g. English Wikipedia is ~28GB) and can take hours to fully download. """ file_url = self._get_file_url() tio.download_file( file_url, filename=self._filestub, dirpath=self.data_dir, force=force, )
def _get_file_url(self): # get dates for the previous two mondays # in case it's too soon for the previous week's dump if self.version == "current": today = datetime.date.today() version_dts = ( today - datetime.timedelta(days=today.weekday()), today - datetime.timedelta(days=today.weekday() + 7), ) # otherwise, version should be a date string like YYYYMMDD else: try: version_dts = ( datetime.datetime.strptime(self.version, "%Y%m%d").date(), ) except ValueError: LOGGER.exception( "version = %s is invalid; must be 'current' " "or a date string like YYYYMMDD", self.version, ) raise for version_dt in version_dts: file_url = urllib.parse.urljoin( DOWNLOAD_ROOT, "{version}/{lang}{project}-{version_dt}-cirrussearch-content.json.gz".format( version=self.version, lang=self.lang, project=self.project, version_dt=version_dt.strftime("%Y%m%d"), ), ) response = requests.head(file_url) if response.status_code == 200: return file_url # check that the version actually exists... response = requests.head(urllib.parse.urljoin(DOWNLOAD_ROOT, self.version)) if response.status_code != 200: raise ValueError( f"no Wikimedia CirrusSearch data found for version='{self.version}'; " f"check out '{DOWNLOAD_ROOT}' for available data" ) else: raise ValueError( f"no Wikimedia CirrusSearch data found for version = '{self.version}', " f"lang = '{self.lang}', project = '{self.project}'; " f"check out '{response.url}' for available data" ) def __iter__(self): if not self.filepath: raise OSError( f"{self.project} database dump file '{self.filepath}' not found; " "has the dataset been downloaded yet?" ) is_bad_category = is_bad_category_funcs.get(self.project, {}).get(self.lang) bad_wl_starts = _bad_wiki_link_starts.get(self.project, {}).get( self.lang, tuple() ) lines = tio.read_json(self.filepath, mode="rb", lines=True) for index, source in itertoolz.partition(2, lines): if source.get("namespace") != self.namespace: continue # split opening text from main body text, if available opening_text = source.get("opening_text") text = source.get("text") if opening_text and text and text.startswith(opening_text): text = opening_text + "\n\n" + text[len(opening_text) :].strip() # do minimal cleaning of categories and wiki links, if available if is_bad_category: categories = tuple( cat for cat in source.get("category", []) if not is_bad_category(cat) ) else: categories = tuple(source.get("category", [])) wiki_links = tuple( wl for wl in source.get("outgoing_link", []) if not any(wl.startswith(bwls) for bwls in bad_wl_starts) ) yield { "page_id": index["index"]["_id"], "title": source["title"], "text": text, "headings": tuple(source.get("heading", [])), "wiki_links": wiki_links, "ext_links": tuple( urllib.parse.unquote_plus(el) for el in source.get("external_link", []) ), "categories": categories, "dt_created": source.get("create_timestamp"), "n_incoming_links": source.get("incoming_links"), "popularity_score": source.get("popularity_score"), } def _get_filters(self, category, wiki_link, min_len): filters = [] if min_len is not None: if min_len < 1: raise ValueError("`min_len` must be at least 1") filters.append(lambda record: len(record.get("text", "")) >= min_len) if category is not None: category = utils.validate_set_members( category, (str, bytes), valid_vals=None ) filters.append( lambda record: ( record.get("categories") and any(ctgry in record["categories"] for ctgry in category) ) ) if wiki_link is not None: wiki_link = utils.validate_set_members( wiki_link, (str, bytes), valid_vals=None ) filters.append( lambda record: ( record.get("wiki_links") and any(wl in record["wiki_links"] for wl in wiki_link) ) ) return filters def _filtered_iter(self, filters): if filters: for record in self: if all(filter_(record) for filter_ in filters): yield record else: for record in self: yield record
[docs] def texts( self, *, category: Optional[Union[str, Set[str]]] = None, wiki_link: Optional[Union[str, Set[str]]] = None, min_len: Optional[int] = None, limit: Optional[int] = None, ) -> Iterable[str]: """ Iterate over wiki pages in this dataset, optionally filtering by a variety of metadata and/or text length, and yield texts only, in order of appearance in the db dump file. Args: category: Filter wiki pages by the categories to which they've been assigned. For multiple values (Set[str]), ANY rather than ALL of the values must be found among a given page's categories. wiki_link: Filter wiki pages by the other wiki pages to which they've been linked. For multiple values (Set[str]), ANY rather than ALL of the values must be found among a given page's wiki links. min_len: Filter wiki pages by the length (# characters) of their text content. limit: Yield no more than ``limit`` wiki pages that match all specified filters. Yields: Text of the next wiki page in dataset passing all filters. Raises: ValueError: If any filtering options are invalid. """ filters = self._get_filters(category, wiki_link, min_len) for record in itertools.islice(self._filtered_iter(filters), limit): yield record["text"]
[docs] def records( self, *, category: Optional[Union[str, Set[str]]] = None, wiki_link: Optional[Union[str, Set[str]]] = None, min_len: Optional[int] = None, limit: Optional[int] = None, ) -> Iterable[types.Record]: """ Iterate over wiki pages in this dataset, optionally filtering by a variety of metadata and/or text length, and yield text + metadata pairs, in order of appearance in the db dump file. Args: category: Filter wiki pages by the categories to which they've been assigned. For multiple values (Set[str]), ANY rather than ALL of the values must be found among a given page's categories. wiki_link: Filter wiki pages by the other wiki pages to which they've been linked. For multiple values (Set[str]), ANY rather than ALL of the values must be found among a given page's wiki links. min_len: Filter wiki pages by the length (# characters) of their text content. limit: Yield no more than ``limit`` wiki pages that match all specified filters. Yields: Text of the next wiki page in dataset passing all filters, and its corresponding metadata. Raises: ValueError: If any filtering options are invalid. """ filters = self._get_filters(category, wiki_link, min_len) for record in itertools.islice(self._filtered_iter(filters), limit): yield types.Record(text=record.pop("text"), meta=record)
[docs]class Wikipedia(Wikimedia): """ Stream a collection of Wikipedia pages from a version- and language-specific database dump, either as texts or text + metadata pairs. Download a database dump (one time only!) and save its contents to disk:: >>> import textacy.datasets >>> ds = textacy.datasets.Wikipedia(lang="en", version="current") >>> ds.download() >>> ds.info {'name': 'wikipedia', 'site_url': 'https://en.wikipedia.org/wiki/Main_Page', 'description': 'All pages for a given language- and version-specific Wikipedia site snapshot.'} Iterate over wiki pages as texts or records with both text and metadata:: >>> for text in ds.texts(limit=5): ... print(text[:500]) >>> for text, meta in ds.records(limit=5): ... print(meta["page_id"], meta["title"]) Filter wiki pages by a variety of metadata fields and text length:: >>> for text, meta in ds.records(category="Living people", limit=5): ... print(meta["title"], meta["categories"]) >>> for text, meta in ds.records(wiki_link="United_States", limit=5): ... print(meta["title"], meta["wiki_links"]) >>> for text in ds.texts(min_len=10000, limit=5): ... print(len(text)) Stream wiki pages into a :class:`textacy.Corpus <textacy.corpus.Corpus>`:: >>> textacy.Corpus("en", data=ds.records(min_len=2000, limit=50)) Corpus(50 docs; 72368 tokens) Args: data_dir: Path to directory on disk under which database dump files are stored. Each file is expected as ``{lang}{project}/{version}/{lang}{project}-{version}-cirrussearch-content.json.gz`` immediately under this directory. lang: Standard two-letter language code, e.g. "en" => "English", "de" => "German". https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes version: Database dump version to use. Either "current" for the most recently available version or a date formatted as "YYYYMMDD". Dumps are produced weekly; check for available versions at https://dumps.wikimedia.org/other/cirrussearch/. namespace: Namespace of the wiki pages to include. Typical, public- facing content is in the 0 (default) namespace. """ def __init__( self, data_dir: Union[str, pathlib.Path] = constants.DEFAULT_DATA_DIR.joinpath( "wikipedia" ), lang: str = "en", version: str = "current", namespace: int = 0, ): super().__init__( "wikipedia", METAS["wikipedia"], "wiki", data_dir, lang=lang, version=version, namespace=namespace, )
[docs]class Wikinews(Wikimedia): """ Stream a collection of Wikinews pages from a version- and language-specific database dump, either as texts or text + metadata pairs. Download a database dump (one time only!) and save its contents to disk:: >>> import textacy.datasets >>> ds = textacy.datasets.Wikinews(lang="en", version="current") >>> ds.download() >>> ds.info {'name': 'wikinews', 'site_url': 'https://en.wikinews.org/wiki/Main_Page', 'description': 'All pages for a given language- and version-specific Wikinews site snapshot.'} Iterate over wiki pages as texts or records with both text and metadata:: >>> for text in ds.texts(limit=5): ... print(text[:500]) >>> for text, meta in ds.records(limit=5): ... print(meta["page_id"], meta["title"]) Filter wiki pages by a variety of metadata fields and text length:: >>> for text, meta in ds.records(category="Politics and conflicts", limit=5): ... print(meta["title"], meta["categories"]) >>> for text, meta in ds.records(wiki_link="Reuters", limit=5): ... print(meta["title"], meta["wiki_links"]) >>> for text in ds.texts(min_len=5000, limit=5): ... print(len(text)) Stream wiki pages into a :class:`textacy.Corpus <textacy.corpus.Corpus>`:: >>> textacy.Corpus("en", data=ds.records(limit=100)) Corpus(100 docs; 33092 tokens) Args: data_dir: Path to directory on disk under which database dump files are stored. Each file is expected as ``{lang}{project}/{version}/{lang}{project}-{version}-cirrussearch-content.json.gz`` immediately under this directory. lang: Standard two-letter language code, e.g. "en" => "English", "de" => "German". https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes version: Database dump version to use. Either "current" for the most recently available version or a date formatted as "YYYYMMDD". Dumps are produced weekly; check for available versions at https://dumps.wikimedia.org/other/cirrussearch/. namespace: Namespace of the wiki pages to include. Typical, public- facing content is in the 0 (default) namespace. """ def __init__( self, data_dir: Union[str, pathlib.Path] = constants.DEFAULT_DATA_DIR.joinpath( "wikinews" ), lang: str = "en", version: str = "current", namespace: int = 0, ): super().__init__( "wikinews", METAS["wikinews"], "wikinews", data_dir, lang=lang, version=version, namespace=namespace, )