あつまるエンジニアブログ

集客プラットフォーム事業を手がける株式会社あつまるのエンジニアブログです

Scrapy のクロール実行時 win32api の ImportError でコケる (Windows10, Python 3.5.2, Scrapy 1.5.0)

f:id:todays_mitsui:20160827190511p:plain


Scrapy でバグっぽい挙動にぶつかったので状況と解決策の記録です。

クローラーの実行に失敗する

いつものように Scrapy でクローラーを走らせようとしたらエラーでコケました。
エラーのログはこんな感じ。

2018-01-27 14:36:06 [scrapy.utils.log] INFO: Scrapy 1.5.0 started (bot: foo)
2018-01-27 14:36:06 [scrapy.utils.log] INFO: Versions: lxml 4.1.1.0, libxml2 2.9.5, cssselect 1.0.3, parsel 1.3.1, w3lib 1.19.0, Twisted 17.9.0, Python 3.5.2 (v3.5.2:4def2a2901a5, Jun 25 2016, 22:01:18) [MSC v.1900 32 bit (Intel)], pyOpenSSL 17.5.0 (OpenSSL 1.1.0g  2 Nov 2017), cryptography 2.1.4, Platform Windows-10-10.0.16299-SP0
2018-01-27 14:36:06 [scrapy.crawler] INFO: Overridden settings: {'BOT_NAME': 'foo', 'SPIDER_MODULES': ['foo.spiders'], 'ROBOTSTXT_OBEY': True, 'LOG_FILE': '143606.log', 'FEED_URI': 'result.csv', 'DOWNLOAD_DELAY': 1, 'FEED_FORMAT': 'csv', 'NEWSPIDER_MODULE': 'foo.spiders'}
2018-01-27 14:36:07 [scrapy.middleware] INFO: Enabled extensions:
['scrapy.extensions.telnet.TelnetConsole',
 'scrapy.extensions.corestats.CoreStats',
 'scrapy.extensions.feedexport.FeedExporter',
 'scrapy.extensions.logstats.LogStats']
2018-01-27 14:36:07 [twisted] CRITICAL: Unhandled error in Deferred:
2018-01-27 14:36:07 [twisted] CRITICAL:
Traceback (most recent call last):
  File "c:\works\foo\env\lib\site-packages\twisted\internet\defer.py", line 1386, in _inlineCallbacks
    result = g.send(result)
  File "c:\works\foo\env\lib\site-packages\scrapy\crawler.py", line 80, in crawl
    self.engine = self._create_engine()
  File "c:\works\foo\env\lib\site-packages\scrapy\crawler.py", line 105, in _create_engine
    return ExecutionEngine(self, lambda _: self.stop())
  File "c:\works\foo\env\lib\site-packages\scrapy\core\engine.py", line 69, in __init__
    self.downloader = downloader_cls(crawler)
  File "c:\works\foo\env\lib\site-packages\scrapy\core\downloader\__init__.py", line 88, in __init__
    self.middleware = DownloaderMiddlewareManager.from_crawler(crawler)
  File "c:\works\foo\env\lib\site-packages\scrapy\middleware.py", line 58, in from_crawler
    return cls.from_settings(crawler.settings, crawler)
  File "c:\works\foo\env\lib\site-packages\scrapy\middleware.py", line 34, in from_settings
    mwcls = load_object(clspath)
  File "c:\works\foo\env\lib\site-packages\scrapy\utils\misc.py", line 44, in load_object
    mod = import_module(module)
  File "c:\works\foo\env\lib\importlib\__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 986, in _gcd_import
  File "<frozen importlib._bootstrap>", line 969, in _find_and_load
  File "<frozen importlib._bootstrap>", line 958, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 673, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 665, in exec_module
  File "<frozen importlib._bootstrap>", line 222, in _call_with_frames_removed
  File "c:\works\foo\env\lib\site-packages\scrapy\downloadermiddlewares\retry.py", line 20, in <module>
    from twisted.web.client import ResponseFailed
  File "c:\works\foo\env\lib\site-packages\twisted\web\client.py", line 42, in <module>
    from twisted.internet.endpoints import HostnameEndpoint, wrapClientTLS
  File "c:\works\foo\env\lib\site-packages\twisted\internet\endpoints.py", line 41, in <module>
    from twisted.internet.stdio import StandardIO, PipeAddress
  File "c:\works\foo\env\lib\site-packages\twisted\internet\stdio.py", line 30, in <module>
    from twisted.internet import _win32stdio
  File "c:\works\foo\env\lib\site-packages\twisted\internet\_win32stdio.py", line 9, in <module>
    import win32api
ImportError: DLL load failed: 指定されたモジュールが見つかりません。

win32api の import に失敗 しているようです。
もともと Scrapy は Windows で走らせる場合に限り win32api に依存します。そのために事前に pip install pypiwin32 して必要なライブラリをインストールしておいたのですが。
というか、数週間前にセットアップした Scrapy は同じ環境で普通に動いているので不思議なことです。


解決策を探る

結論から云うと原因は不明で、 pypiwin32 のバージョンを下げる ことで対処しました。

数週間前にセットアップした環境で pip freeze -l > requirements.txt してインストールされている pypiwin32 のバージョンを比べてみます。

差分だけ取り出すと、

cffi==1.11.2   => cffi==1.11.4
pypiwin32==219 => pypiwin32==222
               => pywin32==222
w3lib==1.18.0  => w3lib==1.19.0

これだけ見ると pypiwin32 のバージョン 220 から 222 の間で何かしらのバグが入った事が原因のように見えます。
または pywin32 に依存するようになったことが原因でしょうか?


今回は取り急ぎ pypiwin32 をアンインストールしてから pypiwin32==219 で古いバージョンの pypiwin32 を入れ直す ことで対応しました。


私からは以上です。

Scrapy+AWS LambdaでWeb定点観測のイレギュラーに立ち向かう

