はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

Elasticsearch公式JavaScriptクライアントをブラウザで使ってみる&Vue.js/Vuetifyでなんちゃって検索サービスSPA風をスクラッチ

はじめに

Elasticsearchには、JavaScript版の公式クライアントライブラリがあるのですが、ブラウザでも動作するようなので、それで主に検索中心に少し遊んでみました。 といっても、ここ↓にある例のまんまです。

www.elastic.co

ただし、さすがに上記のものまんまでは記事としてはウリが弱いです。

そこで、Vue.jsとその有名UIフレームワークであるvuetifyを付け焼き刃で付け足してみて、検索サービスっぽい絵面にしてみました。

f:id:azotar:20190305203709p:plain

Elasticsearchのエンドポイントへhttp/httpsが届く圏内にある端末であれば、後述の内容のコピペ+生のHTMLをちょいと修正するだけで、(ほぼ)ブラウザだけで、Elasticsearchに接続して、多少自分でゴリゴリ・ごにょごにょできるサンドボックスができるねという話です。

この記事の隠れ想定シーン

長くなるので詳しくは述べませんが、この記事は気持ち的には次の記事の続きです。

itdepends.hateblo.jp

本格的ツールを持ち込めない謎区画に、HTMLファイルならただのテキストファイルですからってことで正規に持ち込める中で苦しくも頑張ろう...というようなシーンを意識しています。

その割にVue.jsなどを使って少しズル?をしています。

が...これらも含めて生のjsファイルも持ち込めば、本当に端末のデスクトップでHTMLファイルのアイコンをダブルクリックしても動くものになっています。 *1

ライブラリ等について

ElasticsearchのJavaScript版の公式クライアントについては、様々なAPIが提供されていますが、今回は、クエリDSLがそのまま使えるsearch apiのみを使いました。*2

www.elastic.co

Vue.jsについてはここでは説明しませんが、見てのとおりCDNから読み込みすることとしました。つまりビルド環境などは特に用いません。

vuetifyは、Vue.jsをプラットフォームとしたUIフレームワークです。雑にいうと、マテリアルデザイン風のパーツをBootstrapライクなグリッドシステム型など指定でCSSを用意せずに、それっぽく組み合わせてUIが作成できます。

なお、vuetifyも、CDNから読み込みしています。なのでひとまずエコシステム等を意識せずにひとまず動かしてみることができます。

また、ここでは深くふれませんが、代わりにvuetifyの公式リファレンスへリンクしておきます。 Getting startedのページを流し読みして、その後、画面左のメニューのところで、UI Componentsのところをつまみ食いして覚えていくのが近道だと思いました。

vuetifyjs.com

予備知識: 公式クライアントを用いた最小限の動作方法の例

この手のものは、公式ドキュメントが充実しててもしてなくても、それが動いてみるまではなかなかに厄介です。

試行錯誤の結果(?)、これがおおよそ最小限の検索の例かな...というところを予備知識として示しておきます。

Elasticsearchブラウザ版 JavaScriptクライアントライブラリのミニマム使用例

下記をhoge.htmlなどにコピペ保存して、インデックスや接続先を所定のものに書き換えてください。

ブラウザを起動して、consoleを開くと、検索結果のJSONが返ってくるのが分かると思います。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
</head>
<body>
    ここには何もでません。<br>
    ブラウザのConsoleをごらんください。
</body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="./elasticsearch.jquery.min.js"></script><!-- https://github.com/elastic/bower-elasticsearch-js -->
<script>
    const ES_HOST = { hosts: 'http://localhost:9200' }
    const INDEX_NAME = 'es_index'

    const client = new $.es.Client(ES_HOST)
    async function search() {
        let rslt = await client.search(
            {
                index: INDEX_NAME,
                body: {
                    query: { match_all: {} }
                }
            }
        )
        console.info(JSON.stringify(rslt))
    }

    search()
</script>
</html>

今回用いるElasticsearchのインデックスの塩梅

さて、本題に戻ります。

インデックス名は、es_indexとしました。

また、ここでは簡単のため、localhost:9200でElasticsearchのエンドポイントが待っていることとしました。

なお、インデックスデータですが「livedoorグルメDataSets」 http://blog.livedoor.jp/techblog/archives/65836960.html

を次のイメージのJSONファイルに変換して、これをバルクロードしたものを元ネタとしたという体で記載しています。

