LoginSignup
1
2

More than 3 years have passed since last update.

Python+Reactでレンズ検索データベースを構築した時の技術的な話

Posted at

概要

この度、交換用レンズの情報について、条件を指定して検索できるツールを開発・公開しました。

今回は、その際に工夫したことについてのまとめです。

スクレイピング用ライブラリは適宜ラップした

今回のWebアプリでは、レンズについての情報はJSONファイルとして運用していました。
ただ、各レンズの情報を全て手打ちしたわけではありません。
PythonでWebサイトをスクレイピングし、結果をJSONファイルに保存して、フロントエンド側で読み込ませていました。

……その際に使ったライブラリは、requests-HTMLです。と言っても、そのまま使うのではなく、別途クラスを作成してそちらに処理をまとめています。

from typing import List, MutableMapping, Optional
from requests_html import BaseParser, Element

class DomObject:
    """DOMオブジェクト"""

    def __init__(self, base_parser: BaseParser):
        self.base_parser = base_parser

    def find(self, query: str) -> Optional['DomObject']:
        temp = self.base_parser.find(query, first=True)
        if temp is None:
            return None
        return DomObject(temp)

    def find_all(self, query: str) -> List['DomObject']:
        return [DomObject(x) for x in self.base_parser.find(query)]

    @property
    def text(self) -> str:
        return self.base_parser.text

    @property
    def full_text(self) -> str:
        return self.base_parser.full_text

    # noinspection PyTypeChecker
    @property
    def attrs(self) -> MutableMapping:
        temp: Element = self.base_parser
        return temp.attrs

なぜかと言うと、素のままだと、PyCharm上で自動型推論がちゃんと効かないことがあったからです。
また、将来的にスクレイピング用ライブラリを差し替えたくなっても、ここだけ書き換えればOKという安心もあります。

さらに、Webサイトからデータを取得する部分についても、データベースと連携させてキャッシュする機構を組み込みました。
これにより、無駄なWebアクセスを避け、サーバーへの負荷を極限まで減らしています。
(IDataBaseServiceは自作クラス。詳細は書かないが、データベース操作をラップしたもの)

class ScrapingService:
    """スクレイピング用のラッパークラス"""

    def __init__(self, database: IDataBaseService):
        self.session = HTMLSession()
        self.database = database
        self.database.query('CREATE TABLE IF NOT EXISTS page_cache (url TEXT PRIMARY KEY, text TEXT)')

    def get_page(self, url: str) -> DomObject:
        cache_data = self.database.select('SELECT text from page_cache WHERE url=?', (url,))
        if len(cache_data) == 0:
            temp: HTML = self.session.get(url).html
            time.sleep(5)
            print(f'caching... [{url}]')
            self.database.query('INSERT INTO page_cache (url, text) VALUES (?, ?)',
                                (url, temp.raw_html.decode(temp.encoding)))
            return DomObject(temp)
        else:
            return DomObject(HTML(html=cache_data[0]['text']))

正規表現処理についてもラップした

プログラミング言語により、正規表現の有無・操作方法は様々です。Pythonについてもこの点は変わりません。
ただ、素の状態だとちょっと冗長になるなーってことがあるので、よくラップして運用しています。

def regex(text: str, pattern: str) -> List[str]:
    """グループ入り正規表現にマッチさせて、ヒットした場合はそれぞれの文字列の配列、そうでない場合は空配列を返す"""
    output: List[str] = []
    for m in re.finditer(pattern, text, re.MULTILINE):
        for x in m.groups():
            output.append(x)
    return output

これにより、例えば「regex('24~70mm', r'(\d+)mm~(\d+)mm')」と書いた場合、戻り値が「['24', '70']」となって扱いやすくなります。
また、「そのパターンとマッチしない=配列の要素数が0件である」ということなので、条件分岐も効率よく記述できます。

# 記述例
# ※Qiitaのソース埋め込みが壊れているので、「\d」と書くと自動色分けが正常に動作しない
# ※そのため意図的に「\\d」と記している。適宜読み替えること

# 35mm判換算焦点距離
result1 = regex(record['35mm判換算焦点距離'], r'(\\d+)mm~(\\d+)mm')
result2 = regex(record['35mm判換算焦点距離'], r'(\\d+)mm')
if len(result1) > 0:
    wide_focal_length = int(result1[0])
    telephoto_focal_length = int(result1[1])
else:
    wide_focal_length = int(result2[0])
    telephoto_focal_length = wide_focal_length

dataclassesは積極的に活用した

dataclassesとは、Python3.7から登場した、データクラスを手軽に作成できる仕組みのことです。今回も次のように、レンズ情報を記録するためのクラスとして活用しました。

@dataclass
class Lens:
    id: int = 0
    maker: str = ''
    name: str = ''
    product_number: str = ''
    wide_focal_length: int = 0
    telephoto_focal_length: int = 0
    wide_f_number: float = 0
    telephoto_f_number: float = 0
    wide_min_focus_distance: float = 0
    telephoto_min_focus_distance: float = 0
    max_photographing_magnification: float = 0
    filter_diameter: float = 0
    is_drip_proof: bool = False
    has_image_stabilization: bool = False
    is_inner_zoom: bool = False
    overall_diameter: float = 0
    overall_length: float = 0
    weight: float = 0
    price: int = 0
    mount: str = ''