Web スクレイピングはイレギュラーとの戦いです。特にそれが Web 定点観測のためのスクレイピングであれば難易度はさらに高まります。

  • スクレイピングしようとしたタイミングでサーバーが死んでいるかもしれない
  • クローラーを書いていたときには気づけなかったバグが遅れて発動するかもしれない
  • 知らぬ間にスクレイピングしたいページの URL が変更されるかもしれない

そんなイレギュラーに立ち向かうために、現在 私が試している方法をまとめてみます。


その前に「Web 定点観測」とは?

Web 定点観測 とは、一言でいうと「一つの URL を定期的にスクレイピングして経時的な変化を追っていく行為」のことです。RSS が提供されていないページに置いて更新を常に追っていくためのスクレイピングなどが一般的でしょうか。

あえて「Web 定点観測」と名前を付けているのは、一度実行してデータを取得したら終わりではないよという点を強調したいからです。


構成

それではさっそく構成を、

f:id:todays_mitsui:20161218205656p:plain

システムは前半の クローリングパート と、後半の スクレイピングパート に分けています。 ここでは『ページ内のリンクを探索しながらページを次々に辿っていく処理』をクローリング、『取得したページ(HTML)から必要なデータを適切な形式で抜き出す処理』をスクレイピングと呼び分けています。

前半のクローリングパートでは Scrapy で必要としているページのリンクを辿り S3 に HTML を保存すること だけ をやります。
後半のスクレイピングパートのメインは AWS Lambda です。S3 に HTML が保存されることがトリガーとなって Lambda が呼び出されるように設定しています。やっているのは HTML に記述されている情報を抜き出して、お好みで加工してデータベースに保存することです。

また、クローリングパート, スクレイピングパートそれぞれで発生したエラー情報は Rollbar に投げてログを取っています。


前半と後半に分ける理由

実際のところ Scrapy というフレームワークは充分にフルスタックで、クローリングとスクレイピングを同時にこなすのに充分な機能を持っています。では、なぜわざわざ処理を前半と後半に分けて Scrapy で HTML の保存だけをやるのでしょうか。
それはデータの取得に失敗する原因の多くがスクレイピングパートでのエラーによるからです。

さらに、そのエラーが全てのページで起こるもの(スクレイピングのロジックに起因するもの)であればクローリング自体中止したいし、そのページに特有のもの(特定のページ構造に起因するもの)であればクローリングを続行したいという事情も事態を複雑にします。
目的のデータが取得できなかったとき 何が原因で、誰のせいなのか をはっきりと把握しておく必要があるのです。そして、対処できるものに対しては対処し、そうでないものについてもイレギュラーが起こっていることだけは把握できる準備が必要です。


スクレイピングにおける「イレギュラー」を細分化すると以下のようになるかと思います。

  1. サーバー側でエラーが発生する(404エラー, 500エラー など)
  2. サーバーは正常だが目的のコンテンツが無い
  3. スクレイピング処理中にエラーが発生する
  4. スクレイピング処理は正常終了するが目的のデータが取得できていない

このうち①, ②はサーバー側の要因であり、こちら側で対処できないことが多いです。一方で③, ④はおそらくこちら側での ミス で、何かしらの対処が可能なものです。

処理を前半と後半に分ければ、クローリングパートでは①だけを気にすれば良くなります。スクレイピングパートで気にするべきは②, ③, ④です。


① サーバー側でのエラー

通常、Scrapy のクローリングエンジンはサーバーから 4xx 系や 5xx 系のエラーが返った場合にログにエラーを記録だけしてクローリングを続けます。
ですが、余りにも 5xx 系のエラーが多い場合にはサーバーが死んでいると見るのが自然なので何かしらの特別な処理をしたくなるでしょう。

Scrapy でそれをするには errback() を使います。
errback() はクローリング中に 200 以外のステータスコードが返されたときにだけ呼ばれるコールバック関数です。

import scrapy
from scrapy import Request


class ExampleSpider(scrapy.Spider):
    name = "index"
    allowed_domains = ["example.com"]

    start_urls = ["http://example.com/",]
    error_urls = []

    def parse(self, response):
        for i in range(10):
            yield Request(
                "http://example.com/{0}/".format(i),
                callback=self.callback,
                errback=selferrback,
            )

    def callback(self, response):
        return {
            "url": response.url,
            "title": response.xpath("//title/text()").extract_first(),
        }

    def errback(self, failure):
        if failure.check(HttpError):
            # 200 以外のステータスコードが返った場合
            response = failure.value.response
            self.error_urls.append(response.url)

        elif failure.check(DNSLookupError, TimeoutError, TCPTimedOutError):
            request = failure.request
            self.error_urls.append(request.url)

    def closed(reason):
        self.logger.debug("Errors: {0}".format(len(self.error_urls)))

error_urls という名前で空のリストを用意しておき、200 以外のステータスコードが返されたときにはそのページの URL を放り込んでいきます。
どれほどの数のサーバーエラーが検出されたとき異常と見なすかは一考の余地がありますが、エラーが多い場合には closed() の中で error_urls の中をチェックして然るべきエラー処理をします。現在はとりあえず Rollbar にエラーログを投げたりしてみています。


③ スクレイピング処理中に発生したエラー

ケアレスによるバグがクローリング中に起こるのを恐れてクローリングパートでは HTML を S3 に保存するという最小限の事だけをしています。
保存された HTML から目的のデータを抜き出すのは AWS Lambda のお仕事です。

import urllib
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

import boto3
from lxml import html

import rollbar
rollbar.init("POST_SERVER_ITEM_ACCESS_TOKEN")


def load(bucket, key):
    obj = s3.Object(bucket, key).get()

    doc = obj["Body"].read()
    metadata = obj["Metadata"]

    return doc, metadata

def parse(doc, base_url):
    root = html.fromstring(doc)

    title = root.xpath("//title/text()")[0].strip()
    if 0 == len(title):
        raise Exception("title not found.")

    return {
        "title": title,
    }

