2018年8月28日

ElasticsearchでWikipediaの全文検索にチャレンジ (3)

前置き


前回の記事の続きです。推定所要時間はデータ投入と確認で4時間です。
なんか前置きってテンプレートの様に最初に必ず書くのがお約束になってきている。

本番データ投入


では、ハードディスクの残容量を確認して、本番データの投入を行ってください。
ちなみに、手元の環境では14GB程消費しました。

$WorkDir = "C:\Wikipedia"
$OutputDir = Join-Path $WorkDir "output"

# ■Wikipedia XMLデータ
$Columns = "id", "timestamp", "title", "text"

$HeaderFile = Join-Path $WorkDir "xmlHeader.txt"
$FooterFile = Join-Path $WorkDir "xmlFooter.txt"

$HeaderText = Get-Content $HeaderFile -encoding UTF8
$FooterText = Get-Content $FooterFile -encoding UTF8

# ■Elasticsearch 登録先情報
$Hostname = "localhost"
$PortNumber = 9200
$IndexName = "wikipedia"
$TypeName = "page"
$BaseURL = "http://${Hostname}:${PortNumber}"

# ■投入前確認(何も入っていないこと)
$APIURL = "/${IndexName}/${TypeName}/_search"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $Result -Depth 10

# その他確認
Get-Date
Get-PSDrive

# ■Bulk投入定義
$APIURL = "/${IndexName}/${TypeName}/_bulk"
$RequestURL = $BaseURL + $APIURL
$ReqestType = "POST"
$BulkCommand = @{ "index" = @{ "_index" = $IndexName ; "_type" = $TypeName } } | ConvertTo-Json -Compress

# ■投入対象一覧生成 ※ファイル名の都合でソートは無意味である。
$FileList = Get-ChildItem $OutputDir | Sort-Object -Property Name

ForEach ($File in $FileList) {

 # XML生成
 $PageText = Get-Content $File.Fullname
 [xml]$XMLData = $HeaderText + $PageText + $FooterText

 # ■Bulk実行用のJsonデータ生成
 $BulkData = New-Object System.Collections.ArrayList # 新規配列
 ForEach ($page in $XMLData.mediawiki.page) {
  $Row = New-Object PSObject | Select-Object $Columns
  $Row.id = $page.id
  $Row.timestamp = $page.revision.timestamp
  $Row.title = $page.title
  $Row.text = $page.revision.text."#text"
  $JsonData = $Row | ConvertTo-Json -Compress

  $BulkData.Add($BulkCommand) > $Null
  $BulkData.Add($JsonData) > $Null
 }

 # ■ Bulk実行
 $JsonData = $( $BulkData -join "`n" ) + "`n"
 $PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
 $Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
 
 Write-Output $( $File.name + " : " + $(Get-Date) )

}

# ■投入結果確認
$APIURL = "/${IndexName}/${TypeName}/_search"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $Result -Depth 10

# その他確認
Get-Date
Get-PSDrive

1ファイル投入毎に完了時刻を報告するようにしているので、2300ファイル分が大体どの程度で終わりそうかの目安にしてください。
1ファイル5秒なら3時間強くらいですよね、的な。

どうでもいい補足


ソートに関しては、1番目の記事で連番を桁数合わせてないのでうまくいきません。順番通りに投入する必要性は皆無なので問題ありませんが。
行き当たりばったり故の不具合である。
言い訳をすると、最初の時点で全件がどの程度あるのか想像がつかなかったので、何桁おけばいいのか判断できなかったというのがあります。
実際には2300ファイルだったので、安全マージンで1桁増やして変更すればよさそうです。
最初の記事にはコメントアウトで追記しておりますので、変更したい人はどうぞ。

[string]$( $PageCounter / $Cycle )
 ↓
$( $PageCounter / $Cycle ).ToString("00000")

ついでに細かい補足をしておくと、ファイルのエンコード指定は、初回はBOMなしUTF8なので必須ですが、一旦Powershellから出力したUTF8はBOMつきUTF8で出力されているので、それ以降は自動判定可能であるため指定不要です。
あと出力時の改行コードは本文はLFで最後だけCRLFと不一致になるけれども、Get-Contentに-rawオプションをつけていないので、改行を問わず1行1配列のArrayオブジェクトとして生成され、かつXMLオブジェクトに変換されてしまうので、改行コードの不一致について気にする必要はないでしょう。
多分Sakura Editorで開くと保存するときに直すかいと聞かれるはずなので補足しました。

全文検索


では、検索をしてみましょう。先日の記事で示したように、Elasticsearchが標準外の処理をしているのでここでPowershellはお役御免です。
Kibinaを起動しましょう。そしてDev toolsにて以下のクエリーを実行します。これは、「全文検索」というキーワードに対するスコアを取得します。
GET /wikipedia/page/_search?pretty
{
  "query":{
    "match":{
      "text":"全文検索"
    }
  },
  "_source": ["id", "title"],
  "size": 100
}
なお、量が量なので、kibana.ymlにてelasticsearch.requestTimeoutの値を増やさないと変わったクエリーを投げるとタイムアウトしまくる可能性がありますので注意してください。
掲示した検索クエリーの書き方も先ほどのサイトのページがヒットしたので、こちらを参考にいたしました。
http://pppurple.hatenablog.com/entry/2017/01/29/144132

そして、Powershellの活躍がないままなのは悔しいので、生成された結果のJsonをテキストファイルに納めて、強引に活躍の場を与えましょう。
$JsonText = Get-Content C:\Wikipedia\resultjson.txt
$JsonData = $JsonText | ConvertFrom-Json
$JsonData.hits.hits | Format-Table | Out-File C:\Wikipedia\resulttext.txt -encoding UTF8

テーブルフォーマットに直したことで、スコア順に1行ずつ並んでいるので、順位がわかりやすくなりました。 Apache Solrが16番目にランクインしたにも関わらず、Elasticsearchは92番手でした。もう少し頑張りましょう。

なお、続けて「桃太郎」というキーワードで検索を掛けたところ、上位に女性の名前が出てきたので、榊原郁恵がピーターパンやってたような感じかと思って本文を開いてみたら実はセクシー女優さんで、桃太郎映像出版というところが映像作品をリリースしてたからヒットしてしまったというオチでした。
子供向けを狙って検索してみるとオトナ向けの結果が出てしまう可能性に注意いただきたいと思った次第です。また無駄知識が増えてしまった。
次回の記事はPowershellの代わりにPythonを利用することにしました。Pythonに興味があったのでこれを機に覚えてみようということで。
開発環境がOS標準ではなくなるので、間にPythonの環境構築の記事を挟みます。

ElasticsearchでWikipediaの全文検索にチャレンジ (2)

前置き


前回の記事からの続きです。
Wikipediaのファイルをスクリプトから処理可能なサイズに分割し、Elasticsearchのセットアップ作業を終えている前提で話を進めます。

ファイル整形

前回は、230万件のWikipediaの記事を1000件単位に分割してファイルに納めるということをしました。 まず、先頭ファイルと最終ファイルにそれぞれXMLの開始/終了がくっついているので、これを切り出します。
$WorkDir = "C:\Wikipedia"
$OutputDir = Join-Path $WorkDir "output"

# 書き込み順でソート。
$FileList = Get-ChildItem $OutputDir | Sort-Object -Property "LastWriteTime"
$FirstFile = $FileList | Select-Object -First 1
$LastFile = $FileList | Select-Object -Last 1

# 編集対象のファイルがこちらです
$FirstFile.FullName
$LastFile.FullName

# 失敗した時のためにWorkDirへバックアップ
Copy-Item $FirstFile.FullName $WorkDir
Copy-Item $LastFile.FullName $WorkDir

# ■ヘッダー編集
$LineCounter = 0
$Keyword = '<page>' # 記事の開始タグ
$Header = New-Object System.Collections.ArrayList
[System.Collections.ArrayList]$FirstFileData = Get-Content $FirstFile.FullName

