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

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

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 コマンドの便利さを身体で分からせるための出題ですね。勉強になりました。


私からは以上です。


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

毎月数時間を要していたスキャンデータ整理をOCRで自動化した

f:id:todays_mitsui:20161119134329j:plain

どうも、株式会社あつまるで財務経理部を全力サポートしている三井です。


企業活動をするなかで見積書や請求書といった書類を発送するシーンは多いですよね。
私が勤める会社でもそういった書類をクライアントに郵送していますが、郵送する前の書類をスキャンしてスキャンデータを残しておく決まりになっています。

書類を作るのに必要なデータはすべて手元にあるものの、現物のスキャンデータがあれば安心なのも分かります。
書類に押したハンコを記録しておく意味もあるのかも知れません。


スキャンしたPDFの整理が負担に

しかし、毎月何百枚という書類のスキャンを取り発送するなかで、スキャンデータを整理する作業が負担になっていました。

スキャンを取る作業自体は書類の束をスキャナーに突っ込むだけなのですが、そうやって出来上がったPDFファイルはファイル名が 無機質な連番 になっています。
後で参照するときに目的のスキャンデータを探すことを考えると、一つひとつに適切なファイル名を付け直しておく必要があるわけです。

数百というファイルに適切な名前を付けるのは、単純ながら手間の掛かる作業です。
これまでは手作業でデータ整理をしていたようですが、スキャンデータのリネームだけで 毎月数時間を掛けていた ようです。
必要な作業とはいえあまり生産的とも思えませんし、今回は OCR 技術を使ってこのスキャンデータの整理を自動化してみました。

(※ OCR: Optical character recognition, 光学文字認識)


名前の付け方を確認しよう

最初にスキャンデータに付けるべき 適切なファイル名 について確認しました。

だいたいどの書類にも右上に「注文番号」と「発行した日付」が印字されています。
スキャンデータのPDFに付けるファイル名は注文番号と発行日付を単に並べたものでいいそうです。

f:id:todays_mitsui:20161119133353p:plain

(※画像は Image です)

例えば、注文番号が「123456」で発行日付が「2016年11月15日」であれば、ファイル名は「123456_20161115.pdf」といった具合ですね。

なるほど、

このファイル名のルール、自動化するにはなかなか都合がよさそうです。
一つずつ見ていきましょう。


1. 読み取る書類は全て弊社のフォーマット

書類毎にフォーマットがバラバラになってしまうと、求めている文字を読み取るだけでもかなり難しい課題になってしまいます。

しかし全てが自社フォーマットであればレイアウトも記載されている内容も揃っています。読み取る場所で悩むことはなさそうです。


2. 読み取り対処の文字は全て活字

手書き文字でもOCRで読めなくはないと思いますが、識字率はかなり低くなってしまうでしょう。
全てが活字で印字されているのは、正確に文字認識するうえでは理想的な条件です。


3. 読み取る文字は数字のみ

数字はたったの10種類しかなく、線もシンプルなのでもっとも文字認識しやすい対象だと云えます。
ひらがな・カタカナ・漢字混じりの文書をOCRで読もうとするとある程度の誤読を覚悟しなければいけませんが、対象が数字のみであることが事前に分かっていれば良い精度を出せそうです。

そんなわけで、充分な勝算があると踏んだ私はこのPDF整理自動化プロジェクトを粛々と進めることにしました。


自動化の方針

f:id:todays_mitsui:20161119125144p:plain

続いて自動化の方針を決定します。
自動化のプログラムはPythonで書くことにしました。具体的には以下のような手順です。

  1. PDFMiner で PDF ファイルから画像データの抜き出し
  2. 画像データ(生バイナリ)を PIL の Image オブジェクトに変換
  3. Tesseract で文字認識
  4. PDF ファイルを複製しつつリネーム

一つずつ解説します。


各工程の詳細

1. PDFMiner で PDF ファイルから画像データの抜き出し

今回は Tesseract という OCR ツールで画像の中の文字を読み取りますが。この Tesseract は JPEG や PNG などの形式しか受け付けてくれません。
PDF を直接読ませることができないので、事前準備として対応した形式に変換する必要がありました。


PDF をレンダリングして画像にする方法もあるようですが、下準備が煩雑で挫折...。
仕方なく今回は PDF を画像に変換するのではなく、PDF に埋め込まれた画像データを抽出する方法を採りました。

画像の抽出には PDFMiner という Python のライブラリを使います。
以下のコードを実行すると PDF に埋め込まれた全ての画像を取得することが出来ます。

from pdfminer.pdfinterp import PDFResourceManager
from pdfminer.pdfinterp import PDFPageInterpreter
from pdfminer.layout import LAParams, LTTextBox, LTTextLine, LTImage,  LTFigure
from pdfminer.converter import PDFPageAggregator


def extract_images(document):
    """PDF ドキュメントから画像形式のデータだけを抽出する"""

    # Create a PDF resource manager object that stores shared resources.
    rsrcmgr = PDFResourceManager()
    # Create a PDF device object.
    device = PDFPageAggregator(rsrcmgr, laparams=LAParams())
    # Create a PDF interpreter object.
    interpreter = PDFPageInterpreter(rsrcmgr, device)

    contents = []

    for page in PDFPage.create_pages(document):
        interpreter.process_page(page)
        layout = device.get_result()

        contents.extend(travarse(layout))

    return [to_pil_image(ltImage) for ltImage in contents]