{"index": { "_id":999 }}
{"id":105,"name":"お店の名前","alphabet":null,"name_kana":"おみせのなまえのかな","pref_id":13,"area_id":1,"category_id1":509,"category_id2":0,"category_id3":0,"category_id4":0,"category_id5":0,"zip":"111-9999","address":"中央区金座1-2-3","north_latitude":"99.40.09.271","east_longitude":"199.46.66.911","description":" 金座駅A13出口徒歩1分。","special_count":1,"menu_count":3,"fan_count":0,"name_area":"銀座、新橋、有楽町","cats":["地酒","","","",""],"name_pref":"東京都"}

参考:livedoorデータセットを取り込むための手順の例

なお、elasticsearch.jsはCDNが分からなかったので、ローカルに置いています。

Vue.jsとvuetifyを使って検索サービスの雰囲気を出してみる(本記事の本題)

ということで、SPAならぬ、ほぼシングルファイル1枚資材による検索サービス風、PoC、トラブル調査のツールです。

なお、Elasticsearchに繋げなくても、ひとまず、インターネットに繋がっていれば、それっぽい検索窓は表示されると思います。

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.min.css" rel="stylesheet">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
</head>
<style>
    html {
        overflow-y: scroll;
    }
</style>
<body>
    <div id="app" app-data="true">
        <v-container>
            <v-card>
                <v-flex>
                    <v-card color="grey lighten-4" flat>
                        <v-flex px-4 py-4>
                            <v-layout column>

                                <v-layout row>
                                    <v-flex md5>
                                        <v-text-field v-model="sbox.kw1" label="Find" solo clearable autocomplete="on"
                                            list="sugList" @input="sbox_suggest(1)">
                                        </v-text-field>
                                        <datalist id="sugList">
                                            <option v-for="(item, index) in sugList" :key="index" :value="item"></option>
                                        </datalist>
                                    </v-flex>
                                    <v-flex md5>
                                        <v-text-field v-model="sbox.kw2" label="Near" solo clearable autocompleate="on"
                                            list="sugList2" @input="sbox_suggest(2)"></v-text-field>
                                        <datalist id="sugList2">
                                            <option v-for="(item, index) in sugList2" :key="index" :value="item"></option>
                                        </datalist>
                                    </v-flex>
                                    <v-flex md2>
                                        <v-btn color="blue" v-on:click="es_search(0)">
                                            <v-icon color="white">search</v-icon><span class="white--text">Search</span>
                                        </v-btn>
                                    </v-flex>
                                </v-layout>

                                <v-layout row>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Restaurants">
                                        </v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Home Services"></v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="Auto Services"></v-select>
                                    </v-flex>
                                    <v-flex md3>
                                        <v-select :items="s_items" solo label="More..."></v-select>
                                    </v-flex>
                                </v-layout>

                            </v-layout>
                            <v-layout>
                                <v-flex>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot genre
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot word
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>hot tag
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>sponsered word
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>sponsered filter
                                    </v-btn>
                                    <v-btn flat small color="blue" class="font-weight-bold">
                                        <v-icon>highlight</v-icon>recomend...
                                    </v-btn>
                                </v-flex>
                            </v-layout>

                        </v-flex>
                    </v-card>
                </v-flex>


                <v-flex v-if="total" px-4 py-4>
                    <v-card dark tile flat color="grey lighten-4">
                        <v-layout row>
                            <v-flex md10 grow px-4 py-1>
                                <v-card-text class="black--text">
                                    {{cond}} {{total}}件ヒットしました: 現在{{ctrl.current}}件表示中
                                </v-card-text>
                            </v-flex>
                            <v-flex md1 shrink>
                                <v-menu offset-y open-on-hover>
                                    <v-btn slot="activator" flat color="black">
                                        Sort by<v-icon>expand_more</v-icon>
                                    </v-btn>
                                    <v-list>
                                        <v-list-tile v-for="(item, index) in s_items" :key="index" @click="">
                                            <v-list-tile-title>選択肢</v-list-tile-title>
                                        </v-list-tile>
                                    </v-list>
                                </v-menu>
                            </v-flex>
                        </v-layout>
                    </v-card>
                    <v-flex px-4>
                        <v-breadcrumbs :items="b_items" divider=">"></v-breadcrumbs>
                    </v-flex>

                    <v-flex px-4 py-2>
                        <v-layout column>
                            <v-flex>
                                <v-dialog v-model="push_me_dialog" width="500">

                                    <v-btn color="white" small slot="activator">
                                        <v-icon>flag</v-icon><span>Push Me!</span>
                                    </v-btn>


                                    <v-card>
                                        <v-card-title class="headline grey lighten-2" primary-title>
                                            探すエリアを変更
                                        </v-card-title>

                                        <v-card-text>
                                            下記に選択肢を表示し、検索BOX2の条件を選択し直します。レストラン検索なら住所やエリア、駅などの一覧を表示。
                                        </v-card-text>

                                        <v-divider></v-divider>

                                        <v-card-text>
                                            住所一覧/エリア一覧/駅一覧...
                                        </v-card-text>
                                        <v-card-actions>
                                            <v-spacer></v-spacer>
                                            <v-btn color="primary" flat @click="push_me_dialog = false">
                                                <v-icon>search</v-icon> 再検索
                                            </v-btn>
                                        </v-card-actions>
                                    </v-card>
                                </v-dialog>

                                <template v-for="n in 3">
                                    <v-btn color="white" small>
                                        <v-icon>flag</v-icon><span>Push</span>
                                    </v-btn>
                                </template>

                                <template v-for="n in 2">
                                    <v-menu offset-y open-on-hover>
                                        <v-btn slot="activator" small color="white">
                                            <v-icon>flag</v-icon>
                                            SELECT
                                        </v-btn>
                                        <v-list>
                                            <v-list-tile v-for="(item, index) in s_items" :key="index" @click="">
                                                <v-list-tile-title>単一絞り込み</v-list-tile-title>
                                            </v-list-tile>
                                        </v-list>
                                    </v-menu>
                                </template>

                                <template v-for="n in 3">
                                    <v-btn color="white" small>
                                        <v-icon>flag</v-icon><span>Push</span>
                                    </v-btn>
                                </template>
                            </v-flex>
                            <v-flex>
                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 4">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>
                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <v-btn color="white" small>
                                        <v-icon>schedule</v-icon><span>OpenNow</span>
                                    </v-btn>
                                </v-btn-toggle>

                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 3">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>

                                <v-btn-toggle v-model="toggle_multiple" multiple>
                                    <template v-for="n in 2">
                                        <v-btn color="white" small>
                                            <v-icon>flag</v-icon><span>PUSH</span>
                                        </v-btn>
                                    </template>
                                </v-btn-toggle>

                            </v-flex>
                            <v-flex>
                                <template v-for="(v,k) in aggs">
                                    <v-btn small outline round v-on:click="es_search(0,k,v2.key)" v-for="(v2,i2) in v.buckets"
                                        v-bind:key="v2.key">
                                        {{v2.key}}
                                    </v-btn>
                                </template>
                            </v-flex>
                        </v-layout>
                    </v-flex>




                    <hr>
                    <v-list two-line>
                        <template v-for="(item, index) in hits">
                            <v-subheader v-if="item.header" :key="item.header">
                                {{ item.header }}
                            </v-subheader>

                            <v-divider v-else-if="item.divider" :key="index" :inset="item.inset"></v-divider>

                            <v-list-tile v-else :key="item._source._id" avatar @click=""> 

                                <v-list-tile-avatar>
                                    <v-icon>card_giftcard</v-icon>
                                </v-list-tile-avatar>
                                <v-list-tile-content>
                                    <v-list-tile-title>
                                        <span>{{index + 1}}</span>.
                                          {{item._source.name}}({{item._source.name_kana}})
                                        <!-- //CONFIG -->
                                    </v-list-tile-title>
                                    <v-list-tile-sub-title v-if="item._source.cats && item._source.location">
                                        レコードID:{{item._id}} / {{item._source.address}}
                                        /カテゴリ:{{item._source.cats.join(',')}}/緯度経度:{{item._source.location.join(',')}}/スコア:{{item._score}}
                                    </v-list-tile-sub-title>
                                </v-list-tile-content>
                            </v-list-tile>
                        </template>
                        <span id="rslt_jump_anchor"></span>

                        <v-list-tile>
                            <v-list-tile-content>
                                <v-layout v-show="ctrl.current != total">
                                    <v-fab-transition>
                                        <v-btn color="grey lighten-1 " dark v-on:click="fetch_by_pushed" href="#rslt_jump_anchor">
                                            <v-icon>flash_on</v-icon>次のページを読み込み
                                        </v-btn>
                                    </v-fab-transition>
                                </v-layout>
                            </v-list-tile-content>
                        </v-list-tile>

                    </v-list>

                    <v-layout column style="position: fixed;  bottom: 0px; right: 50px; z-index: 999; ">
                        <v-fab-transition>
                            <v-btn v-show="current_scroll_position > 800 && ctrl.expand_less.valid" color="red lighten-1"
                                dark fab href="#app">
                                <v-icon>expand_less</v-icon>
                            </v-btn>
                        </v-fab-transition>
                    </v-layout>


                </v-flex>

                <div v-show="total==0">
                    検索結果が0件でした。以下の...
                    <v-layout column>
                        <v-btn flat small color="primary"> <v-icon>highlight</v-icon>もしかして(正確な用語に補正、ピボット、検索範囲を拡大...)</v-btn>
                        <v-btn flat small color="primary"> <v-icon>highlight</v-icon>関連検索ワード(履歴、ピボット、検索範囲を拡大...)</v-btn>
                    </v-layout>
                </div>

            </v-card>
        </v-container>

    </div>

