サクラの共有サーバーでPython3のWebサービスを動かす

目的

サクラの共有サーバーは2020/05時点でPythonが2.7です。
今回はPython3のWebサービスを動かす方法について紹介します。

【デモサイト】
https://needtec.sakura.ne.jp/yakusyopdf/

【コード】
https://github.com/mima3/yakusyopdf/tree/master/web

Python3のインストール

自分に権限のあるユーザディレクトリにPythonをソースからコンパイルしてインストールします。
今回の例では以下にインストールしました。

/home/ユーザ/local/bin/python3

2020/05時点ではPython3.8をサクラレンタルサーバーにビルドしてインストールする場合、以下のようなconfigureの実行後にmake installするだけでいいと思います。

./configure --prefix=$HOME/local/ 

※過去にはOpenSSLのバージョン違いのせいで、自前でOpenSSLもビルドする必要があったりした。

なお、過去に自前でPythonをインストールしていた場合は、自分でインストールした分は削除してから実行した方がいいです。

Webフレームワーク―Bottle

PythonのWebフレームワークは色々ありますが、今回のような環境ではBottleが最も楽です。

https://bottlepy.org/docs/dev/tutorial.html

Bottleの特徴として、bottle.pyという1ファイルをインポートするだけでルーティングの処理が記載できるようになります。

今回はbottleをcgiとして起動しています。
BottleをCGIとして起動するにはbottle.runを実行する際にパラメータにcgiを渡します。

from bottle import run
app = Bottle()
run(app, server='cgi')

サクラの共有サーバーでBottleを動かすには

さて、サクラの共有サーバーでBottleを動かすにはいくつかのコツがあります。

まず、cgiを動作させる際にpythonのパスを認識させる必要があります。
以下は実際のindex.cgiを抜粋したものです。

#!/home/needtec/local/bin/python3
# coding: utf-8
from bottle import run
# 略

一番先頭行にインストールしたpython3へのパスを記述します。
これにより、index.cgiを動作させる場合は、インストールしたpython3が動くようになります。

しかし、python3のパスだけを与えても動作はしません。
たとえば、pip3等でライブラリをインストールしていた場合などは、そのライブラリのパスもcgiが認識できるようにする必要があります。このためにはsys.pathにインポート対象のフォルダを追加する必要があります。

import sys
sys.path.append('/home/needtec/local/lib/python3.8/site-packages')
sys.path.append('/home/needtec/local/lib/python3.8/peewee-3.13.3-py3.8.egg-info')

また.htaccessを使用して「hoge/yakusyopdf/json/get_hospital」というリクエストを「hoge/yakusyopdf/index.cgi/json/get_hospital」に置き換えます。

.htaccess

RewriteEngine On

# Some hosts may require you to use the `RewriteBase` directive.
# If you need to use the `RewriteBase` directive, it should be the
# absolute physical path to the directory that contains this htaccess file.
#
# RewriteBase /

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.cgi/$1 [L]

RewriteCondeでリクエストしたファイルが存在しない場合は、index.cgiを前に付与するようなルールになっています。

ここまでやることで、サクラの共有サーバーでPython3を使用したBottleのWebアプリケーションが実行できます。

開発環境も考慮する

先までで挙げた方法は実機だけで開発する分には問題ないですが、ローカルの環境でも動かすには辛いものがあります。
たとえば、index.cgiに記載するPythonのパスやsys.pathに追加するディレクトリは環境ごとにことなります。環境を変えるたびにそれらを修正するのは、辛いです。

そこで今回は、index.cgiを自動で作成するという手法をとっています。

index.original.cgiを元にapplication.iniとpythonのパスを元にcreate_index_cgi.pyを使用してindex.cgiを作成します。

index.cgi.original

#![python_path]
# coding: utf-8
from bottle import run
# import ConfigParser
import configparser  #python3
import sys

conf = configparser.ConfigParser()
conf.read("[ini_path]")
try:
  i = 0
  path = conf.get('system', 'path' + str(i))
  while path != "":
    i = i + 1
    sys.path.append(path)
    path = conf.get('system', 'path' + str(i))
except configparser.NoOptionError as e:
  pass

from application import app, setup
setup(conf)
run(app, server='cgi')

これを元に作成したindex.cgiは以下の通りです。

#!/home/needtec/local/bin/python3
# coding: utf-8
from bottle import run
# import ConfigParser
import configparser  #python3
import sys

conf = configparser.ConfigParser()
conf.read("/home/needtec/work/yakusyopdf/application.ini")
try:
  i = 0
  path = conf.get('system', 'path' + str(i))
  while path != "":
    i = i + 1
    sys.path.append(path)
    path = conf.get('system', 'path' + str(i))
except configparser.NoOptionError as e:
  pass

from application import app, setup
setup(conf)
run(app, server='cgi')

[python_path]と[ini_path]がそれぞれ、Pythonのパスとapplication.iniへのパスに変換されています。また、application.iniに記載されたパス情報を基に、sys.pathに追加を行い、インクルード対象のフォルダを追加します。

あとはindex.cgiには処理を記載しなければ様々な環境でデプロイしやすい構造となります。(※今回はメインの処理はapplication.pyに記載している)

まとめ

今回はサクラの共有サーバーでPython3のWebサービスを動かす方法について述べました。
現時点ではPython3は自前でビルドしてインストールする必要があります。
また、CGIからそれらを使用する場合にはインストールしたパスを適切に設定してあげる必要があります。

sklearnを使用して文章の類似度を調べる

目的

TF-IDFによる2013年参議院選挙のパンフレットの解析で使用した文章の類似度を調べる処理をsklearnJanomeを使用して実行してみます。

実験コード

from janome.tokenizer import Tokenizer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

def stems(doc):
    result = []
    t = Tokenizer()
    tokens = t.tokenize(doc)
    for token in tokens:
        result.append(token.surface.strip())
    return result

documents = [
    "私は猫と犬と神が好きだ",
    "私は猫と犬と神が好きではない",
    "私は神です",
    "私は戦争が好きだ",
    "ウンコティンティン!ウンコティンティン!",
]
vectorizer = TfidfVectorizer(analyzer=stems)
t = Tokenizer()
tfidf_matrix = vectorizer.fit_transform(documents)
for ix in range(len(documents)):
    print(documents[ix], '------------------------')
    similarities = cosine_similarity(tfidf_matrix[ix], tfidf_matrix)
    for similarity in similarities:
        for ix2 in range(len(similarity)):
            if ix2 == ix:
                continue
            print('  ', documents[ix2], similarity[ix2])

出力結果

私は猫と犬と神が好きだ ------------------------
   私は猫と犬と神が好きではない 0.8161301674485676
   私は神です 0.29355926474682525
   私は戦争が好きだ 0.47854281861265613
   ウンコティンティン!ウンコティンティン! 0.0
私は猫と犬と神が好きではない ------------------------
   私は猫と犬と神が好きだ 0.8161301674485676
   私は神です 0.32643300489571514
   私は戦争が好きだ 0.3486675556492787
   ウンコティンティン!ウンコティンティン! 0.0
私は神です ------------------------
   私は猫と犬と神が好きだ 0.29355926474682525
   私は猫と犬と神が好きではない 0.32643300489571514
   私は戦争が好きだ 0.24652369447564157
   ウンコティンティン!ウンコティンティン! 0.0