また、dataclassesだけだとJSONデータにシリアライズする処理が面倒なので、dataclasses-jsonを追加導入して対処しています。

フィルター処理における抽象化

当Webアプリでは、検索条件を追加すると、即座に画面下のレンズ一覧が書き換わる仕組みです。
この際、レンズ情報を各種条件でフィルターする処理が挟まっているのですが、フィルター処理をどう記述しようか迷いました。

例えば、真っ先に思いつくのは次のようなコードでしょう。

// サンプルのフィルター設定
const filterList = [{'type': 'MaxWideFocalLength', 'value': 24, 'type': 'MinTelephotoFocalLength', 'value': 70}];

// フィルター処理
let temp = [...lensList];
for (const filter of filterList) {
  // switchで種類ごとに分岐
  switch (filter.type) {
    case 'MaxWideFocalLength':
      temp = temp.filter(lens => lens.wide_focal_length <= filter.value);
      break;
    case 'MinTelephotoFocalLength':
      temp = temp.filter(lens => lens.telephoto_focal_length >= filter.value);
      break;
  }
}

ただ、これだとフィルターの種類を増やすたびに、switch文がズラズラと連なることになります。可読性が悪い
そこで、「フィルター処理を行う機構」をクラスにラップすることで解決を見ました。
また、「フィルター処理を行う機構」と「フィルターのパラメーター」を分離することで、前者の複雑度を軽減しています。

※実際のコードでは、QueryType型は他にもプロパティを生やしています

// 「フィルター処理」を表現するための抽象クラス
abstract class QueryType {
  // フィルタ処理
  abstract filter(lensList: Lens[], value: number): Lens[];
}

// 個別のフィルター処理についての具象クラス
class MaxWideFocalLength implements QueryType {
  filter(lensList: Lens[], value: number): Lens[] {
    return lensList.filter(lens => lens.wide_focal_length <= value);
  }
}

class MinTelephotoFocalLength implements QueryType {
  filter(lensList: Lens[], value: number): Lens[] {
    return lensList.filter(lens => lens.telephoto_focal_length >= value);
  }
}

// 「1つのフィルター」を表現するためのインターフェース
interface Query {
  type: QueryType;
  value: number;
}

// サンプルのフィルター設定
const queryList: Query[] = [{'type': new MaxWideFocalLength(), 'value': 24, 'type': new MinTelephotoFocalLength(), 'value': 70}];

// フィルター処理
let temp = [...lensList];
for (const query of queryList) {
  temp = query.type.filter(temp, query.value);
}

抽象化の副次的作用

上記のQueryTypeですが、実際のコードではより多くのプロパティが生えています。

abstract class QueryType {
  // 型名
  abstract readonly name: string = '';
  // 数値部分の「手前」に表示するMessage
  abstract readonly prefixMessage: string;
  // 数値部分の「後」に表示するMessage
  abstract readonly suffixMessage: string;

  // フィルタ処理
  abstract filter(lensList: Lens[], value: number): Lens[];
}

これにより、例えばMaxWideFocalLengthは次のような定義になっています。

class MaxWideFocalLength implements QueryType {
  readonly name: string = 'MaxWideFocalLength';
  readonly prefixMessage: string = '広角端の換算焦点距離が';
  readonly suffixMessage: string = 'mm 以下';

  filter(lensList: Lens[], value: number): Lens[] {
    return lensList.filter(lens => lens.wide_focal_length <= value);
  }
}

こうした定義なのは、このアプリの性質上、「使用できるフィルターの一覧」を表示する需要があるからです。


<select>内に<option>を並べる場合、Reactだと次のように実装される方が多いと思います。

const queryTypeList = [
  {type: 'MaxWideFocalLength', prefixMessage: '広角端の換算焦点距離が'},
  {type: 'MaxWideFocalLength', prefixMessage: '望遠端の換算焦点距離が'}];

return (
  <select>
    {queryTypeList.map(q => <option key={q.type} value={q.type}>{q.prefixMessage}</option>)}
  <select>
);

何も間違ってはいないのですが、このまま実装すると、<select>された値から、MaxWideFocalLengthなどの(QueryTypeを継承した型)を生成する際にswitch文を使うことになってしまいます。これでは先ほど頑張って排除した意味がありません。

そこで、型ごとに使いたいプロパティを埋め込んでおきます。
すると、<select>された値をqueryTypeとした際、queryTypeList.filter(q => q.name === queryType)[0]とするだけで、所望の(QueryTypeを継承した)型のインスタンスを取得できます。switch文なんて要らんかったんや!

※この、「クエリの種類のインスタンス(フィルター処理を行う機構)を使い回せる」点が、「フィルター処理を行う機構」と「フィルターのパラメーター」を分離したご利益とも言えます

const queryTypeList = [
  new MaxWideFocalLength(),
  new MaxWideFocalLength()
];

return (
  <select>
    {queryTypeList.map(q => <option key={q.name} value={q.name}>{q.prefixMessage}</option>)}
  <select>
);
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2