</body>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify/dist/vuetify.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="./elasticsearch.jquery.min.js"></script><!-- https://github.com/elastic/bower-elasticsearch-js -->
<script>

    //CONFIG
    const ES_HOST = { hosts: 'http://localhost:9200' }
    const INDEX_NAME = 'es_index'

    class OrganicSearchQueryBuilder {
        constructor() {
            //CONFIG
            this.SIZE = 10
            this.KWBOX1_FIELDS = ["name"]
            this.KWBOX2_FIELDS = ["address"]
            this.AGGS_FIELDS = ["cats"]
            this.AGGS = {}
            for (let v of this.AGGS_FIELDS) {
                this.AGGS[v] = { terms: { field: v } }
            }
            this.postFilterWordsSet = new Set()
        }
        getMatchDSL(kw, fields) {
            return { multi_match: { query: kw, fields: fields, operator: "and" } }
        }
        getAggsDSL(from, pfw) {
            if (from == 0 || from === undefined) {
                this.postFilterWordsSet = new Set()
            }
            if (pfw !== undefined) {
                this.postFilterWordsSet.add(pfw)
            }
            if (this.postFilterWordsSet.size == 0) {
                return { bool: { must: [{ match_all: {} }] } }
            }
            let postFilter = { bool: { must: [] } }
            for (let i of this.postFilterWordsSet.values()) {
                postFilter.bool.must.push({ match: { [this.AGGS_FIELDS[0]]: i } }) //CONFIG
            }
            return postFilter
        }
        get2BoxQuery(kw1, kw2, from, pfw) {
            let b = {
                size: this.SIZE, from: from,
                query: {
                    bool: {
                        must: [
                            (kw1 === undefined || kw1 == null || kw1 == '') ? { match_all: {} } : this.getMatchDSL(kw1, this.KWBOX1_FIELDS),
                            (kw2 === undefined || kw2 == null || kw2 == '') ? { match_all: {} } : this.getMatchDSL(kw2, this.KWBOX2_FIELDS)
                        ]
                    }
                },
                post_filter: this.getAggsDSL(from, pfw),
                aggs: this.AGGS
            }
            // console.log(JSON.stringify(b))
            return { index: INDEX_NAME, body: b }
        }
        getFilterWordsString() {
            return "  " + Array.from(this.postFilterWordsSet).join(",")
        }
    }

    const client = new $.es.Client(ES_HOST)
    let QB = new OrganicSearchQueryBuilder()

    var vm = new Vue({
        el: '#app',
        data: () => ({
            s_items: ['Foo', 'Bar', 'Fizz', 'Buzz'],
            sbox: {
                kw1: "",
                kw2: ""
            },
            cond: "",
            total: null,
            hits: null,
            aggs: null,
            filter: null,
            ctrl: {
                current: 0,
                loading: false,
                expand_less: {
                    valid: true
                }
            },
            sugList: [],
            sugList2: [],
            sugLock: false,
            current_scroll_position: 0,
            toggle_multiple: [],
            b_items: [
                {
                    text: 'トップ',
                    disabled: false,
                    href: '#'
                },
                {
                    text: 'パンくず1',
                    disabled: false,
                    href: '#'
                },
                {
                    text: 'パンくず2',
                    disabled: false,
                    href: '#'
                },
                {
                    text: '条件1, 条件2',
                    disabled: true,
                    href: 'breadcrumbs_link_2'
                }
            ],
            push_me_dialog: false
        })
        ,
        mounted: function () {
            window.addEventListener('scroll', this.fetch_by_scrolled)
            window.removeEventListener('wheel', this.fetch_by_scrolled)

            document.onscroll = (e) => {
                this.current_scroll_position = document.documentElement.scrollTop || window.scrollY // document.body.scrollTop ||
            }
        },
        destroyed: function () {
            window.addEventListener('scroll', this.fetch_by_scrolled)
            window.removeEventListener('wheel', this.fetch_by_scrolled)
        },
        methods: {
            es_search: async function (from, aggsField, aggsValue) {
                if (from > 0 && (this.total != null && this.total == this.ctrl.current)) {
                    return true
                }
                if (this.ctrl.loading == true) { // ざっくり排他制御 -- ここから
                    return true;
                }
                this.ctrl.loading = true
                {
                    let rslt = await client.search(QB.get2BoxQuery(this.sbox.kw1, this.sbox.kw2, from, aggsValue))
                    if (from == 0) {
                        this.total = rslt.hits.total
                        this.filter = QB.getFilterWordsString()
                        this.hits = rslt.hits.hits
                        this.aggs = rslt.aggregations
                        this.ctrl.current = Object.keys(rslt.hits.hits).length   //rslt.hits.hitsは配列ではなくObjectらしい
                    } else {
                        this.hits = this.hits.concat(rslt.hits.hits) // 無限スクロールを考慮して後ろに結合する
                        this.ctrl.current += Object.keys(rslt.hits.hits).length   //rslt.hits.hitsは配列ではなくObjectらしい
                    }
                }
                this.cond = this.sbox.kw1 + " " + this.sbox.kw2 + " " + this.filter
                this.ctrl.loading = false; // ざっくり排他制御 -- ここまで
            },
            fetch_by_pushed: function () {
                let next = this.ctrl.current
                this.es_search(next)
            },
            fetch_by_scrolled: function (e) {
                let docHeight = document.documentElement.scrollHeight // 参考: window.innerHeight 現在表示中のウィンドウのサイズ
                // console.log([this.current_scroll_position, docHeight * 0.25])
                if (this.current_scroll_position > docHeight * 0.25) { // スクロールのカーソルがドキュメントの90%あたり(この計算方法だと、閾値 0.25ぐらい)まで来たらフェッチする。
                    let next = this.ctrl.current
                    this.es_search(next)
                } else {
                    //無視する
                }
            },
            sbox_suggest1: function (FIELD, timeout) {
                let kwbox = this.sbox.kw1
                if (this.sugLock || kwbox <= 1) {
                    return true
                }
                console.log(FIELD)
                this.sugLock = true
                setTimeout(async function () {
                    let suggest = await client.search({
                        index: INDEX_NAME,
                        body:
                            { query: { match_phrase_prefix: { [FIELD]: kwbox } }, _source: [FIELD], size: 10 }
                    })
                    this.sugList = Array.from(new Set(suggest.hits.hits.map(x => x._source[FIELD]))) //ユニークにする
                    this.sugLock = false
                }.bind(this), timeout)
            },
            sbox_suggest2: function (FIELD, timeout) {
                let kwbox = this.sbox.kw2
                if (this.sugLock || kwbox <= 1) {
                    return true
                }
                console.log(FIELD)
                this.sugLock = true
                setTimeout(async function () {
                    let suggest = await client.search({
                        index: INDEX_NAME,
                        body:
                            { query: { match_phrase_prefix: { [FIELD]: kwbox } }, _source: [FIELD], size: 10 }
                    })
                    this.sugList2 = Array.from(new Set(suggest.hits.hits.map(x => x._source[FIELD]))) //ユニークにする
                    this.sugLock = false
                }.bind(this), timeout)
            },
            sbox_suggest: function (box_id) {
                if (box_id == 1) {
                    this.sbox_suggest1("name", 300) //CONFIG
                } else {
                    this.sbox_suggest2("address", 300) //CONFIG
                }
            }
        }
    })