def lambda_handler(event, context):
    bucket = event["Records"][0]["s3"]["bucket"]["name"]
    key = urllib.unquote_plus(event["Records"][0]["s3"]["object"]["key"].encode("utf8"))
    logger.info("KEY: {0}".format(key))

    doc, metadata = load(bucket, key)

    try:
        data = parse(doc, base_url=metadata["src"])
    except Exception as e:
        logger.error(str(e))
        rollbar.report_exc_info()

    return data

具体例を示すほどの事でもないのですが、lxml で HTML を etree に変換して xpath でデータを指定し、抜き出し、加工しています。
クローリングパートよりも少々複雑な事をする場合もあり予期せぬエラーも起こりがちです。なるべく柔軟なコーディングを心がけますが、対象ページが知らぬ間にリニューアルされていて DOM 構造が大幅に変更されていることもあります。そのへんはスクレイピングという行為の性質上 仕方のないことかと思います。


スクレイピング処理中に起こったエラーは Rollbar に投げられます。
エラーに(私が)気づいたら元気よくコードを修正して Lambda を再び走らせます。対象のページは既に S3 に保存されているので、スクレイピングのトライ&エラーを何度繰り返そうとも元のサーバーに余計なトラフィックを発生させることはありません。


④ スクレイピング処理中に発生しなかったエラー、しかし目的のデータが得られていない場合

このパターンの方が厄介ですね。
スクレイピング処理中は要所要所で取得したデータをバリデーションチェックして、想定しない形式のデータが取れた場合には例外を投げるようにしています。そうやって投げられた例外は ③ で補足されて手動リトライされるわけですが、正しい結果が得られていないにも関わらず例外を投げ損なうと、異常の発見が遅れますね。


② 取得したページに目的のコンテンツが無い場合

これも厄介なパターンです。
そもそも何をもって 目的のコンテンツが無い とするのかも曖昧で、一概に判定できなかったりします。とはいえ、目的の情報が得られない場合にはスクレイピング処理中に例外が投げられるようなコーディングを心がけます。
異常があると分かったら一刻も早く停止させるべきなので。


このやり方でやってみて

メリット

  • クローリングパートとスクレイピングパートに分ける戦略は管理が楽
  • スクレイピング処理中に失敗しても原因究明とリトライがしやすい


デメリット

  • A/B テストに弱い
  • S3 への PUT が多くなるので場合によっては料金がかさむかも


一番のデメリットは A/Bテストに弱い ことです。
目的のデータが取得できていないことはスクレイピングパートで発覚しますが、そこからクローリングをリトライする綺麗な流れを今のところ考えられていません。


まとめ

Web 定点観測において現状やってみていることを紹介してみました。
『自分のことだからどうせしょうもないミスしてるって』という疑り深い精神と、リトライ時にもコンテンツ配信元のサーバーに迷惑を掛けないようにとの心づかいが働いている...、つもりです。


私からは以上です。

Python3 で言語処理100本ノック 2015 - 第3章

乾・岡崎研究室が公開している 言語処理100本ノック 2015 に取り組んで行きます。
使用する言語は Python3 です。

第3章です。一部の方が大好きな 正規表現 の章です。
私は...、あんまり好きじゃないですね。


第3章: 正規表現

Wikipediaの記事を以下のフォーマットで書き出したファイルjawiki-country.json.gzがある.

  • 1行に1記事の情報がJSON形式で格納される
  • 各行には記事名が"title"キーに,記事本文が"text"キーの辞書オブジェクトに格納され,そのオブジェクトがJSON形式で書き出される
  • ファイル全体はgzipで圧縮される

以下の処理を行うプログラムを作成せよ.


20. JSONデータの読み込み

Wikipedia記事のJSONファイルを読み込み,「イギリス」に関する記事本文を表示せよ.
問題21-29では,ここで抽出した記事本文に対して実行せよ.

import codecs
import json


for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
    article = json.loads(row)
    if u"イギリス" == article["title"]:
        print(article["text"])
# =>
# {{redirect|UK}}
# {{基礎情報 国
# |略名 = イギリス
# |日本語国名 = グレートブリテン及び北アイルランド連合王国
# ...
# [[Category:島国|くれいとふりてん]]
# [[Category:1801年に設立された州・地域]]

Python3 で UTF-8 エンコードの JSON をうまく読み込むために codecs モジュールを使っています。
読み込んだ各行が JSON 形式になっているようなので(いわゆる JSON Lines 形式 ですね)、1行ずつ処理して json.loads() で JSON をパースします。


21. カテゴリ名を含む行を抽出

記事中でカテゴリ名を宣言している行を抽出せよ.

import codecs
import json
import re


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]


text = extract_text(u"イギリス")
for line in text.split("\n"):
    if re.search(r"Category:", line):
        print(line)
# =>
# [[Category:イギリス|*]]
# [[Category:英連邦王国|*]]
# [[Category:G8加盟国]]
# [[Category:欧州連合加盟国]]
# [[Category:海洋国家]]
# [[Category:君主国]]
# [[Category:島国|くれいとふりてん]]
# [[Category:1801年に設立された州・地域]]

今後、「ファイルからタイトルが『イギリス』の記事を取り出す」という処理は頻繁に登場するので extract_text() という関数にまとめました。

JSON のパースまで終わったら各行毎に処理して "Category:" というテキストを含む行だけを抽出します。
このあたりはまだ正規表現を持ち出すまでもない処理ですね。


22. カテゴリ名の抽出

記事のカテゴリ名を(行単位ではなく名前で)抽出せよ.

import codecs
import json
import re


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]


text = extract_text(u"イギリス")
for line in text.split("\n"):
    m = re.search(r"Category:(?P<category>.+?)(\||])", line)
    if m:
        print(m.group("category"))