# ヘッダ開始位置の確認
ForEach ($Line in $FirstFileData) {
 if ( $Line.Contains($Keyword) ) {break}
 $Header.Add($Line) >$Null
 $LineCounter = $LineCounter +1
}
$LineCounter
$Header

# ヘッダーを別ファイルへ保存
$HeaderFile = Join-Path $WorkDir "xmlHeader.txt"
$Header  -join "`n" | Out-File -FilePath $HeaderFile -Encoding utf8

# Header分離
$FirstFileData[0] #分離前確認
$FirstFileData[$LineCounter] #分離前確認

ForEach ($i in @(1..$LineCounter) ) {
 $FirstFileData.RemoveAt(0)
}

$FirstFileData[0] #分離後確認

# 上書き保存
$FirstFileData -join "`n" | Out-File -FilePath $FirstFile.FullName -Encoding utf8

# ■フッター編集
# 配列ArrayListとしてGet-Contentを実行
[System.Collections.ArrayList]$LastFileData = Get-Content $LastFile.FullName
$LastLine = @($LastFileData).length -1
$LastFileData[$LastLine] # これが最終タグ

# フッターを別ファイルへ保存
$FooterFile = Join-Path $WorkDir "xmlFooter.txt"
$LastFileData[$LastLine] | Out-File $FooterFile -Encoding UTF8

# 最終行を削除
$LastFileData.RemoveAt($LastLine)
@($LastFileData).length

# 上書き保存
$LastFileData -join "`n" | Out-File -FilePath $LastFile.FullName -Encoding utf8

これくらい開発するよりエディタでやったほうが絶対に早いんだけれども、乗り掛かった舟なのでコードにしました。

XMLオブジェクト生成


では、生成したファイルからXMLファイルを合成し、XMLオブジェクトへ変換しましょう。
$WorkDir = "C:\Wikipedia"
$OutputDir = Join-Path $WorkDir "output"
$HeaderFile = Join-Path $WorkDir "xmlHeader.txt"
$FooterFile = Join-Path $WorkDir "xmlFooter.txt"

$HeaderText = Get-Content $HeaderFile -encoding UTF8
$FooterText = Get-Content $FooterFile -encoding UTF8
$PageText = Get-Content $(Join-Path $OutputDir File-0.txt)

# XML生成
[xml]$XMLData = $HeaderText + $PageText + $FooterText

# 生成結果確認
$XMLData
$XMLData.mediawiki
$XMLData.mediawiki.page[0]
$XMLData.mediawiki.page[0].revision
$XMLData.mediawiki.page[0].revision.text

最終的にこれを0~2300のファイルに実行するイメージです。Pageは[0]~[999]です。
※未検証ですが、オフラインだとスキーマにアクセスできないのでXMLの生成に失敗すると思われます。
 その場合にはスキーマをローカルにダウンロードしてヘッダをローカル参照に書き換えてください。

投入データ選定


まず、Elasticsearchへの取り込み対象を絞りましょう。
  • page/id
  • page/revision/timestamp
  • page/title
  • page/revision/text."#text"
ぱっと見では、これだけ取っておけば全文検索には役に立ちそうな気がします。
では試してみましょう。

$Columns = "id", "timestamp", "title", "text"
$Table = New-Object System.Collections.ArrayList # 新規配列

ForEach ($page in $XMLData.mediawiki.page) {
 $Row = New-Object PSObject | Select-Object $Columns
 $Row.id = $page.id
 $Row.timestamp = $page.revision.timestamp
 $Row.title = $page.title
 $Row.text = $page.revision.text."#text"
 $Table.Add($Row) > $Null
}

$Table

それっぽい出力ができました。これを取り込めばよさそうです。
Wiki記法が気になりますが、恐らくSTOPWORDというElasticsearchの機能でなんとかなるんじゃないかと楽観視しているので、一旦編集処理はしません。
もし必要なら$Row.textを文字列置換とかでいい感じに編集するようにしてみてください。

設定ファイル生成


先日の記事でもやりましたが、まずはアップロードするファイルをElasticsearchに納めるうえで、Mappingの定義ファイルを作成します。
まず、先ほど作成した変数$Tableから5件Elasticsearchに登録します。

# ■Elasticsearch 登録先情報
$Hostname = "localhost"
$PortNumber = 9200
$IndexName = "wikipedia"
$TypeName = "page"
$BaseURL = "http://${Hostname}:${PortNumber}"
$APIURL = "/${IndexName}/${TypeName}/_bulk"
$RequestURL = $BaseURL + $APIURL
$ReqestType = "POST"
$BulkCommand = @{ "index" = @{ "_index" = $IndexName ; "_type" = $TypeName } } | ConvertTo-Json -Compress

# ■Bulk実行用のJsonデータ生成
$BulkData = New-Object System.Collections.ArrayList # 新規配列
ForEach ($Counter in @(0..4) ) {
 $JsonData = $Table[$Counter] | ConvertTo-Json -Compress
    $BulkData.Add($BulkCommand) > $Null
    $BulkData.Add($JsonData) > $Null
}

Write-Output $BulkData

# ■ Bulk実行
$JsonData = $( $BulkData -join "`n" ) + "`n"
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
$Result

# ■投入結果確認
$APIURL = "/${IndexName}/${TypeName}/_search"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $Result -Depth 10

# ■現在のMAPを取得
$MapFile = Join-Path $WorkDir "map.json"
$APIURL = "/${IndexName}/${TypeName}/_mapping"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL

ConvertTo-JSON $Result."${IndexName}" -Depth 10 | Out-File -Encoding Default -FilePath $MapFile

では、このMAPファイルを編集していきましょう。データ型は以下のページを参考にします。
https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html
更にKuromojiを利用するための設定として以下のページの内容も組み込みます。 https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji-tokenizer.html

{
    "mappings": {
        "page": {
            "properties": {
                "id": {
                    "type": "integer"
                },
                "text": {
                    "type": "text",
                    "fielddata": true,
                    "store": true
                },
                "timestamp": {
                    "type": "date"
                },
                "title": {
                    "type": "keyword"
                }
            }
        }
    },
    "settings": {
        "index": {
            "analysis": {
                "tokenizer": {
                    "kuromoji_user_dict": {
                        "type": "kuromoji_tokenizer",
                        "mode": "search"
                    }
                },
                "analyzer": {
                    "wikipedia_analyzer": {
                        "type": "custom",
                        "tokenizer": "kuromoji_user_dict"
                    }
                }
            }
        }
    }
}

textフィールドの謎指定「fielddata」「store」は何かうまく動かなかったので以下を参考に指定してみた的なおまじないです。時間がある時に何者でどんな効果が期待できるのか調べたいです。よくわかってないので一旦ぶん投げておきます。
https://qiita.com/tsgkdt/items/9ac7c8ffd74e2baee79d

では既存Indexを削除してこの定義を取り込んでみましょう。

# ■定義更新
# ○既存Indexの削除
Function Delete-Index () {
    $APIURL = "/${IndexName}"
    $RequestURL = $BaseURL + $APIURL
    $ReqestType = "DELETE"
    Invoke-RestMethod -Uri $RequestURL -Method $ReqestType
    Write-Output $("`t Delete-Index() : " + $(Get-Date) )
}

Delete-Index

# ○MAP定義登録
Function Create-TypeItem ( [string]$JsonFilePath ) {
    $APIURL = "/${IndexName}"
    $TargetURL = $BaseURL + $APIURL
    $ReqestType = "PUT"
    $JsonData = Get-Content $JsonFilePath
    # 文字化けするのでUTF8を強制
    $PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData)
    Invoke-RestMethod -Uri $TargetURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
    Write-Output $("`t Create-TypeItem( ${JsonFilePath} ) : " + $(Get-Date) )
}

$JsonFilePath = Join-Path $WorkDir "map.json"
Create-TypeItem -JsonFilePath $JsonFilePath