</script>
</html>

ここで、条件に従ったインデックスが用意してあれば、次の部分は動くようにしかけてあります。

  1. フリー入力の検索BOX(2BOXのAND検索)とお店名と住所のオートコンプリート
  2. 検索結果
  3. 検索時に表示される角丸のお店のカテゴリ分類による絞り込み
  4. おまけで、無限スクロール風のギミック
  5. Elasticsearchのクエリ設定と上記などの検索結果の編集部分は、「CONFIG」というコメント文をつけているので、その周辺をそれらしく編集して、接続先のインデックスを   変更したりしてみてください。私のスキル(弱いエンジニアリング)と今回のあえて薄い作りにしたいという

一方、話のついでにvuetifyを使ってみたくて、とりあえずコンポーネントを配置しただけのものもあります。...ので上記以外はハリボテで動かないパーツもあります。ご了承ください。

まとめ

冒頭で設定した(言い訳した?)とおり、閉区画などにありがちなシーンを想定して、開発環境らしきものがなくても、頑張ればサクッとファイルの内容を変更しやすいように、1プログラム資材としてみました。

が試しにやってみたのですが、ハイライトのないWindowsメモ帳エディタなどで開いたらそれだけで死にそうでした。が、その気になればなんとかなると思います。

また、見れば分かると思いますが、Vue.jsもvuetifyの利用の仕方や、同期系の処理や排他についてはツールとしての割り切りになっているところがあります。

