Python でハッシュタグ機能を実装

# はじめに

**ハッシュタグ**は、SNS などで広く利用される、話題やカテゴリを示すキーワードです。

本記事では、Unicode Standard で規定されている[ハッシュタグの仕様](https://unicode.org/reports/tr31/#hashtag_identifiers) を元に、ハッシュタグ機能の実装します。この仕様はハッシュタグの共通的な処理を促進する一方で、実装の自由度も一定程度確保しています。

# ハッシュタグの仕様

ハッシュタグの仕様について簡単に説明します。

```:ハッシュタグの構文
<Hashtag-Identifier> := <Start> <Continue>* (<Medial> <Continue>+)*


`:=` : 右辺が左辺の構文を定義
`<>` : 定義された構文
`*`  : 直前の構文が 0 回以上繰り返されることを示す
`+`  : 直前の構文が 1 回以上繰り返されることを示す
`()` : グループ化を表す

Start    : ハッシュタグの開始文字
Continue : 続く文字
Medial   : オプションで使用できる文字
```

[ハッシュタグの仕様](https://unicode.org/reports/tr31/#hashtag_identifiers)には、さまざまなバリエーションが書かれていますが、今回は以下のようにいします。

- `Start` 文字は `#`
- `Continue` 文字には `XID_Continue` プロパティに `Extended_Pictographic` プロパティ、`Emoji_Component` プロパティ、`_`、`-`、`+` を加えたものを含め、`#` を除外
- `Medial` は空

ハッシュタグを解析する際には、**`Start` 文字の前に `Continue` 文字がない場合にのみハッシュタグとして認識**することが推奨されます。例えば、`foo#bar` ではハッシュタグは認識されませんが、`foo #bar` や `foo.#bar` ではハッシュタグとして認識されます。

また、比較やマッチングをする際には **`NFKC_CF` 形式**へ変換した後に行う必要があります。例えば、`#MötleyCrüe` は `#MÖTLEYCRÜE` と一致する必要があります。

ハッシュタグの仕様について詳しくは以下の記事で説明しています。

https://qiita.com/shikuno_dev/items/1d65e43631e33d047c9f

# ハッシュタグ機能の実装

Post と Hashtag の 2 つのエンティティが多対多の関係であることを前提に実装します。

```
# Post
- ID: ユニークな識別子
- Content: 投稿のテキスト内容
- Timestamp: 投稿が作成された日時

# Hashtag
- ID: ユニークな識別子
- Tag: ハッシュタグテキスト（`#` から始まる）

# PostHashtag
- PostID: 関連する投稿のID (外部キー)
- HashtagID: 関連するハッシュタグのID (外部キー)

# Relationships
- Post は複数の Hashtag を持つことができる。
- Hashtag は複数の Post に関連付けられることができる。

+-----------+         +-----------+        +-------+
| Post      |         |PostHashtag|        |Hashtag|
+-----------+         +-----------+        +-------+
| ID        |<-------|| PostID    ||------>| ID    |
|-----------|         | HashtagID |        |-------|
| Content   |         +-----------+        | Tag   |
| Timestamp |                              +-------+
+-----------+
```

POST の **Content** から Hashtag の **Tag** になる部分を抽出し、**NFKC_CF 形式**に変換する関数を実装します。Tag を NFKC_CF 形式に統一することで検索機能を強化することができます。

フロントエンド側の実装は以下の記事で行なっています。

https://qiita.com/shikuno_dev/items/5b69ca5f8ad1e281fe9f


## 必要なライブラリのインストール

Python の正規表現 `regex` ライブラリをインストールします。標準ライブラリの `re` は **Unicode プロパティ**（`\p{XID_Continue}` など）を使うことができないためです。

```bash
pip3 install regex
```

## ハッシュタグ抽出と正規化の実装

以下のコードは、POST の Content から Hashtag を抽出し、NFKC_CF 形式に変換する関数を実装します。

- `hashtag.py` ファイルを作成
- `NFKC_CF` 関数: 文字列を NFKC 形式に正規化し、casefold する。
- `extract_hashtags` 関数: テキストをトークンに分割し、ハッシュタグ部分を NFKC_CF 形式に正規化する。

```python:hashtag.py
# 以下の仕様で実装
# - <Hashtag-Identifier> := <Start> <Continue>*
# - オプションの (<Medial> <Continue>+)* の部分は実装しない
# - Start の前に Continue がない場合にのみハッシュタグとして認識
# - ハッシュタグは NFKC_CF 形式に正規化

import regex as re
import unicodedata

def NFKC_CF(text):
    return unicodedata.normalize('NFKC', text).casefold()

def extract_hashtags(text):
    tokens = []
    current_token = ''
    in_hashtag = False

    def add_token(value):
        tokens.append(value)

    for char in text:
        if re.match(r'#', char):
            if in_hashtag:
                add_token(NFKC_CF(current_token))
                in_hashtag = False
                current_token = char
            else:
                in_hashtag = True
                current_token = char
        elif in_hashtag:
            if re.match(r'\p{XID_Continue}|\p{Extended_Pictographic}|\p{Emoji_Component}|[_+-]', char):
                current_token += char
            else:
                add_token(NFKC_CF(current_token))
                in_hashtag = False

    # 最後の部分を処理
    if current_token:
        add_token(NFKC_CF(current_token))

    return tokens


# 動作確認
content = "#MötleyCrüe #MÖTLEYCRÜE foo #bar#foo #b🦰ar foo.#bar"
hashtags = extract_hashtags(content)
for hashtag in hashtags:
    print(hashtag)
```

## 動作確認

```bash
python3 hashtag.py
```

```bash:結果
#mötleycrüe
#mötleycrüe
#bar
#foo
#b🦰ar
#bar
```

# おわりに

本記事では、Unicode Standard で規定されているハッシュタグの仕様を元に、ハッシュタグ機能を実装しました。

フロントエンドからタグ検索を行う場合は、上記で実装した `NFKC_CF` 関数で検索ワードを正規化した上で、データベースに問い合わせすることで検索機能を強化することができます。