# =>
# イギリス
# 英連邦王国
# G8加盟国
# 欧州連合加盟国
# 海洋国家
# 君主国
# 島国
# 1801年に設立された州・地域

正規表現の キャプチャ を使います。
個人的に 名前付きキャプチャ が大好きです。正規表現は書けるけど読めないという地獄のようなものでっすが、名前付きキャプチャを使う事でいくらか可読性を向上させることができます。


23. セクション構造

記事中に含まれるセクション名とそのレベル(例えば"== セクション名 =="なら1)を表示せよ.

ここから先はまず MediaWiki のマークアップ記法 について知っておいたほうがよさそうです。

セクション名は == セクション名 == のようにマークアップされます。先頭と末尾の == は Level1(==) から Level5(======) まであります。
Level0(=) も許容されるけどページのタイトルをマークアップするのに使うから本文中では使わないようにという注釈がどこかにあったような。

ともかく連続する = によって行頭と行末が囲まれている行を抽出して = の数を数えれば良さそうです。

import codecs
import json
import re


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]


text = extract_text(u"イギリス")
for line in text.split("\n"):
    m = re.search(r"^(?P<level>=+)(?P<header>.+)\1$", line)
    if m:
        header = m.group("header")
        level = m.group("level").count("=") - 1

        print("{0}: {1}".format(level, header))
# =>
# 1: 国名
# 1: 歴史
# 1: 地理
# 2: 気候
# ...
# 1: 外部リンク

セクション名をキャプチャしている正規表現に着目してください。

^(?P<level>=+)(?P<header>.+)\1$

行頭の = と行末の = が対になっていることを確認するために 後方参照 を使っています。
行が == から始まったときは == で終わらなければマッチしませんし、 ===== から始まったときは ===== で終わらなければマッチしないという具合です。

後方参照は可読性が低くなりがちなのでなるべく使いたくなかったのですが、セクション名に = を含む場合を許容したかったことと、始まりの = の数と終わりの = の数が不一致になる場合を弾きたかったので使いました。


24. ファイル参照の抽出

記事から参照されているメディアファイルをすべて抜き出せ.

記事中に埋め込まれたメディアファイルは [[ファイル:Wiki.png|thumb|説明文]] というような形をしているそうです。

import codecs
import json
import re


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]


text = extract_text(u"イギリス")
for line in text.split("\n"):
    m = re.search("ファイル:(?P<filename>[^|]+)\|", line)
    if m:
        print(m.group("filename"))
# =>
# Royal Coat of Arms of the United Kingdom.svg
# CHANDOS3.jpg
# The Fabs.JPG
# PalaceOfWestminsterAtNight.jpg
# ...
# Wembley Stadium, illuminated.jpg

やるだけですね。


25. テンプレートの抽出

記事中に含まれる「基礎情報」テンプレートのフィールド名と値を抽出し,辞書オブジェクトとして格納せよ.

生の「基礎情報」は以下のような形式になっています。

{{基礎情報 国
|略名 = イギリス
|日本語国名 = グレートブリテン及び北アイルランド連合王国
|公式国名 = {{lang|en|United Kingdom of Great Britain and Northern Ireland}}<ref>英語以外での正式国名:<br/>
*{{lang|gd|An Rìoghachd Aonaichte na Breatainn Mhòr agus Eirinn mu Thuath}}([[スコットランド・ゲール語]])<br/>
*{{lang|cy|Teyrnas Gyfunol Prydain Fawr a Gogledd Iwerddon}}([[ウェールズ語]])<br/>
*{{lang|ga|Ríocht Aontaithe na Breataine Móire agus Tuaisceart na hÉireann}}([[アイルランド語]])<br/>
*{{lang|kw|An Rywvaneth Unys a Vreten Veur hag Iwerdhon Glédh}}([[コーンウォール語]])<br/>
*{{lang|sco|Unitit Kinrick o Great Breetain an Northren Ireland}}([[スコットランド語]])<br/>
**{{lang|sco|Claught Kängrick o Docht Brätain an Norlin Airlann}}、{{lang|sco|Unitet Kängdom o Great Brittain an Norlin Airlann}}(アルスター・スコットランド語)</ref>
|国旗画像 = Flag of the United Kingdom.svg
|国章画像 = [[ファイル:Royal Coat of Arms of the United Kingdom.svg|85px|イギリスの国章]]
|国章リンク = ([[イギリスの国章|国章]])
...
|国際電話番号 = 44
|注記 = <references />
}}

一見、 | 区切りで key = value が並んでいるように見えますが、区切り文字の | が value の中にエスケープ無しで現れるのが曲者です。
ちょっと考えて、 \n| を区切り文字と見なして split() することにしてみました。

import codecs
import json
import re
from pprint import pprint


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]

def extract_base_info(text):
    m = re.search("{{基礎情報[^|]+\|(?P<info_body>.+?)\n}}", text, re.DOTALL)
    if not m:
        return {}

    info_body = m.group("info_body")

    info_dict = {}

    for item in info_body.split("\n|"):
        key, val = re.split(r"\s+=\s+", item, maxsplit=1)
        info_dict[key] = val

    return info_dict

text = extract_text(u"イギリス")
base_info = extract_base_info(text)

pprint(base_info, indent=4)
# =>
# {
#     '公式国名': '{{lang|en|United Kingdom of Great Britain and Northern Ireland}}<ref>英語以外での...コットランド語)</ref>',
#     '国旗画像': 'Flag of the United Kingdom.svg',
#     '日本語国名': 'グレー トブリテン及び北アイルランド連合王国',
#     '国章リンク': '([[イギリスの国章|国章]])',
#     ...
#     '首都': '[[ロンドン]]'
# }

最終的に dict に格納してあげます。


26. 強調マークアップの除去

25の処理時に,テンプレートの値からMediaWikiの強調マークアップ(弱い強調,強調,強い強調のすべて)を除去してテキストに変換せよ
(参考: マークアップ早見表).