取り込みエラーが出なかったら即座に確認です。
$JsonData = '{"analyzer": "wikipedia_analyzer", "text": "吾輩は猫である。"}'
$ReqestType = "POST"
$APIURL = "/${IndexName}/_analyze"
$RequestURL = $BaseURL + $APIURL
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
ConvertTo-JSON $Result -Depth 10

N-Gramではなく、Kuromojiが適用されていそうです。なお、確認方法は以下のページを参考にしました。
http://pppurple.hatenablog.com/entry/2017/05/28/141143

つまりここでKuromojiを指定しなければ、N-Gramされてしまうのですね。単語間にスペースのついてない言語って英語圏の人たちは頭を抱えたくなるのでしょうか。
$JsonData = '{"text": "monster analyzed you"}'
$ReqestType = "POST"
$APIURL = "/${IndexName}/_analyze"
$RequestURL = $BaseURL + $APIURL
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
ConvertTo-JSON $Result -Depth 10

$JsonData = '{"text": "吾輩は猫である。"}'
$ReqestType = "POST"
$APIURL = "/${IndexName}/_analyze"
$RequestURL = $BaseURL + $APIURL
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
ConvertTo-JSON $Result -Depth 10

あとはデータを投入する処理で完了という感じですが、長くなったので次回に持ち越しです。いいところで続くって続きが楽しみになりませんか。

2018年8月26日

ElasticsearchでWikipediaの全文検索にチャレンジ (1)

前置き


以下のブログで紹介されていたWikipediaの全文検索に興味を持ち、どのようにすればいいのか調査をした記録です。
http://www.mwsoft.jp/programming/munou/wikipedia_solr.html

以降の記事は、以下のWikipediaアーカイブからjawiki-latest-pages-articles.xml.bz2とjawiki-latest-stub-articles.xml.gzをダウンロードし、アーカイブファイルからデータを展開済みという前提で話を進めます。
https://dumps.wikimedia.org/jawiki/latest/

例によって例のごとく、以下のコードは基本的にPowershellコンソールに直接コピペして使う前提です。スクリプトファイルにする必要すらありません。らくちんですね。
なお、推定所要時間はファイルダウンロードに3時間、データ生成に2時間です。

初めに


2018年8月現在、Wikipedia全文を収めたXMLファイルは11GBを超えており、XMLパーサにそのまま読ませてアップロードしようとか正気の沙汰ではありません。
ファイルをシステムで処理可能なレベルまで分割して、順次処理するというのが現実的な話でしょう。
実際に、記事本文を除いた一覧「jawiki-latest-stub-articles.xml」については、ファイルサイズが1.2GBだったので読み込みを試してみたのですが、メモリーを使い果たし、且ついつまで処理が掛かるのかわからない状況であったため断念しました。

# 32GB程度の搭載メモリーでは太刀打ちできない
# $Inputfile = Join-Path $WorkDir "jawiki-latest-stub-articles.xml"
# [xml]$XmlData = Get-Content $InputFile -encoding utf8


ちなみに、環境はCore i7-7700K + 32GBメモリーです。

試行


まずは先頭1000行を読み込んでXMLの構造を確認してみます。
そうすると、<PAGE></PAGE>というタグで1つの記事を示していることがわかります。

$WorkDir = "C:\Wikipedia"
$Inputfile = Join-Path $WorkDir "jawiki-latest-stub-articles.xml" # まずは本文なしで構造確認
$TextData = Get-Content $InputFile -totalcount 1000 -encoding utf8
$TextData

では、ページ単位で取り出すことにしましょう。
5万行で記事単位でXML構造を取得できるか試してみます。

$WorkDir = "C:\Wikipedia"
$OutputDir = Join-Path $WorkDir "output"
If (-not (Test-Path $OutputDir)) { New-Item $OutputDir -ItemType Directory }

$Inputfile = Join-Path $WorkDir "jawiki-latest-stub-articles.xml" # まずは本文なしで構造確認

$PageCounter = 0 # 記事件数カウンター
$Cycle = 100 # 1ファイルに納める上限値
$Keyword = '</page>' # 記事の終了タグ
$FileBase = "File-PickupTest-"
$OutputFile = Join-Path $OutputDir $( $FileBase  + [string]$( $PageCounter / $Cycle ) + ".txt")
$OutputFile

Get-Date
Get-Content $InputFile -totalcount 50000 -encoding utf8 | ForEach { # 5万行で試行
 $_ | Out-File -FilePath $OutputFile -Encoding utf8 -Append
 if ( $_ -match $Keyword ) { # 特定件数に達したら出力ファイルを変更
  $PageCounter = $PageCounter + 1
  if ($PageCounter % $Cycle -eq 0) {
   $OutputFile = Join-Path $OutputDir $( $FileBase + [string]$( $PageCounter / $Cycle ) + ".txt")
   $OutputFile
  }
 }
}

Get-Date
Write-Output "記事件数:${PageCounter}"

今回のファイルでは2631件、3ファイルに出力されました。
5万行縛りなので、恐らく大きくぶれないでしょう。

本番開始


うまくいきそうなので、対象ファイルを本文つきXMLに切り替えて流すことにします。
多大な時間とI/Oが発生しますので、SSD構成+数時間放置推奨です。

$WorkDir = "C:\Wikipedia"
$OutputDir = Join-Path $WorkDir "output"
If (-not (Test-Path $OutputDir)) { New-Item $OutputDir -ItemType Directory }

$InputFile = Join-Path $WorkDir "jawiki-latest-pages-articles.xml"

$PageCounter = 0 # 記事件数カウンター
$Cycle = 1000 # 1ファイルに納める上限値
$Keyword = '</page>' # 記事の終了タグ
$FileBase = "File-"
$OutputFile = Join-Path $OutputDir $( $FileBase  + [string]$( $PageCounter / $Cycle ) + ".txt")
$TextData = New-Object System.Collections.ArrayList # 新規配列

Get-Date
Get-Content $InputFile -encoding utf8 | ForEach { 
 $TextData.Add($_) > $Null # 一旦配列に格納
 # $_ | Out-File -FilePath $OutputFile -Encoding utf8 -Append # 直接出力は時間が掛かるので方式変更
 if ( $_ -match $Keyword ) { # 特定件数に達したら出力ファイルを変更
  $PageCounter = $PageCounter + 1
  if ($PageCounter % $Cycle -eq 0) {
   $TextData -join "`n" | Out-File -FilePath $OutputFile -Encoding utf8
   $TextData = New-Object System.Collections.ArrayList # 新規配列
   $OutputFile = Join-Path $OutputDir $( $FileBase + [string]$( $PageCounter / $Cycle ) + ".txt")
   # 桁数を揃える場合 $( $PageCounter / $Cycle ).ToString("00000") に変更
  }
 }
}

$TextData -join "`n" | Out-File -FilePath $OutputFile -Encoding utf8

Get-Date
Write-Output "記事件数:${PageCounter}"

最初、1行ずつファイル書き出ししてたけど、とても時間が掛かったので、出力する記事1000件分を一旦配列に入れて、1000件分溜まったらまとめてファイル出力するようにしました。速度は圧倒的に後者のが早いので興味があるかたは比べてみてください。
ただ、メモリーにキャッシュする訳で、搭載メモリーが8GB以下の人は$Cycleを200とかにして利用率を確認してから実行いただくのがよいかと思います。
スペックの良いマシンで生成完了まで1時間というところでしょうか。
今回は、約50分で2300ファイル生成されました。記事件数は2,300,820件だそうです。

Elasticsearch導入とKuromojiプラグイン


長くなりましたので、Wikipediaのデータをうんぬんするのは次の記事で扱います。
その際にElasticsearchを利用するので、導入していない人は以下の記事を参考に導入してください。
REST API をPowershellで扱うための Elasticsearch のススメ (1)