私は戦争が好きだ ------------------------
   私は猫と犬と神が好きだ 0.47854281861265613
   私は猫と犬と神が好きではない 0.3486675556492787
   私は神です 0.24652369447564157
   ウンコティンティン!ウンコティンティン! 0.0
ウンコティンティン!ウンコティンティン! ------------------------
   私は猫と犬と神が好きだ 0.0
   私は猫と犬と神が好きではない 0.0
   私は神です 0.0
   私は戦争が好きだ 0.0

tf-idfを使用した類似度の算出は単語の頻度によって類似度が決まります。
すなわち意味までは解析しないので「私は猫と犬と神が好きだ」と「私は猫と犬と神が好きではない」は類似度が高い文章として扱われます。

コードの説明

TfidfVectorizerはドキュメントのコレクションをTF-IDF機能のマトリックスに変換するクラスです。
インスタンスを作成する際に、文章を解析するための関数をanalyzerに渡します。
上記の例ではstems関数を渡しており、この処理の中でJanomeを使用して形態素解析を行い単語単位に分割した結果の一覧を返します。

TfidfVectorizer.fit_transformでは解析対象のドキュメントの一覧を与えてドキュメント用語マトリックスを返します。

その用語魔トリックを用いてcosine_similarityは類似度を計算します。

OCRで画像からテキストを抽出してみる

目的

PDFはテキストの情報が含まれている場合がありますが、場合によっては画像として文字を使用している場合があります。

今回はOCR機能を使用して画像からテキストを抽出してみます。
https://github.com/mima3/pdf_udon

PDFからの画像取得方法

PyMuPDFを使用すればPDFから画像を簡単に抽出できます。

import fitz
fitz.open('data/賛成0123.pdf')
doc = fitz.open('data/賛成0123.pdf')
pix = doc[0].getPixmap()
pix.writePNG('test.png')

Page.getPixmapメソッドを利用することで、Pixmapのオブジェクトを取得します。ピクセルの情報を保持しており、画像ファイルとして出力することも可能です。

これらの機能を使用してPDFからPNGを抽出するスクリプトが以下になります。
https://github.com/mima3/pdf_udon/blob/master/convert_image.py

OCRでテキストを取得する

今回はOCRでテキストを取得する方法で2種類ためしました。

  • Tesseract-OCR
  • Googleドキュメント

Tesseract-OCR

Tesseract-OCRはOpenSourceのOCRプログラムで、コマンドラインまたは、ダイナミックリンクのライブラリとして使用できます。
また、PyOCRを使用することでPythonから使用可能です。

詳しくは、GAMMASOFTの資料がわかりやすいです。

Tesseract OCR をWindowsにインストールする方法
https://gammasoft.jp/blog/tesseract-ocr-install-on-windows/

PythonでOCRを実行する方法
https://gammasoft.jp/blog/ocr-by-python/

単純なサンプル

以下が単純なサンプルになります。
この例ではPillowを使用してPNGファイルを読み取り、その内容をpyocrに渡しています。

import pyocr
import pyocr.builders
from PIL import Image

# tesseractをインストールしたフォルダにパスが通っていること
tools = pyocr.get_available_tools()
text=tools[0].image_to_string(
    Image.open('test.png'),
    lang='jpn',
    builder=pyocr.builders.TextBuilder()
)
print(text)

OpenCVを使用して画像に加工をする場合

もし、OpenCVで何らかの画像の処理をしてからpyocrに渡す場合は以下のようになります。

import pyocr
import pyocr.builders
import numpy as np
import cv2
from PIL import Image

def imread_jp(image_full_path):
    # cv2::imereadは日本語を含むパスを読めない
    # https://github.com/opencv/opencv/issues/4292
    # https://stackoverflow.com/questions/11552926/how-to-read-raw-png-from-an-array-in-python-opencv
    with open(image_full_path, 'rb') as img_stream:
        file_bytes = np.asarray(bytearray(img_stream.read()), dtype=np.uint8)
        img_data_ndarray = cv2.imdecode(file_bytes, cv2.IMREAD_UNCHANGED)
        return img_data_ndarray

# OpenCVを使用した何らかの処理
img = imread_jp('test.png')
img = cv2.fastNlMeansDenoisingColored(img,None,10,10,7,21)

# tesseractをインストールしたフォルダにパスが通っていること
tools = pyocr.get_available_tools()
text=tools[0].image_to_string(
    Image.fromarray(img),
    lang='jpn',
    builder=pyocr.builders.TextBuilder()
)
print(text)

下記の実装ではノイズの除去だけでなく角度の調整も行っています。
https://github.com/mima3/pdf_udon/blob/master/convert_text.py

テキスト座標の取得

TextBuilder()の代わりに、LineBoxBuilder(),WordBoxBuilder()を使用することで、行の位置や単語の位置も取得できます。

import pyocr
import pyocr.builders
from PIL import Image

# tesseractをインストールしたフォルダにパスが通っていること
tools = pyocr.get_available_tools()
line_boxs=tools[0].image_to_string(
    Image.open('test.png'),
    lang='jpn',
    builder=pyocr.builders.LineBoxBuilder()
)

for line_box in line_boxs:
    # 件 名 : バブ リッ クコ メン ト へ の 意見 ((68, 182), (235, 191))
    print(line_box.content, line_box.position)
    for word_box in line_box.word_boxes:
        print('  ', word_box.content, word_box.position)

出力例

件 名 : バブ リッ クコ メン ト へ の 意見 ((68, 182), (235, 191))
   件 ((68, 182), (87, 190))
   名 ((80, 172), (92, 200))
   : ((99, 172), (106, 200))
   バブ ((113, 182), (128, 191))
   リッ ((131, 182), (144, 190))
   クコ ((148, 183), (165, 191))
   メン ((168, 183), (186, 190))
   ト ((190, 182), (195, 190))
   へ ((197, 184), (204, 190))
   の ((207, 183), (215, 190))
   意見 ((217, 182), (235, 191))

GoogleドキュメントをOCRとして使う

Googleドライブにアップロードした画像をGoogleドキュメントとして開くことにより、画像に含まれている文字を認識してテキストに変換してくれます。

以下の実装はGoogleDriveのAPIを使用して画像をアップロードして、Googleドキュメントとして開いた内容のテキストを取得するサンプルになります。

https://github.com/mima3/pdf_udon/blob/master/convert_text_googledoc.py

このサンプルを実行するにはGoogle DriveにCSVをアップロードしてGoogleスプレッドシートとして編集後、ダウンロードするで紹介した前提条件を満す必要があります。

なお、Googleドキュメントに変換する場合、どうしてもローカルでtesseractを使用する場合より、速度的に劣ります。

OCR実行結果

今回紹介した方法で香川県のゲーム依存症対策パブリックコメントの画像からテキストを抽出した結果は以下の通りです。

https://github.com/mima3/pdf_udon/releases

ダウンロードすると各方式で取得したテキストを格納したSQLiteが取得できます。

Google DriveにCSVをアップロードしてGoogleスプレッドシートとして編集後、ダウンロードする

目的

今回はGoogle DriveniにCSVをアップロードしてGoogleスプレッドシートとして編集してみます。

まず、前提条件として以下のチュートリアルを実行してください。

Google Drive API- Python Quickstart
https://developers.google.com/drive/api/v3/quickstart/python