例えば、オートコンプリートも本来はこの例だとwatchの方がスジが良いと思いますが、設定よりも規約よりもハードコーディングとしましたので、そんな人はいないと思いますが、動作確認や簡易なツール以上のものを用意する際には、あるべき姿のコーディングスタイルを別途ご確認ください。

付録1: livedoorレストランデータセットについて

上記でlivedoorレストランデータセットを使った...としました。

参考までに、Elasticsearchさえ入っていれば、というところになりますが、このデータセットを取り込む例を示します。ここでは、こういうフォーマットであれば、こうやって取り込む方法があるという例で示しますので、データそのものの引用は控えています。

行間を読んでいただく必要はありますが、ご参考になればということで以下ご覧ください。

データの変換〜インポート

元ネタはタイトル付きCSVですので、これをJSONファイルにしてやります。 コード値で確保されているカテゴリやエリアデータをそれらのマスタCSVと結合してやります。

次のpythonスクリプトを保存してください。

import pandas as pd
import json

#件数を絞り込む場合
res = pd.read_csv('restaurants.csv',skiprows=lambda x: x >1000)
#全件取り込む場合
#res = pd.read_csv('restaurants.csv')

#都道県名結合
pref = pd.read_csv('prefs.csv')
res = pd.merge(res,pref,left_on=["pref_id"], right_on=["id"],how='left',indicator='rp',suffixes=['','_pref'])