ちなみに、この記事を書いてから2週間も経っていないのにバージョンが上がっていました。 そんな訳で自分は入れなおしました。運用してるソフトウェアじゃないし、最新で評価したほうがいいよね。
# URLを以下に変更。
$URL = "https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.4.0.zip"
$URL = "https://artifacts.elastic.co/downloads/kibana/kibana-6.4.0-windows-x86_64.zip"

そして、日本語解析としてKuromojiを入れます。
Kuromojiが何ぞやと語れる程知識がないので、以下の記事を紹介をしておきますね。
https://qiita.com/yamamotoshu1127/items/ae081c8fa1c9b2804f83

端的に言えば日本語の文章を単語に分解して品詞を示してくれる機能です。精度としては、完璧ではないけど、実用的な範疇というところです。過信は禁物だけど自分でやらなくてもいいなんてとっても楽ちんです。

では張り切って導入しましょう。

# ■作業ディレクトリーへ移動
# WorkDirにElasticsearchが導入されている前提
$WorkDir = "C:\Elastic"
Set-Location $WorkDir

# ■plugin:kuromoji
# https://www.elastic.co/guide/en/elasticsearch/plugins/6.4/analysis-kuromoji.html#analysis-kuromoji
$URL = "https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-kuromoji/analysis-kuromoji-6.4.0.zip"
$FileName = Split-Path $URL -Leaf
$FileFullPath = Join-Path $WorkDir $FileName
Invoke-WebRequest -Uri $URL -Outfile $FileFullPath
# ZIP展開は不要

$InstallPath = "file:///${FileFullPath}"
$ESbinDir = Join-Path $WorkDir "elasticsearch-6.4.0\bin"
Set-Location $ESbinDir

# プラグインリスト:事前確認
.\elasticsearch-plugin.bat list

.\elasticsearch-plugin.bat install $InstallPath

# プラグインリスト:事後確認
.\elasticsearch-plugin.bat list

install時にはZIPファイルを指定すればOKで、ZIPの展開は必要ありません。
事前にZIPを置いておけるのでオフライン環境でも安心ですね。

次回の記事に続きます。

2018年8月25日

ブログを作って早々だが、前言を撤回する。ElasticsearchとPowershellは相性が悪い。

前置き

先日、Elasticsearchの操作をPowershellで行うために
REST API をPowershellで扱うための Elasticsearch のススメ(1)、(2)、(3)
という記事を書いたのだけど、根本的にはElasticsearchとPowershell(Invoke-RestMethod)は相性が悪いのでElasticsearchを触りたいという目的の場合にはcurl.exeの利用をお勧めする。
※元々の目的であるInvoke-RestMethodを使用したいという場合には余り影響はない

これはREST APIにおけるGET、POST、PUT、DELETEという各名称(機能)と、そもそもGETとPOSTの違いというHTTPの仕様に起因する話である。
そして、HTTPの仕様で話すならElasticsearchが誤っている。ただ、今後 REST APIが普及するに従って、後追いで拡張される可能性もあるので標準に囚われない仕様が悪いとは必ずしもいえない(それはMSだって通ってきた道)。

追記:search API

あとで知ったのであれだが、この記事は無意味かも。Elasticsearch のsearch APIはGETとPOSTのどちらでも動くので、POSTにしてしまえば支障なく使えます。

内容と回避策(?)

具体的にいうと、GETメソッドにJSONデータを渡す処理ができないのである。
Kibanaにて検索を掛けるとき、以下のような構文になる。これをInvoke-RestMethodではリクエストすることができない。
GET _search
{
  "query": {
    "match_all": {}
  }
}

最初、該当のリクエストはGETメソッドのお作法に則りURLエンコードして渡す処理なんだろうと勝手に勘違いし、上手く動かないなぁと無駄に時間をかけていた。
それもそのはず、実はPOSTメソッドと同様にデータを送っているのである。気が付いた瞬間はマジかーと思ったし、見当違いなコードを書いていた自分が悲しくなったりもした。

そして暫く放置していたのだが、先人の知恵を借りるべくググってみたところ、以下のブログを発見した。
Learning Elasticsearch with PowerShell

Flaw Workaround
.NET is right. Elasticsearch is wrong. Breaking the rules for the sake of what you personally feel makes you a vigilante. Forcing a square peg into a round hole just for the sake of making sure "gets" are done with GET makes you an extremist. Bodies belong in POST and PUT, not GET.

私が言いたかったことが書いてあった。そして回避策まで提示してくれている。かっこいい!
ただ、リクエストで使っている「source」というJsonを投げるパラメーターがうまく動かない。
記事が2015年のものだし、公式(現時点の最新:6.4)ドキュメントにも載ってないし、現在では廃止された!?
https://www.elastic.co/guide/en/elasticsearch/reference/current/search-uri-request.html

結論

そんな訳で、Invoke-RestMethodでElasticsearchをあれこれするのは諦めることにした。
Powershellで処理したい人は素直にcurl.exeをダウンロードして連携してください。


2018年8月19日

小ネタ:Windows Spotlightの画像を取得する

Windows10のロック画面で表示される壁紙は、Windows Spotlightという機能で表示されています。そして、その画像を気に入ったのに、気が付くともう別の画像に差しか割っているということが往々にしてあります。
以下のページでその保存方法を知ったので、簡単に取得できるようにPowershellで書いてみました。気に入った画像を見つけた時には実行してみてください。
http://ascii.jp/elem/000/001/091/1091657/

使い方は、Powershellプロンプトを立ち上げて、以下のコードをコピーして張り付けるだけです。
事前に適当なフォルダーへSet-Location(CD)しておくといいと思います。

$MinSize = 200kb # 足切りサイズ
$WorkDir = Join-Path $(Get-Location).Path "SpotLight"
If (-not (Test-Path $WorkDir) ) { New-Item $WorkDir -ItemType Directory}

$SpotlightFolder = Join-Path $(Get-Item env:LOCALAPPDATA).value "\Packages\Microsoft.Windows.ContentDeliveryManager_cw5n1h2txyewy\LocalState\Assets"
$FileList = Get-ChildItem $SpotlightFolder | Where-Object {$_.Length -gt $MinSize}

foreach ($File in $FileList){ 
 $DstName = Join-Path $WorkDir $($File.Name + ".jpg")
 Copy-Item $File.Fullname $DstName
}

Start $WorkDir

なお、足きりを200KBに設定してるのは目視確認で正確な値ではないです。
該当フォルダーにはSpotlightの壁紙以外にもスタートボタン押したときに表示される画像の類が格納されます。それらは小さいjpegであるため、200KBで足切りすれば大抵除外されるでしょうという話です。

2018年8月18日

Google Search Consoleを試してみた。

前置き

ブログを作成して1週間ほど経ったが、想定するキーワードで検索しても一向に自分のブログにヒットしないなと思って調べ始めた。
その結果、Bloggerを使う場合にはGoogleのSearch Consoleというツールで登録してあげるといいという話が見つかった。

そこで、Search Consoleに登録することにした。
https://www.google.com/webmasters/tools/home?hl=ja

登録

まず、自分のBlogのURLである「https://ntnl-it-wiz.blogspot.com/」を登録した。


そして、サイト情報が表示されたのだが、見事にクロールエラーとなった。
なるほど。これではGoogle八分と変わらない。

この状態を脱却するにはサイトマップを登録する必要があるとのこと。
で、これはATOMを登録すればいいという話である。

では、ATOMはどこよという話になるが、これは適当なページでソースコードを開いてみれば直ぐにわかる話です。
以下のように、MIMEがapplication/atom+xmlのlinkタグを探せば書いてあります。
<link rel="alternate" type="application/atom+xml"
 title="No technology, no life. - Atom"
 href="https://ntnl-it-wiz.blogspot.com/feeds/posts/default" />
※長かったので改行入れましたがソースコードに記載されている該当のタグは1行です。

該当サイトのどこにサイトマップがあるかという指定をすればいいので、
「feeds/posts/default」だけ送信するとサイトが登録できました。