Google Sheets API- Python Quickstart
https://developers.google.com/sheets/api/quickstart/python

上記のチュートリアルで以下のことが行われると思います。

  • Google APIを使用するためのプロジェクトが作成される
  • 作成したプロジェクトにてGoogle Drive APIが有効になる。
  • 作成したプロジェクトにてGoogle SheetsのAPIが有効になる。
  • 認証用の情報を格納したJSONを取得している
  • Pythonのライブラリで下記をインストールしている。
    • google-api-python-client
    • google-auth-httplib2
    • google-auth-oauthlib

実験

Google DriveにCSVをアップロードする

コード

from __future__ import print_function
import sys
import pickle
import io
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from apiclient.http import MediaFileUpload
from googleapiclient.http import MediaIoBaseDownload

# スコープについては下記を参照してください.
# https://developers.google.com/drive/api/v3/about-auth
# 変更した場合は「token.pickle」を削除する必要があります.
SCOPES = [
    'https://www.googleapis.com/auth/drive'
]

def authenticate(client_secret_json_path):
    """QuickStartで行った認証処理と同じ認証処理を行う
    https://developers.google.com/drive/api/v3/quickstart/python
    """
    creds = None
    # ファイルtoken.pickleはユーザーのアクセストークンと更新トークンを格納し、
    # 認証フローが初めて完了すると自動的に作成されます。
    if os.path.exists('token.pickle'):
        with open('token.pickle', 'rb') as token:
            creds = pickle.load(token)
    # 有効な資格情報がない場合は、ユーザーにログインさせます。
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            # ユーザーにブラウザーで認証URLを開くように指示し、ユーザーのURLを自動的に開こうとします。
            # ローカルWebサーバーを起動して、認証応答をリッスンします。
            # 認証が完了すると、認証サーバーはユーザーのブラウザーをローカルWebサーバーにリダイレクトします。
            # Webサーバーは、応答とシャットダウンから認証コードを取得します。その後、コードはトークンと交換されます
            flow = InstalledAppFlow.from_client_secrets_file(client_secret_json_path, SCOPES)
            creds = flow.run_local_server(port=0)
        # 次回実行のために「google.oauth2.credentials.Credentials」をシリアライズ化して保存します。
        # https://google-auth.readthedocs.io/en/latest/reference/google.oauth2.credentials.html
        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)
    return creds

def main(client_secret_json_path):
    """CSVのアップロードを行う"""
    creds = authenticate(client_secret_json_path)
    service_drive = build('drive', 'v3', credentials=creds)

    # CSVをGoogleスプレッドシートで編集できるようにUploadします.
    # https://developers.google.com/drive/api/v3/manage-uploads#python
    file_metadata = {
        'name': 'Test',
        'mimeType': 'application/vnd.google-apps.spreadsheet'
    }
    media = MediaFileUpload('test.csv',
                            mimetype='text/csv',
                            resumable=True)
    file = service_drive.files().create(body=file_metadata,
                                    media_body=media,
                                    fields='id').execute()
    print('File ID: %s' % file.get('id'))

if __name__ == '__main__':
    # 第1引数にQuickStartで作成したcredentials.jsonのパスを指定してください.
    main(sys.argv[1])

結果

コンソール
以下のように作成したファイルIDが出力されます

>python hello1.py client_secret.json
File ID: 1iJAdUMkXraufM3Wu0bsbzZvXZLxQQPNYaDvwd6nj97w

Google Drive
「Test」という名称のSheetsが追加されます。

スプレッドシートを開くと「Test」というシートにCSVの内容が表示されています。

Google DriveにアップロードしたCSVをGoogle Sheets APIで編集する

B列とC列を全て変更するサンプルです。
C列はGOOGLETRANSLATEを使用してB列の内容を日本語から英語に翻訳しています。

コード

from __future__ import print_function
import sys
import pickle
import io
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request

# スコープについては下記を参照してください.
# https://developers.google.com/drive/api/v3/about-auth
# 変更した場合は「token.pickle」を削除する必要があります.
SCOPES = [
    'https://www.googleapis.com/auth/drive'
]

def authenticate(client_secret_json_path):
    """QuickStartで行った認証処理と同じ認証処理を行う
    https://developers.google.com/drive/api/v3/quickstart/python
    """
    creds = None
    # ファイルtoken.pickleはユーザーのアクセストークンと更新トークンを格納し、
    # 認証フローが初めて完了すると自動的に作成されます。
    if os.path.exists('token.pickle'):
        with open('token.pickle', 'rb') as token:
            creds = pickle.load(token)
    # 有効な資格情報がない場合は、ユーザーにログインさせます。
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            # ユーザーにブラウザーで認証URLを開くように指示し、ユーザーのURLを自動的に開こうとします。
            # ローカルWebサーバーを起動して、認証応答をリッスンします。
            # 認証が完了すると、認証サーバーはユーザーのブラウザーをローカルWebサーバーにリダイレクトします。
            # Webサーバーは、応答とシャットダウンから認証コードを取得します。その後、コードはトークンと交換されます
            flow = InstalledAppFlow.from_client_secrets_file(client_secret_json_path, SCOPES)
            creds = flow.run_local_server(port=0)
        # 次回実行のために「google.oauth2.credentials.Credentials」をシリアライズ化して保存します。
        # https://google-auth.readthedocs.io/en/latest/reference/google.oauth2.credentials.html
        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)
    return creds

def main(client_secret_json_path, file_id):
    """Googleスプレッドシートの更新を行う"""
    creds = authenticate(client_secret_json_path)
    service_sheets = build('sheets', 'v4', credentials=creds)
    result = service_sheets.spreadsheets().values().get(
        spreadsheetId=file_id,
        range="Test!A1:A").execute()
    print("行数", len(result.get('values')))

    # 全ての行のB列とC列を書き換える
    values = []
    for i in range(len(result.get('values'))):
        values.append(
            [
                # A列に文字を追加した内容をB列に埋め込む
                'こんにちわ ' + result.get('values')[i][0],
                # B列を日本語から英語に翻訳する数式をC列に埋め込む
                '=GOOGLETRANSLATE(indirect("RC[-1]", false),"ja","en")'
            ]
        )

    body = {
        'values': values
    }
    result = service_sheets.spreadsheets().values().update(
        spreadsheetId=file_id,
        range="Test!B1:C",
        valueInputOption='USER_ENTERED',
        body=body
    ).execute()
    print('{0} cells updated.'.format(result.get('updatedCells')))

if __name__ == '__main__':
    # 第1引数にQuickStartで作成したcredentials.jsonのパスを指定してください.
    # 第2引数に編集対象のGoogleスプレッドシートのFile IDを指定してください。
    # https://docs.google.com/spreadsheets/d/[ここの値を入力]/edit#gid=XXXXX
    main(sys.argv[1], sys.argv[2])

結果

すべての行のB列とC列が変更されていることが確認できます。
また、C列の数式も機能しています。

編集したCSVのダウンロード方法

指定のGoogle Drive中のファイルをローカルPCにダウンロードします。

コード

from __future__ import print_function
import sys
import pickle
import io
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.http import MediaIoBaseDownload

# スコープについては下記を参照してください.
# https://developers.google.com/drive/api/v3/about-auth
# 変更した場合は「token.pickle」を削除する必要があります.
SCOPES = [
    'https://www.googleapis.com/auth/drive'
]

