Flat UI ColorsのWebサイトをスクレイピングしてRGB値を抽出してみよう

# はじめに

僕は普段アプリの配色を考えるときに[Flat UI Colors (https://flatuicolors.com/)](https://flatuicolors.com/)を参考にしています。このサイトの配色に従うことで、色の選択に悩むことが少なくなり、整った印象を持つアプリが作れるようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/ca84a0ca-f43c-d2d8-c33b-0e66bd4f927e.png" alt="Defo.png" width="55%" style="min-width: 300px;" />

このサイトはワンクリックでHEX値やRGB値などをコピーできるように作られていて便利なのですが、それでも毎回サイトからコピーするのはわりと面倒です。

*「エンジニアならもっと楽したい…。ならば、Flat UI ColorsをスクレイピングしてRGB値を抽出し、ソースコードファイルに変換してしまおう」*

という発想です。

この記事で紹介するソースコードは[GitHub (https://github.com/HituziANDO/flat_ui_colors)](https://github.com/HituziANDO/flat_ui_colors)にプッシュしていますので、あわせてご参照ください。

# スクレイピングの成果物

まず最初に、スクレイピングの成果物は以下のようなRGB値を列挙したソースコードです。以下は成果物の一つのJavaScriptコードの一部です。完全版は[こちら](https://github.com/HituziANDO/flat_ui_colors/blob/main/dist/flat_ui_colors.js)にあります。他にも[TypeScript](https://github.com/HituziANDO/flat_ui_colors/blob/main/dist/FlatUIColors.ts)、[SCSS](https://github.com/HituziANDO/flat_ui_colors/blob/main/dist/flat_ui_colors.scss)、[Swift](https://github.com/HituziANDO/flat_ui_colors/blob/main/dist/FlatUIColors.swift)に対応しています。

```javascript:flat_ui_colors.jsの一部
/**
 * Flat UI Palette v1 by Flat UI Colors
 * https://flatuicolors.com/palette/defo
 */
const Defo = {
    turquoise: rgb(26, 188, 156),
    emerald: rgb(46, 204, 113),
    peterRiver: rgb(52, 152, 219),
    amethyst: rgb(155, 89, 182),
    wetAsphalt: rgb(52, 73, 94),
    greenSea: rgb(22, 160, 133),
    nephritis: rgb(39, 174, 96),
    belizeHole: rgb(41, 128, 185),
    wisteria: rgb(142, 68, 173),
    midnightBlue: rgb(44, 62, 80),
    sunFlower: rgb(241, 196, 15),
    carrot: rgb(230, 126, 34),
    alizarin: rgb(231, 76, 60),
    clouds: rgb(236, 240, 241),
    concrete: rgb(149, 165, 166),
    orange: rgb(243, 156, 18),
    pumpkin: rgb(211, 84, 0),
    pomegranate: rgb(192, 57, 43),
    silver: rgb(189, 195, 199),
    asbestos: rgb(127, 140, 141),
}

const FlatUIColors = {
    Defo,
};
```

# ディレクトリ構成

プロジェクトのディレクトリ構成は以下の通りです。

```
L app/
  L __init__.py
  L main.py
  L flat_ui_colors_parser.py
  L writer/
    L __init__.py
    L js_writer.py
    L scss_writer.py
    L swift_writer.py
    L ts_writer.py
  L util/
    L __init__.py
    L string_util.py
L dist/
L tmp/screenshots/
L requirements.txt
L README.md
```

appディレクトリがPythonコードを格納しているところになり、main.pyがエントリポイントとなります。ターミナルで`python app/main.py`と打つことでスクレイピングを開始できます。distとtmpディレクトリはプログラムを実行することで生成され、スクレイピングの結果が出力されます。

flat_ui_colors_parser.pyがスクレイピングおよびHTMLパーサの実装になります。writerディレクトリには、スクレイピングで得たデータを各プログラミング言語のソースコードに変換するWriterが実装されています。

プロジェクトのセットアップに関しては、[README.md](https://github.com/HituziANDO/flat_ui_colors/blob/main/README.md)を読んでください。

# プログラムの解説

このプログラムは主に`Main`、`Parser`、`Writer`の3つのパートからなります。

## Main

エントリポイントとなるmain.pyはシンプルです。以下の処理をしています。

1. Writerの初期化
1. WebサイトのURLをfor文で順番にFlatUiColorsParserに渡し実行
1. Writerの終了処理および出力

```python:main.py
import os
from flat_ui_colors_parser import FlatUiColorsParser
from writer.js_writer import JsWriter
from writer.scss_writer import ScssWriter
from writer.swift_writer import SwiftWriter
from writer.ts_writer import TsWriter

if __name__ == '__main__':
    # Writerの初期化
    writers = [SwiftWriter(), JsWriter(), TsWriter(), ScssWriter()]

    urls = [
        "https://flatuicolors.com/palette/defo",
        "https://flatuicolors.com/palette/us",
        "https://flatuicolors.com/palette/au",
        "https://flatuicolors.com/palette/gb",
        "https://flatuicolors.com/palette/ca",
        "https://flatuicolors.com/palette/cn",
        "https://flatuicolors.com/palette/nl",
        "https://flatuicolors.com/palette/fr",
        "https://flatuicolors.com/palette/de",
        "https://flatuicolors.com/palette/in",
        "https://flatuicolors.com/palette/ru",
        "https://flatuicolors.com/palette/es",
        "https://flatuicolors.com/palette/se",
        "https://flatuicolors.com/palette/tr"
    ]
    for url in urls:
        FlatUiColorsParser.exec(url, writers)

    # Writerの終了処理
    for writer in writers:
        writer.finish()

    # distディレクトリがない場合は作成
    DIST_DIR = "dist"
    os.makedirs(DIST_DIR, exist_ok=True)

    # 結果の出力
    for writer in writers:
        f = open(f"{DIST_DIR}/{writer.filename()}", "w", encoding="UTF-8")
        f.write(writer.source_code())
        f.close()
```

## Parser

次にFlatUiColorsParserクラスを見ていきます。このクラスはスクレイピングとHTML解析を担当しています。

```python:flat_ui_colors_parser.py
import os
import re
import time
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver import Chrome
from util.string_util import to_camel


class FlatUiColorsParser:
    @classmethod
    def exec(cls, url, writers):
        # URLからリスト名を抽出
        # 例) https://flatuicolors.com/palette/defo -> Defo
        list_name = to_camel(url.rsplit('/', 1)[-1], True)

        # ヘッドレスモードでChromeを動かす
        opts = webdriver.ChromeOptions()
        opts.add_argument("--headless")
        opts.add_argument("--no-sandbox")
        driver = Chrome(executable_path="/usr/local/bin/chromedriver", options=opts)

        # サイトを取得
        driver.get(url)
        # Flat UI ColorsのサイトはVue.jsで画面が作られるため、
        # ページ取得およびJS実行が終わるまで少し待機
        time.sleep(4)

        # HTMLを取得
        html = driver.page_source

        # 色見本用にサイトのスクリーンショットを撮っておく
        os.makedirs("tmp/screenshots", exist_ok=True)
        driver.save_screenshot(f"tmp/screenshots/{list_name}.png")

        # webdriverの終了
        driver.quit()

        # HTML解析にBeautifulSoup4を使用
        soup = BeautifulSoup(html, 'html.parser')

        # このカラーパレットを作ったデザイナー名を抽出
        authors = []
        elements = soup.select(".author")
        for element in elements:
            author = element.text.replace("\n", "").strip()
            authors.append(author)

        # RGB情報を抽出
        colors = []
        # class="color"の要素をすべて取得
        elements = soup.select(".color")
        for element in elements:
            # 色名を取得。余計な文字は除去し、キャメルケースに変換
            color_name = element.text \
                .replace('\'', '') \
                .replace('-', ' ') \
                .replace('!', '') \
                .replace('?', '') \
                .replace('=', '') \
                .strip()
            color_name = to_camel(color_name, False)

            # 色名は後で変数名に使うため、
            # 数字から始まる色名は先頭に'_'を付ける
            if re.match(r'^\d.*$', color_name):
                color_name = '_' + color_name

            # アルファベット以外の文字を置換
            color_name = color_name \
                .replace('á', 'a') \
                .replace('ó', 'o') \
                .replace('è', 'e') \
                .replace('ā', 'a') \
                .replace('â', 'a')

            # 要素のstyle属性に"background: rgb(r, g, b);"の形式でRGB値が入っているので、
            # rgb(r, g, b)だけを抽出
            color = element.get("style").replace('background: ', '').replace(';', '').strip()

            colors.append((color_name, color))

        # Writerに抽出した情報を渡す
        for writer in writers:
            writer.add_color_list(url, authors, list_name, colors)
```

## Writer

最後にWriterを見ていきます。ここではJsWriterクラスを使って説明します。このクラスはFlatUIColorsParserクラスの解析結果を受け取って、JavaScriptのソースコードに変換する役目を担っています。

```python:js_writer.py
class JsWriter:
    """
    JavaScript code writer.
    """

    def __init__(self, indent="    "):
        self.__list_names = []
        self.__indent = indent
        # JavaScriptコード
        # RGB値を扱う基本クラスやユーティリティ関数を定義
        self.__source_code = """export class FlatUIColor {
    /**
     * @param {number} red Red. The range of its value is 0 to 255.
     * @param {number} green Green. The range of its value is 0 to 255.
     * @param {number} blue Blue. The range of its value is 0 to 255.
     */
    constructor(red, green, blue) {
        this.red = red;
        this.green = green;
        this.blue = blue;
    }

    /**
     * Returns rgb(r, g, b).
     */
    rgbAsCSS() {
        return `rgb(${this.red}, ${this.green}, ${this.blue})`;
    }

    /**
     * Returns rgba(r, g, b, a).
     * @param {number} alpha Alpha. The range of its value is 0 to 1.0.
     */
    rgbaAsCSS(alpha) {
        return `rgba(${this.red}, ${this.green}, ${this.blue}, ${alpha})`;
    }
}

const rgb = (r, g, b) => new FlatUIColor(r, g, b);\n\n"""

    def filename(self):
        # 出力されるファイル名
        return "flat_ui_colors.js"

    def source_code(self):
        # JsWriterで書き込まれたJSコード
        return self.__source_code

    def add_color_list(self, url, authors, list_name, colors):
        """
        :param url: URL of the color list page.
        :param authors: Authors.
        :param list_name: The color list name.
        :param colors: (color_name, color)[]. color_name: A color name. color: 'rgb(r, g, b)'
        """

        # コメントの追加
        # このカラーパレットを作ってくれたデザイナーに敬意を払ってauthorは書きましょう
        self.__source_code += "/**\n"
        for author in authors:
            self.__source_code += f" * {author}\n"
        self.__source_code += f" * {url}\n"
        self.__source_code += " */\n"

        # begin object {list_name}
        self.__source_code += f"const {list_name} = {{\n"

        # Add colors
        for color in colors:
            self.__add_color(color[0], color[1])

        self.__source_code += "\n"

        # begin array
        self.__source_code += f"{self.__indent}palette: [\n"
        for color in colors:
            self.__source_code += f"{self.__indent}{self.__indent}{list_name}.{color[0]},\n"
        # end array
        self.__source_code += f"{self.__indent}]\n"

        # end object {list_name}
        self.__source_code += "};\n\n"

        self.__list_names.append(list_name)

    def finish(self):
        # begin export default object
        self.__source_code += "const FlatUIColors = {\n"

        for name in self.__list_names:
            self.__source_code += f"{self.__indent}{name},\n"

        # end export default object
        self.__source_code += "};\n"
        self.__source_code += "export default FlatUIColors;\n"

    def __add_color(self, color_name, color):
        """
        :param color_name: A color name.
        :param color: rgb(r, g, b)
        """

        self.__source_code += f"{self.__indent}{color_name}: {color},\n"
```

見ての通りFlatUIColorsParserから受け取った情報をもとに完成形をイメージしてJavaScriptコードを作っていきます。このJsWriterで出力されるソースコードは、スクレイピングの成果物で紹介したものになります。

Writerは、`filename`、`source_code`、`add_color_list`、`finish`メソッドを実装しておけば、ダックタイピングにより様々なWriterを動かすことができます。つまり、このプロジェクトに用意されていないプログラミング言語でも、他のWriterを参考に書き、main.pyに追加すれば、同様に出力を得ることができます。

# おわりに

上記のJsWriterで出力されたflat_ui_colors.jsは、以下のように使うことができます。僕は、CSS in JSで使うことが多いため、`rgbAsCSS`というメソッドを生やしています。

```javascript:JavaScript
const color = FlatUIColors.Gb.protossPylon.rgbAsCSS()
console.log(color)  // -> "rgb(0, 168, 255)"
```

最後に、スクレイピング・プログラムを回しまくるのは控えましょう。成果物も[GitHub](https://github.com/HituziANDO/flat_ui_colors/tree/main/dist)にプッシュしてあるので、皆さんはここからコピーして使ってください。もし、使っているプログラミング言語のソースコードファイルが無いときは、Writerを書いてプルリクくだされば有り難いです。