クロールはすぐには行われないということなので、これで一晩待ってから様子を確認すればよいかと考えております。
以下によると、ATOMを登録しても数日掛かるもののようですね。。。
https://support.google.com/webmasters/answer/6065812

蛇足

livedoor blogではこんな面倒なことしなくてもよかったので、専用のblogサービスを使うというのはそれなりに便利なことなんだなと改めて思った次第。
https対応さえちゃんとできそうであればなぁ。

追記(2018/08/25)


その後、Search Consoleにてクロールされたことが確認できた。
インデックスまでにはSearch Consoleへ登録してから数日掛かるので、しばらく放置してブログコンテンツの作成にいそしむのが正解ということのようだ。


2018年8月16日

RedmineにPrism.jsを組み込んで、シンタックスハイライトを拡張する

前置き

2019年6月1日追記:
Redmine 4.0でPowershellを含めた100を超える言語に対応したので、あえてPrismを組み込む必要はなくなりました。公式対応はありがたいですね。
https://www.redmine.org/projects/redmine/wiki/RedmineCodeHighlightingLanguages

※以下の元記事も、行の強調などを採用してみたい方のために残しておきます。

Windows屋なのでPowershellをよく書くのだけれども、RedmineにはPowershellのハイライト機能がないのである。<PRE></PRE>だと見目麗しくない。
苦々しく思っていたところ、先日の記事でPrism.jsのハイライト手法がRedmineと同じだったので、「これ、いれるんじゃない?」と思いついて試してみたら本当にできてしまったというだけの話です。


ということで、邪道ではありますが、ありものを組み合わせてそれっぽく使ってしまおうというのが今回のお題です。
なお、実用範囲とは思っていますが、若干いけてない部分もあるので、それも併せてお知らせします。

設定

以下の流れです。
  1. prism.js、prism.cssを公式サイトからダウンロードする
  2. redmineのpublicディレクトリ(Apache公開ディレクトリ)にファイルを配置する
  3. 以下のファイルにリンクを付与する。
    /var/lib/redmine/app/views/layouts/base.html.erb
1は公式サイトからダウンロードしてください。
なお、成功パターンのパラメーターでのリンクも張り付けておきます。(いつまでちゃんと動くかわかりませんが)
うまくハイライトされないという場合には、成功パターンのリンクからそのままダウンロードして試してください。
うまくいったらパラメーターを変えて、自分向けにアレンジしてください。

2はprism.jsとprism.cssをscpで転送後、以下のコマンドで公開ディレクトリーに配置するイメージです。
mkdir /var/lib/redmine/public/prism
cp ./prism.* /var/lib/redmine/public/prism/

chown -R apache:apache /var/lib/redmine/public/prism
ls -la /var/lib/redmine/public/prism/

# ファイル公開チェック
curl http://localhost/redmine/prism/prism.css
curl http://localhost/redmine/prism/prism.js
※構築はほぼ以下のブログにある通りなので、/var/lib/redmine へ導入している想定の手順です。環境に応じて適宜読み替えて実施ください。
http://blog.redmine.jp/articles/3_1/installation_centos/
※MariaDB(MySQL)が好きなので、あえてVer.3.1版の記事を拾ってます。

3はRedmineが出力するHTMLのテンプレートを直接編集する作業です。
※以下は趣味で設定変更前後の差分を取ってますが、やってることはbase.html.erbをviで編集してhttpdを再起動しているだけなので惑わさらないようにしてください。
# ○変数設定
BKDIR=~/bkconfig/; [ ! -e $BKDIR ] && mkdir -p $BKDIR
TARGETFILE=/var/lib/redmine/app/views/layouts/base.html.erb

# ○事前バックアップ
cat $TARGETFILE
echo $TARGETFILE | sed -e 's/\//_/g' | xargs -I{} cp -pi $TARGETFILE ${BKDIR}bk$(date +%Y%m%d){}
ls -la --time-style=long-iso $BKDIR
ls -la --time-style=long-iso $TARGETFILE

# ○編集
vi $TARGETFILE

# 以下の行を</head>の直前に追加
# --------------------------------------
<link href='/redmine/prism/prism.css' media='all' rel='stylesheet' type='text/css'/>
<script src='/redmine/prism/prism.js' type='text/javascript'></script>
# --------------------------------------

# ○事後確認
cat $TARGETFILE
echo $TARGETFILE | sed -e 's/\//_/g' | xargs -I{} diff $TARGETFILE ${BKDIR}bk$(date +%Y%m%d){}
ls -la --time-style=long-iso $TARGETFILE

# ○設定の反映
systemctl restart httpd

注意事項

CODEタグのあとは何も書かずに1行空けるのがRedmineのお作法ですが、Prism.jsだと1行空けずにCODEタグの後ろが書き始めとなります。
なので、既存の場所に適用すると1行空いてしまいます。行番号がないので些事かとは思いますが。
プラグインはRedmine組み込み側の何かしらとバッティングするらしく、行番号とかも動きません。
デフォルトのToolbar以外は外してしまうのがいいと思います。
時間があったら解析したいけど、それだったら、いっそ組み込み可能なテーマとか作ってしまったほうが建設的なのではという思いさえあります。まぁやらないだろうな。。。

ヘッダへの組み込みのファイル名は以下のページを参考にさせていただきました。面倒なんでRuby記法使わずに直接埋め込んでますけど。感謝。
http://www.lesstep.jp/step_on_board/redmine/785/

なお、prismのライセンスはMITとのことです。
https://github.com/PrismJS/prism/blob/master/LICENSE

2018年8月15日

REST API をPowershellで扱うための Elasticsearch のススメ (3)

前置き

前回の記事からの続きです。
以下のページにある内容をPowershellの Invoke-RestMethod コマンドレットで実施していきます。
https://qiita.com/math1101/items/311277789868ebd07835


MAP再作成

例によって例のごとく、以下のコードをPowershellのプロンプトへ順次張り付けていきます。
# ■作業ディレクトリー再設定
$WorkDir = "C:\Elastic\data"
Set-Location $WorkDir

# ■Elasticsearch 登録先情報
$Hostname = "localhost"
$PortNumber = 9200
$IndexName = "air"
$TypeName = "ppm"
$BaseURL = "http://${Hostname}:${PortNumber}"
$RequestURL = $BaseURL + $APIURL

# ■MAP定義更新
# ○既存Indexの削除
Function Delete-Index () {
    $APIURL = "/${IndexName}"
    $RequestURL = $BaseURL + $APIURL
    $ReqestType = "DELETE"
    Invoke-RestMethod -Uri $RequestURL -Method $ReqestType
    Write-Output $("`t Delete-Index() : " + $(Get-Date) )
}

Delete-Index

# ○MAP定義登録
Function Create-TypeItem ( [string]$JsonFilePath ) {
    $APIURL = "/${IndexName}"
    $TargetURL = $BaseURL + $APIURL
    $ReqestType = "PUT"
    $JsonData = Get-Content $JsonFilePath
    # 文字化けするのでUTF8を強制
    $PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData)
    Invoke-RestMethod -Uri $TargetURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
    Write-Output $("`t Create-TypeItem( ${JsonFilePath} ) : " + $(Get-Date) )
}

$JsonFilePath = Join-Path $WorkDir "map.json"
Create-TypeItem -JsonFilePath $JsonFilePath

# ■現在のMAPを取得
$MapFile = Join-Path $WorkDir "newmap.json"
$APIURL = "/${IndexName}/${TypeName}/_mapping"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $Result.air -Depth 10 | Out-File -Encoding Default -FilePath $MapFile

# ■件数チェック
$APIURL = "/${IndexName}/${TypeName}/_count"
$RequestURL = $BaseURL + $APIURL
$( Invoke-RestMethod -Uri $RequestURL ).count
新しく作成したnewmap.jsonを確認すると、TimeStampのデータ型が無事dateになっていることが確認できます。あとはデータを投入するだけで完了です。