def authenticate(client_secret_json_path):
    """QuickStartで行った認証処理と同じ認証処理を行う
    https://developers.google.com/drive/api/v3/quickstart/python
    """
    creds = None
    # ファイルtoken.pickleはユーザーのアクセストークンと更新トークンを格納し、
    # 認証フローが初めて完了すると自動的に作成されます。
    if os.path.exists('token.pickle'):
        with open('token.pickle', 'rb') as token:
            creds = pickle.load(token)
    # 有効な資格情報がない場合は、ユーザーにログインさせます。
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            # ユーザーにブラウザーで認証URLを開くように指示し、ユーザーのURLを自動的に開こうとします。
            # ローカルWebサーバーを起動して、認証応答をリッスンします。
            # 認証が完了すると、認証サーバーはユーザーのブラウザーをローカルWebサーバーにリダイレクトします。
            # Webサーバーは、応答とシャットダウンから認証コードを取得します。その後、コードはトークンと交換されます
            flow = InstalledAppFlow.from_client_secrets_file(client_secret_json_path, SCOPES)
            creds = flow.run_local_server(port=0)
        # 次回実行のために「google.oauth2.credentials.Credentials」をシリアライズ化して保存します。
        # https://google-auth.readthedocs.io/en/latest/reference/google.oauth2.credentials.html
        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)
    return creds

def main(client_secret_json_path, file_id):
    """Googleスプレッドシートのダウンロードを行う"""
    creds = authenticate(client_secret_json_path)
    service_drive = build('drive', 'v3', credentials=creds)

    # download
    request = service_drive.files().export_media(fileId=file_id, mimeType='text/csv')
    fh = io.BytesIO()
    downloader = MediaIoBaseDownload(fh, request)
    done = False
    while done is False:
        status, done = downloader.next_chunk()
        print ("Download %d%%." % int(status.progress() * 100))
    with open('download.csv', 'wb') as f:
        f.write(fh.getvalue())

if __name__ == '__main__':
    # 第1引数にQuickStartで作成したcredentials.jsonのパスを指定してください.
    # 第2引数に編集対象のGoogleスプレッドシートのFile IDを指定してください。
    # https://docs.google.com/spreadsheets/d/[ここの値を入力]/edit#gid=XXXXX
    main(sys.argv[1], sys.argv[2])

注意

元ファイルの行数によってはダウンロード時に翻訳が完了していない場合があります。
もし翻訳が完了するまで待つ場合は以下のようにします。

https://github.com/mima3/pdf_translate/blob/master/translate_google_sheets.py#L45

PDFを翻訳したい

はじめに

PDFを翻訳する方法は色々あります。
ここでは色々の方法で実験してみます。

今回は以下のPDFを使用して[MS-CFB].pdfを対象にどのような翻訳がされるか確認してみましょう。

先駆者の方法

Google翻訳

Google翻訳でドキュメントタブからファイルをアップロードして翻訳が可能です。

お手軽に実行できますが、レイアウトが維持されない問題があります。
たとえば以下は MS-CFB.pdfの翻訳した結果の5ページ目にあたるのですが、図が削除されていることが確認できます。

WORDの利用

WORDの翻訳機能を使用する案です。
まず、PDFをWordで開いてWORD形式に保存します。
その後、「校閲タブ」の「翻訳」から翻訳を実行します。

残念ながら今回実験対象のPDFは下記のエラーが発生して翻訳が行えませんでした。

※なお、ページ数を減らすとできたので、ページ数の問題か、あるいは削除したページに含まれるデータの問題と考えられます。

DocTranslator

DocTranslatorというWebサービスが存在しており、PDFのレイアウトを維持したまま翻訳してくれます。
https://www.onlinedoctranslator.com/ja/

概ね、きれいに表示されるのですが、フォントの都合で対応できない場合もあります。

また、PDFのサイズによって翻訳に失敗するようで、PDF32000_2008.pdfは翻訳できませんでした。

Weblio英和辞典プラグイン

単語レベルでいいなら、下記のページに紹介されているAdobeReaderのWeblio英和辞典プラグインを使用する案もあります。

https://www.itmedia.co.jp/bizid/articles/1312/03/news034.html

別の方法を考える

先駆者の方法は多くの場合に十分ですが、PDF32000_2008.pdfで上手くいかなかったので別の方法を考えてみました。

pdf_translate
https://github.com/mima3/pdf_translate/blob/master/README.md

PDFに翻訳した結果の注釈を埋め込むことでレイアウトと原文を維持しつつ翻訳情報を差し込むことができます。

以下がPDF32000_2008.pdf
にたいして実際、注釈を付与したPDFになります。

http://needtec.sakura.ne.jp/doc/tmp/output.pdf

※ブラウザで見ると注釈が文字化けするのでダウンロードしてスマホ以外のAdobeReaderで閲覧してみてください。

メリット

  • 大き目のPDFの翻訳に対応できる。
  • 中間ファイルとして翻訳用のCSVを作成するので、気に入らなければ人手で修正することもできる。

デメリット

  • 手間がかかる。
    • Pythonを使うのはともかく、機械翻訳を作る部分の手作業が多い。
  • Adobe Acrobat Readerで閲覧しないと注釈が正常に表示されない
    • Chromeで見ると注釈ば文字化けする
    • スマホで見ると注釈がポップアップしない
  • サイズが増える。
  • こんなメンドクサイことしなくても良い方法が他にありそうに思えて仕方がない。

PDFの点線を実線におきかえる(PyMuPDF)

はじめに

下記の記事でPyPDF2とreportlabを用いてPDFの点線を実線に置き換える実験を行いました。

この時、PyPDF2の制限で保存したPDFのサイズが増加しました。

今回は別のライブラリを用いて、同じ処理を行います。

PyMuPDF

PyMuPDFはPythonを使用してMuPDFを操作します。
MuPDFは軽量の軽量のPDF、XPS、および電子書籍ビューアです。
ピュアなPythonで記載されたPyPDF2と異なりパフォーマンスの向上が期待できます。
パフォーマンスの詳細は下記を参照してください。
https://pymupdf.readthedocs.io/en/latest/app1/#

簡単な例

以下のサンプルはPDFの内容をSVGに出力後、左上に四角を描画します。
入力に使用しているPDFは以下のものを使用しています。
https://github.com/atlanhq/camelot/files/3565115/Test.pdf

import fitz

doc = fitz.open('test.pdf')
print(doc.pageCount)
page = doc.loadPage(0)
print('ページの領域:', page.rect)
# SVGとして出力
with open('tmp1.svg', 'w') as fp:
    fp.write(page.getSVGimage())

# 左上に四角を描画
rect = fitz.Rect(0, 10, 300, 40)
page.drawRect(rect, color=(1,0,0), width=1)

#doc.saveIncr()   # update file. Save to new instead by doc.save("new.pdf",...)
doc.save("new.pdf")

出力されたSVGをChromeで表示した結果は以下の通りとなります。

点線が削除されています。
また、残念なことにPyPDF2で行ったようにオペコードを直接取得はできないようなので、正確に座標情報を取得したい場合はPyPDF2を用いる必要があるでしょう。

次に、図形を描画したPDFを見てみます。

