2019年の勉強会を集計してみた〜都道府県別&キーワード別〜

この記事は[Ateam Hikkoshi Samurai Inc. & Ateam Connect Inc.（エイチーム引越し侍、エイチームコネクト） Advent Calendar 2019](https://qiita.com/advent-calendar/2019/hikkoshi) 15日目の記事になります。

## はじめに

皆さん勉強会はお好きでしょうか？
私は興味のあるものを見つけては社内外の開催に関わらず参加しています
それまで知らなかった技術や知見に出会えることがその醍醐味かなと思っています🙌

さて、2019年はどのような勉強会がどこで多く開催されたのでしょうか？
個人的に気になって集計してみたので結果とそのやり方を公開します！

## 結果

2019年に開催された勉強会(イベント)の集計結果です。

### 都道府県別開催数

まずは都道府県別の開催数です。
東京の開催数が多すぎて単純な数値にすると他県の棒が潰れてしまうため対数軸にしています。
さすが東京！

<img width="1070" alt="スクリーンショット 2019-12-15 18.00.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/5b3a1bd2-8414-243c-7f96-265fa2f4dc4b.png">

割合で見てもやはり東京が際立ちます。

<img width="784" alt="スクリーンショット 2019-12-15 18.17.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/a06d7c54-3246-4e1d-7004-ac4507bf5966.png">


上位10県の数値は以下の通りです。

| 都道府県   | 開催数      | 
|------|-------| 
| 東京都  | 23486 | 
| 大阪府  | 4360  | 
| 愛知県  | 1812  | 
| 福岡県  | 1294  | 
| 神奈川県 | 1012  | 
| 兵庫県  | 951   | 
| 京都府  | 681   | 
| 北海道  | 678   | 
| 沖縄県  | 395   | 
| 広島県  | 291   | 

### ソフトウェア業,情報処理・提供サービス業,インターネット附随サービス業の事業従事者数との割合

都道府県別のエンジニア1人あたりの勉強会開催数を出そうと思いました。
しかし、都道府県別のエンジニア人口のデータが見つからなかったため、[「平成 30 年特定サービス産業実態調査報告書（経済産業省）｣](https://www.meti.go.jp/statistics/tyo/tokusabizi/result-2/h30.html)から以下の業種の県別事業従事者数を合計した値を参考値として使いました。
なお、令和元年の数値は本記事執筆時点では公開されていません。

* ソフトウェア業
    * 日本標準産業分類に掲げる小分類 391－ソフトウェア業に属する業務を主業として営む
事業所
* 情報処理・提供サービス業
    * 日本標準産業分類に掲げる小分類 392－情報処理・提供サービス業に属する業務を主
業として営む事業所
* インターネット附随サービス業
    * 日本標準産業分類に掲げる小分類 401－インターネット附随サービス業に属する業務を
主業として営む事業所

結果は以下の通りです。`割合 = 開催数 / (上記3業種の事業従業者数の合計)`です。

<img width="1066" alt="スクリーンショット 2019-12-15 23.55.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/5fb4a591-7615-c8e5-d23a-ac23f3f8ae8e.png">

奈良の数値が高いのは少し意外でした。島根はRubyゆかりの地であることが影響しているのかもしれません。

### タグ(キーワード)別開催数

勉強会のタイトルや内容説明を基にタグ付けを行い集計しました。
なお、1つの勉強会に対して複数のタグがつくことがあります。

<img width="901" alt="スクリーンショット 2019-12-15 20.16.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/876133ba-8f1b-1e74-9bf2-f18b28d5b366.png">


`勉強会`、`プログラミング`、`初心者`、`Web`、`入門`、`初心者向け`などが多くの割合を締めていますが、もう少し具体的な技術の傾向を見たかったので、これらを除外して再度集計しました。

<img width="903" alt="スクリーンショット 2019-12-15 20.15.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/5f05c2c4-d1c1-6ef4-1136-1405293ab0b6.png">


これを見るとAIやIoTなどの近年のITトレンドが反映されていると感じます。

### 都道府県別タグ上位

東京、大阪、愛知でタグの多いものを見てみましょう。
なお、`勉強会`、`プログラミング`、`初心者`、`Web`、`入門`、`初心者向け`は除外しています。

#### 東京
<img width="820" alt="スクリーンショット 2019-12-15 20.27.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/df15a28c-b950-8014-c328-ae30fbef78c4.png">

#### 大阪
<img width="818" alt="スクリーンショット 2019-12-15 20.29.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/56b7f272-e91a-84ef-515c-8e19dd79b114.png">

#### 愛知

<img width="830" alt="スクリーンショット 2019-12-15 20.31.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/152ead24-384c-1fc5-8d55-e059deb5d09d.png">

例えば愛知だけは`PHP`が4位に入っているなど、4位以下で少しづつ地域の違いが出ているように見えます。


### 注意事項

以上の結果について、後述の集計方法に記載の通り厳密に勉強会だけを集計していない点はご了承ください。

## 集計方法

ここからはどうやって集計したかを説明します。

### 情報源

勉強会の情報は以下のサービスが提供しているAPIから取得しています。

* [ATND](https://atnd.org/)
* [connpass](https://connpass.com/)
* [Doorkeeper](https://www.doorkeeper.jp/)

今回の集計では勉強会=これらのサイトで登録されたイベントとしています。
厳密には懇親会などの勉強会でないイベントも含まれています。
また、ITに関係ないものも存在しますが、今回の集計では除外していません🙇

タグの一覧は[QiitaのAPI](https://qiita.com/api/v2/docs)から取得しています
今回はQiitaの記事数が多いタグ順に300件を取得するようにしました。

### 方法

データベースにイベント情報を保存し、[Metabase](https://www.metabase.com)で集計をしています。
テーブルは以下の通りです。

* prefectures:都道県マスタ
* events:勉強会(イベント)
* tags:タグマスタ
* event_tag:勉強会とタグの多対多関係を表現するための中間テーブル

<img width="533" alt="スクリーンショット 2019-12-15 13.10.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33324/42601d0c-688a-814f-1571-035cfc51cbf4.png">

### 処理

勉強会の情報をテーブルに保存するために以下の処理をしています。

1. 都道府県マスタのデータ登録
1. タグ一覧を取得
1. 勉強会情報を取得
1. 勉強会がどの都道府県で開催されているか判定する
1. 勉強会にタグ付けをする

今回は現在絶賛学習中のLaravelで作ってみました。
以下からはコードの話が中心になるので、興味のある方は読んでいただけると幸いです。

#### 都道府県マスタのデータ登録

都道府県データはlaravelのseederで初期登録できるようにしました。

```bash
# seederファイルの作成
$php artisan make:seeder PrefectureSeeder
```

seederの内容です。
なお、`prefectures.id`はJISで規定されている都道府県コードの値にしています。

```php
<?php
use Illuminate\Database\Seeder;
use App\Models\Prefecture;

class PrefectureSeeder extends Seeder
{
    public function run()
    {
        $prefectures = [
            [1, '北海道', 'ホッカイドウ', 'hokkaido'],
            [2, '青森県', 'アオモリケン', 'aomori'],
            [3, '岩手県', 'イワテケン', 'iwate'],
            //省略
            [45, '宮崎県', 'ミヤザキケン', 'miyazaki'],
            [46, '鹿児島県', 'カゴシマケン', 'kagoshima'],
            [47, '沖縄県', 'オキナワケン', 'okinawa']
        ];

        foreach ($prefectures as $prefecture) {
            Prefecture::updateOrCreate([
                'id' => $prefecture[0],
                'name' => $prefecture[1],
                'kana_name' => $prefecture[2],
                'english_name' => $prefecture[3]
            ]);
        }
    }
}
```

これで`$php artisan db:seed --class PrefectureSeeder`を実行するとデータが登録されるようになります。

#### タグ一覧を取得

コマンドでQiitaのAPIからタグを取得するようにします。
`$ php artisan make:command FetchQiitaTagsFromApi`を実行するとコマンドの雛形となるファイルが作成されます。

```php
<?php
namespace App\Console\Commands;
use App\Services\QiitaService;
use Illuminate\Console\Command;
use Exception;

class FetchQiitaTagsFromApi extends Command
{
    protected $signature = 'fetch:tags';//コマンド名

    protected $description = 'fetch Qiita tags from api';//説明

    public function __construct()
    {
        parent::__construct();
    }

    public function handle()
    {
        try {
            QiitaService::fetchTagsFromApi(1, 3);
        } catch (Exception $e) {
            $this->error($e->getMessage());
        }
    }
}
```

QiitaServiceではAPIで取得した結果を、DBに保存しています。
なお、`$jsonArray = ApiClient::getJsonArray($url, $params, 60000);`の60000はAPIアクセスのディレイ時間(ms)です。
値はAPIの利用規約やrobots.txtに従って設定します。

```php
<?php
namespace App\Services;
use App\Repositories\TagRepository;
use App\ApiClients\ApiClient;

class QiitaService
{

    const QIITA_HOST = 'https://qiita.com';
    const API_PATHS = ['tags' => '/api/v2/tags',];

    public static function fetchTagsFromApi(int $startPage = 1, int $endPage = 10)
    {

        $url = self::QIITA_HOST . self::API_PATHS['tags'];
        $params = ['per_page' => '100', 'sort' => 'count'];
        $tagRepository = app(TagRepository::class);

        for ($i = $startPage; $i <= $endPage; $i++) {
            $params['page'] = $i;
            $jsonArray = ApiClient::getJsonArray($url, $params, 60000);
            $tagNames = array_column($jsonArray, 'id');
            $tagRepository->saveTagsFromNames($tagNames);
        }
    }
}
```

ApiClientはAPIアクセスを実行するクラスです。
HTTPクライアントのライブラリ[Guzzle](http://docs.guzzlephp.org/en/stable/)を使用しています。
後述するイベント情報の取得でもこのクラスを使用しています。

```php3
<?php
namespace App\ApiClients;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;

class ApiClient
{
    public static function getJsonArray(string $url, array $queryParams, float $delay = 0.0, array $headers = [])
    {
        try {
            $client = new Client();
            $res = $client->request('GET', $url, ['query' => $queryParams, 'delay' => $delay, 'headers' => $headers]);
            return json_decode($res->getBody(), true);
        } catch (ClientException $e) {
            throw $e;
        }
    }
}
```

#### 勉強会情報を取得

やっていることはタグ一覧を取得でやっていることとほぼ同じです。(APIから情報取得->DBに保存)
ここではDoorkeeperのイベント取得処理を例に出していますが、他のサイトの場合も行っていることは基本的に同じです。

```php
<?php
namespace App\Console\Commands;
use App\Services\DoorkeeperService;
use Carbon\Carbon;
use Illuminate\Console\Command;

class FetchDoorKeeperEventsFromAPI extends Command
{

    protected $signature = 'fetch:doorkeeper
    {--p|page= : the page offset of the results }
    {--l|locale=ja : the localized text for an event }
    {--o|sort=starts_at : The order of the results(One of published_at, starts_at, updated_at) }
    {--s|since=-1month : Only events taking place during or after this date will be included (YYYYMMDD) }
    {--u|until= : Only events taking place during or before this date will be included (YYYYMMDD) }
    {--k|keyword= : Keyword to search for from the title or description fields }
    {--g|expand_group=0 : Expands the group object(0:false, 1:true) }
    {--a|all=1 : paging to get all records(0:false, 1:true) }';

    protected $description = 'fetch doorkeeper events from api and store to DB';

    public function __construct()
    {
        parent::__construct();
    }

    public function handle()
    {
        $queryParams = $this->getQueryParamFromOptions();
        DoorkeeperService::fetchEventsFromAPI($queryParams, !!$this->option('all'));
    }

    private function getQueryParamFromOptions()
    {

        $options = $this->options();

        //除外オプション
        $excludeOptions = ['expand_group', 'all'];
        foreach ($excludeOptions as $excludeOption) {
            unset($options[$excludeOption]);
        }

        //オプションとパラメータの対応
        $optionToParam = [
            'page' => 'page',
            'locale' => 'locale',
            'sort' => 'sort',
            'since' => 'since',
            'until' => 'until',
            'keyword' => 'q',
            'expand_group' => 'expand[]',
        ];

        $params = [];
        foreach ($options as $option => $value) {
            if ($value) {
                $params[$optionToParam[$option]] = $value;
            }
        }

        if ($options['since']) {
            $jst = new Carbon($options['since']);
            $params['since'] = $jst->setTimezone('UTC')->format('Y-m-d\TH:i:s\Z');
        }

        if ($options['until']) {
            $jst = new Carbon($options['until']);
            $params['until'] = $jst->setTimezone('UTC')->format('Y-m-d\TH:i:s\Z');
        }

        if ($this->option('expand_group')) {
            $params[$optionToParam['expand_group']] = 'group';
        }

        return $params;
    }
}
```

`DoorkeeperService`ではDoorkeeperのイベント情報の取得と保存を行います。
APIから返される情報に都道府県だけの情報は存在しないので、取得する際に`PrefectureService`で判定をしています。

```php
<?php
namespace App\Services;
use App\Models\Event;
use Carbon\Carbon;
use App\ApiClients\ApiClient;

class DoorkeeperService
{
    private const EVENT_API_URL = 'https://api.doorkeeper.jp/events';

    static public function fetchEventsFromAPI(array $params, bool $isAllPage)
    {
        if ($isAllPage) {
            for ($p = 1; true; $p++) {
                $params['page'] = $p;
                $headers = ['Authorization' => 'Bearer ' . config('env.doorkeeper_api_token')];
                $jsonArray = ApiClient::getJsonArray(self::EVENT_API_URL, $params, 5000.0, $headers);
                if (empty($jsonArray)) {
                    break;
                }
                self::updateOCreateEventsFromAPIResult($jsonArray);
            }

        } else {
            $jsonArray = ApiClient::getJsonArray(self::EVENT_API_URL, $params, 5000.0);
            self::updateOCreateEventsFromAPIResult($jsonArray);
        }
    }

    static private function updateOCreateEventsFromAPIResult(array $eventJson)
    {
        foreach ($eventJson as $record) {
            $event = $record['event'];
            //住所、または緯度経度から都道府県を判定
            $prefecture = $event['address'] ? PrefectureService::getPrefectureFromAddress($event['address']) : null;
            if (!$prefecture && $event['lat'] && $event['long']) {
                $prefecture = PrefectureService::getPrefectureFromCoordinates($event['lat'], $event['long']);
            }

            Event::updateOrCreate(
                [
                    'site_name' => Event::DOORKEEPER,
                    'event_id' => $event['id']
                ],
                [
                    'title' => $event['title'],
                    'description' => $event['description'],
                    'event_url' => $event['public_url'],
                    'prefecture_id' => $prefecture ? $prefecture->id : null,
                    'address' => $event['address'],
                    'place' => $event['venue_name'],
                    'lat' => $event['lat'],
                    'lon' => $event['long'],
                    'started_at' => self::formatUtcTimeToMySqlDateTime($event['starts_at']),
                    'ended_at' => self::formatUtcTimeToMySqlDateTime($event['ends_at']),
                    'limit' => $event['ticket_limit'],
                    'participants' => $event['participants'],
                    'waiting' => $event['waitlisted'],
                    'group_id' => $event['group'],
                    'event_created_at' => self::formatUtcTimeToMySqlDateTime($event['published_at']),
                    'event_updated_at' => self::formatUtcTimeToMySqlDateTime($event['updated_at']),
                ]
            );
        }
    }

    static private function formatUtcTimeToMySqlDateTime(string $date)
    {
        $carbon = new Carbon($date);
        return $carbon->setTimezone('JST')->format('Y-m-d H:i:s');
    }
}
```

#### 勉強会がどの都道府県で開催されているか判定する

`getPrefectureFromAddress`は住所から都道府県を判定する処理に使います。
`getPrefectureFromCoordinates`は[HeartRails Geo API](http://geoapi.heartrails.com/api.html#geolocation)で緯度経度から都道府県を判定する処理に使います。

```php
<?php
namespace App\Services;
use App\Models\Prefecture;
use App\ApiClients\ApiClient;

class PrefectureService
{

    public static function getPrefectureFromAddress(string $address = ''): ?Prefecture
    {
        $prefectures = Prefecture::select(['id', 'name'])->get();
        foreach ($prefectures as $prefecture) {
            if (mb_strpos($address, $prefecture->name) !== false) {
                return $prefecture;
            }
        }
        return null;
    }

    public static function getPrefectureFromCoordinates(float $lat, float $lon): ?Prefecture
    {
        $url = config('const.geoapi_url');
        $params = [
            'method' => 'searchByGeoLocation',
            'x' => $lon,
            'y' => $lat,
        ];
        $jsonArray = ApiClient::getJsonArray($url, $params, 5000.0);

        if (isset($jsonArray['response']['error'])) {
            return null;
        }

        //1件目のデータの都道府県名
        $firstPrefectureName = $jsonArray['response']['location'][0]['prefecture'];
        $prefecture = Prefecture::where('name', $firstPrefectureName)->first();
        return $prefecture;
    }
}
```
ただ、これらの処理で判定できないもの(開催住所が市や区ではじまり、緯度経度情報もない)は手動でデータを整えました^^;
政令指定都市や東京都の区くらいはマスタ化して判定を自動化してもよかったかもと思いました。
また、判定できなかったものの中にはそもそもZoomなどで完全にオンラインで開催されている場合がありました。
将来的にはどこに住んでいる人でも参加しやすいオンライン形式が増えていくのかもしれません。

#### 勉強会にタグ付けをする

APIで取得できるイベント情報にタグ情報はないため自前で付与します。
イベント情報にはタイトル、キャッチ、説明があり、これらの中にタグに該当するキーワードがあるかで判定しています。
ただし、例えば`R`や`Go`の場合、単純にこれらの文字列があるかで判定すると誤ってタグ付けされる可能性が高いため正規表現で検索するようにします。`Go`の場合は`/golang|go言語/ui`としました。
他にも`AI`は`/AI/u`として小文字を含まないようにするなどキーワードごとに調整しています。

この正規表現はDBの`tags.pattern`に保存しています(ここは手作業^^;)
また、説明は全部でなく最初の200文字のみを検索対象にしています。

```php
<?php
namespace App\Console\Commands;
use App\Models\Tag;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use App\Models\Event;
use Illuminate\Support\Facades\DB;

class TaggingForEvents extends Command
{
    protected $signature = 'tagging:events';

    protected $description = 'tagging for events';

    public function __construct()
    {
        parent::__construct();
    }

    public function handle()
    {
        $characterLimit = 200; //descriptionのタグの探索範囲
        $events = Event::all();
        $tags = DB::table('tags')->orderBy('id')->limit(400)->get();

        foreach ($events as $event) {
            $tagIds = [];
            foreach ($tags as $tag) {
                $pattern = $tag->pattern;
                $description = mb_substr(strip_tags($event->description), 0, $characterLimit);
                if (preg_match($pattern, $event->title) || preg_match($pattern, $event->catch)
                    || preg_match($pattern, $description)) {
                    $tagIds[] = $tag->id;
                }
            }
            $event->tags()->sync($tagIds);
        }
    }
}
```

### Metabaseでの集計

集計結果でお見せしたグラフはMetabaseで作成しています。
dockerが使える環境であれば、以下のコマンドで簡単にMetabaseを実行することができます。
ただし、このやり方の場合はコンテナを止めるとMetabaseで保存した集計は全て消えるのでご注意ください。

```bash
$ docker run -d -p 12345:3000 --name metabase metabase/metabase
```

後はブラウザから`localhost:12345`にアクセスすれば、Metabaseの画面が表示されます。
画面上でユーザ登録と接続先DBの情報を入力してやればすぐに使い始めることができます。
ちなみにコンテナからlocalホストへ接続する場合、macであれば`docker.for.mac.localhost`をwindowsであれば`docker.for.win.localhost`をホスト名に指定します。


## まとめ

* ATND、connpass、DoorKeeperから勉強会(イベント)の情報を取得し集計した。
* イベントに対して、Qiitaのタグでタグ付けした。
* 東京、大阪、愛知が都道府県別のイベント開催数のトップ3
* 全国的にAI(Python、機械学習、AIなど)に関連するイベントが多く開催された。



## お知らせ
エイチームグループでは一緒に活躍してくれる優秀な人材を募集中です。
興味のある方はぜひとも[エイチームグループ採用ページ](https://www.a-tm.co.jp/recruit/requirements/career/lifestylesupport-webengineer/)よりご応募ください！

Qiita Jobsの[エイチーム引越し侍社内システム企画 / 開発チーム](https://jobs.qiita.com/employers/a-hikkoshi/development_teams/176)、[社内システム開発エンジニアを募集！](https://jobs.qiita.com/postings/309)からチャットでご質問いただくことも可能です！

## 明日

明日は後輩の面倒見の良さが評判の @umesour  さんの記事です！
どんな記事を書いてくださるのかとても楽しみです！！
乞うご期待！！