実は先に挙げた元記事で、一番変更したかったのもここです。
元の内容は、CSVをBULK用のJSONファイルに変換しており、その変換処理だけで数時間掛かるので、気軽に試せなかったんですね。
でも、前回の記事にて示したように、CSVから直接BULK登録が可能なので、その工程を省いてしまえばいいのです。

この処理は、再利用性を考慮してスクリプトファイルとして保存しましょう。
※Powershellの実行ポリシーを変更していないと動かないのでまだの人は Set-ExecutionPolicy でググってください。
以下の内容を「UpdateESFromCsv.ps1」というファイル名で、フォルダー「C:\Elastic\data」に保存します。
<#
■Elasticsearch へのBulk投入
#>
####################################################################### 引数定義
[CmdletBinding()]
Param(
    $Hostname = "localhost"
    ,$PortNumber = 9200
    ,$IndexName = "air"
    ,$TypeName = "ppm"
    ,$CsvFile = ".\target.csv"
)
##################################################################### 初期化処理
# ■ ログ採取 //////////////////////////////////////////////
# Set-PSDebug -Trace 2
# ログファイル名設定
$ScriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path # スクリプトパス指定
$LogFileName = $MyInvocation.MyCommand.Name.substring(0,$MyInvocation.MyCommand.Name.length - 4) + $(Get-Date).ToString("yyyyMMddHHmm") + "_" + (Get-ChildItem env:computername).value + ".log"
$LogFilePath = Join-Path $ScriptPath $LogFileName

# ログ採取開始
Start-Transcript $LogFilePath

# ログ出力の折り返し防止設定
$BufferSize = $(Get-Host).UI.RawUI.BufferSize
$BufferSize.Width = 512 ; $BufferSize.Height = 512
$(Get-Host).UI.RawUI.BufferSize = $BufferSize

# ■ 既定値入力 ////////////////////////////////////////////
$BulkMax = 5000 # Bulkの実行件数
$BaseURL = "http://${Hostname}:${PortNumber}"
$BulkCommand = @{ "index" = @{ "_index" = $IndexName ;"_type" = $TypeName } } | ConvertTo-Json -Compress
$Counter = 0 # 初期化
$BulkData = New-Object System.Collections.ArrayList # 新規配列

####################################################################### 関数定義
Function BulkCreate-DocsItemFromJson ( [array]$BulkData ) {
    $TimeoutSec = 600 # タイムアウト時間を秒で指定(一括取り込みに時間が掛かる)
    $APIURL = "/${IndexName}/${TypeName}/_bulk"
    $RequestURL = $BaseURL + $APIURL
    $ReqestType = "POST"
    $JsonData = $( $BulkData -join "`n" ) + "`n"
    $PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
    $Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType -TimeoutSec $TimeoutSec
    Write-Output $("`t BulkCreate-DocsItemFromJson() : " + $(Get-Date) + " <= ${CsvFile}")
}

##################################################################### メイン処理
# ■メイン処理開始 /////////////////////////////////////////

Write-Output $("■メイン処理開始 : " + $(Get-Date) )

$CsvData = Import-Csv -Path $CsvFile -Encoding Default | Where-Object { $_.'測定項目名称' -like "*${TypeName}*"}
ForEach ($CsvRecord in $CsvData) {
    If ($Counter -gt $BulkMax) { # 規定件数を超えたらBULK実行
        BulkCreate-DocsItemFromJson $BulkData
        $Counter = 0 # 初期化
        $BulkData = New-Object System.Collections.ArrayList # 新規配列
    }
    $Counter = $Counter + 1
 $TimeStamp = $CsvRecord."年" + $CsvRecord."月" + $CsvRecord."日" + "-" + $CsvRecord."時"
 $CsvRecord | Add-Member -NotePropertyName "TimeStamp" -NotePropertyValue $TimeStamp
 $JsonData = $CsvRecord | ConvertTo-Json -Compress
    $BulkData.Add($BulkCommand) > $Null
    $BulkData.Add($JsonData) > $Null
}
BulkCreate-DocsItemFromJson $BulkData

# 終了処理 ////////////////////////////////////////////////
Write-Output $("■スクリプト終了 : " + $(Get-Date) )

Stop-Transcript

前回の記事で5件だけ登録した処理を、スクリプトファイルに変更し、一定件数毎にBULK実行するように改定したものになります。5000件であれば、10以内にはBULK実行するのではないでしょうか。
余り応答がないと不安になるでしょうし、配列に突っ込むためのメモリーも無尽蔵ではないので上限値を設けています。5000でメモリーが足りないという方は$BulkMaxを1000なりに値を変更してください。

そして、以下のコードを実行すれば順次CSVファイルを読み取ってElasticsearchへ登録してくれます。
# ■作業ディレクトリー再設定
$WorkDir = "C:\Elastic\data"
Set-Location $WorkDir

# ■ファイル確認
Get-Item ".\UpdateESFromCsv.ps1"

# ■スクリプト実行
$CsvList = Get-ChildItem $WorkDir | Where-Object {$_.Extension -eq ".csv" }
ForEach ($CsvFile in $CsvList) {
 .\UpdateESFromCsv.ps1 -CsvFile $CsvFile.FullName
}

# ■投入結果確認
$Hostname = "localhost"
$PortNumber = 9200
$IndexName = "air"
$TypeName = "ppm"
$BaseURL = "http://${Hostname}:${PortNumber}"
$APIURL = "/${IndexName}/${TypeName}/_search"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $Result -Depth 10

# ■件数チェック
$APIURL = "/${IndexName}/${TypeName}/_count"
$RequestURL = $BaseURL + $APIURL
$( Invoke-RestMethod -Uri $RequestURL ).count

ここの内容は全CSVファイルを先のスクリプトで順次処理しているだけですので、詳細な説明は割愛します。
結果が126040件になってれば同じ結果です。うまくいかなかった人は頑張って原因を探してください。

三回に渡ってお送りしましたが、件の記事をForkした箇所は以上となります。お付き合いいただきありがとうございました。
なお、Kibanaの内容はWebUIですので、Windows関係ないし、変更点もありません。
元記事を参照して試してみてください。

REST API をPowershellで扱うための Elasticsearch のススメ (2)

前置き

前回の記事からの続きです。
以下のページにある内容をPowershellの Invoke-RestMethod コマンドレットで実施していきます。
https://qiita.com/math1101/items/311277789868ebd07835

なお、本記事はPowershell Ver.5.0向けです。3.0以降なら動きそうな気もしますが、2.0は存在しないコマンドレット多数、import-csvのencoding指定もNGのはず、ということで対象外としております。
Windows7のサポート期限も目前なので問題ないでしょう。

元データダウンロード

神戸市大気汚染常時監視結果 から灘大気測定局のデータを過去3年分(現在2018年なので2015年~2017年)ダウンロードします。
なお、観測局も年数も開始年も特に意味はありませんので、自由に変更いただいて構いません。

前回の記事と同様に、以下のコードをPowershellのプロンプトへ順次張り付けていきます。

# ■作業ディレクトリー作成
$WorkDir = "C:\Elastic\data"
if ( -not (Test-Path -Path $WorkDir) ) { # フォルダチェック
 New-Item -path $WorkDir -ItemType "directory"
}

# ■作業ディレクトリーへ移動
Set-Location $WorkDir

# ■ZIPファイルダウンロード
Function Get-TargetFile( $WorkDir, $URL ) {
    $FileName = Split-Path $URL -Leaf
    $FileFullPath = Join-Path $WorkDir $FileName
    Invoke-WebRequest -Uri $URL -Outfile $FileFullPath
}

Get-TargetFile -WorkDir $WorkDir -URL "http://kobe-taikikanshi.jp/kankyo/download/hour/kaku/2017_0103_hour_kakutei.zip"
Get-TargetFile -WorkDir $WorkDir -URL "http://kobe-taikikanshi.jp/kankyo/download/hour/kaku/2016_0103_hour_kakutei.zip"
Get-TargetFile -WorkDir $WorkDir -URL "http://kobe-taikikanshi.jp/kankyo/download/hour/kaku/2015_0103_hour_kakutei.zip"