PyPDF2+reportlabで使用した場合とPyMuPDFで座標が異なることに注意してください。
前者は(0、0)を左下としていますが、MuPDFはページの左上隅を(0、0)としています。これについては下記のIssueで言及されています。

Question: is PyMuPDF rect compatible with reportlab rect? #348
https://github.com/pymupdf/PyMuPDF/issues/348

点線の実線にする

ではPDFの点線を実線におきかえる(PyPDF2 + reportlab)で行ったのと同じように処理を行います。

線情報の取得方法は同じですが、ファイルの書き込みをPyMuPDFで行います。

対象のPDFは以下になります。
https://github.com/mima3/yakusyopdf/blob/master/20200502/%E5%85%B5%E5%BA%AB%E7%9C%8C.pdf

import fitz
import PyPDF2
from PyPDF2.pdf import ContentStream
from PyPDF2.utils import b_

def get_rectangle(content):
    result = []
    for operands, operator in content.operations:
        if operator == b_('re'):
            result.append(fitz.Rect(operands[0], operands[1], operands[0]+ operands[2], operands[1] + operands[3]))
    return result

def read_pdf(path):
    rects = []
    with open(path, "rb") as input_stream:
        input_pdf = PyPDF2.PdfFileReader(input_stream)
        for page in range(input_pdf.numPages):
            print('read', page + 1, '/', input_pdf.numPages)
            page_obj = input_pdf.getPage(page)
            content = page_obj['/Contents'].getObject()
            if not isinstance(content, ContentStream):
                content = ContentStream(content, input_pdf)
            rects.append(get_rectangle(content))

    return rects

path = "test/兵庫県.pdf"
rects = read_pdf(path)
doc = fitz.open(path)
ix = 0
for page in doc:
    print('write', ix + 1, '/', len(doc))
    for rc in rects[ix]:
        # https://github.com/pymupdf/PyMuPDF/issues/348
        new_rc = rc * page._getTransformation()
        page.drawRect(new_rc, color=(1,0,0), width=1)
    ix += 1
# https://pymupdf.readthedocs.io/en/latest/tutorial/#saving
doc.save("new.pdf",  garbage=4, clean=1, deflate=1)  # or doc.saveIncr() 

PyMuPDFでは保存時に圧縮の指定や余分なオブジェクトの削除が行えるので、ファイルサイズを抑えることが可能になっています。

まとめ

PyMuPDFを用いることでファイルサイズを抑制して図形を描画することが可能であることが確認できました。

今回はPyPDF2で線の座標を取得していますが、完全な線でなくていいなら、SVGから線の座標情報を取得することも可能であると思います。

PDFの点線を実線におきかえる(PyPDF2 + reportlab)

はじめに

以下の記事でcamelotを使用してPDFからテーブルを抽出する場合に、PDFが点線で構成されているとテーブルを旨く認識できない問題を上げました。
camelotで点線を実線として処理する

この時はcamelotが保持する中間の画像データに対してOpenCVを使用して点線を実線に書き換えるアプローチをとりました。

今回はPDFに含まれている線情報を、そもそも書き換えることができるかを検証してみます。

PDFのコンテンツの情報を取得する

camelotをインストールする際に同時にインストールされるPyPDF2を使用して解析してみます。

PyPDF2にはextractTextというPDF中からテキスト情報を抽出する処理がありますが、これを参考にすることでPDF中の線情報を取得できると考えられます。

以下のコードはextractTextを参考に記載したものでPDFからコンテンツ情報を列挙するものになります。

import PyPDF2
from PyPDF2.pdf import ContentStream
from PyPDF2.utils import b_

pdf = PyPDF2.PdfFileReader(open("Test.pdf", "rb"))
page = pdf.getPage(0)

content = page['/Contents'].getObject()
if not isinstance(content, ContentStream):
    content = ContentStream(content, pdf)

for operands, operator in content.operations:
    print(operator, operands)

PDFはoperatorとoperandsの組み合わせでテキストや画像を描画しており、その詳細の仕様は下記にあります。

https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/PDF32000_2008.pdf

この資料の「A.2 PDF Content Stream Operators」を元にoperatorがどんな動きをしているか解析することが可能になります。

camelotで点線を実線として処理するにて実験に使用したPDFファイルは以下のoeratorが使用されていることがわかります。

b're' [116.3, 596.38, 374.11, 1.92]

これの意味は以下のようになります。

Operator Operands Description 解説ページ
re x y width height Append rectangle to path 133

これで四角形の座標と大きさを認識することができます。

PDFのコンテンツの情報を更新する

PDFの図形情報を書き換えるのは、簡単にいきそうもありませんでした。
しかしながら、reportlabを使用することで図形を追加することはできそうです。

具体的な手順は以下のようになります。

  • 入力のPDFを開いて図形の座標を取得する。
  • reportlabを使用してキャンバスに取得した図形と同じ箇所に、同じ図形を描画して新しいPDFのページを作る
  • 入力につかったページと新規のページをマージして出力する。

これを実装したものが以下のようになります。

import PyPDF2
from PyPDF2 import PdfFileWriter, PdfFileReader
from io import BytesIO
from reportlab.pdfgen import canvas
from PyPDF2.pdf import ContentStream
from PyPDF2.utils import b_

def get_rectangle(content):
    result = []
    for operands, operator in content.operations:
        if operator == b_('re'):
            result.append(operands)
    return result

with open('output.pdf', 'wb') as output_stream, open("Test.pdf", "rb") as input_stream:
    output = PdfFileWriter()

    # read your existing PDF
    input_pdf = PyPDF2.PdfFileReader(input_stream)
    for page in range(input_pdf.numPages):
        print(page + 1, '/', input_pdf.numPages)
        page_obj = input_pdf.getPage(page)
        content = page_obj['/Contents'].getObject()
        if not isinstance(content, ContentStream):
            content = ContentStream(content, input_pdf)
        rectangles = get_rectangle(content)
        #
        buffer = BytesIO()
        c = canvas.Canvas(buffer, pagesize=(page_obj.mediaBox.getWidth(), page_obj.mediaBox.getHeight()))
        c.setStrokeColorRGB(255, 0, 0)
        for rect in rectangles:
            c.rect(rect[0], rect[1], rect[2], rect[3], fill=0)
        c.showPage()
        c.save()
        buffer.seek(0)
        ### DEBUG
        #pdf_tmp = buffer.getvalue()
        #open('tmp{}.pdf'.format(page), 'wb').write(pdf_tmp)
        ###
        new_pdf = PdfFileReader(buffer)
        page_obj.mergePage(new_pdf.getPage(0))
        output.addPage(page_obj)
        output.write(output_stream)

変換結果:

画像のリンク

Excelの罫線のないところも線を描画していますが、camelotで認識するという目的では使用できると思います。

問題点

このアプローチでは大きな問題点が2点あります。
まず、ファイルサイズが激増します。3MBのPDFが440MBになりました。
また、ファイルの増加と関係すると思いますが使用メモリも大量になります。700MBほど使用していました。

この問題はIssueとしてあがっていますが、解決はしていません。

StackOverflowでもファイルサイズの問題は質問されていますが、別ツールを使用しろという結果になっています。

オープンソースのcpdfsqueezeを使用することでファイルのサイズの問題は解決できますが、処理中のメモリの問題は依然として残る結果となります。

