~fvknk97034/bk

技術関連の備忘録

GitHub Pages に React.js で作ったページを公開する

概要

React.js で作った簡単なアプリケーションを GitHub Pages にデプロイしました。

背景

先日、ふと思い立って手元で簡単なゲームのアプリケーションの作成をしました。

fvknk97034.github.io

また、作成したアプリケーションがサーバー側で必要な処理がないものであったことと、Vercel は使ったことがあるものの GitHub Pages を使ったことがないため、GitHub Pages でデプロイを試してみることにしました。

手段・アプローチ

GitHub Pages を利用する要件

今回作成したのは、簡単なゲームを行うアプリケーションで、特に大人数のアクセスは想定していないものとなっています。

これを踏まえて GitHub Pages の制約と自分の想定する利用方法を照らし合わせました。

GitHub Pages の制約 想定する利用方法
商用利用は不可。 個人的用途。金銭のやり取りの発生なし。
ユーザーサイトは1ユーザー1つだけ ユーザーサイト( fvknk97034.github.io )としての公開をしない
ソースリポジトリの推奨上限は1GB 2026/6/27 現在、ローカルで合計 181MB。今後大幅に増える予定はない
公開サイトは1GB未満 2026/6/27 現在、ローカルでビルドしたファイルのサイズが合計 408KB。今後大幅に増える予定はない
デプロイ時間は10分まで 1ページのみ、かつ機能も多くないため、依存ライブラリの導入・テスト・ビルドに時間は多くはかからない想定。
月当たり 100 GB の帯域幅制限あり ちょっとした遊びのサイトのため、アクセスできなくて困るサイトでない、かつ大量アクセスも見込まれにくい
1時間当たり 10 ビルドの制限あり(カスタム GitHub Actions ワークフローを使用する場合、対象外) デプロイ用ワークフローは自前で用意する想定。GitHub 提供のワークフローを使う場合でも、1時間に10回デプロイすることは個人開発のため想定しにくい。
レート制限の適用可能性あり アクセスできずに困るサイトでなく、大量アクセスも見込まれにくい

https://docs.GitHub.com/ja/pages/getting-started-with-GitHub-pages/GitHub-pages-limitsdocs.GitHub.com

上記を踏まえると、今回の利用に問題はないと判断できるため、GitHub Pages でデプロイすることにしました。

初期設定

今回は Dev Container 内で開発しました。

アプリの初期生成は npm create vite@latest . で行いました。

node ➜ /workspaces/keycap-puzzle (main) $ npm create vite@latest .
Need to install the following packages:
create-vite@9.0.7
Ok to proceed? (y) y

> npx
> "create-vite" .

│
◇  Current directory is not empty.
│   Please choose how to proceed:
│  Ignore files and continue
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Install with npm and start now?
│  Yes
│
◇  Scaffolding project in /workspaces/keycap-puzzle...
│
◇  Installing dependencies with npm...

added 152 packages, and audited 153 packages in 14s

42 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
│
◇  Starting dev server...

> keycap-puzzle@0.0.0 dev
> vite

  VITE v8.0.16  ready in 270 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

最後の出力の Local で指定されているポートを公開するために、 .devcontainer/devcontainer.jsonaddPort を追加しました。

 "appPort": [
  "5173:5173"
 ],

また、DevContainer 内で開発用にアプリを立ち上げるため、package.jsonnpm run dev のスクリプトを以下のように書き換えました。

  "scripts": {
    "dev": "vite --host",
    // 略

npm run dev でアプリを立ち上げて、ブラウザで http://localhost:5173 へアクセスすると、以下のように生成したアプリが確認できました。

また、最終的には以下のようなアプリを作成しました。

デプロイ設定をする

一通り実装ができたら、GitHub Pages へデプロイするために設定とワークフローの作成をしました。

まずは Vite のガイドに沿って、base を設定しました。

今回は https://github.com/<USERNAME>/<REPO> として公開するため、 base にはリポジトリ名を設定しました。

// vite.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  base: "/keycap-puzzle/",
  plugins: [react()],
  test: { environment: "node", restoreMocks: true },
});

ja.vite.dev

次に GitHub 上でワークフローの設定をしました。

まずはリポジトリの Settings の画面から Pages を選択しました。

そこで Source で GitHub Actions を選択しました。

さらに .github/workflows/deploy.yml を作成し、以下のように設定しました。

name: Deploy static content to Pages

on:
  push:
    branches: ["main"]

  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: "pages"
  cancel-in-progress: true

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
      - name: Set up Node
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
        with:
          node-version: "24.15.0"
          cache: "npm"

      # 依存するライブラリをインストールする
      - name: Install dependencies
        run: npm ci
        
      # テストを実行する
      - name: Test
        run: npm run test
      
      # React.js から静的ファイルをビルドする
      - name: Build
        run: npm run build
        
      # GitHub Pages にビルドしたファイルをデプロイする
      - name: Setup Pages
        uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d
      - name: Upload artifact
        uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9
        with:
          path: "./dist"
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128

これで以下にアプリケーションをデプロイできました。

fvknk97034.github.io

その他

ゲームルール説明の生成

説明を書き起こすことが面倒だったため、以下のようなプロンプトで Claude にルール文章の生成を依頼しました(現在無料版利用のため Sonnet 4.6、Claude Code の利用なし)。

このゲームのルールを記述せよ。

# Game.ts のコード

Claude からは以下のような返答が得られました。中身を確認する限り、実装によった説明も見られますが、ルールとしては問題のないように見えます。

ゲームルール

概要 JIS キーボードのキーキャップを正しい位置に配置するパズルゲーム。