前節で作った dict に加工を加えます。
強調マークアップは ''他との区別'', '''強調''', '''''斜体と強調''''' のいずれかです。ここでは単純に2回以上連続する ' を除去すれば充分でしょう。

import codecs
import json
import re
from pprint import pprint


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]

def extract_base_info(text):
    m = re.search("{{基礎情報[^|]+\|(?P<info_body>.+?)\n}}", text, re.DOTALL)
    if not m:
        return {}

    info_body = m.group("info_body")

    info_dict = {}

    for item in info_body.split("\n|"):
        key, val = re.split(r"\s*=\s*", item, maxsplit=1)
        info_dict[key] = val

    return info_dict

def remove_emphasis(text):
    """強調マークアップを除去"""
    return re.sub(r"'{2,}", "", text)

text = extract_text(u"イギリス")
base_info = extract_base_info(text)


sanitized_base_info = {}
for k, v in base_info.items():
    v = remove_emphasis(v)
    sanitized_base_info[k] = v


pprint(sanitized_base_info, indent=4)

「基礎情報」を抜き出して dict 型に変換する処理を extract_base_info() にまとめました。

強調マークアップの除去は re.sub() を用いて正規表現にマッチする部分を空文字列 "" に置換することで処理しています。
'{2,} と書くことで2回以上連続する ' にのみマッチさせることができます。


27. 内部リンクの除去

26の処理に加えて,テンプレートの値からMediaWikiの内部リンクマークアップを除去し,テキストに変換せよ(参考: マークアップ早見表).

先ほどに引き続き。
内部リンクは [[記事名]], [[記事名|表示文字]], [[記事名#節名|表示文字]] のいずれかの形をしています。

import codecs
import json
import re
from pprint import pprint


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]

def extract_base_info(text):
    m = re.search("{{基礎情報[^|]+\|(?P<info_body>.+?)\n}}", text, re.DOTALL)
    if not m:
        return {}

    info_body = m.group("info_body")

    info_dict = {}

    for item in info_body.split("\n|"):
        words = re.split(r"\s+=\s+", item, maxsplit=1)
        info_dict[words[0]] = words[1]

    return info_dict

def remove_emphasis(text):
    """強調マークアップを除去"""
    return re.sub(r"'{2,}", "", text)

def remove_internal_links(text):
    """内部リンクのマークアップを除去"""
    return re.sub(r"\[\[([^]]+)\]\]", lambda m: m.group(1).split("|")[-1], text)


text = extract_text(u"イギリス")
base_info = extract_base_info(text)

sanitized_base_info = {}
for k, v in base_info.items():
    v = remove_emphasis(v)
    v = remove_internal_links(v)
    sanitized_base_info[k] = v


pprint(sanitized_base_info, indent=4)

さきほどと同様に re.sub()で。
置換処理に無名関数を使用しています。| を区切り文字としてリストに分解して末尾の要素を取り出していますが...、これはちょっと魔術的かも知れませんね。半年後とかに読み返したとき後悔しそう。


28. MediaWikiマークアップの除去

27の処理に加えて,テンプレートの値からMediaWikiマークアップを可能な限り除去し,国の基本情報を整形せよ.

やるだけです!

import codecs
import json
import re
from pprint import pprint


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]

def extract_base_info(text):
    m = re.search("{{基礎情報[^|]+\|(?P<info_body>.+?)\n}}", text, re.DOTALL)
    if not m:
        return {}

    info_body = m.group("info_body")

    info_dict = {}

    for item in info_body.split("\n|"):
        [key, word] = re.split(r"\s+=\s+", item, maxsplit=1)

        word = remove_section_header(word)
        word = remove_emphasis(word)
        word = remove_category_links(word)
        word = remove_internal_links(word)
        word = remove_external_links(word)
        word = remove_template(word)
        word = remove_unordered_list(word)
        word = remove_define_list(word)
        word = remove_redirect(word)
        word = remove_comment(word)

        info_dict[key] = word

    return info_dict

def remove_section_header(text):
    """見出しのマークアップを除去"""
    return re.sub(r"(=+)(.+?)\1", lambda m: m.group(2), text)

def remove_emphasis(text):
    """強調マークアップを除去"""
    return re.sub(r"'{2,}", "", text)

def remove_category_links(text):
    """カテゴリリンクのマークアップを除去"""
    return re.sub(r"\[\[Category:(.+?)\]\]", lambda m: m.group(1).split("|")[0], text)

def remove_internal_links(text):
    """内部リンクのマークアップを除去"""
    return re.sub(r"\[\[([^]]+)\]\]", lambda m: m.group(1).split("|")[-1], text)

def remove_external_links(text):
    """外部リンクのマークアップを除去"""
    return re.sub(r"\[([^]]+)\]", lambda m: m.group(1).split(" ")[-1], text)

def remove_template(text):
    """スタブのマークアップを除去"""
    return re.sub(r"\{\{(.+?)\}\}", lambda m: m.group(1).split("|")[-1], text)

def remove_unordered_list(text):
    """箇条書きのマークアップを除去"""
    return re.sub(r"^\*+\s*", "", text, flags=re.MULTILINE)

def remove_ordered_list(text):
    """番号付箇条書きのマークアップを除去"""
    return re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)

def remove_define_list(text):
    """定義の箇条書きのマークアップを除去"""
    return re.sub(r"^(:|;)\s*", "", text, flags=re.MULTILINE)

def remove_redirect(text):
    """リダイレクトのマークアップを除去"""
    return re.sub(r"#REDIRECT \[\[(.+?)\]\]", lambda m: m.group(1), text)

def remove_comment(text):
    """コメントアウトのマークアップを除去"""
    return re.sub(r"<!--.*?-->", "", text)


text = extract_text(u"イギリス")
base_info = extract_base_info(text)

pprint(base_info, indent=4)

やるだけ!