下記のページでは代替のライブラリであるPyMuPdf libraryが紹介されています。

まとめ

PDF中の点線を実線に書き換えるというアプローチは、PDF上のreオペレータを見ることで場所を特定して、線を上書きするという方法で可能です。
この場合、Excelの罫線のないところも線が引かれてしまうので、これを回避するには別のoperatorも観る必要があります。

また、PyPDF2を使用した場合、圧縮がサポートされていないためファイルサイズが増加し、また、処理中のメモリも大きなものとなりまします。
ファイルサイズの増加については外部のツールで圧縮することが可能ですが、メモリの増加についてはPyPDF2上で回避する方法は現時点でなさそうです。

camelotで点線を実線として処理する

はじめに

camelotではパラメータの調整だけでは点線を含むテーブルの処理が上手く動作しません。

たとえば、以下のようなPDFがそれにあたります。
➀縦の点線
https://github.com/atlanhq/camelot/files/3565115/Test.pdf

②横の点線
https://github.com/mima3/yakusyopdf/blob/master/20200502/%E5%85%B5%E5%BA%AB%E7%9C%8C.pdf

この問題は下記のIssueとして挙げられていますが、解決されていないようです。

Detect dotted line #370
https://github.com/atlanhq/camelot/issues/370

対応方法

Camelotのテーブル認識の仕組み

対応方法の説明の前にCamelotがどのようにテーブルを認識しているか説明します。
Camelotにはテーブルの解析の方法に2種類存在します。1つはStreamでもう1つはLatticeです。
デフォルトではLatticeで解析しています。

Latticeではhostscriptを使用してPDFページを画像に変換し、次に、OpenCVを使用して線とその交点を求めます。
そしてそれらの座標を元に、テーブルのセルに割り当てていきます。

対応方法

Latticeでは一旦、画像に変換しているわけですから、その画像において点線を実線に置き換えればよいです。

縦の点線を実線に変換する場合は、縦方向にdilateとerodeを行います。
横の点線を実線に変換する場合は、横方向にdilateとerodeを行います。
こうやって変更した画像をcamelotの線分を見つける処理に渡せばいいのです。

具体的な実装例

まずLatticeをベースクラスとしたLatticeExを用意します。
https://github.com/mima3/yakusyopdf/blob/master/camelot_ex.py

これを以下のように使用します

import camelot_ex
import camelot
import os
import cv2
import numpy

from camelot.utils import (
    TemporaryDirectory,
)

def image_proc_tate_tensen(threshold):
    el = numpy.zeros((5,5), numpy.uint8)
    el[:, 1] =1
    threshold = cv2.dilate(threshold, el, iterations=1)
    threshold = cv2.erode(threshold, el, iterations=1)
    return threshold

def image_proc_yoko_tensen(threshold):
    el = numpy.zeros((5,5), numpy.uint8)
    el[2, :] =1
    threshold = cv2.dilate(threshold, el, iterations=1)
    threshold = cv2.erode(threshold, el, iterations=1)
    return threshold

def parse(filepath, pages, password=None, suppress_stdout=False, layout_kwargs={}, **kwargs):
    handler = camelot.handlers.PDFHandler(filepath)
    handler_pages = handler._get_pages(filepath, pages)

    tables = []
    with TemporaryDirectory() as tempdir:
        for p in handler_pages:
            handler._save_page(filepath, p, tempdir)
        tmp_pages = [
            os.path.join(tempdir, "page-{0}.pdf".format(p)) for p in handler.pages
        ]
        parser = camelot_ex.LatticeEx(
            **kwargs
        )
        for p in tmp_pages:
            t = parser.extract_tables(
                p,
                suppress_stdout=suppress_stdout,
                layout_kwargs=layout_kwargs
            )
            tables.extend(t)

        return tables

# 縦の点線を含むPDF
ret = parse(
     'test.pdf', 
     'all', 
     line_scale=60,
     image_proc = image_proc_tate_tensen
)
for ix in ret[0].df.index.values:
    s = ''
    for col in ret[0].df.columns:
        s = s + ',' + ret[0].df.loc[ix][col]
    print(ix, s)

# 横の点線を含むPDF
ret = parse(
     '20200502/兵庫県.pdf', 
     '1', 
     layout_kwargs={
         'char_margin': 0.25
     },
     line_scale=60,
     copy_text=['v'],
     image_proc = image_proc_yoko_tensen
)
for ix in ret[0].df.index.values:
    s = ''
    for col in ret[0].df.columns:
        s = s + ',' + ret[0].df.loc[ix][col]
    print(ix, s)

参考

厚生労働省のPDFをCSVやJSONに変換する

目的

政府がオープンデータを叫び出して何年かが過ぎましたが、多くの政府が公開するデータはPDFベースになっています。
さすがにひと昔前のように紙をスキャンしただけのデータではなくなりましたがCSVやJSONなどの機械的に処理を行うのが楽であるとは言い難い現状です。

今回は下記のページに公開されているPDFを処理しやすい形式(CSV,JSON)に変換する実験を行います。
 

新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療について

https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/iryou/rinsyo/index_00014.html

このページのPDFは以下の性質をもっています。

  • 文字情報をもっている。(画像ではない)
  • 日々更新される可能性がある
  • 県によってはフォーマットが異なる可能性がある

実験環境

  • Windows10 64bit
  • Python 3.7.5 (tags/v3.7.5:5c02a39a0b, Oct 15 2019, 00:11:34) [MSC v.1916 64 bit (AMD64)]
  • Java8
  • Office365

PDFからテーブルを取得する方法

まずPDFからデータを取得する方法を考えてみます。
前述した実験環境においてPDFからテーブルの情報を取得するには3つの方法があります。

  • PDFをWORDに変換してからテーブルを取得する
  • tabulaを使用する
  • camelotを使用する

PDFをWORDに変換してからテーブルを取得する

PDFをWORDで開いたのち、WORDのテーブルをExcelに張り付けることで処理しやすい形式に変換することが可能です。

この方法は、お手軽で少ない少ない件数のPDFを処理する場合において有効ですが、失敗するケースがあります。

たとえば、下記のPDFをダウンロードしてWORDで開いてみます。
https://www.mhlw.go.jp/content/000625002.pdf

下記のようなエラーが発生します。

このPDFを開くときに問題が発生しました。Wordによってサポートされている最大ページ数を超えています。

このエラーメッセージはPDFの用紙サイズがWord文書の最大用紙サイズである558.7mm×558.7mmを超えている場合に発生します。

tabulaを使用する

tabula-javaはJavaで実装されており、PDFからテーブルの情報を抜き出すことが可能です。また、tabula-pyを使用することでPythonからtabula-javaを使用することが可能になります。

GUIでtabulaを使用する。

まずJava7以降の実行環境を構築してください。

次に下記のページから環境にあったファイルをダウンロードしてください。今回はWindowsで実行します。
https://tabula.technology/

解凍したexeを起動することでwebサーバーが起動して、ブラウザ経由でPDFの解析処理が可能になります。

ただし、exeを普通に起動するだけでは以下のようなエラーが発生します。

(ArgumentError) invalid byte sequence in Windows-31J

そのため以下のようなバッチファイルを記述して、バッチファイル経由で起動する必要があります。

set RUBYOPT=-EUTF-8
tabula.exe