#エリア名結合
area = pd.read_csv('areas.csv')
res = pd.merge(res,area,left_on=["area_id"], right_on=["id"],how='left',indicator='ra',suffixes=['','_area'])

#カテゴリ結合〜catsフィールドの派生
cat = pd.read_csv('categories.csv')
res = pd.merge(res,cat,left_on=["category_id1"], right_on=["id"],how='left',indicator='rc',suffixes=['','_cat1'])
res = pd.merge(res,cat,left_on=["category_id2"], right_on=["id"],how='left',indicator='rcc',suffixes=['','_cat2'])
res = pd.merge(res,cat,left_on=["category_id3"], right_on=["id"],how='left',indicator='rccc',suffixes=['','_cat3'])
res = pd.merge(res,cat,left_on=["category_id4"], right_on=["id"],how='left',indicator='rcccc',suffixes=['','_cat4'])
res = pd.merge(res,cat,left_on=["category_id5"], right_on=["id"],how='left',indicator='rccccc',suffixes=['','_cat5'])

df = res

df.name_cat1 = df.name_cat1.fillna('')
df.name_cat2 = df.name_cat2.fillna('')
df.name_cat3 = df.name_cat3.fillna('')
df.name_cat4 = df.name_cat4.fillna('')
df.name_cat5 = df.name_cat5.fillna('')
catcols = 'name_cat1,name_cat2,name_cat3,name_cat4,name_cat5'.split(',')
df = df.assign(cats = df[catcols].values.tolist())

df.north_latitude = df.north_latitude.fillna('')
df.east_longitude = df.east_longitude.fillna('')