開始(start()

  • キーボードの全キャップがシャッフルされ、「手持ちキャップ一覧(caps)」として渡される
  • タイマーが起動する(startMs

プレイ中(updateKeyboard(slot, cap)

  • スロット(キーボード上の位置)にキャップを置く、または外す操作を行う
    • キャップを置く場合:手持ちからそのキャップが除去され、スロットに配置される
    • キャップを外す場合(cap = null):スロットからキャップが取り出され、手持ちに戻る

終了条件(finish()

  • 手持ちキャップが0枚(caps.length == 0)のときのみ終了できる
  • 終了時にタイマーが止まる(endMs

クリア判定(isCompleted()

  • 手持ちキャップが0枚、かつ
  • 現在のキーボード配置が「正解のキーボード配置(correctKeyboard)」と一致する

→ 全キャップを正しい位置に配置して初めてクリアとなる。


補足

  • 手持ちが0枚でも誤配置であれば isCompleted()false になる(canFinish() == true だが equals()false
  • タイマーは elapsedMs() で経過時間を取得でき、終了後は確定値、プレイ中はリアルタイム値を返す

また、操作方法の説明を出すため、以下のように別コンテキストでプロンプトを投げてみました。

ユーザー向け操作説明を作成したい。以下を元に操作説明を記述できるか。

# Game.View.tsx のコード

いくつか確認がしたい点があるようですが、操作説明としては十分な粒度に見える内容が出力されました。

コードから読み取れる範囲で操作説明を記述します。ただし、以下の情報がコードだけでは確認できないため、補足をお願いします。

確認が必要な点:

  1. slot.fixed とは何か(固定スロットの概念をユーザーにどう説明するか)
  2. game.canFinish() の条件(全スロットに配置済み、など)
  3. game.isCompleted()canFinish() の違い(正解判定の条件)
  4. トレイのキャップは何個あるか、キーボードのスロットは何個か
  5. ゲームの目的(正しいキーキャップを正しいスロットに配置する、など)

上記が分かる範囲で書ける草案は以下です。足りない部分は [要補足] としています。


操作説明(草案)

ゲームの目的

トレイにあるキーキャップを、キーボードの正しい位置に配置してください。[要補足: 正解条件の説明]

操作手順

  1. スタートボタンを押してゲームを開始します。タイマーが計測を始めます。
  2. トレイからキーキャップを選択します(タップで選択、もう一度タップで選択解除)。
  3. キーボードのスロットをタップしてキーキャップを配置します。
  4. スロットをもう一度タップすると、配置したキーキャップを取り外せます。
  5. すべて配置できたら終了ボタンを押します。[要補足: 配置が間違っている場合のメッセージ内容]

注意事項

  • 灰色(固定)のスロットは操作できません。[要補足: fixed スロットの見た目・意味]
  • 終了ボタンはゲーム中かつ[要補足: canFinish 条件]の場合のみ押せます。

この文言をベースとして、手直ししつつ最終的な以下のようなルール説明を作成しました。

実装時にドメインと描画の切り分けを気にしながら実装をしていたのですが、それがうまく効いて手間を削減できたように思いました。

Github の Deployments の削除

今回のデプロイでリポジトリ内のサイドパネルにデプロイ結果が表示されるようになりました。

これを確認すると、デプロイの履歴が確認できるようになっていました。

ただ、上部の失敗のデプロイ結果は再デプロイした後にもいつまでも残ってしまい、ノイズのように感じました。

そのため、以下のコマンドで失敗の Deployments を削除しました。

# `リポジトリオーナー名/リポジトリ名`を取得する
owner_repo=$(gh repo view --json nameWithOwner -q .nameWithOwner)

# deployments を1件ずつ確認する
gh api --paginate /repos/$owner_repo/deployments | jq -r '.[] | "\(.id) \(.statuses_url)"' | while read id statuses_url; do
 # deployment の最終ステータスを取得する  
  state=$(gh api $statuses_url | jq -r '.[0].state')

  # ステータスが failure であれば、対象の deployment を削除する
  if [ "$state" = "failure" ]; then
    gh api -X DELETE repos/$owner_repo/deployments/$id
  fi
done

上記を実行した結果、失敗したデプロイ結果を削除できました。

まとめ

React.js で作成した簡単なアプリケーションを GitHub Pages にデプロイしました。

デプロイにあたっては Vite の base 設定と GitHub Actions ワークフローの作成で行えました。GitHub Pages を初めて利用しましたが、問題なくデプロイできることがわかりました。

MeCab 用地名辞書を作る(実装)

概要

駅名を Mecab で構文解析をした際に、以下のようにうまく地名として分ち書きできないケースがありました。

島氏永
島氏永 名詞,固有名詞,一般,*,*,*,島氏永,シマウジナガ,シマウジナガ
EOS
島 名詞,一般,*,*,*,*,島,シマ,シマ
氏永 名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
EOS
# 以下略

www.meitetsu.co.jp

この問題の解消のため、郵便局データから地名データを独自に生成して、辞書として読み込むことで、正しく構文解析できるようにしました。

手順

大まかに手順は以下の通りになっています。

  1. 地名と読みの一覧を作成する
  2. 1 を使って MeCab 用のユーザー辞書を生成する

また、前回記事を踏まえてより検討し、ABR をベースにデータを加工し、郵便番号データで補正していくことにしました。

1. 地名と読みの一覧を作成する

まずは、アドレス・ベース・レジストリ(以下 ABR)から「全国 町字マスター(フルセット)」の CSV ファイルを取得しました。

dataset.address-br.digital.go.jp

過去の情報配下のデータフォーマット(仕様確定版)と実際のデータを確認すると、以下をのことがわかりました。

  1. 都道府県・郡・市・区・大字町・小字のように分割されている
  2. 「大字」、「字」、「小字」は省略なしで表記
  3. カナ・ローマ字に表記揺れや誤りと思われる表記が散見される(拗音・促音の大文字小文字表記混在、濁りがローマ字と一致しない、明らかに異なる読みが格納されている、等)

一方、郵便番号データは以下のようになっていました。

  1. 郡・市・区がまとめて格納される
  2. 「大字」、「字」、「小字」は基本省略
  3. 括弧書きで小字が含まれるデータを含むことがある
  4. 読みの精度は ABR と比べると高そう

そのため、まずは正確な読みを入れるために、以下の処理を行うことにしました。

  1. ABR のデータを整形する
  2. 小字、もしくは大字・町まで連結した ABR 漢字名を郵便番号データの漢字名と照合し、結果に応じた処理をする
    • 郵便番号データに一致する漢字名がある場合
      • 読みが一致した場合、正しいデータとしてそのまま扱う
      • 読みが一致しない場合、ABR のカナを郵便番号データの読みに差し替える
    • 郵便番号データに一致する漢字名がない場合
      • 大字まで連結した漢字名で郵便番号データの漢字名と照合する
        • 照合できた場合、小字まで連結したデータと同じ処理を施す
        • 照合できない場合、該当データを用いない
  3. 結果を元に辞書用 CSV ファイルを作成する

1. ABR のデータを整形する

ABR のデータの整形は以下のように行いました。

  1. 「1号」、「1番地」など、地名として意味の薄い文字列を削除する
  2. 「大字」、「字」、「小字」の表記を削除する
  3. ローマ字を参照しつつ、漢字・カナの対応する位置に空白を入れる

3.の分割処理は、VS Code 上で以下の正規表現を使って目視で確認しながら進めました。

,(<名前>)([^, ]+?),(<名前カナ>)([^ ,]+?),(<名前ローマ> [A-Z][a-z]+),

,$1 $2,$3 $4,$5,

2. 郵便番号データの漢字名と照合する

ABR のデータを整形したあと、以下のコードを使って郵便番号データとの照合しました。照合は以下のスクリプトを用いました。

# 照合本体
import csv
import os
import re
import shutil

import traceback

from yubin.yubin_data import YubinData
from abr.toponym_container import ToponymContainer

yubin_file = '../data/KEN_ALL.CSV'
invalid_file = '../output/invalid_data.csv'
valid_file = '../output/valid_data.csv'
backup_file = '../output/invalid_data.csv.bak'

keys = ['pref', 'county', 'city', 'ward', 'oaza_cho', 'koaza']

def isMatched(toponym):
    toponym_name = toponym.name.replace(' ', '')
    if toponym.kana.replace(' ', '') == yubin_data.get(toponym_name, None):
        return {
            'judge': True,
            'data': yubin_data.get(toponym_name, None),
            'exclude_koaza': False
        }

    new_attributes = {}
    for attribute in toponym.toponym_attributes()[:-1]:
        new_attributes[attribute.__class__.key] = str(attribute.name)
        new_attributes[attribute.__class__.kana_key] = str(attribute.kana)

    new_toponym = ToponymContainer(new_attributes)
    new_toponym_name = new_toponym.name.replace(' ', '')
    if new_toponym.kana.replace(' ', '') == yubin_data.get(new_toponym_name, None):
        if toponym.koaza.name == toponym.koaza.kana:
            return {
                'judge': True,
                'data': yubin_data.get(new_toponym_name, None),
                'exclude_koaza': False
            }

        return {
            'judge': True,
            'data': yubin_data.get(new_toponym_name, None),
            'exclude_koaza': True
        }

    return {
        'judge': False,
        'data': '',
        'exclude_koaza': False
    }

try:
    shutil.copy2(invalid_file, backup_file)
    shutil.copy2(valid_file, backup_file)

    valid_data = []
    with open(valid_file, encoding='utf-8', newline='') as f:
        reader = csv.DictReader(f)
        fieldnames = reader.fieldnames or ''
        valid_data = list(reader)

    yubin_data = YubinData(yubin_file).load()

    invalid_data = []
    with open(invalid_file, encoding='utf-8', newline='') as f:
        reader = csv.DictReader(f)
        fieldnames = reader.fieldnames or ''

        for row in reader:
            toponym = ToponymContainer(row)
            if len(toponym.names) != len(toponym.kanas):
                continue

            found = isMatched(toponym)
            if not found['judge']:
                row['remarks'] = found['data']
                invalid_data.append(row)
                continue

            if not found['exclude_koaza']:
                row['remarks'] = ''
                valid_data.append(row)
                continue

            invalid_data.append(row)

            cloned = row.copy()
            cloned['koaza'] = ''
            cloned['koaza_kana'] = ''
            cloned['koaza_roma'] = ''
            valid_data.append(cloned)

    with open(invalid_file, 'w', encoding='utf-8', newline='') as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(invalid_data)

    with open(valid_file, 'w', encoding='utf-8', newline='') as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(valid_data)

    print(f'valid_data: {len(valid_data)}, invalid_data: {len(invalid_data)}')

except Exception as e:
    print(f'エラーが発生しました: {e}')
    traceback.print_exc()
    print('ファイルを復元します')
    if os.path.exists(backup_file):
        shutil.copy2(backup_file, invalid_file)
        os.remove(backup_file)
# 郵便番号データ格納用
import re
import unicodedata

class BaseToponymName:
    def __init__(self, val: str) -> None:
        if not isinstance(val, str):
            raise ValueError(f"{val!r} is invalid class")

        self._val = unicodedata.normalize('NFKC', val)

from collections import defaultdict

from .prefecture.prefecture import Prefecture
from .city.city import City
from .town.town import Town

class ToponymContainer:
    @property
    def name(self) -> str:
        name = ''
        for attribute in self.toponym_attributes():
            if attribute:
                name = ''.join([name, str(attribute.name)])
        return name

    @property
    def names(self) -> list[str]:
        names = []
        for attribute in self.toponym_attributes():
            if str(attribute.name) != '':
                names.append(str(attribute.name))

        return names

    @property
    def kana(self) -> str:
        kana = ''
        for attribute in self.toponym_attributes():
            if attribute:
                kana = ''.join([kana, str(attribute.kana)])
        return kana

    @property
    def kanas(self) -> list[str]:
        kanas = []
        for attribute in self.toponym_attributes():
            if str(attribute.kana) != '':
                kanas.append(str(attribute.kana))

        return kanas

    def __init__(self, data):
        data = defaultdict(str, data)
        self._prefecture = Prefecture(
            data['pref'], data['pref_kana']
        )
        self._city = City(
            data['city'], data['city_kana']
        )
        self._town = Town(
            data['town'], data['town_kana']
        )

    def toponym_attributes(self):
        return [
            self.prefecture,
            self.city,
            self.town
        ]

    def is_continue(self):
        return '(' in self.town.name.val and not self.town.name.val.endswith(')')

    def has_koaza(self):
        return '(' in self.town.name.val and '(' in self.town.kana.val

    def append_town(self, row):
        d = {
            'pref': str(self.prefecture.name),
            'pref_kana': str(self.prefecture.kana),
            'city': str(self.city.name),
            'city_kana': str(self.city.kana),
            'town': str(self.town.name) + row['town'],
            'town_kana': str(self.town.kana) + row['town_kana'],
        }
        return self.__class__(d)

import csv
import re

from yubin.toponym_container import ToponymContainer

class YubinData:
    COL_KANA_PREF = 3
    COL_KANA_CITY = 4
    COL_KANA_TOWN = 5
    COL_PREF = 6
    COL_CITY = 7
    COL_TOWN = 8

    def __init__(self, file: str):
        self._file = file
        self._data: dict[str, str] = {}

    def load(self) -> dict[str, str]:
        with open(self._file, encoding='utf-8', newline='') as f:
            rows = self._merge_continued_rows(csv.reader(f))
            for toponym in rows:
                for t in self._split_koaza(toponym):
                    self._data[str(t.name)] = str(t.kana)
        return self._data

    def _merge_continued_rows(self, reader) -> list[ToponymContainer]:
        result = []

        toponym = None
        for row in reader:
            row = {
                'pref': row[self.COL_PREF],
                'pref_kana': row[self.COL_KANA_PREF],
                'city': row[self.COL_CITY],
                'city_kana': row[self.COL_KANA_CITY],
                'town': row[self.COL_TOWN],
                'town_kana': row[self.COL_KANA_TOWN],
            }

            toponym = ToponymContainer(
                row
            ) if not toponym or not toponym.is_continue() else toponym.append_town(row)

            if not toponym.is_continue():
                result.append(toponym)
                toponym = None

        if toponym is not None:
            result.append(toponym)

        return result

    def _split_koaza(self, toponym: ToponymContainer) -> list[ToponymContainer]:
        town_name = str(toponym.town.name)
        town_kana = str(toponym.town.kana)
        base = {
            'pref': str(toponym.prefecture.name),
            'pref_kana': str(toponym.prefecture.kana),
            'city': str(toponym.city.name),
            'city_kana': str(toponym.city.kana),
        }

        if not toponym.has_koaza():
            result = [toponym]

            if town_name.endswith('町'):
                result.append(
                    ToponymContainer(
                        {
                            **base,
                            'town': re.sub(r'町$', '', town_name),
                            'town_kana': re.sub(r'(マチ|チョウ)$', '', town_kana)
                        }
                    )
                )
            return result

        match_name = re.match(
            r'^(?P<base_name>.+?)\((?P<koaza_names>.+)\)$', town_name
        )
        base_name = match_name.group('base_name')
        koaza_names = match_name.group('koaza_names').split('、')

        match_kana = re.search(
            r'^(?P<base_kana>.+?)\((?P<koaza_kanas>.+)\)$', town_kana
        )
        base_kana = match_kana.group('base_kana')
        koaza_kanas = match_kana.group('koaza_kanas').split('、')

        result = [
            ToponymContainer(
                {**base, 'town': base_name, 'town_kana': base_kana}
            )
        ]

        for name, kana in zip(koaza_names, koaza_kanas):
            full_town = base_name + name
            full_kana = base_kana + kana
            result.append(
                ToponymContainer(
                    {**base, 'town': full_town, 'town_kana': full_kana}
                )
            )

            if full_town.endswith('町'):
                result.append(
                    ToponymContainer(
                        {
                            **base,
                            'town': re.sub(r'町$', '', full_town),
                            'town_kana': re.sub(
                                r'(マチ|チョウ)$',
                                '',
                                full_kana
                            )
                        }
                    )
                )
        return result

漢字のみ一致した場合、郵便番号データのデータの区切り方の判断が必要なため、郵便番号データのデータを備考欄に入れて、別途目で確認・修正としました。

3. 辞書用 CSV ファイルを作成する

辞書の元ファイルとなる CSV ファイルの形式は以下のようになっているようです。

表層形,左文脈ID,右文脈ID,コスト,品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用形,活用型,原形,読み,発音

shogo82148.github.io

辞書用 CSV ファイルの作成には以下のスクリプトを用いました。

# 辞書 CSV 作成(本体)
import csv
import traceback

from toponym_dict.prefecture.prefecture import Prefecture
from toponym_dict.county.county import County
from toponym_dict.city.city import City
from toponym_dict.ward.ward import Ward
from toponym_dict.oaza_cho.oaza_cho import OazaCho
from toponym_dict.koaza.koaza import Koaza

toponyms = [Prefecture, County, City, Ward, OazaCho, Koaza]

input_file = '../output/valid_data.csv'
output_file = '../output/dict_source.csv'

try:
    data = set()
    with open(input_file, encoding='utf-8', newline='') as f:
        reader = csv.DictReader(f)

        for row in reader:
            for klass in toponyms:
                toponym = klass(row[klass.key], row[klass.kana_key])
                data.update(toponym.build_dict_entry())

    with open(output_file, 'w', encoding='utf-8', newline='') as f:
        writer = csv.writer(f)
        writer.writerows(data)

    print(f'valid_data: {len(data)}')
except Exception as e:
    print(f'エラーが発生しました: {e}')
    traceback.print_exc()
# 各クラス・親クラスファイル(一部抜粋)
class BaseToponym:
    @property
    def name(self) -> BaseToponymName:
        return self._name

    @property
    def kana(self) -> BaseToponymName:
        return self._kana

    @property
    def hiragana(self) -> BaseToponymName:
        return BaseToponymName(
            ''.join(
                chr(ord(c) - 0x60) if 'ァ' <= c <= 'ン' else c
                for c in self.kana.val
            )
        )

    @property
    def regexes(self) -> list[dict[str, str]]>:
        return self._regexes

    def build_dict_entry(self) -> list[tuple]:
        results = []
        names = self.name.split()
        kanas = self.kana.split()

        for name, kana in zip(names, kanas):
            trimmed_toponym = self.__class__(name.val, kana.val).trim()
            d = [
                trimmed_toponym.name.val,
                '',
                '',
                '',
                '名詞',
                '固有名詞',
                '地域',
                '一般',
                '*',
                '*',
                trimmed_toponym.hiragana.val,
                trimmed_toponym.kana.val,
                trimmed_toponym.kana.val
            ]

            results.append(tuple(d))

        return results

    def trim(self):
        new_toponym = self
        for regex in self.regexes:
            if not self.name.search(regex['name']):
                continue

            new_toponym = self.__class__(
                self.name.delete(regex['name']).val,
                self.kana.delete(regex['kana']).val,
            )
            break

        return new_toponym

class Prefecture(BaseToponym):
    key = 'pref'
    kana_key = f'{key}_kana'

    def __init__(self, name: str, kana: str):
        # 値オブジェクトに格納
        self._name = PrefectureName(name)
        self._kana = PrefectureNameKana(kana)
        self._regexes = [
            {'name': r'都$', 'kana': r'ト$'},
            {'name': r'府$', 'kana': r'フ$'},
            {'name': r'県$', 'kana': r'ケン$'}
        ]

最終的には以下のような CSV が生成できました。

御料,,,,名詞,固有名詞,地域,一般,*,*,ごりょう,ゴリョウ,ゴリョウ
上祖師谷,,,,名詞,固有名詞,地域,一般,*,*,かみそしがや,カミソシガヤ,カミソシガヤ
下上野,,,,名詞,固有名詞,地域,一般,*,*,しもうわの,シモウワノ,シモウワノ
北河堀,,,,名詞,固有名詞,地域,一般,*,*,きたかわほり,キタカワホリ,キタカワホリ
清原,,,,名詞,固有名詞,地域,一般,*,*,きよはら,キヨハラ,キヨハラ
原谷乾,,,,名詞,固有名詞,地域,一般,*,*,はらだにいぬい,ハラダニイヌイ,ハラダニイヌイ
出津,,,,名詞,固有名詞,地域,一般,*,*,でづ,デヅ,デヅ
小栗須,,,,名詞,固有名詞,地域,一般,*,*,こぐるす,コグルス,コグルス
有井川,,,,名詞,固有名詞,地域,一般,*,*,ありいがわ,アリイガワ,アリイガワ
甲牧堀,,,,名詞,固有名詞,地域,一般,*,*,こうまきぼり,コウマキボリ,コウマキボリ
鈴川中,,,,名詞,固有名詞,地域,一般,*,*,すずかわなか,スズカワナカ,スズカワナカ

2. MeCab 用辞書を生成する

1.で生成した CSV ファイルを元に、辞書ファイルの生成をしました。

また、文脈 ID やコストは自動推定した結果で CSV ファイルを作成してくれるコマンドがあるため、今回は自動推定することにしました。

そのため、以下の手順で辞書の生成をしました。

  1. 文脈 ID、コストを自動推定するコマンドを実行する
  2. 1 のファイルを使って辞書を追加する
  3. 実行結果を見つつコストを調整する

1. 文脈 ID、コストを自動推定するコマンドを実行する

文脈 ID、コストの推定には以下の別途モデルファイルが必要です。

しかし、公式のモデルファイルを参照したところ、リンクが無効になってしまっていて入手できないことがわかりました。

taku910.github.io

そのため、代わりになるものを探したところ、公式を fork してメンテナンスをしている GitHub リポジトリがあったので、こちらを利用することにしました。

github.com

また、念の為ではありますが、MeCab 本体・辞書共に fork 先のリポジトリでビルドするものを使うことにしました。

そのため、まずは以下に従って MeCab と辞書のインストールを行いました。

https://shogo82148.github.io/mecab/#install

# 本体
$ curl -OL https://github.com/shogo82148/mecab/releases/download/v0.996.13/mecab-0.996.13.tar.gz
$ tar zxfv mecab-0.996.13.tar.gz 
$ cd mecab-0.996.13/
$ ./configure
$ make
$ make check
$ sudo make install

# 辞書
$ curl -OL https://github.com/shogo82148/mecab/releases/download/v0.996.13/mecab-ipadic-2.7.0-20070801.tar.gz
$ tar zxfv mecab-ipadic-2.7.0-20070801.tar.gz
$ cd mecab-ipadic-2.7.0-20070801/
$ ./configure
$ make
$ sudo make install

ここから、さらに以下のページ内のモデルファイルをダウンロードして、コマンドを実行したところ、feature.def がないというエラーが出ました。

shogo82148.github.io

$ curl -OL https://github.com/shogo82148/mecab/raw/refs/heads/main/mecab-ipadic-2.7.0-20070801.model.bz2
$ bunzip2 mecab-ipadic-2.7.0-20070801.model.bz2

$ /usr/local/libexec/mecab/mecab-dict-index \
  -m ./tmp/mecab-ipadic-2.7.0-20070801.model \
  -d /usr/local/lib/mecab/dic/ipadic \
  -u ./output/toponym.csv \
  -f euc-jp \
  -t euc-jp \
  -a ./output/dict_source.csv
tmp/mecab-ipadic-2.7.0-20070801.model is not a binary model. reopen it as text mode...
feature_index.cpp(81) [ifs] no such file or directory: /usr/local/lib/mecab/dic/ipadic/feature.def

別途情報を確認したところ、どうやら推定に必要なファイルは -d で指定したディレクトリには生成されていないようです。そのため、以下を参考にダウンロードした辞書ディレクトリを参照するように変更しました。

https://hack.nikkei.com/blog/advent20231205/#コストの自動推定hack.nikkei.com

$ /usr/local/libexec/mecab/mecab-dict-index \
  -m ./tmp/mecab-ipadic-2.7.0-20070801.model \
  -d ./tmp/mecab-ipadic-2.7.0-20070801 \
  -u ./output/toponym.csv \
  -f euc-jp \
  -t euc-jp \
  -a ./output/dict_source.csv 
tmp/mecab-ipadic-2.7.0-20070801.model is not a binary model. reopen it as text mode...
reading ./output/dict_source.csv ... 
done!

# 左文脈ID,右文脈ID,コストが埋まっている状態で出力されている
$ iconv -f EUC-JP -t UTF-8 ./output/toponym.csv | head
御料,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,ごりょう,ゴリョウ,ゴリョウ
上祖師谷,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,かみそしがや,カミソシガヤ,カミソシガヤ
下上野,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,しもうわの,シモウワノ,シモウワノ
北河堀,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,きたかわほり,キタカワホリ,キタカワホリ
清原,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,きよはら,キヨハラ,キヨハラ
原谷乾,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,はらだにいぬい,ハラダニイヌイ,ハラダニイヌイ
出津,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,でづ,デヅ,デヅ
小栗須,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,こぐるす,コグルス,コグルス
有井川,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,ありいがわ,アリイガワ,アリイガワ
甲牧堀,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,こうまきぼり,コウマキボリ,コウマキボリ
鈴川中,1293,1293,3062,名詞,固有名詞,地域,一般,*,*,すずかわなか,スズカワナカ,スズカワナカ

これで左文脈 ID、右文脈 ID、コストが埋まっているデータを生成できました。

2. 辞書を追加する

今回はお試しでの作成のため、手軽に更新が可能なユーザー辞書を作成しました。

$ /usr/local/libexec/mecab/mecab-dict-index \
  -d ./tmp/mecab-ipadic-2.7.0-20070801 \
  -u toponym.dic \
  -f euc-jp \
  -t euc-jp \
  ./output/toponym.csv
reading ./output/toponym.csv ... 73093
emitting double-array: 100% |###########################################| 

done!

# dic ファイルの生成を確認する
$ ls toponym.dic 
toponym.dic

# MeCab の設定ファイルに作成したファイルをユーザー辞書に含めることを追記
$ echo "userdic = /path/to/toponym.dic" | sudo tee -a /usr/local/lib/mecab/dic/ipadic/dicrc 
userdic = /path/to/toponym.dic

# 追記内容を確認
$ tail /usr/local/lib/mecab/dic/ipadic/dicrc 
; ChaSen
node-format-chasen = %m\t%f[7]\t%f[6]\t%F-[0,1,2,3]\t%f[4]\t%f[5]\n
unk-format-chasen  = %m\t%m\t%m\t%F-[0,1,2,3]\t\t\n
eos-format-chasen  = EOS\n

; ChaSen (include spaces)
node-format-chasen2 = %M\t%f[7]\t%f[6]\t%F-[0,1,2,3]\t%f[4]\t%f[5]\n
unk-format-chasen2  = %M\t%m\t%m\t%F-[0,1,2,3]\t\t\n
eos-format-chasen2  = EOS\n
userdic = /path/to/toponym.dic

shogo82148.github.io

最後に、前回と同じく「島氏永」で検証を試したところ、2番目の結果で「島」も「氏永」も地名として認識されることを確認できました。

$ echo "島氏永" | mecab -N5 
島氏永 名詞,固有名詞,一般,*,*,*,島氏永,シマウジナガ,シマウジナガ
EOS
島 名詞,固有名詞,地域,一般,*,*,しま,シマ,シマ
氏永 名詞,固有名詞,地域,一般,*,*,うじなが,ウジナガ,ウジナガ
EOS
島 名詞,一般,*,*,*,*,島,シマ,シマ
氏永 名詞,固有名詞,地域,一般,*,*,うじなが,ウジナガ,ウジナガ
EOS
島 名詞,接尾,地域,*,*,*,島,トウ,トー
氏永 名詞,固有名詞,地域,一般,*,*,うじなが,ウジナガ,ウジナガ
EOS
島 名詞,固有名詞,人名,姓,*,*,島,シマ,シマ
氏永 名詞,固有名詞,地域,一般,*,*,うじなが,ウジナガ,ウジナガ
EOS

まとめ

独自辞書を作成することで、既存の辞書ではうまく解析できていなかった地名を解析できるようにしました。

地名データの補正部分は、現状目視での検査部分もかなり多く、データ作成にかなりの時間を要しましたが、今後 ABR の辞書の精度が向上すればより細かい地名の解析が簡単にできるようになりそう。

MeCab 用地名辞書を作る(アプローチ)

概要

駅名を Mecab で構文解析をした際に、以下のようにうまく地名として分ち書きできないケースがありました。

島氏永
島氏永   名詞,固有名詞,一般,*,*,*,島氏永,シマウジナガ,シマウジナガ
EOS
島 名詞,一般,*,*,*,*,島,シマ,シマ
氏永  名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
EOS
# 以下略

www.meitetsu.co.jp

この問題の解消のため、郵便局・デジタル庁のデータから地名データを生成して、辞書として読み込むことで、正しく構文解析するアプローチを検討しました。

背景

駅名の中には「新宿三丁目駅」や「新宿西口駅」など、固有の地名が含まれることがあります。一方で「フェリーターミナル駅」のように固有の地名が含まれないことがあります。

日本のすべての駅名の中から固有の地名由来の駅名を抜き出す際に、MeCab で形態素解析することを考えました。

このとき、郵便局の郵便番号データや駅名データも取り込んでいる mecab-ipadic-NEologd を使うことで、より良い精度で駅名に対する形態素解析ができると考えました。

github.com

例えば「新宿」を駅名に含む駅の場合、以下のように形態素解析されて、「新宿」の駅名であることが推測できるのではないかと考えました。

駅名 結果
新宿 新宿 名詞,固有名詞,地域,一般,,,新宿,シンジュク,シンジュク
新宿西口 新宿 名詞,固有名詞,地域,一般,,,新宿,シンジュク,シンジュク
西口 名詞,一般,,,,,西口,ニシグチ,ニシグチ
新宿三丁目 新宿 名詞,固有名詞,地域,一般,,,新宿,シンジュク,シンジュク
三 名詞,数,,,,,三,サン,サン
丁目 名詞,接尾,助数詞,,,*,丁目,チョウメ,チョーメ
新宿御苑前 新宿 名詞,固有名詞,地域,一般,,,新宿,シンジュク,シンジュク
御苑 名詞,一般,,,,,御苑,ギョエン,ギョエン
前 名詞,副詞可能,,,,,前,マエ,マエ
東新宿 東 名詞,一般,,,,,東,ヒガシ,ヒガシ
新宿 名詞,固有名詞,地域,一般,,,新宿,シンジュク,シンジュク
南新宿 南 名詞,一般,,,,,南,ミナミ,ミナミ
新宿 名詞,固有名詞,地域,一般,,,新宿,シンジュク,シンジュク
西武新宿 西武 名詞,固有名詞,組織,,,,西武,セイブ,セイブ
新宿 名詞,固有名詞,地域,一般,
,*,新宿,シンジュク,シンジュク
新線新宿 新線 名詞,固有名詞,一般,,,,新線,シンセン,シンセン
新宿 名詞,固有名詞,地域,一般,
,*,新宿,シンジュク,シンジュク

課題

mecab-ipadic-NEologd をフルでインストールした上でも、以下のように地名としてうまく出力できない例を見つけました。

新宿西口
新宿西口    名詞,固有名詞,一般,*,*,*,新宿西口,シンジュクニシグチ,シンジュクニシグチ
EOS

また、以下のように地名をうまく切り出せない例を見つけました。

西松任
西松  名詞,固有名詞,人名,姓,*,*,西松,ニシマツ,ニシマツ
任 名詞,固有名詞,人名,名,*,*,任,ツトム,ツトム
EOS

これらに対して、地名として認識してもらいやすくするために、末尾に「駅」を付与して解析すると、「西松任」については人名ではない形で切り出すことができました。しかし、いずれも地名と「駅」がつながってしまい、「新宿」「松任」という地名が抜き出せませんでした。

新宿西口駅
新宿西口駅 名詞,固有名詞,一般,*,*,*,新宿西口駅,シンジュクニシグチエキ,シンジュクニシグチエキ
EOS

西松任駅
西 名詞,一般,*,*,*,*,西,ニシ,ニシ
松任駅   名詞,固有名詞,地域,一般,*,*,松任駅,マットウエキ,マットーエキ
EOS

そこで、指定の個数だけ確からしい解析結果を出してくれる N-Best 解の出力機能を試しました。

N-Best 解出力機能を使うと、以下のように「新宿」「松任」という地域名の出力が得られました。

taku910.github.io

新宿西口
新宿西口    名詞,固有名詞,一般,*,*,*,新宿西口,シンジュクニシグチ,シンジュクニシグチ
EOS
新宿西口    名詞,固有名詞,地域,一般,*,*,新宿西口,シンジュクニシグチ,シンジュクニシグチ
EOS
新宿  名詞,固有名詞,地域,一般,*,*,新宿,シンジュク,シンジュク
西口  名詞,一般,*,*,*,*,西口,ニシグチ,ニシグチ
EOS
新宿  名詞,固有名詞,一般,*,*,*,新宿,シンジュク,シンジュク
西口  名詞,一般,*,*,*,*,西口,ニシグチ,ニシグチ
EOS
新宿  名詞,固有名詞,地域,一般,*,*,新宿,シンジュク,シンジュク
西口  名詞,固有名詞,地域,一般,*,*,西口,ニシグチ,ニシグチ
EOS

西松任
西松  名詞,固有名詞,人名,姓,*,*,西松,ニシマツ,ニシマツ
任 名詞,固有名詞,人名,名,*,*,任,ツトム,ツトム
EOS
西松  名詞,固有名詞,人名,姓,*,*,西松,ニシマツ,ニシマツ
任 名詞,一般,*,*,*,*,任,ニン,ニン
EOS
西松  名詞,固有名詞,地域,一般,*,*,西松,サイマツ,サイマツ
任 名詞,一般,*,*,*,*,任,ニン,ニン
EOS
西 名詞,一般,*,*,*,*,西,ニシ,ニシ
松任  名詞,固有名詞,地域,一般,*,*,松任,マットウ,マットー
EOS
西 名詞,一般,*,*,*,*,西,ニシ,ニシ
松任  名詞,固有名詞,人名,姓,*,*,松任,マットウ,マットー
EOS

しかし、上記方法でもうまく地名を抽出できないケースも見られました。

例えば「島氏永」という駅は「島」と「氏永」から構成される駅名のようですが、「氏永」に関しては出力個数を最大の 512 に指定しても地名として出力することができませんでした。

# 以下、島/氏/永、島氏/永 を除外
島氏永   名詞,固有名詞,一般,*,*,*,島氏永,シマウジナガ,シマウジナガ
EOS
島 名詞,一般,*,*,*,*,島,シマ,シマ
氏永  名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
EOS
島 名詞,固有名詞,人名,姓,*,*,島,シマ,シマ
氏永  名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
EOS
島 名詞,固有名詞,地域,一般,*,*,島,シマ,シマ
氏永  名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
EOS
島 名詞,接尾,地域,*,*,*,島,トウ,トー
氏永  名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
EOS
島 名詞,接尾,地域,*,*,*,島,ジマ,ジマ
氏永  名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
EOS

www.meitetsu.co.jp

mecab-ipadic-NEologd では郵便番号データを参照しているとのことで、「大和町氏永」で解析すると、「大和町氏永」の単位では地名として登録されていることがわかりました。

echo '大和町氏永' | mecab -d /usr/lib/aarch64-linux-gnu/mecab/dic/mecab-ipadic-neologd/ -N 512 | grep '氏永'
大和町氏永 名詞,固有名詞,地域,一般,*,*,大和町氏永,ヤマトチョウウジナガ,ヤマトチョーウジナガ
氏永  名詞,固有名詞,人名,姓,*,*,氏永,ウジナガ,ウジナガ
# 以下同じ

www.post.japanpost.jp

これらの問題に対し、原因を以下のように推測しました。

  • mecab-ipadic-NEologd は地名だけでなく人名や新出の語もカバーする汎用性に富む辞書であるため、表現によっては地名以外が優先される
  • 地名の判定に使う辞書の元データとして考えると、郵便番号データはデータ上の名前の区切り方の粒度が粗いことがある
    • e.g. 「氏永」は「大和町氏永」として登録されている

これらの問題の解消のために、MeCab 用のより詳細な地名辞書を作成することで、このような地名を含んだワードの解析に特化した形態素解析を効率的に行うことを考えました。

アプローチ

辞書の作り方を確認する

MeCab の辞書の作り方は以下で解説されています。

taku910.github.io

内容を確認すると、以下のことがわかりました。

  • 辞書の元データ CSV で定義して、専用のコマンドで辞書データに変換する
  • 各カラムは以下のように定義されている

      表層形,左文脈ID,右文脈ID,コスト,品詞,品詞細分類1,品詞細分類2,品詞細分類3,活用型,活用形,原形,読み,発音
    

また、上記のカラムのうち、左・右文脈 ID は以下のようなもののようです。

左文脈IDは, その単語を左から見たときの内部状態IDです. 通常システム 辞書と同一場所にある left-id.def から該当する ID を選択します. 空にしておくと mecab-dict-index が自動的に ID を付与します.

右文脈IDは, その単語を右から見たときの内部状態IDです. 通常システム 辞書と同一場所にある right-id.def から該当する ID を選択します. 空にしておくと, mecab-dict-index が自動的に ID を付与します.

こちらは私には今一つ理解できなかったため、自動的に付与される ID を採用することにしました。

また、コストは以下のようなもののようです。

コストは,その単語がどれだけ出現しやすいかを示しています. 小さいほど, 出現しやすいという意味になります. 似たような単語と 同じスコアを割り振り, その単位で切り出せない場合は, 徐々に小さくしていけばいいと思います.

今回は地名を優先的に出すために小さめの値を定義したいですが、加減がわからないため、以下の自動推定機能で推定後に調整することにしました。

taku910.github.io

他のカラムは、単語に直接付随する情報のため、別途情報収集して生成することにしました。

日本の住所表記ルールを確認する

住所表記において今回情報収集が必要な範囲を決めるために、日本の住所表記の基本的なルールを確認しました。

日本の住所は一般に以下のようなルールで構成されるようです。

(ナビタイムジャパンより)

note.com

また、丁目や番地が数値でないケースがある、京都や北海道で見られる通りの名前基準の住所があるなど、例外ケースも多く見られます。

note.com

このような複雑なルールになっているのは、日本の住所の表記制度には不動産登記法、住居表示法などの複数の制度が関わるものの、現状行政は制度の一元管理をできていないという問題に起因するようです。

www.digital.go.jp

細かいルールへの対応の正確さ担保のために、できるだけ官公庁や官公庁に近い組織のデータを使うことにしました。

データセットを比較する

今回使用するデータセットの候補として、2つ挙がりました。

名前 作成者 URL
郵便番号データ 郵便局 https://www.post.japanpost.jp/zipcode/dl/kogaki-zip.html
アドレス・ベース・レジストリ デジタル庁 https://www.digital.go.jp/policies/base_registry_address

これらの提供データ形式や中身を確認し、比較した表が以下の通りです。

項目 郵便番号データ アドレス・ベース・レジストリ
都道府県 含む 含む
市区町村に含む 含む
市区町村に含む 含む
市区町村に含む 含む
大字・町 含む 含む
小字 含まないことが多い 含む
カナの扱い 半角カナ 全角カナ、表記揺れが多い(e.g. 一丁目→1チョウメ、1チヨウメ、イッチョウメ、イツチョウメ)。読み登録がないデータも多い
ローマ字の有無 なし あり(スペース区切りを含む)

www.post.japanpost.jp

www.digital.go.jp

これを踏まえて、以下のように郵便番号データをアドレス・ベース・レジストリで補完処することにしました。

  • 都道府県から大字・町まではアドレス・ベース・レジストリを元に地名の区切りデータを生成し、郵便番号データを加工する
    • アドレス・ベース・レジストリは行政区分は細かく分かれていて扱いやすい、ローマ字データから地名の分割をしやすいが、カナデータが怪しい・表記揺れで処理しにくい
  • 小字はアドレス・ベース・レジストリのみ利用する
    • 郵便番号データに存在しないデータのため

まとめ

MeCab による駅名の形態素解析において地名をうまく抽出できないケースを確認し、その原因と解決アプローチを検討しました。

調査の結果、以下のアプローチを検討しました。

  • mecab-ipadic-NEologd は汎用辞書であるため、地名特化の辞書を別途作成する
  • 辞書の元データは郵便番号データをベースに、アドレス・ベース・レジストリで補完する

次回は実際に辞書を作成し、「島氏永」などの問題ケースに対する精度を検証します。

Git submodule でリポジトリを分割管理する

概要

運用しているリポジトリの中で、異なる用途のコードが混在し、管理が複雑になっているものがありました。

この問題を解消するために、Git の Submodule を使ってリポジトリを目的ごとに分割管理する方法を試しました。

背景

従来は、ブログ記事を含むブログ管理用のディレクトリを以下のように構成して、Git で管理していました。

.
├── web/ # React.js でのレンダリング用ディレクトリ
│  ├── _articles/ # ブログ記事用ディレクトリ
│  ├── xxx/
│  └── xxx/
└── actions/ # はてなブログへの投稿用ディレクトリ
   ├── xxx/
   └── xxx/

このリポジトリでは、はてなブログへの投稿用コードと、React.js で書いたレンダリング用のコードの両方が1リポジトリに格納されており、それぞれ _articles を参照するようになっていました。

課題

上記運用の中で、_articles を参照するために2つの異なる目的のコードを1リポジトリに混在させることになり、管理が複雑であるという問題を抱えていました。

そこで、この問題を解消する解決策として以下を行うことを考えました。

  • _articles ディレクトリを別リポジトリに分離する(以下、分離したリポジトリをコンテンツ用リポジトリと呼ぶ)
  • はてなブログへの投稿用コードと React.js でのレンダリング用コードのリポジトリをそれぞれ分割する(それぞれ投稿用リポジトリ、レンダリング用リポジトリと呼ぶ)
  • 投稿用リポジトリとレンダリング用リポジトリからコンテンツ用リポジトリを読み込めるようにする

最終的に以下のような構造を目標としました。

# 投稿用リポジトリ
.
├── _articles/ # コンテンツ用リポジトリを参照
├── xxx/
└── xxx/

# レンダリング用リポジトリ
.
├── _articles/ # コンテンツ用リポジトリを参照
├── xxx/
└── xxx/

# コンテンツ用リポジトリ
.
├── 2024/
├── 2025/
└── 2026/

また、この環境構築のための要件の優先度を以下のように定めました。

  1. 端末交換時の環境構築も視野に入れた上で、環境構築の手間が少ないこと
    • 環境構築の手順書をメンテナンスしたくない
  2. サブリポジトリの取得の手間が少ないこと
  3. 投稿用リポジトリ・レンダリング用リポジトリは、同じコンテンツ用リポジトリを参照すること
    • 個別管理を避けるため、投稿用リポジトリ・レンダリング用リポジトリで独自に変更したコンテンツ用のディレクトリを持つことを禁止する
  4. 投稿用リポジトリ・レンダリング用リポジトリはコンテンツ用リポジトリの main ブランチの最新コミットを参照する
  5. 履歴をリポジトリ間で混在させない

アプローチ

リポジトリ内で別のリポジトリを Git で管理する方法として、Subtree と Submodule があります。

どちらも任意のリポジトリ配下に他のリポジトリを入れて管理できる技術ですが、今回の要件を踏まえると以下のような違いがありそうです。

項目 Subtree Submodule
親リポジトリ clone 後の初期セットアップ サブリポジトリの取得・更新でリポジトリ URL を省略する場合のみgit remote add <sub-tree-name> <url> git submodule update --init --recursive
サブリポジトリの更新の取得 git subtree pull --prefix=<path> [url] <branch> git submodule update --remote --recursive
履歴管理 親リポジトリの履歴にサブリポジトリの履歴をマージする
サブリポジトリのディレクトリでの変更も通常親プロジェクトブランチにコミットされる(サブリポジトリの履歴に反映は可能)
履歴はリポジトリごとに個別で管理する
サブリポジトリの更新方法 git subtree push --prefix=<path> [url] <branch> サブモジュールディレクトリにいる場合:git push
親ディレクトリにいる場合:git push --recurse-submodules=on-demand

training.github.com

上記の比較表を見る限り、環境構築からサブリポジトリの取得までの手間は大差ないようです。

しかし、履歴周りは以下の理由で Submodule の方が要件にあっていそうに見えました。

  • Subtree は変更対象となるファイルが属するディレクトリを意識していないと、意図せずサブリポジトリと異なるファイルを参照する可能性がある
    • 間違ったコミットを発生させない手段として .git/pre-commit の設定が考えられるが、.git 配下は基本的にリポジトリ管理外のため、初期セットアップの手間が増える
  • Subtree は親リポジトリにサブリポジトリをマージする仕組みのため、履歴の混在が発生する

そのため、今回は、Git の Submodule 機能を利用して、上記の課題を解消することにしました。

また、初期設定の手間の軽減は、シェルスクリプトを用意してリポジトリの管理に含めることで行うことにしました。

手順

0. コンテンツ用リモートリポジトリ・投稿用リモートリポジトリを作成する

コンテンツ・投稿用スクリプトが格納されているディレクトリを別リポジトリとして分離しました。

なお、レンダリング用リポジトリは従来のリポジトリをそのまま運用することにしました。

本作業完了後の構成は以下の通りになりました。

$ ls
blog-articles # コンテンツ用リポジトリ
blog-post # 投稿用リポジトリ
react-site # レンダリング用リポジトリ

1. 投稿用リポジトリ・レンダリング用リポジトリでサブモジュールを設定する

投稿用リポジトリ・レンダリング用リポジトリで以下のコマンドでサブモジュールの設定をしました。今回はブランチも固定するため、ブランチも合わせて指定しています。

$ git submodule add -b <ブランチ> <リポジトリ> <パス>

これを実行すると以下のように .gitmodules ファイルが作成されました。

[submodule "<パス>"]
    path = <パス>
    url = <リポジトリ>
    branch = <ブランチ>

また、VS Code で親リポジトリを開いてサブリポジトリのファイルを編集したときに、以下のようにブランチを分離して管理できていることを確認できました。

2. 投稿用リポジトリ・レンダリング用リポジトリで実行用スクリプトを作成する

投稿用リポジトリ・レンダリング用リポジトリで以下のような実行用のスクリプトを作成しました。

サブモジュール取得のコマンドで--init--remote を指定しておくことで、初回の取得でも2回目以降の取得でも同じように動くようにしています。

# サブモジュールの最新コミットを取得する
git submodule update --init --remote --recursive
# 以下、各リポジトリ用のコマンド

上記を用意しておくことで、環境構築の手間とサブリポジトリの取得の手間を低減できました。

$ . ./script.sh

まとめ

Git の Submodule を使うことで、リポジトリを目的ごとに分割管理できるようになりました。

また、実行用スクリプトに submodule の初期化・更新を含めることで、環境構築手順を別途メンテナンスする手間を低減できました。

VS Code + Dev Containers + textlint で複数環境でも文章構成ができる環境を構築した

概要

複数端末で VS Code を使って Git で管理している文書を触ることがありました。

このとき、環境に依らず textlint を効かせられるように、Dev Containers で環境の整備をしました。

背景

私は VS Code を使った開発が可能な環境として、2台のパソコンを使い分けています。

  • ノート PC(MacOS)
  • デスクトップ PC(Windows、WSL 導入済み)

また、この PC の運用に際し、従来では以下のように環境を整えていました。

  1. パッケージマネージャーを導入する
  2. 必要なライブラリをローカルにインストールする
  3. 2で生成された lock ファイルをほかの PC に展開する
  4. ほかの PC で lock ファイルを使ってライブラリをインストールをする
  5. 必要に応じて、環境間で設定を合わせる

また、個人向け環境であることもあり、これらの手順は明文化していませんでした。

課題

しかし、この運用には以下の問題がありました。

  • 手順1や5で環境差異が生まれかねない
  • 各設定を環境間で共有するための手間がかかる

実際 PC を変えた際には「おそらくこんな感じの手順だろう」と推測しながら環境を構築しました。そのため、最終的にはそれらしく動く環境になったものの、手戻りも多く発生しました。

また、設定は設定調整後にそれなりの期間が空いたのもあり、共有すべき設定がどれか判別できなくなっていました。

手段・アプローチ

上記の問題に際し、以下2点の解決策が考えられます。

  • 環境構築手順書を整備する
  • 環境構築を自動実行可能にする

このうち1つ目の案は以下の問題があると考えました。

  • 運用している環境差異の考慮が必要
  • パッケージマネージャーや拡張機能の更新に合わせて適宜手順書のメンテナンスが必要

これらを踏まえると、得るものの割に正しく動作する手順書を整備し続けるコストがかなり重く、現実的な解決策ではないと考えました。

そのため、2つめの案である「環境構築を自動実行可能にする」の方針で考えました。

環境構築の自動実行

今回は VS Code の拡張機能である Dev Containers を使って環境構築を自動実行することにしました。

Dev Containers は開発環境をコンテナとして VS Code で扱えるようになる VS Code の拡張機能です。

containers.dev marketplace.visualstudio.com

Dev Containers は Docker コンテナ上で VS Code を実行できる機能です。このとき Dev Containers は、環境設定用のディレクトリ配下に置かれているファイル(設定用の JSON ファイル + 必要に応じて Docker ファイル)を元に、VS Code を動作させるための環境を構築します。

システム要件を確認する限り、動作のために必要な追加ソフトウェアは Docker だけのようです。

code.visualstudio.com

Docker を使える環境の構築だけであれば、 Docker 公式マニュアルの手順から外れて環境構築することはないため、環境構築コストは比較的低いと考えました。

そのため、Dev Containers を使ってリポジトリごとに作業環境を定義することにしました。

手順

0. 前提

今回は textlint を npm で導入して動かせる環境を目標としました。

また、以下の状態から環境構築を始めました。

  • すべての端末に Docker をインストール済みである
  • すべての端末に VSCode をインストール済みである

また、全体的な手順は以下を参照しました。

code.visualstudio.com

containers.dev

1. Dev Containers をインストールする

VS Code で以下の拡張機能をインストールしました。

marketplace.visualstudio.com

2. Dev Containers 用の環境構築ファイルを作成する

Dev Containers では開発コンテナに必要なメタデータや設定を devcontainer.json へ記述します。

containers.dev

一般にリポジトリ直下の .devcontainer ディレクトリにファイルを配置するようなので、 .devcontainer に以下のような devcontainer.jsonを配置しました。

{
  "name": "Markdown Editing",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:24",
  "initializeCommand": "docker pull --platform linux/arm64 mcr.microsoft.com/devcontainers/javascript-node:24",
  "runArgs": [
    "--platform=linux/arm64"
  ]
}

今回指定しているイメージには Node.js 以外にも Git や ESLint が含まれていますが、一部利用する機能を含むことと設定の手軽さを加味して、そのまま利用することにしました。

イメージの詳細は以下に記載があるため、気になる方は以下を確認してみることを勧めます。

hub.docker.com

また、Apple Silicon 由来の Docker 周りのエラーを回避するため、linux/arm64 に対応している Docker image を取得し、引数に --platform=linux/arm64 を追加しました。

docs.docker.jp

3. Dev Containers 上で VS Code を起動する

この段階で一度 Dev Containers を起動できるようになっているため、VS Code 上で以下を実施しました。

  1. コマンドパレットを開く
  2. "Dev Containers: Reopen in Container" を実行

起動すると左下やヘッダーに「開発コンテナ:〜」のような表記が含まれるようになりました。

ターミナルを起動して Node.js、および npm のバージョンを確認してみたところ、以下のようにバージョンを確認できました。

node ➜ /workspaces/test $ node -v
v24.13.0
node ➜ /workspaces/test $ npm -v
11.6.2

また、初期ディレクトリ内を確認すると、.devcontainer ディレクトリのあるディレクトリの中身が表示されました。

node ➜ /workspaces/test $ ls -A
.devcontainer  .gitignore

4. 必要なパッケージをインストールする

ここからは textlint 本体とルールを構成するためのライブラリをインストールします。

ディレクトリ直下のpackage.json には textlint 用のライブラリを記載しました。

{
  "devDependencies": {
    "textlint": "15.5.0"
  }
}

また、 .devcontainer/devcontainer.jsonpostStartCommandnpm install を追加することで、コンテナを初めて起動した際にpackage.json に指定したライブラリをインストールするようにしました。

{
  "name": "Markdown Editing",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:24",
  "initializeCommand": "docker pull --platform linux/arm64 mcr.microsoft.com/devcontainers/javascript-node:24",
  "postStartCommand": "npm install",
  "runArgs": [
    "--platform=linux/arm64"
  ]
}

初回起動のみ実行であれば postStartCommand ではなく postCreateCommand でも良いのですが、package.json 更新時にコンテナの作り直しを失念する可能性があると考えました。そのため、コンテナ起動の度にコマンドを実行してくれる postStartCommand を利用することにしています。

上記の状態で、コマンドパレットで“Dev Containers: Rebuild Container Without Cache”を実行したところ、問題なく起動することを確認できました。

また、textlint のバージョン確認をしたところ、正常にコマンドを実行できることを確認できました。

node ➜ /workspaces/test $ npx textlint -v
v15.5.0

5. textlint を動作させる

ここでは textlint を動かすための設定をして VS Code 上で textlint を動作させました。

textlint の設定手順は本筋から離れてしまうため、割愛させていただきます。

textlint の設定手順が知りたい方は以下を参照してください。

github.com

textlint の設定完了後、動作確認用の文章ファイルを作成して textlint コマンドを実行したところ、正しくエラーが出ることを確認できました。

node ➜ /workspaces/test $ cat test.md 
食べれると思います。
node ➜ /workspaces/test $ npx textlint test.md 

/workspaces/test/test.md
  1:3  error  ら抜き言葉を使用しています。             ja-technical-writing/no-dropping-the-ra
  1:6  error  弱い表現: "思います" が使われています。  ja-technical-writing/ja-no-weak-phrase

✖ 2 problems (2 errors, 0 warnings, 0 infos)

ただし、この段階では拡張機能は入っていないため、VS Code でファイルを開いたときには以下のようにエラーが出てこない状態になっています。そのため、コンテナ内で利用する拡張機能の設定をしました。

6. コンテナ内で拡張機能を利用できるようにする

コンテナ内で導入する拡張機能も devcontainer.json 上で管理できます。

以下を確認すると、 customizations.vscode.extensions に利用する拡張機能を記載できるようです。

containers.dev

そのため、https://marketplace.visualstudio.com/items?itemName=3w36zj6.textlint の拡張機能の ID 3w36zj6.textlint を記載しました。

{
  "name": "Markdown Editing",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:24",
  "initializeCommand": "docker pull --platform linux/arm64 mcr.microsoft.com/devcontainers/javascript-node:24",
  "postStartCommand": "npm install",
  "runArgs": [
    "--platform=linux/arm64"
  ],
  "customizations": {
    "vscode": {
      "extensions": [
        "3w36zj6.textlint"
      ]
    }
  }
}

上記の状態で、コマンドパレットで“Dev Containers: Rebuild Container Without Cache”を実行して VS Code 上で先ほどのファイルを開いたところ、以下のように正しくエラーを表示できることを確認できました。

また、このファイル群を別 PC に共有して、別 PC でも開発コンテナを起動してみたところ、同じように動作することを確認できました。

まとめ

Dev Containers を利用することで、textlint での文章構成が可能、かつ複数端末で共有可能な環境を作成できました。

今回は textlint を例に説明しましたが、他の開発ツールや言語環境にも応用可能なため、他のリポジトリにも逐次適用していこうと考えています。

JavaScript でのゼロ除算の扱いを見る

概要

JavaScript でゼロ除算が発生した際に、分子に当たる数値によって返却される値が以下のように異なっていました。

> 1 / 0
=> Infinity
> -1 / 0
=> -Infinity
> 0 / 0
=> NaN

また、ゼロ除算の場合にエラーになる言語を主に取り扱ってきたので、上記仕様に麺を食らった部分もありました。そのため、JavaScript におけるゼロ除算の挙動の妥当性の調査をしてみました。

背景

今まで Java や Python、Ruby、JavaScript と扱ってきましたが、JavaScript 以外のいずれもがゼロ除算が発生した場合にエラーを返却する言語でした。

その中で、JavaScript ではゼロ除算が発生した場合にエラーを返さずに以下のような出力をすることを確認しました。

> 1 / 0
=> Infinity
> -1 / 0
=> -Infinity
> 0 / 0
=> NaN

上記の結果を見て「この出力が妥当であるか」という疑問を抱いたため、なぜこのような処理になっているかや、他の言語での扱いの調査をしました。

JavaScript におけるゼロ除算

MDN のリファレンス

MDN のリファレンスの除算演算子の項では「数値除算で 0 を割ると Infinity または -Infinity を返す」との記載がありました。

developer.mozilla.org

また、 NaN の項では NaN となるのは以下のケースとの記載がありました。

  • 数値が解釈不能
  • 結果が実数にならない数学演算
  • オペランドが NaN
  • 不確定形 (例えば 0 * Infinity または undefined + undefined)
  • 文字列が関わる加算以外の何らかの演算

developer.mozilla.org

上記のうちだと 0 / 0 のケースは消去法で「不確定形」のパターンであると考えました。

IEEE754 規格

Wikipedia で確認したところ、浮動小数点のゼロ除算は IEEE754 にて規定がされているようです。

ja.wikipedia.org

そこで IEEE754 規格を抜粋した資料を見たところ、例外の中にゼロ除算の扱いが含まれていることを確認できました。

計算 結果 備考
正数 / 0.0 +Inf ゼロ除算
負数 / 0.0 -Inf ゼロ除算
0.0 / 0.0 NaN 演算不可能

nalab.mind.meiji.ac.jp

JavaScript では数値は浮動小数点として取り扱われるようであるため、JavaScript のゼロ除算は IEEE754 に沿った扱いをしているようです。

developer.mozilla.org

参考

他の言語における浮動小数展のゼロ除算

調査対象選定

2025年11月の TIOBE 指数の Top 10 を参考に今回の調査対象選定対象としました。

www.tiobe.com

ただし、Delphi は利用環境を用意できないため、リファレンスの記述内容から推測するにとどめています。

調査方法

各言語ごとの実行環境を用意するのは手間がかかるため、基本的には paiza の実行環境で実行結果の確認をしました。

paiza.io

調査結果

Python3

いずれの場合でもゼロ除算はエラーになるようです。

print(1 / 0)
# => Traceback (most recent call last):
#   File "/workspace/Main.py", line 2, in <module>
#     print(1 / 0)
#           ~~^~~
# ZeroDivisionError: division by zero

print(0 / 0)
# => Traceback (most recent call last):
#   File "/workspace/Main.py", line 1, in <module>
#     print(0 / 0)
#           ~~^~~
# ZeroDivisionError: division by zero

print(1 / 0.0)
# => Traceback (most recent call last):
#   File "/workspace/Main.py", line 2, in <module>
#     print(1 / 0.0)
#           ~~^~~~~
# ZeroDivisionError: float division by zero

print(0 / 0.0)
# => Traceback (most recent call last):
#   File "/workspace/Main.py", line 2, in <module>
#     print(0 / 0.0)
#           ~~^~~~~
# ZeroDivisionError: float division by zero

C 言語

実行時エラーを確認するために、一度変数に格納してからゼロ除算を実行するようにしました。

もとの数値の型と出力の型によって出力結果が変わるようです。また、浮動小数点の計算の場合は、JavaScript と同じように IEEE754 に沿った出力になっているようです。

int x = 1, y = 0;
printf("%d, %f", x / y, x / y);
// => -380212504, 0.000000

int x = 0, y = 0;
printf("%d, %f", x / y, x / y);
// => -190564392, 0.000000

float x = 1, y = 0;
printf("%d, %f", x / y, x / y);
// => -306924424, inf

float x = 0, y = 0;
printf("%d, %f", x / y, x / y);
// => -1051881800, nan

C++

C 言語と同様、もとの数値の型と出力の型によって出力結果が変わり、浮動小数点の計算の場合は、IEEE754 に沿った出力になっているようです。

int x = 1, y = 0;
printf("%d, %f", x / y, x / y);
// => -952639672, 0.000000

int x = 0, y = 0;
printf("%d, %f", x / y, x / y);
// => -532579320, 0.000000

float x = 1, y = 0;
printf("%d, %f", x / y, x / y);
// => -484949864, inf

float x = 0, y = 0;
printf("%d, %f", x / y, x / y);
// => -312541176, nan

Java

整数型として定義した場合エラーになり、浮動小数点型として定義すると IEEE754 に従う結果になるようです。

int x = 1, y = 0;
System.out.println(x / y);
// => Exception in thread "main" java.lang.ArithmeticException: / by zero
//  at Main.main(Main.java:6)

int x = 0, y = 0;
System.out.println(x / y);
// => Exception in thread "main" java.lang.ArithmeticException: / by zero
//  at Main.main(Main.java:6)

float x = 1, y = 0;
System.out.println(x / y);
// => Infinity

float x = 0, y = 0;
System.out.println(x / y);
// => NaN

C

数値を整数型と浮動小数点型のどちらで定義するかで結果が変わるようです。浮動小数点型で定義している場合は IEEE754 で定義された出力形式問わずに処理するようです。

int x = 1, y = 0;
System.Console.WriteLine("{0:D}, {0:F}", x / y, x / y);
// => Unhandled Exception:
// System.DivideByZeroException: Attempted to divide by zero.
//   at Hello.Main () [0x00005] in /workspace/Main.cs:4 
// [ERROR] FATAL UNHANDLED EXCEPTION: System.DivideByZeroException: Attempted to divide by zero.
//   at Hello.Main () [0x00005] in /workspace/Main.cs:4 

int x = 0, y = 0;
System.Console.WriteLine("{0:D}, {0:F}", x / y, x / y);
// => Unhandled Exception:
// System.DivideByZeroException: Attempted to divide by zero.
//   at Hello.Main () [0x00005] in /workspace/Main.cs:4 
// [ERROR] FATAL UNHANDLED EXCEPTION: System.DivideByZeroException: Attempted to divide by zero.
//   at Hello.Main () [0x00005] in /workspace/Main.cs:4 

float x = 1, y = 0;
System.Console.WriteLine("{0:D}, {0:F}", x / y, x / y);
// => Infinity, Infinity

float x = 0, y = 0;
System.Console.WriteLine("{0:D}, {0:F}", x / y, x / y);
// => NaN, NaN

各言語でリファレンスを確認したところ、冒頭に提示したようなゼロ除算は以下のような処理になっているようです。

JavaScript

いずれの場合でも IEEE754 に沿った処理を行うようです。これは JavaScript が数値を Number 型という浮動小数点型として扱うためではないかと推測しています。

console.log(1 / 0)
// => Infinity

console.log(0 / 0)
// => NaN

console.log(1 / 0.0)
// => Infinity

console.log(0 / 0.0)
// => NaN

VisualBasic

いずれの場合でも IEEE754 に沿った処理を行うようです。

Dim x As Integer = 1
Dim y As Integer = 0
Console.WriteLine (x / y)
// => Infinity

Dim x As Integer = 0
Dim y As Integer = 0
Console.WriteLine (x / y)
// => NaN

Dim x As Single = 1
Dim y As Single = 0
Console.WriteLine (x / y)
// => Infinity

Dim x As Single = 0
Dim y As Single = 0
Console.WriteLine (x / y)
// => NaN

Delphi/Object Pascal

Delphi は利用環境を用意できないため、リファレンスでの確認としました。

www.embarcadero.com

エラーメッセージを見る限りゼロ除算ではエラーが出るようです。

docwiki.embarcadero.com

また、浮動小数点のゼロ除算でもエラーが出るようです。

docwiki.embarcadero.com

Perl

いずれの場合でもエラーを投げるようです。

my $x = 1;
my $y = 0;
printf("%d, %f", $x / $y, $x / $y);
// => Illegal division by zero at Main.pl line 3.

my $x = 0;
my $y = 0;
printf("%d, %f", $x / $y, $x / $y);
// => Illegal division by zero at Main.pl line 3.

my $x = 1;
my $y = 0.0;
printf("%d, %f", $x / $y, $x / $y);
// => Illegal division by zero at Main.pl line 3.

my $x = 0;
my $y = 0.0;
printf("%d, %f", $x / $y, $x / $y);
// => Illegal division by zero at Main.pl line 3.

SQL(MySQL のみ)

いずれの場合でも NULL を返却するようです。

SELECT (1 / 0);
// => NULL

SELECT (0 / 0);
// => NULL

SELECT (1 / 0.0);
// => NULL

SELECT (0 / 0.0);
// => NULL

またこの挙動は設定によって変わるようです。

dev.mysql.com

上記を見ると、「整数型の計算ではゼロ除算はエラーを投げ、浮動小数点では JavaScript と同様に IEEE754 に従う」、もしくは「いずれでもエラーを投げる」というパターンが多いようです。

しかし、他の言語での整数のように見えるような値で計算をしているにもかかわらず、浮動小数点と同じような挙動になるため、JavaScript の挙動が異質に見えるという現象が起きるようです。

まとめ

JavaScript におけるゼロ除算の取り扱いと他言語でのゼロ除算の取り扱いを確認しました。

JavaScript では意図的に小数として表現していない場合でも数値を浮動小数点の数値として取り扱うため、IEEE754 で定義されている浮動小数点でのゼロ除算の取り扱いに従っているようです。

また、他の言語でも浮動小数点の場合は同じ処理をするため、除算の分母に変数を入れる場合は、0が絶対入らないようにするか、前処理で0の場合に別の処理をして返すようにするかを意識した方が良さそうです。

転職活動を振り返る

概要

転職に伴い、現職を2025年12月末に退職することになりました。

初めての転職活動で、考えたことや感じたことをメモとして残しておきます。

背景

経歴

大まかに以下のような経歴になります。すべて同一企業での経歴です。

期間 職種
2020年6月-2021年3月 IT コンサルタント?
2021年4月-2025年12月 フロント・バックエンドエンジニア

社会人としては6年目になりますが、現職の初期配属の関係上システムエンジニアとしては1年短い5年目という経歴でした。縁がありシステムエンジニアではないスタートになっていましたが、もともとシステムエンジニア志望だったため、上長へ相談の上で転向をしています。

また、初年度は職種としては IT コンサルタントとなると思われますが、やっていたこととしては提案材料としての分析や調査で、主たる業務として想像する提案は上司が実施しておりました。そのため、誤解を避けるために実際の面接では職種名は挙げずに、業務内容のみさっと説明するにとどめていました。

転職理由

大きな理由は以下の2点でした。

  • スキルアップを目指したい
  • 給与を上げたい

基礎的なアプリケーションレイヤー以外の実践的な知識が乏しく、独学レベルのことはやってましたが、現職では実践的な経験は難しいというのがスキル面での理由でした。

また、給与面もこれまでの傾向からシミュレーションした結果、少なくとも現職のままでは苦しい結果になりそうだったというのがあります。

結果

タイムライン

実際のタイムラインは以下のようになりました。

時期 内容
2025年1月 職務経歴書作成開始
2025年9月上旬 エージェントサービス登録/履歴書・職務経歴書提出/エージェントとの初回面談
2025年9月中旬 書類選考申込
2025年9月下旬 面接開始
2025年10月下旬 内定

転職自体は年末年始には真剣に考えており、前段階の準備は先んじて進めた状態で本格的な転職活動に入りました。

やったこと

1. 職務経歴書の作成

転職に向けて本格的に動き出す前の2025年1月には、今まで携わったプロジェクトの内容を洗い出して職務経歴書を作成しました。

また、この内容は2-3か月に1回程度の頻度でアップデートするようにしていました。

転職直前に慌てて整理することがない状態にでき、気持ちの面で少し余力がある状態で望めたのではないかと思います。

2. エージェントサービス登録

初めての転職で、かつ就職活動の時も面接がかなりしんどかったので、サポートをもらうためにエージェントサービスに登録しました。

実際にはよくある質問と質問への回答案のレビューのような面接対策を重点的に行っていただき、気持ちに少し余力が持てた気がしています。

3. エージェントとの想定質問への回答案すり合わせと面接練習

回答内容のブラッシュアップのために文章を考えてエージェントに送付の上、レビューをしてもらいました。

図示や記号を用いた構造化は簡素でわかりやすいのですが、説明が不足すると記号の解釈にブレが生まれやすいので、文章だけで論理的なつながりを解釈できるレベルに落とし込んだ方がレビューの目的に適うと判断しました。そのため、話す内容をそのまま文章へと落とし込んだものをレビューしていただいてましたが、細かい文章を見てもらうというより論理的に不自然な点はないかという大枠の部分でレビューを受けることを意識していました。

一方、個人練習は文章をそのまま出すと文章を丸暗記しようとしてしまうため、構造化したものを用意して練習をしていました。

また、エージェントと一緒に想定質問をもとに模擬面接をしてみて、面接で無理なく答えられるような形式に再修正するということを行いました。私の場合は緊張で話そうとしていたことを思い出せなくなることが多いため、全体的に話す内容を減らして自信を持って話せるようにした方が良さそうというフィードバックをいただき、調整を入れました。

結果

最終結果

最終的には以下のようになりました。

  • 書類選考落ち:14社
  • 技術選考落ち:1社
  • 書類選考通過:7社
  • 面接落ち:3社
  • 辞退:3社
  • 内定:1社

第一志望は叶いませんでしたが、志望度の高い企業に内定が決まり、ホッとしています。

また、面接落ちは経験不足、実際に話を聞いた結果カルチャーフィットしてなさそうなど、自分でも落ちたことに納得のいく部分も大きかったと感じています。

やってよかったこと

転職エージェントの利用

就活の経験から面接にかなりの苦手意識があったため、エージェントにサポートいただけたことで、言っていることがおかしくないかといった不安を軽減できたと感じています。

また、スケジュールの調整などの連絡をある程度お任せできるたおかげで面接の準備に時間を割けたという面でもありがたかったです。

企業研究への AI 活用

いわゆる3C分析をしていたのですが、自分だけだと埋めるのに苦戦したため、テンプレート化して ChatGPT に書かせて、妥当性を確かめるということをしました。特に自社と顧客の覧はその会社自身、および採用サイトを指定の上で書かせることで、精度を上げることを試みました。

競合は、自分の質問の仕方では自社のサービスそのものや、競合の軸としてズレが大きそうなものを挙げる、サービスと企業が一致しないなど信頼性に難がありそうな出力が頻発していたため、最終的にはニュースサイトや就活系の比較サイトで調べていました。

やらなくてもよかったこと

AI を使った面接回答案の校正

職務経歴とか渡したり試行錯誤やってみましたが、考慮事項の抜け漏れ、やってないことまでやった風に書かれるなどがあり、面接に使うのは厳しい印象でした。この辺りは現状は素直にエージェントに頼ってしまった方が早く、精度も良い印象でした。

まとめ

現職でも、エンジニアとしてだけでなく社会人としても多くのことを学ばせていただきました。また、転職を選んだ手前不満がまったくなかったとは言えませんが、人間関係のトラブルにも見舞われることなく、少し惜しむ気持ちを抱えながらも円満退職となったことから、決して悪い会社ではなかったと考えております。

初めての転職活動はエージェントのサポートを受けながら無事完遂できました。今後は、より広い分野で実践経験を積極的に積み、エンジニアとしてのさらなる成長を目指したいと考えています。