def travarse(layout):
    """Layout オブジェクトを再帰的に走査して LTImage オブジェクトのみを list で返す"""

    images = []

    for obj in layout:
        if isinstance(obj, LTTextBox) or isinstance(obj, LTTextLine) or isinstance(obj, LTFigure):
            images.extend(travarse(obj))

        elif isinstance(obj, LTImage):
            images.append(obj)

    return images

ちなみに抽出されるデータの形式は生のバイナリ形式になります。


2. 画像データ(生バイナリ)を PIL の Image オブジェクトに変換

画像データを抽出できたのはいいものの、バイナリ形式のままでは何かと扱いにくいので Python で扱いやすい形式にします。 Python における代表的な画像処理ライブラリである Pillow の Image オブジェクト形式に変換しました。

import StringIO
from PIL import Image


def to_pil_image(ltImage):
    """Raw Binary を Image オブジェクトに変換"""

    buffer = StringIO.StringIO()
    buffer.write(ltImage.stream.get_rawdata())
    buffer.seek(0)
    return Image.open(buffer)

Image 形式に変換することで、元々埋め込まれていた画像データが JPG だったのか PNG だったのかを意識する必要がなくなります。
さらに、PIL の機能を使ってトリミングなどの補正処理もやりやすくなるので一石二鳥です。

標準的には Image オブジェクトはファイルストリームから生成することになっているので、 StringIO を使ってバイナリをもとにファイルストリームをエミュレートして Image オブジェクトを作ってあげます。


3. Tesseract で文字認識

いよいよ OCR に掛けて文字を読み取ってみましょう。

今回、OCR のツールとして Tesseract を利用しました。
Tesseract 自体は Python とは直接関係のない一般的な OCR ツールです。
160種類以上もの言語の学習済みデータが最初から付属している のが魅力で、中にはもちろん日本語用のものも含まれています。

事前に Tesseract をセットアップは済ませておく必要があります。
Tesseract を Python から呼び出すのには pyocr というラッパーモジュールを使いました。

試しに注文番号にあたる部分を読ませてみましょう。

f:id:todays_mitsui:20161119125229p:plain

はい、うまく読めているようですね。
先ほど述べたように、活字の数字のみであれば安定して文字認識できます。


続いて日付にあたる部分を読み取りましょう。

日付は「2016年11月15日」というような形式で書かれています。
最終的に正規表現で数字部分だけを抜き出すとはいえ、OCRに掛ける段階では「年」や「月」といった日本語の文字を読み取る必要があります。
Tesseract 標準の日本語用学習済みファイルを使って文字認識してみましょう。上手くいくでしょうか。

f:id:todays_mitsui:20161119125238p:plain

うーん、イマイチですね。
数字の 1] と読み違えています。全く似ていない (長音)に誤読してしまうのは学習に使った教師データに縦書きの文章が含まれているからでしょうか…。

とはいえ幸いなことにいま私が読み取ろうとしている文字は 0~9 と「年月日」の13文字だけです。それ以外の文字はありえない事が分かっているので、誤読の訂正も簡単なのです。
OCRに掛けた結果に ] が含まれれば 1 の誤読であろうと分かります。 Z が含まれれば恐らく 2 の誤読ですね。

というわけで、誤読されていそうな文字を一つひとつ補正する処理を挟みます。

REPLACE_PAIR = (
    (u']', u'1'), (u'}', u'1'), (u'ー', u'1'),
    (u'仔', u'年'), (u'El', u'日'), (u'E|', u'日'),
    (u'E', u'日'), (u'口', u'日'), (u'曰', u'日'),
    (u'Z', u'2'), (u'O', u'0'), (u'〇', u'0'),
    (u'I', u'1'), (u'l', u'1'),
)

# よくある誤読をヒューリスティックに訂正
for before, after in REPLACE_PAIR:
    txt = txt.replace(before, after)

# 空白を削除
txt = re.sub(r'\s+', '', txt)

このような工夫の結果、テスト用に用意したスキャンデータ20個では何とか正答率100%を達成できました。


4. PDF ファイルを複製しつつリネーム

注文番号と日付を読み取ることさえ出来れば、最後のリネームはとても簡単です。
shutil モジュールに含まれる copyfile 関数で複製しつつファイル名を変えてあげます。

import shutil


after = '{0[ordernum]}_{1[year]:0>4}{1[month]:0>2}{1[day]:0>2}.pdf'.format(ordernum, date)
shutil.copyfile(before, 'AFTER/'+after)

このような処理をフォルダに用意したスキャンデータの一つひとつに適用していけば、ファイル整理の自動化は達成されます。

f:id:todays_mitsui:20161119130455p:plain

いい感じです。


配布する

これにて目的は達成出来ましたが、最後にこのプログラムを自分以外の人にも配布することについて検討します。
実務で必要な人が手軽に使えるようにとかんがえると、やはり Windows の実行形式である EXE ファイルを作って配布するべきでしょうね。

Python で書いたプログラムにおいては、Pinstaller というツールを使う事で必要なライブラリなどを自動で取り込んだ EXE ファイルを吐き出してくれます。

$ pyinstaller main.py --onefile --clean

実行するコンピュータに Tesseract が別途セットアップされている必要はあるものの、EXE ファイルを配りさえすれば使ってもらえるのはとても便利です。
Pyinstler のインストールは pip コマンドひとつで済むので使い始めるのも楽でした。


まとめ

というわけで、PDFファイルから画像を抽出し、文字を読み取ってスキャンデータをリネームするところまで一通りの処理を自動化してみました。
Python は様々な分野で必要になる基本的ツールが何かしら揃っていて成熟しているので便利ですね。これはいいものです。

今回書いたコードの一式は GitHub に置いています。

github.com


私からは以上です。