#その場しのぎのadhocな緯度経度形式の変換
def funny_dms_to_deg(dms_str):
    if dms_str == '':
        return None
    else:
        dms_str_arr = [ s for s in str(dms_str).split('.') ]
        deg = dms_str_arr[0] + '.' +  dms_str_arr[1] + dms_str_arr[2] + dms_str_arr[3]
        if deg == None:
            return None
        return float(aaa)
    
dtd = funny_dms_to_deg
    
df = df.assign(lon = df['east_longitude'].apply(dtd))
df = df.assign(lat = df['north_latitude'].apply(dtd))
df = df.assign(location =  df[['lon','lat']].values.tolist())

#バルクロード用のjsonldファイルの出力

columns = 'id,name,alphabet,name_kana,pref_id,area_id,category_id1,category_id2,category_id3,category_id4,category_id5,zip,address,north_latitude,east_longitude,description,special_count,menu_count,fan_count,access_count,created_on,modified_on,closed,name_area,cats,name_pref,location'.split(',')
df = pd.DataFrame(df,columns=columns )

df_lines = df.to_json(force_ascii=False, orient='records', lines=True)

for i in iter(df_lines.split("\n")):
    v_json = json.loads(i)
    print('{"index": { "_id" : ' +  str(v_json['id']) + '}}' )
    print(i)


なお、pandasを使っているので、pipなどでpandasをインストールしておいてください。

上記のpythonスクリプト(ld.py)とします。

ld.pyをレストランデータセットのダウンロード〜unzip先のディレクトリ(restaurants.csvなどが存在するディレクトリ)に置いて

python3 ld.py > output.jsonld.in

などとして、output.jsonld.inファイルを出力します。

さらに、curlなどでバルクロードします。

curl -H "Content-type: application/x-ndjson" -X POST localhost:9200/es_index/doc/bulk?refresh --data-binary @output.jsonld.in

さらに補足:mappingの設定

説明が前後しましたが、mappingは雑にdynamic mappingで次の設定としました。

PUT es_index
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer":{ "type": "kuromoji_tokenizer", "mode": "search" },
        "my_ngram_tokenizer":{ "type": "ngram","min_gram":2,"max_gram":3, "token_chars":["letter","digit" ]  }
      },
      "analyzer": {
        "my_ja_default_analyzer": { 
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": ["icu_normalizer","kuromoji_iteration_mark","html_strip" ],
      "filter": [ "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_number", "kuromoji_stemmer" ]
        },
        "my_kuromoji_readingform_analyzer": {
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [ "icu_normalizer","kuromoji_iteration_mark","html_strip" ],
          "filter": [ "kuromoji_readingform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_stemmer" ]
        },
        "my_ngram_analyzer":{ 
          "type":"custom", "tokenizer":"my_ngram_tokenizer",
          "char_filter": ["icu_normalizer","html_strip"], "filter": [ ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true, "store": true,
              "fields": {
            "readingform":{ "type":"text", "analyzer":"my_kuromoji_readingform_analyzer" },
            "ngram":{ "type":"text","analyzer":"my_ngram_analyzer" },
        "raw": { "type":"keyword" }
              }
            }
          }
        }
      ]
    }
  }
}


付録2:速習vuetifyでの項目レイアウトについて

vuetifyの各コンポーネントはなんとなく配置しているうちにわかるのですが、bootstrapライクなグリッドシステムはなれない人にはちょいとわかりにくいかもしれません。というか私は良く分かっていません。

が、次の公式リンクにまとまっているのと、

Layout grid system — Vuetify.js vuetifyjs.com

これをみると、つまるところ、簡単なレイアウトであれば次のパターンで良いのではないかと思いましたので、骨組みとしてご紹介しておきます。

<v-container>
    これで、ページ全体の大枠を縛る。

    <v-layout row>
        囲んだものの中にあるコンポーネントをできるだけ横方向に詰め込むレイアウトとする
        <v-flex md5>
            5単位の幅確保
            <foo>
                表示したい項目A
            </foo>
        </v-flex>

        <v-flex md7>
            7単位の幅確保
            <foo>
                表示したい項目B
            </foo>
        </v-flex>

    </v-layout>

</v-container>

付録3: gistに貼りました

*1:が、モダンブラウザを想定しているので、あくまでシチュエーションコントということで、ご了承くださいまし。

*2:jquery版です。