29. 国旗画像のURLを取得する

テンプレートの内容を利用し,国旗画像のURLを取得せよ.
(ヒント: MediaWiki APIのimageinfoを呼び出して,ファイル参照をURLに変換すればよい)

import codecs
import json
import re
from pprint import pprint


def extract_text(title):
    for row in codecs.open("./src/jawiki-country.json", "r", "utf-8"):
        article = json.loads(row)
        if title == article["title"]:
            return article["text"]

def extract_base_info(text):
    m = re.search("{{基礎情報[^|]+\|(?P<info_body>.+?)\n}}", text, re.DOTALL)
    if not m:
        return {}

    info_body = m.group("info_body")

    info_dict = {}

    for item in info_body.split("\n|"):
        [key, word] = re.split(r"\s+=\s+", item, maxsplit=1)

        word = remove_section_header(word)
        word = remove_emphasis(word)
        word = remove_category_links(word)
        word = remove_internal_links(word)
        word = remove_external_links(word)
        word = remove_template(word)
        word = remove_unordered_list(word)
        word = remove_define_list(word)
        word = remove_redirect(word)
        word = remove_comment(word)

        info_dict[key] = word

    return info_dict

def remove_section_header(text):
    """見出しのマークアップを除去"""
    return re.sub(r"(=+)(.+?)\1", lambda m: m.group(2), text)

def remove_emphasis(text):
    """強調マークアップを除去"""
    return re.sub(r"'{2,}", "", text)

def remove_category_links(text):
    """カテゴリリンクのマークアップを除去"""
    return re.sub(r"\[\[Category:(.+?)\]\]", lambda m: m.group(1).split("|")[0], text)

def remove_internal_links(text):
    """内部リンクのマークアップを除去"""
    return re.sub(r"\[\[([^]]+)\]\]", lambda m: m.group(1).split("|")[-1], text)

def remove_external_links(text):
    """外部リンクのマークアップを除去"""
    return re.sub(r"\[([^]]+)\]", lambda m: m.group(1).split(" ")[-1], text)

def remove_template(text):
    """スタブのマークアップを除去"""
    return re.sub(r"\{\{(.+?)\}\}", lambda m: m.group(1).split("|")[-1], text)

def remove_unordered_list(text):
    """箇条書きのマークアップを除去"""
    return re.sub(r"^\*+\s*", "", text, flags=re.MULTILINE)

def remove_ordered_list(text):
    """番号付箇条書きのマークアップを除去"""
    return re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)

def remove_define_list(text):
    """定義の箇条書きのマークアップを除去"""
    return re.sub(r"^(:|;)\s*", "", text, flags=re.MULTILINE)

def remove_redirect(text):
    """リダイレクトのマークアップを除去"""
    return re.sub(r"#REDIRECT \[\[(.+?)\]\]", lambda m: m.group(1), text)

def remove_comment(text):
    """コメントアウトのマークアップを除去"""
    return re.sub(r"<!--.*?-->", "", text)


text = extract_text(u"イギリス")
base_info = extract_base_info(text)


from urllib.parse import urlencode
from urllib import request

flag_image_name = base_info["国旗画像"]
query = urlencode({
    "action": "query",
    "titles": "File:{0}".format(flag_image_name),
    "prop": "imageinfo",
    "iiprop": "url",
    "format": "json",
})
url = "https://commons.wikimedia.org/w/api.php?{0}".format(query)

with request.urlopen(url) as response:
    body = response.read()
    data = json.loads(body.decode("utf-8"))

    pprint(data, indent=4)
    # =>
    # {
    #     'continue': {'continue': '||', 'iistart': '2007-09-03T09:51:34Z'},
    #     'query': {
    #         'pages': {
    #             '347935': {
    #                 'imageinfo': [{
    #                     'descriptionshorturl': 'https://commons.wikimedia.org/w/index.php?curid=347935',
    #                     'descriptionurl': 'https://commons.wikimedia.org/wiki/File:Flag_of_the_United_Kingdom.svg',
    #                     'url': 'https://upload.wikimedia.org/wikipedia/commons/a/ae/Flag_of_the_United_Kingdom.svg'
    #                 }],
    #                 'imagerepository': 'local',
    #                 'ns': 6,
    #                 'pageid': 347935,
    #                 'title': 'File:Flag of the United ''Kingdom.svg'
    #             }
    #         }
    #     }
    # }

    flag_image_url = list(data["query"]["pages"].values())[0]["imageinfo"][0]["url"]

    print(flag_image_url)
    # => https://upload.wikimedia.org/wikipedia/commons/a/ae/Flag_of_the_United_Kingdom.svg

ヒントの通りにやってます。

MediaWiki API という API があるようです。
エンドポイントは https://commons.wikimedia.org/w/api.php

URI のクエリ部分に action=queryprop=imageinfo を与えることで画像情報の問い合わせができます。
format=json を指定することでレスポンスが JSON 形式になり、iiprop=url を指定することでレスポンスに画像の URL を含めてくれるようになるので、レスポンスを JSON パースして国旗画像の URL を取得できます。

レスポンスのパース後のデータの取り出し部分がひたすら汚いことについては目を瞑ってください。


所感

正規表現あんまり好きじゃないんでひたすらツラかったです。

じゃあ、何ならツラくないのかというと 関数型パーサ ですね。


私からは以上です。


コード全部まとめ

回答 - 言語処理100本ノック 2015 - 第3章 · GitHub


Python3 で言語処理100本ノック 2015 - 第2章

どうも、株式会社あつまるで Python 製の社内ツールなどを作っている三井です。
まさか続くとは。

乾・岡崎研究室が公開している 言語処理100本ノック 2015 に取り組んで行きます。
使用する言語は Python3 です。

第2章まで出来たんでまとめます。


第2章: UNIXコマンドの基礎