あとは、ブラウザを経由してPDFをアップロードし、PDF中のテーブルのエクスポートが可能になります。

参考:
http://deathon2legs.blogspot.com/2018/08/tabulapdf.html

Pythonでtabulaを使用する

tabula-pyを使用することでPythonでtabulaが使用できます。

tabula-pyを使用するにはPython3.5+,Java 8+の環境が必要になります。

インストール方法や必要要件は下記を参照してください。
https://tabula-py.readthedocs.io/en/latest/getting_started.html

実装例

下記のサンプルはPDFからテーブルを取得して施設名の一覧を出力するものになります。

import tabula

# 当該ページは日々更新されるため、下記のリンクから最新のURLを取得してください
# https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/kenkou_iryou/iryou/rinsyo/index_00014.html
df_list = tabula.read_pdf("https://www.mhlw.go.jp/content/000626082.pdf", pages='all', lattice=True)
for df in df_list:
    for ix in df.index.values:
        print(df.loc[ix][1])

制限

PDFの複雑さによっては、表の内容の正確さを抽出することが難しい場合があります。すくなくとも新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてで公開されているPDFのいくつは正確に抽出することはできませんでした。

camelotを使用する

camelotはPDFの表をより正確に抽出可能になっています。

camelotはghostscriptを使用しているのでWindowsの場合、以下からインストールを行う必要があります。
https://www.ghostscript.com/download/gsdnld.html

依存関係の詳細は以下を参照してください。
https://camelot-py.readthedocs.io/en/master/user/install-deps.html

インストール方法については下記を参照してください。今回はsource codeを使用してインストールしました。
https://camelot-py.readthedocs.io/en/master/user/install.html

なお、camelotはtabula-pyにくらべメモリを大量に使用する傾向があるので64bitのプロセスで実行することをお勧めします。すくなくとも東方の32bit環境では愛知県のデータは取得できませんでした。

実装例

下記のサンプルはPDFからテーブルを取得して施設名の一覧を出力するものになります。

import camelot
tables = camelot.read_pdf(
  'https://www.mhlw.go.jp/content/000626082.pdf',
  pages = 'all',      # ページによってはメモリ使用量が跳ね上がるので1ページづつ解析した方がよい
  line_scale = 30,   # 数が大きいほど細い線でも認識するようになるが、大きすぎると文字を線とみなす デフォルト15
  copy_text=['v'],      # 空のデータを取得した場合に縦方向からデータをコピーする。表を結合している場合に有効
  layout_kwargs = {
    'char_margin': 0.25 # 2つの文字がこのマージンよりも接近している場合、それらは同じ行の一部と見なされます。 デフォルト:2
  }
)
for tbl in tables:
  for ix in tbl.df.index.values:
    print(tbl.df.loc[ix][1])

各方法の比較

PDFからテーブルを抽出する方法として以下の3つを比較します。

  • PDFをWORDに変換してからテーブルを取得する
  • tabulaを使用する
  • camelotを使用する

PDFをWORDに変換してからテーブルを取得する方法は、お手軽に行えるメリットがありますが、今回使用するには問題があります。
まず、当該PDFは日々更新されています。そのため手動で行う処理が辛いです。仮にこの問題をマクロ等でカバーできたとしても、WORDの最大用紙サイズの制限があるため、完全に今回のデータを処理するには適さない方法です。

tabulaとcamelotについては精度の話を抜きにした場合、今回のPDFを機械的に読み取ることが可能です。
処理速度と使用メモリについてはtabulaが有利です。
逆に精度についてはcamelotが有利です。
camelotはいくつかのパラメータでチューニングが可能になっています。

主なパラメータの説明
https://camelot-py.readthedocs.io/en/master/api.html#main-interface

camelotはPDFMinerの機能を元に作成されており、PDFMinerで調整できる項目についてはlayout_kwargsのパラメータで指定できます。この項目についてはPDFMinerのドキュメントで確認できます。

https://euske.github.io/pdfminer/

(2020/05/03追記)
Camelotでは点線を含むPDFの処理が上手く動作しません。この対応については以下の通りになります。
https://needtec.sakura.ne.jp/wod07672/2020/05/03/camelot%e3%81%a7%e7%82%b9%e7%b7%9a%e3%82%92%e5%ae%9f%e7%b7%9a%e3%81%a8%e3%81%97%e3%81%a6%e5%87%a6%e7%90%86%e3%81%99%e3%82%8b/

「新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療について」のページを解析する

ここまででPDFからテーブル情報を取得する基本的な方法について説明しました。
しかしながら、実際、抽出するにはいくつか問題点があります。

問題点

対応医療機関リストのPDFは日々更新され、そのURLも変わる

対応医療機関リストのPDFは日々更新されますが、そのURLは更新のたびに変更されるようです。
例えば東京都のURLは以下のようになります。

2020/04/28時点
https://www.mhlw.go.jp/content/000625693.pdf

2020/04/29時点
https://www.mhlw.go.jp/content/000626106.pdf

このため、PDFのURLは新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてのリンクから取得する必要があります。

凡例の有無

データの1行目に凡例が入っている場合とない場合があります。
東京都の場合は凡例がありますが、北海道の場合はありません。

つまり県ごとに1ページ目のデータ行の取得位置を調整する必要があります。

各県ごとにページヘッダの取り扱いが異なる。

たとえば、北海道と茨城県のPDFを比較してみてください。
北海道は2ページ目以降にヘッダが含まれていますが、茨城県には含まれません。

つまり県ごとに2ページ目以降のデータ行の取得位置を調整する必要があります。

各県ごとに項目の取り扱いが異なる。

たとえば、北海道と愛知県を比較してみてください。
愛知県には所管保健所の列がありますが、北海道には存在しません。

つまり県ごとに必要な列の位置を調整する必要があります。

複数ページにまたがるデータ

以下は2020/04/28時点の奈良県のPDFです。
https://github.com/mima3/yakusyopdf/blob/master/20200428100754/%E5%A5%88%E8%89%AF%E7%9C%8C.pdf

ここで2ページと3ページ目にまたがっているデータが存在することが分かります。
施設名などは3ページ目にあります。

以下は同日の鳥取県のPDFです。
https://github.com/mima3/yakusyopdf/blob/master/20200428100754/%E9%B3%A5%E5%8F%96%E7%9C%8C.pdf

ここで6ページと7ページ目にまたがっているデータが存在することが分かります。
施設名などは6ページ目にあります。

つまり、施設名などが空の行を発見した場合に、改ページによって分割されている可能性を考慮する必要があります。

長い文字を含むデータ

たとえば以下のようにセルからはみ出るようなデータが存在する場合、結合された情報として抽出される可能性があります。

これを完全に回避する方法はなく、郵便番号の形式や電話番号の形式に当たらない場合は結合されているとみなして分割するなどの次善策を行う必要があります。

問題点を踏まえた上での処理

以下のようになります。
https://github.com/mima3/yakusyopdf

使用方法

# 新型コロナウイルス感染症の感染拡大を踏まえたオンライン診療についてのページからPDFをダウンロードしてフォルダに保存する
python download_pdf.py

# 作成したフォルダを指定して、PDFの内容からJSONとCSVを作成する
python analyze_pdf.py 20200428100754

結果
https://github.com/mima3/yakusyopdf/tree/master/20200428100754

まとめ

ある程度まではPDFからCSVやJSONなどに変換することはできました。