# ZIPファイルの展開
$ZipList = Get-ChildItem $WorkDir | Where-Object {$_.Extension -eq ".zip" }
ForEach ($ZipFile in $ZipList) {
 Expand-Archive -Path $ZipFile.FullName -DestinationPath $WorkDir -Force
}

# ■展開後のファイル確認
Get-ChildItem $WorkDir

以上で該当ZIPファイルを展開して、以下のCSVファイルを取得するところまでできているはずです。
  • 2015_0103_hour.csv
  • 2016_0103_hour.csv
  • 2017_0103_hour.csv

これで投入データの準備が整いました。

ちなみに、3ファイル以上取得する人のために即席で関数 Get-TargetFile を作ってます。
このように即席で関数が作れるのがPowershellのありがたいところです。


MAP作成

前回の記事でも軽く触れましたが、Elasticsearchでは、Json形式でデータを投入できます。
ただ、何も考えずにデータを投入すると、勝手にデータ型を文字列型にして登録します。
日付をDate型にしたいという場合には「Mapping」を先に行います。
データベースでいうところのテーブル定義(Create Table)です。

この作業は、一旦すべてを文字列として登録し、そのMAP情報を取得して、必要なところだけデータ型を変更して、それを正式なMAPファイルとするというのが一番手間が掛かりません。
MS SQL ServerでGUIでテーブルを生成してからSQL構文を生成するようなものですね。え?違う??

ともかくやってみましょう。
以下、「■」のついている単位で順に実行してください。
(まとめて実行しても支障はありませんが、ステップ実行したほうが流れがくみ取れるかと思います)
# ■作業ディレクトリー再設定
$WorkDir = "C:\Elastic\data"
Set-Location $WorkDir

# ■Elasticsearch 登録先情報
$Hostname = "localhost"
$PortNumber = 9200
$IndexName = "air"
$TypeName = "ppm"
$BaseURL = "http://${Hostname}:${PortNumber}"
$APIURL = "/${IndexName}/${TypeName}/_bulk"
$RequestURL = $BaseURL + $APIURL
$ReqestType = "POST"
$BulkCommand = @{ "index" = @{ "_index" = $IndexName ; "_type" = $TypeName } } | ConvertTo-Json -Compress

# ■CSVファイル読み込み
$CsvList = Get-ChildItem $WorkDir | Where-Object {$_.Extension -eq ".csv" }
$CsvFile = Join-Path $WorkDir $CsvList[0]
$CsvData = Import-Csv -Path $CsvFile -Encoding Default

# ■CSV取り込み対象データ抽出
$FilterData = $CsvData | Where-Object { $_.'測定項目名称' -like "*${TypeName}*" } 
$FilterData.count

# ■Bulk実行用のJsonデータ生成
$BulkData = New-Object System.Collections.ArrayList # 新規配列
ForEach ($Counter in @(0..4) ) {
 $CsvRecord = $FilterData[$Counter]
 $TimeStamp = $CsvRecord."年" + $CsvRecord."月" + $CsvRecord."日" + "-" + $CsvRecord."時"
 $CsvRecord | Add-Member -NotePropertyName "TimeStamp" -NotePropertyValue $TimeStamp
 $JsonData = $CsvRecord | ConvertTo-Json -Compress
    $BulkData.Add($BulkCommand) > $Null
    $BulkData.Add($JsonData) > $Null
}

Write-Output $BulkData

# ■ Bulk実行
$JsonData = $( $BulkData -join "`n" ) + "`n"
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
$Result

# ■投入結果確認
$APIURL = "/${IndexName}/${TypeName}/_search"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $Result -Depth 10

$APIURL = "/${IndexName}/${TypeName}/_count"
$RequestURL = $BaseURL + $APIURL
$( Invoke-RestMethod -Uri $RequestURL ).count

# ■現在のMAPを取得
$MapFile = Join-Path $WorkDir "map.json"
$APIURL = "/${IndexName}/${TypeName}/_mapping"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL

ConvertTo-JSON $Result.air -Depth 10 | Out-File -Encoding Default -FilePath $MapFile
急に何を始めたのかというレベルになってきたので解説を挟みつつ。
まず、登録先URLの変数「$RequestURL」は「http://localhost:9200/air/ppm/_bulk」になります。
他のサイトなどでElasticsearchの解説を見ながら確認いただきたいですが、以下になります。
  • index:データベースだとdatabaseに相当する概念
  • type:データベースだとtableに相当する概念
そして「_bulk」がREST APIです。
index:air、type:ppmの中にまとめてデータを流し込む_bulk APIを使用するというのをURLで示している訳です。
以下のページのDocuments APIとか参考になるかもしれません。
https://medium.com/hello-elasticsearch/elasticsearch-api-83760ce1424b

そして、CSVファイルを読み込んで、登録対象「ppm」でフィルタリングします。
※ちなみに、ppmはperts per millionの略で体積濃度だそうです。門外漢のため細かいことは知りません。

続けて、MAP作成のひな型として、5件データを登録します。
フィルターしたCSVファイルから5件だけ情報を取得し、Timestanpというプロパティ(CSVの列に相当)を追加して、その1行をJson形式に変換します。

その後、_bulkのお作法に則ってJsonのリストを生成します。
index指定($BulkCommand)で1行、投入データ内容($JsonData)で1行の2行で1セットになっています。
_bulkのお作法は以下を参照してください。
https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html

そして、「■ Bulk実行」というところで5件のデータを登録しております。
Forで回しているときには配列に入れておき、Bulk実行直前でjoinで改行付きテキストに変換し、文字コードUTF8のバイトデータに置き換えてPOSTしております。
なお、_bulkに渡すJsonDataは、Forの中で直接テキストを生成しても問題ないです。配列に入れるのはForとかで回しやすいから癖で入れてるようなものです。

実行時にエラーが出なければ、投入結果確認のところで、投入されたデータと件数が表示されます。
問題がなければ、mapファイルを出力します。

このmapファイルをメモ帳等で開いてみましょう。
ちなみに、おすすめはVisual Studio Codeです。「ドキュメントのフォーマット」機能でJsonファイルを見やすく整えてくれます。エンコード指定しないとすぐに化けるのが難点ですが。
このファイルのTimeStampを編集します。
Before:
                "TimeStamp": {
                    "type": "text",
                    "fields": {
                        "keyword": {
                            "type": "keyword",
                            "ignore_above": 256
                        }
                    }
                },

After:
                "TimeStamp": {
                    "type":"date",
                    "format":"YYYYMMdd-HH"
                },
長くなったので続きは次の記事とします。
ただ、この記事の中で、本来の目的である Invoke-WebRequest によるREST APIの操作方法はなんとなく理解できたと思いますので、それが目的の人はここで離脱すればいいと思います。
せっかくなんでCSV全データ投入まで見てやるぜという人はもう少しお付き合いください。

REST API をPowershellで扱うための Elasticsearch のススメ (1)

前置き

この記事は、PowershellでREST APIを扱うためのコマンドレット Invoke-RestMethod を扱う記事と見せかけて、実は少し違います。
もともと、WindowsでElasticsearchを動かそうということでWebを探したところ、以下の記事がヒットしました。
https://qiita.com/math1101/items/311277789868ebd07835
これはこれでありがたかったのですが、Windows屋さんとしては、やはりcurlをダウンロードしてきて使うという時点で、「OS標準であるPowershellで同じことができるんですよ!」って思ってしまったので、そのフォロー記事ということでForkさせてみました。

Elasticsearchは簡単に導入できて、かつREST APIが利用できます。
ですので、いきなり本番サービスへ Invoke-RestMethod して使い方を覚えるより、Elasticsearchにあれこれ試してみて、使い方を把握すると便利ですよという趣旨のもと、本記事を公開いたします。