hightemp.txtは,日本の最高気温の記録を「都道府県」「地点」「℃」「日」のタブ区切り形式で格納したファイルである.
以下の処理を行うプログラムを作成し,hightemp.txtを入力ファイルとして実行せよ.さらに,同様の処理をUNIXコマンドでも実行し,プログラムの実行結果を確認せよ.

hightemp.txt の内容はこんな感じ、

高知県 江川崎 41 2013-08-12
埼玉県 熊谷 40.9 2007-08-16
岐阜県 多治見 40.9 2007-08-16
山形県 山形 40.8 1933-07-25
山梨県 甲府 40.7 2013-08-10
和歌山県 かつらぎ 40.6 1994-08-08
静岡県 天竜 40.6 1994-08-04
山梨県 勝沼 40.5 2013-08-10
埼玉県 越谷 40.4 2007-08-16
群馬県 上里見 40.3 1998-07-04
愛知県 愛西 40.3 1994-08-05
千葉県 牛久 40.2 2004-07-20
静岡県 佐久間 40.2 2001-07-24
愛媛県 宇和島 40.2 1927-07-22
山形県 酒田 40.1 1978-08-03
岐阜県 美濃 40 2007-08-16
群馬県 前橋 40 2001-07-24
千葉県 茂原 39.9 2013-08-11
埼玉県 鳩山 39.9 1997-07-05
大阪府 豊中 39.9 1994-08-08
山梨県 大月 39.9 1990-07-19
山形県 鶴岡 39.9 1978-08-03
愛知県 名古屋 39.9 1942-08-02

タブ区切り形式です。


以下の課題は基本的に Python でコーディングして結果の検算を UNIX コマンドで行います。

もう一つ、第2章に取り組むにあたって方針を立てます。 入力ファイルが巨大なものになってもメモリを圧迫せずに動作する というものです。
テキストファイルを一気に全て読み込むことは避けて、なるべく行毎に読み込むコードに落とし込みます。

10. 行数のカウント

行数をカウントせよ.確認にはwcコマンドを用いよ.

Python

import codecs

count = sum(1 for line in codecs.open("./src/hightemp.txt", "r", "utf-8"))

print(count)
# => 24

というわけでジェネレーター内包表記です。

つづいて UNIX コマンドを用いた場合、

Bash

cat ./src/hightemp.txt | wc -l
# => 24

いいですね。


11. タブをスペースに置換

タブ1文字につきスペース1文字に置換せよ.確認にはsedコマンド,trコマンド,もしくはexpandコマンドを用いよ.

Python

import codecs

for line in codecs.open("./src/hightemp.txt", "r", "utf-8"):
    print(line.replace("\t", " "), end="")
# =>
# 高知県 江川崎 41 2013-08-12
# 埼玉県 熊谷 40.9 2007-08-16
# ...

実質的には .replace() してるだけです。

Bash

cat ./src/hightemp.txt | sed -e 's/\t/ /g'
# =>
# 高知県 江川崎 41 2013-08-12
# 埼玉県 熊谷 40.9 2007-08-16
# ...

sed 好きなんですよね。


12. 1列目をcol1.txtに,2列目をcol2.txtに保存

各行の1列目だけを抜き出したものをcol1.txtに,2列目だけを抜き出したものをcol2.txtとしてファイルに保存せよ.
確認にはcutコマンドを用いよ.

Python

import codecs

with codecs.open("./dest/col1.txt", "w", "utf-8") as f1 \
     , codecs.open("./dest/col2.txt", "w", "utf-8") as f2:
    for line in codecs.open("./src/hightemp.txt", "r", "utf-8"):
        cols = line.split("\t")

        f1.write(cols[0]+"\n")
        f2.write(cols[1]+"\n")

with 記法を使ってます。open() と with 記法を同時に使うと1行が長くなりがちなのがちょっと嫌ですね。

Bash

[ -d dest ] || mkdir dest

cat ./src/hightemp.txt | cut -f1 > dest/col1.txt
cat ./src/hightemp.txt | cut -f2 > dest/col2.txt

cut したものをリダイレクトするだけ。便利ですね。


13. col1.txtとcol2.txtをマージ

12で作ったcol1.txtとcol2.txtを結合し,元のファイルの1列目と2列目をタブ区切りで並べたテキストファイルを作成せよ.
確認にはpasteコマンドを用いよ.

Python

import codecs

with codecs.open("./dest/col1.txt", "r", "utf-8") as rf1 \
     , codecs.open("./dest/col2.txt", "r", "utf-8") as rf2 \
     , codecs.open("./dest/col1+2.txt", "w", "utf-8") as wf:
    for col1, col2 in zip(rf1, rf2):
        wf.write("{0}\t{1}\n".format(col1.strip(), col2.strip()))

面倒だー。ファイルを3つも開いて、さらにその中でループ回してるのでコードが非常にもっさりしてます。

Bash

[ -d dest ] || mkdir dest

paste ./dest/col1.txt ./dest/col2.txt > ./dest/col1+2.txt

一撃!UNIX コマンド便利!


14. 先頭からN行を出力

自然数Nをコマンドライン引数などの手段で受け取り,入力のうち先頭のN行だけを表示せよ.確認にはheadコマンドを用いよ.

Python

import sys
import codecs
import itertools

count = int(sys.argv[1])

with codecs.open("./src/hightemp.txt", "r", "utf-8") as f:
    for line in itertools.islice(f, 0, count):
        print(line, end="")

itertools.islice() を使えばイテレーターを対象にしてスライスが可能なようで。

Bash

cat ./src/hightemp.txt | head -n $1

表示だけが目的ならこれで充分ですね。


15. 末尾のN行を出力

自然数Nをコマンドライン引数などの手段で受け取り,入力のうち末尾のN行だけを表示せよ.確認にはtailコマンドを用いよ.

Python

import sys
import codecs
import itertools

count = int(sys.argv[1])
max_count = sum(1 for line in codecs.open("./src/hightemp.txt", "r", "utf-8"))