しかしながら、完璧に行うことは不可能です。
たとえば、県ごとに細かい設定とかを行えるようにはしましたが、これはあくまで現時点のものであり、PDFの書き方が急に変ったりします。

このため官公庁のPDFを解析する場合は、解析した時点の日付を明示する必要があり、また、フォーマットが変わった場合の検知が肝要と思われます。

はてなブックマークのREST APIを利用する

目的

はてなブックマークをREST API経由で登録してみます。
環境は以下の通りです。
・windows 10
・python 3.7.4

はてなブックマークのREST APIについては下記を参照してください。
http://developer.hatena.ne.jp/ja/documents/bookmark/apis/rest#authorization

事前準備

pythonを使用してはてなブックマークをREST API経由で登録するにはOAuth認証をする必要があります。
ここではOAuth認証を行うための事前準備について説明します。

Consumer Keyの取得

(1)Consumer Keyを取得するため、OAuth 開発者向け設定ページにアクセスします。この際、はてなのユーザーIDに紐づくパスワードの入力が要求されます。

(2)「新しいアプリケーション」の作成ボタンを押します。

(3)下記を参考に必要な項目を変更し、Consumer KeyとConsumer Secretをメモしておきます。

・アプリケーションの名称、説明、URLを変更します。スクリプトで使用するのでURLは127.0.0.1になっています。

・Consumer KeyとConsumer Secretをメモします。この値は外部に漏れないようにしましょう。

・必要な権限を割り当てます。はてなブックマークを登録するだけなら「write_public」が必要になります。

・「変更する」ボタンを押下します。
・「提供アプリケーション一覧に戻る」をクリックすると、新しいアプリケーションが追加されています。
※どうも削除する方法はないようで、使わなくなったら「無効化」するようです。

requests-oauthlibのインストール

OAuth認証を自前で実装するのは辛いのでrequests-oauthlibをインストールします。
https://github.com/requests/requests-oauthlib

はてなブックマークの登録

はてなブックマークを登録するためのサンプルコード

https://github.com/mima3/qiita_exporter/blob/master/hatena_api.py

"""はてなREST APIを操作する.

はてなのRESTAPIについては下記を参照してください。
http://developer.hatena.com/ja/documents/bookmark/apis/rest

また、OAuthの認証方法については下記のページを参考にしています.

"""

import requests
# https://github.com/requests/requests-oauthlib
from requests_oauthlib import OAuth1Session

class HatenaApiError(Exception):
    """HatenaApiErrorのエラーが発生したことを知らせる例外クラス"""

class HatenaApi:
    """はてなAPIを操作するクラス."""
    LOGIN_URL = 'https://www.hatena.ne.jp/login'
    session = None

    def _get_rk(self, hatena_id, password):
        """はてなIDとログインパスワードからrkを取得します。
        以下のコードを利用しています
        
        https://github.com/iruca/hatena-oauth-python/blob/master/get_access_token_util.py

        Args:
            hatena_id:  はてなID文字列
            password: はてなIDに対応するはてなログイン用のパスワード
        Returns:
            rk文字列
        Raises:
            HatenaApiError: rk文字列が取得できなかったとき。ID/パスワードが間違っているか、rkを取得するためのはてなAPIの仕様が変わった
        """
        payload = {'name': hatena_id, 'password': password}
        response = requests.post(self.LOGIN_URL, data=payload)
        if not "Set-Cookie" in response.headers:
            raise HatenaApiError("cannot get rk.ID/Password is wrong, or Hatena API spec changed.")
        if not "rk=" in response.headers['Set-Cookie']:
            raise HatenaApiError("cannot get rk.ID/Password is wrong, or Hatena API spec changed.")
        rk = response.headers["Set-Cookie"].split("rk=")[1].split(";")[0]
        return rk

    def _get_authorization_redirect_url(self, user_id, password, authorization_url):
        """authorization_urlにアクセスしてアプリケーションの許可を行う."""
        rk = self._get_rk(user_id, password)
        res_auth = requests.get(authorization_url, headers={"Cookie" : "rk="+ rk}).text
        rkm = res_auth.split("<input type=\"hidden\" name=\"rkm\" value=\"")[1].split("\"")[0]
        oauth_token = res_auth.split("<input type=\"hidden\" name=\"oauth_token\" value=\"")[1].split("\"")[0]
        res_redirect = requests.post(
            authorization_url,
            headers={"Cookie": "rk="+ rk},
            params={
                "rkm": rkm,
                "oauth_token": oauth_token,
                "name": "%E8%A8%B1%E5%8F%AF%E3%81%99%E3%82%8B"
            }
        )
        return res_redirect.url

    def authorize(self, user_id, password, consumer_key, consumer_secret, scope):
        """OAuth認証を行う.

        Args:
            hatena_id:  はてなID文字列
            password: はてなIDに対応するはてなログイン用のパスワード
            consumer_key: 「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_key
            consumer_secret:「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_secret
            scope : 取得権限. ex "read_public,write_public"
        """
        self.session = OAuth1Session(
            consumer_key,
            consumer_secret,
            callback_uri="http://localhost/hogehoge"  # このURLは実際にアクセスできる必要はありません.
        )
        self.session.fetch_request_token(
            "https://www.hatena.com/oauth/initiate?scope={}".format(scope)
        )
        authorization_url = self.session.authorization_url("https://www.hatena.ne.jp/oauth/authorize")
        redirect_response = self._get_authorization_redirect_url(
            user_id,
            password,
            authorization_url
        )
        self.session.parse_authorization_response(redirect_response)
        self.session.fetch_access_token("https://www.hatena.com/oauth/token")

    def add_bookmark(self, url, comment="", tags=None):
        """はてなブックマークの追加または更新を行う.
        このメソッドを実行する前に、authorizeを呼び出す必要がある.

        Args:
            url:  ブックマーク対象のURL
            comment: コメント
            tags: タグの一覧
        """
        if not self.session:
            raise HatenaApiError("call authorize method.")
        if tags:
            for t in tags:
                comment += "[{}]".format(t)
        return self.session.post(
            "https://bookmark.hatenaapis.com/rest/1/my/bookmark?url=",
            params={
                "url": url,
                "comment" : comment,
            }
        )

上記のクラスは以下のように使用します。

    api = HatenaApi()
    api.authorize("はてなのユーザ名", "パスワード", "「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_key", "「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_secret", "write_public")
    res = api.add_bookmark("http://developer.hatena.com/ja/documents", "test!", [])
    res.raise_for_status()
    print(res.text)

解説

OAuth認証を行ったあとに,はてなブックマーク用のAPIを実行するだけですが、WebアプリケーションではなくスクリプトだけでOAuth認証を行うのは結構面倒くさいです。

理由はOAuth認証の途中で以下のような画面を表示してユーザが該当のアプリケーションを許可するプロセスが入るためです。

この問題は以下のページにて解決策が書かれています。

はてなのOAuth API用のアクセストークンを簡単に取得する [python]
https://www.iruca21.com/entry/2017/05/24/090000

簡単にいうと、ユーザーが画面で許可するボタンを押したのと同じ動きを行っています。先のコードでいうと_get_authorization_redirect_urlが、その動きに当たります。

また、python3xで動くようにするのと、できるだけ処理をrequests_oauthlibで行うように直してあります。