Elasticsearchとは

Elasticsearch公式:https://www.elastic.co/jp/

ElasticsearchはJsonで扱える全文検索エンジンです。
KibanaはそれをWebから確認するためのツールです。
本記事では詳細は扱わないので、触ってみて興味を持ったのであれば色々と調べてください。
Logstashと連携させて管理サーバーのログを取り込んで解析するとかメジャーな使い方のようです。

Elasticsearchの導入

以下、Windows 10にElasticsearchを導入して動かすところまでPowershellで書いてます。
PS1ファイルで保存してもいいですが、1回しか実施しないのでPowershellのプロンプトに直接コピペで張り付ければそれだけでOKです。
# ■前提:Java導入済み環境
Get-Item "Env:JAVA_HOME"
java.exe -version
# Elasticsearchに対応した Java が入っていなければ公式からダウンロード&セットアップ
# https://java.com/ja/download/

# ■作業ディレクトリー作成
$WorkDir = "C:\Elastic"
if ( -not (Test-Path -Path $WorkDir) ) { # フォルダチェック
 New-Item -path $WorkDir -ItemType "directory"
}

# ■作業ディレクトリーへ移動
Set-Location $WorkDir

# ■Elasticsearch
# ○製品ダウンロード:https://www.elastic.co/jp/
$URL = "https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.3.2.zip"
$FileName = Split-Path $URL -Leaf
$FileFullPath = Join-Path $WorkDir $FileName
Invoke-WebRequest -Uri $URL -Outfile $FileFullPath

# ○ZIP展開:Powershell 5.0以下なら素直にGUIから展開してね。
$FolderName = $FileName -replace "\.zip$", ""
Expand-Archive -Path $FileFullPath -DestinationPath $WorkDir -Force
Start "${FolderName}\bin"

# ■ツール:Kibina
# ○製品ダウンロード:https://www.elastic.co/jp/
$URL = "https://artifacts.elastic.co/downloads/kibana/kibana-6.3.2-windows-x86_64.zip"
$FileName = Split-Path $URL -Leaf
$FileFullPath = Join-Path $WorkDir $FileName
Invoke-WebRequest -Uri $URL -Outfile $FileFullPath

# ○ZIP展開:Powershell 5.0以下なら素直にGUIから展開してね。
$FolderName = $FileName -replace "\.zip$", ""
Expand-Archive -Path $FileFullPath -DestinationPath $WorkDir -Force
Start "${FolderName}\bin"
以上で「C:\Elastic」に「elasticsearch-6.3.2」と「kibana-6.3.2-windows-x86_64」が生成されます。
該当バージョンは本日時点の最新版なので採用しただけです。新しいバージョンが出ていればそちらを採用いただいて構いません。

Elasticsearchの起動

binフォルダーの中にある、製品名と同名のバッチファイルを起動すればそれだけで動きます。
設定等は一切なくていいので、動かなかったら多分Javaのせいじゃないですかね。64bit版入れてJAVA_HOME通してあれば普通は動きます。
※localhost宛なのでWindows Firewallは影響ないですが、アンチウィルスソフトがポートブロックするようであれば解除も忘れずに

以下の内容を「C:\Elastic\StartES.bat」」として保存しておけば、ワンクリックでElasticsearchとKibanaを両方起動できます。
なお、「bin\elasticsearch-service.bat」を使うとサービス登録もできるようなので、サーバー利用の場合には検討してください。
Set ScriptPath=%~dp0
Set ES_PATH=%ScriptPath%elasticsearch-6.3.2
Set KB_PATH=%ScriptPath%kibana-6.3.2-windows-x86_64

CD /D "%ES_PATH%\bin"
Start "Elasticsearch" /min ".\elasticsearch.bat"

REM Elasticsearch 起動までの待機:環境依存なので適当に
Timeout /T 60

CD /D "%KB_PATH%\bin"
Start "Kibana" /min ".\kibana.bat"

Echo 起動が完了しました。
REM pause

Elasticsearchの動作確認

簡単に動作確認しましょう。先ほどと同様、プロンプトへ以下を直接貼り付けます。
# ■動作確認
# ○バージョン確認
$Hostname = "localhost"
$PortNumber = 9200
$BaseURL = "http://${Hostname}:${PortNumber}"
$RequestURL="${BaseURL}/"

$JsonData = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $JsonData

# ○プラグイン確認
$RequestURL="${BaseURL}/_nodes/plugins"
$JsonData = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $JsonData -Depth 10
Json形式でバージョン情報、プラグイン情報が表示されればOKです。
ちゃんと内容を確認しなくても支障はないので、エラーにさえならなければ、なんか出たぞくらいに思ってください。

以上にて、GETメソッドによるInvoke-RestMethodの動作が確認できました。
結構長くなったので、先に挙げた記事の内容については次の記事にて記載します。

まずはシンタックスハイライトを設定、改。

ITブログなら、コードも沢山載せていきたいよなぁということで、 コードハイライトを設定することを検討した。
最初は google-code-prettify にしようかと考えたが、2013年から 更新がないので、恐らくメインストリームではないと判断し、別のものを探した。

検索した結果、以下のサイトで紹介されていたprism.jsを使用することにした。
https://thk.kanzae.net/net/wordpress/t1171/

表示例は以下の通り。
function main()
{
  alert("Hello world!");
}

使用方法は、先に挙げたブログにある通り、以下の公式サイトにて言語とプラグインを選択してjavascript「prism.js」とcss「prism.css」をダウンロードする。
https://prismjs.com/index.html

然る後、該当ファイルをアップロードして、カスタムスクリプトをブログで有効化した。
使い方も先のブログにある通り、<pre><code>で指定する。

ここまでは引っ越し前のlivedoor blogと同じ内容だけど、bloggerではファイルアップロード機能がないので、代替手段を検討した。
http://blogger.weblix.net/2014/07/blogger-external-file-github-pages.html
http://blogger.weblix.net/2014/01/github-pages-create-web-site.html

そのものずばりのページをみつけたので、それにそってgithubにprismのファイルを置かせていただくことにした。
ゆくゆくはWebサイトとして構成したい。気が向いたら。

ともあれ、ファイルは格納したので、テーマのHTML編集機能でヘッダに組み込んだ。

2018年8月14日

心機一転、再び

インターネットで学んだ内容を備忘録としてまとめるブログを作ろうと決意しました。
月1回くらい、何かしらの記事が書ければいいなぁと考えてます。

そして、livedoor blog から引っ越すこと、引っ越し先をBloggerにすることを決意した!
1週間も経たないうちにお引越しである。

2018年8月1日

このブログについて

このブログでは主に技術に関する紹介を行います。

本ブログで公開しているコードについて、著作権は放棄しませんが、一切の権利を主張しません。
そして、コードを利用したことで不利益を被ったとしても一切の責任を負いません。

簡単に言ってしまえば、以下のライセンスです。
Do What the Fuck You Want to Public License(WTFPL)
http://www.wtfpl.net/

認証されてないから通らないということであれば次点でApache 2.0とみなしてください。
Apache License 2.0
https://www.apache.org/licenses/LICENSE-2.0

参考:
https://qiita.com/lovee/items/484ae3fc038314a64ee2

ちなみに、著作権を放棄すると、他者が権利を主張した挙句利用料を請求される可能性が存在するので放棄しないという程度です。
何らかの制約を科したいわけではないので安心してご利用ください。

例外として、いずれかのWebサイト等から引用したコードに関しては、それを引用物として表記いたします。
これらの引用コードは、それを作成した人が著作権を有するものであるため、原典の著作権を確認ください。
※利便性を考慮し、ライセンス規約は極力探して提示するようにします。

TIPS:VSCodeで日本語化がうまくいかないとき

前置き Visual Studio Codeで拡張機能「 Japanese Language Pack for Visual Studio Code 」を入れたら日本語になりますよね。 でも、「 Remote Development 」で色々な環境を日本語化してると、偶に...