with codecs.open("./src/hightemp.txt", "r", "utf-8") as f:
    for line in itertools.islice(f, max_count - count, None):
        print(line, end="")

itertools.islice() は引数にマイナスの数を使えないようです。 f[-5:] みたいに書けると便利なんですが。

Bash

cat ./src/hightemp.txt | tail -n $1

先ほどと同様です。


16. ファイルをN分割する

自然数Nをコマンドライン引数などの手段で受け取り,入力のファイルを行単位でN分割せよ.同様の処理をsplitコマンドで実現せよ.

Python

import sys
import codecs

def line_counts(max_count, n):
    quo = max_count // n
    rem = max_count % n

    return [quo+1] * rem + [quo] * (n - rem)

n = int(sys.argv[1])
max_count = sum(1 for line in codecs.open("./src/hightemp.txt", "r", "utf-8"))

with codecs.open("./src/hightemp.txt", "r", "utf-8") as rf:
    for i, line_count in enumerate(line_counts(max_count, n)):
        with codecs.open("./dest/split.{0}.txt".format(i), "w", "utf-8") as wf:
            for _ in range(line_count):
                wf.write(rf.readline())

line_counts() という関数を定義しています。
この関数は整数 max_count をN分割します。戻り値は整数のリストで、各要素は高々1しか差が無いように調整されます。

具体的に、

line_counts(13, 5)
# => [3, 3, 3, 2, 2]

この関数を使って、例えば入力ファイルの行数が13行で、それを5分割するなら、出力ファイルの行数は3行, 3行, 3行, 2行, 2行にするという方針です。

Bash

これの UNIX コマンドでの解き方が分からなかったんですよね。
split コマンドを素朴に使うと「N分割」ではなく「M行毎に分割」という感じになるので。

というわけで カンニングしました
これをシェルスクリプトで書きたくはないですね、個人的に。


17. 1列目の文字列の異なり

1列目の文字列の種類(異なる文字列の集合)を求めよ.確認にはsort, uniqコマンドを用いよ.

Python

import codecs

prefs = set(line.split("\t")[0] for line in codecs.open("./src/hightemp.txt", "r", "utf-8"))

print(prefs)
# =>
# {
#     '埼玉県', '千葉県', '群馬県', '山形県', '静岡県', '愛知県',
#     '高知県', '岐阜県', '山梨県', '愛媛県', '和歌山県', '大阪府'
# }

unique な集合を得るのが目的なので set を使いました。

Bash

cat ./src/hightemp.txt | cut -f1 | sort | uniq
# =>
# 愛知県
# 愛媛県
# 岐阜県
# 群馬県
# 高知県
# 埼玉県
# 山形県
# 山梨県
# 静岡県
# 千葉県
# 大阪府
# 和歌山県

パイプで繋ぐだけでデータを加工していけている感覚、いいですね。


18. 各行を3コラム目の数値の降順にソート

各行を3コラム目の数値の逆順で整列せよ(注意: 各行の内容は変更せずに並び替えよ).
確認にはsortコマンドを用いよ(この問題はコマンドで実行した時の結果と合わなくてもよい).

Python

import codecs

sorted_lines = sorted(
    codecs.open("./src/hightemp.txt", "r", "utf-8"),
    key=lambda line: float(line.split("\t")[2]),
    reverse=True,
)

print("".join(sorted_lines))
# =>
# 高知県  江川崎  41      2013-08-12
# 埼玉県  熊谷    40.9    2007-08-16
# 岐阜県  多治見  40.9    2007-08-16
# 山形県  山形    40.8    1933-07-25
# 山梨県  甲府    40.7    2013-08-10
# ...

sorted() の機能をフル活用しています。

Bash

# -n オプション: 対象を数値としてソート
# -r オプション: 降順(逆順)ソート
# -k3 オプション: タブ区切りの3列目を比較対象として各順をソート
cat ./src/hightemp.txt | sort -nrk3
# =>
# 高知県  江川崎  41      2013-08-12
# 埼玉県  熊谷    40.9    2007-08-16
# 岐阜県  多治見  40.9    2007-08-16
# 山形県  山形    40.8    1933-07-25
# 山梨県  甲府    40.7    2013-08-10
# ...

sort コマンド便利ですね。タブ区切り形式と相性が良い。


19. 各行の1コラム目の文字列の出現頻度を求め,出現頻度の高い順に並べる

各行の1列目の文字列の出現頻度を求め,その高い順に並べて表示せよ.確認にはcut, uniq, sortコマンドを用いよ.

Python

import codecs
from collections import Counter

pref_counter = Counter(line.split("\t")[0] for line in codecs.open("./src/hightemp.txt", "r", "utf-8"))

print(pref_counter.most_common())
# =>
# [
#     ('山形県', 3), ('埼玉県', 3), ('群馬県', 3), ('山梨県', 3), ('岐阜県', 2),
#     ('愛知県', 2), ('千葉県', 2), ('静岡県', 2), ('愛媛県', 1), ('高知県', 1),
#     ('大阪府', 1), ('和歌山県', 1)
# ]
# ...

collections.Counter を使います。そのために用意されてるモジュールなので。

Bash

cat ./src/hightemp.txt | cut -f1 | sort | uniq -c | sort -rk1
# =>
# 3 山梨県
# 3 山形県
# 3 埼玉県
# 3 群馬県
# 2 千葉県
# 2 静岡県
# 2 岐阜県
# 2 愛知県
# 1 和歌山県
# 1 大阪府
# 1 高知県
# 1 愛媛県

uniq コマンドに -c オプションを渡すことで要素の出現回数をカウントしてくれるようになるんですね。これはいい。
sort を2回しちゃってるところが若干気になりますが。


所感

全体的に UNIX コマンドの便利さを身体で分からせるための出題ですね。勉強になりました。


私からは以上です。


その他の章の回答はこちらから