« ScrapBook及びSave Page WEで保存したWebページをApache Solrで検索できるようにしてみた(その1) | トップページ | Dockerを使ったJupyter Labの環境を用意する »

2020.05.16

ScrapBook及びSave Page WEで保存したWebページをApache Solrで検索できるようにしてみた(その2)

Apache SolrにWebページのテキストデータを登録するところまでを
ScrapBook及びSave Page WEで保存したWebページをApache Solrで検索できるようにしてみた(その1)
に書きました。今回は、WebブラウザをクライアントにしてApache Solrからテキスト検索する簡単なサンプルコードを書いてみます。

Apache Solrが起動しているとき、標準的には、http://localhost:8983/solr/scrapbook/select?q=pythonのような形でテキスト検索を実行できます。するとJSON形式のレスポンスが返ってきます。これを一歩進めてWebブラウザをクライアントにした簡単なテキスト検索システムを作ります。

(2020.11.02全面改訂、20210105修正)

index.html


<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>ScrapBook Solr Search</title>
</head>
<body>
<h1>ScrapBook Solr Search</h1>
<form id="textbox">
<table border="0">
<tr><td>Query: </td><td><input type="text" id="query" size="80"/></td></tr>
<tr><td>Sub Queries: </td><td><input type="text" id="subqueries" size="80"/></td></tr>
<!- ->
<tr><td></td><td><input type="button" value="Search" id="search">
<input type="button" value="Clear" id="clear">
<a href="http://[host name]/[path to]/scrapbook_top.html" target="_blank">索引</a></td></tr>
</table>
</form>
<!- ->
<hr />
<p/><pre>
<span id="info"></span>
<span id="pages1"></span>
<span id="result"></span>
<span id="pages2"></span>
</pre>
<!- ->
<script type="text/javascript" src="solrsearch.js"></script>
<!- ->
</body>
</html>

sorsearch.js


// Soler Search Tool
// Variables
const askSolrURL = 'api.cgi?';
const askBibURL = 'bib.cgi?';
const beforeURL = '/[Path to]/ScrapBook';
const afterURL = 'http://[host name]/[user name]/ScrapBook';

// Number of answers per page
const rowsMAX = 25;
// Selective Range
const sRange = 20;
// SearchAPI parameters
const defOpt = '&fl=' + encodeURIComponent('id,title') +
'&rows=' + rowsMAX +
'&hl=on&hl.fl=*' +
'&hl.simple.pre=%3Cstrong%3E&hl.simple.post=%3C%2Fstrong%3E';

// Subroutines
const waitLoad = () => {
return new Promise((resolve, reject) => {
document.addEventListener('DOMContentLoaded',
() => {resolve();}, false);
});
};

const clickSearch = () => {
document.getElementById('search').addEventListener('click', () => {
let start = 0;
communicateWithServer(start);
}, false);
};

const enterText = () => {
document.getElementById('query').onkeypress = (e) => {
const key = e.keyCode || e.charCode || 0;
if (key == 13) {
// e.preventDefault();
let start = 0;
communicateWithServer(start);
}
};
document.getElementById('subqueries').onkeypress = (e) => {
const key = e.keyCode || e.charCode || 0;
if (key == 13) {
// e.preventDefault();
let start = 0;
communicateWithServer(start);
}
};
});

const communicateWithServer = (start) => {
const xhr = new XMLHttpRequest();

let opt = defOpt + '&start=' + start;
let subqueries = document.getElementById('subqueries').value.split(',');
for (let x = 0; x < subqueries.length; x++) {
if (subqueries[x].length > 0) {
opt += '&fq=' + encodeURIComponent(subqueries[x]);
}
}
let query = document.getElementById('query');
let result = document.getElementById('result');
let arg = askSolrURL + 'q=' + encodeURIComponent(query.value) + opt;

xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// console.log(xhr.responseText);
let data = JSON.parse(xhr.responseText);
if (data === null) {
result.textContent = 'データが存在しませんでした。';
} else {
makeList(data);
document.getElementById('pages1').innerHTML =
makeTOC(data, start);
document.getElementById('pages2').innerHTML =
makeTOC(data, start);
}
} else {
result.innerHTML = 'サーバエラーが発生しました。';
}
} else {
result.innerHTML = '通信中...';
}
};

xhr.open('GET', arg, true);
xhr.send(null);
};

const makeList = (data) => {
let lines = data.response.docs;
let hls = data.highlighting;
let hlText = makeHighlightText(hls);
document.getElementById('info').textContent = data.response.numFound +
' Documents Found';
if (lines.length === 0) {
document.getElementById('result').textContent = '';
}
let html = new Array(rowsMAX);
let elemIcon = new Array(rowsMAX);
let hlTextBlock = new Array(rowsMAX);
// execute askBib functions (0 - lines.length size)
Promise.all([...Array(lines.length)].map((_, i) => i).map(
(value, index, array) => {
return askBib(lines[value].id);
}
)).then((success) => {
success.map((value, index, array) => {
let bib = value;
let url = lines[index].id;
if (bib.response.message === 'OK') {
url = url.replace(beforeURL, afterURL);
let catURL = bib.response.record[5];
catURL = catURL.replace(beforeURL, afterURL);
let icon = '▼';
if (! hasHighlight(hlText[lines[index].id])) {
icon = '―';
}
html[index] = '<tr valign="top"><td>[' +
bib.response.record[0] + ']</td>' +
'<td><span id="hl' + index + '">' + icon +
'</span></td>' +
'<td><a href=\"' + url + '\" target=\"_blank\">' +
decodeURIComponent(bib.response.record[2]) + '</a>' +
' <a href=\"' + bib.response.record[3] +
'\" target=\"_blank\">ソースURLを開く</a>' +
' <a href=\"' + catURL + '\" target=\"_blank\">' +
'カテゴリ:' + bib.response.record[4] + '</a>' +
'<div id="hlText' + index + '">' +
hlText[lines[index].id] + '</div></td></tr>\n';
} else if (bib.response.message === 'ERROR') {
html[index] = '<tr><td> </td>' +
'<td><span id="hl' + index + '"> </span></td>' +
'<td>' + url + '<div id="hlText' + index +
'">文書データを取得できませんでした。' +
'</div></td></tr>\n';
}
});
}).then((success) => {
// result部にHTMLを埋め込む
document.getElementById('result').innerHTML =
'<table border="0">' + html.join('') + '</table>';
}).then((success) => {
// マウスオーバー時にhilight表示するためのイベント追加
for (let i = 0; i < lines.length; i++) {
addEventF(i, lines, elemIcon, hlText, hlTextBlock);
}
});
};

const askBib = (id) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();

let arg = askBibURL + id;

let data;
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status === 200) {
// console.log(xhr.responseText);
data = JSON.parse(xhr.responseText);
if (data === null) {
document.getElementById('result').innerHTML =
'インデックス情報が存在しませんでした。';
} else {
resolve(data);
}
}
};

xhr.open('GET', arg, true);
xhr.send(null);
});
};

const makeTOC = (data, start) => {
let f = false;
let html = '';
let iMax = data.response.numFound;
let maxPage = Math.ceil(iMax / rowsMAX);
let pos = Math.ceil(start / rowsMAX);
for (let x = 0; x < maxPage; x++) {
if (x !== pos) {
if (x == 0) {
html += '<a href=\"javascript:onLinkClick(' +
x + ')\">' + x + '</a> ';
} else if (x == maxPage - 1) {
html += '<a href=\"javascript:onLinkClick(' +
x + ')\">' + x + '</a> ';
} else if (x >= pos - sRange && x <= pos + sRange) {
html += '<a href=\"javascript:onLinkClick(' +
x + ')\">' + x + '</a> ';
f = false;
} else if (f == false) {
html += '... ';
f = true;
}
} else {
html += x + ' ';
}
}
return html;
};

const onLinkClick = (x) => {
communicateWithServer(x * rowsMAX);
};

const makeHighlightText = (hls) => {
let hlText = {};
for (let key1 in hls) {
let i = 0;
let hlArray = new Array();
for (let key2 in hls[key1]) {
hlArray[i] = hls[key1][key2][0];
i++;
}
// 重複排除
let hlArray2 = Array.from(new Set(hlArray));
hlText[key1] = hlArray2.join('<br />');
}
return hlText;
};

const hasHighlight = (hlText) => {
if (hlText.length > 0) {
return true;
} else {
return false;
}
};

const addEventF = (i, lines, elemIcon, hlText, hlTextBlock) => {
elemIcon[i] = document.getElementById('hl' + i);
hlTextBlock[i] = document.getElementById('hlText' + i);
hlTextBlock[i].style.display = "none";
if (hasHighlight(hlText[lines[i].id])) {
elemIcon[i].addEventListener('mouseover', () => {
elemIcon[i].innerHTML = '▲';
hlTextBlock[i].style.display = "block";
}, false);
elemIcon[i].addEventListener('mouseout', () => {
elemIcon[i].innerHTML = '▼';
hlTextBlock[i].style.display = "none";
}, false);
}
};

const clickClear = () => {
document.getElementById('clear').addEventListener('click', () => {
document.getElementById('query').value = '';
document.getElementById('subqueries').value = '';
document.getElementById('info').textContent = '';
document.getElementById('pages1').textContent = '';
document.getElementById('pages2').textContent = '';
document.getElementById('result').textContent = '';
}, false);
};

// Main Routine
waitLoad()
.then(() => {clickSearch();
enterText();
clickClear();})
.catch((e) => console.log(e));

// EOF

api.cgi


#!/bin/sh

URL='localhost:8983/solr/scrapbook/select?'
cat <<HTTP_RESPONSE
Content-Type: application/json;charset=UTF-8


$(curl -s ${URL}${QUERY_STRING})
HTTP_RESPONSE
exit 0

# EOF

bib.cgi


#!/bin/sh

LISTFILE='./listfile.txt'
ID=$(echo ${QUERY_STRING} | awk -f decodeURL.awk)

RES=$(cat ${LISTFILE} | awk -F '\t' -v ID="${ID}" 'BEGIN{OFS="\t"}{if($2==ID)print $0}' | tr '\t' ',' | sed 's/,/","/g')

if [ ${#RES} -eq 0 ]; then
cat <<HTTP_RESPONSE0
Content-Type: application/json;charset=UTF-8


{"response":{"message":"ERROR", "id":"${ID}"}}
HTTP_RESPONSE0
exit 0
fi

cat <<HTTP_RESPONSE1
Content-Type: application/json;charset=UTF-8


{"response":{"message":"OK", "record":["${RES}"]}}
HTTP_RESPONSE1
exit 0

# EOF

decodeURL.awkは、URLエンコード・デコードする -- Qiitaのコードを活用しました。

listfile.txtのサンプル


20090504095930 /Volumes/Home/www/ScrapBook/macmini2016/ScrapBook/data/20090504095930/index.html Mac%E3%81%A7%E3%83%A2%E3%83%90%E3%82%A4%E3%83%AB%E3%82%AA%E3%83%95%E3%82%A3%E3%82%B9%E3%81%AF%E5%AE%9F%E7%8F%BE%E3%81%A7%E3%81%8D%E3%82%8B%E3%81%8B%EF%BC%9F--%E3%83%93%E3%82%B8%E3%83%8D%E3%82%B9%E3%81%A7%E4%BD%BF%E3%81%86Mac%E3%83%AC%E3%83%93%E3%83%A5%E3%83%BC%3A%E3%82%B3%E3%83%A9%E3%83%A0%20-%20CNET%20Japan http://japan.cnet.com/column/bizmac/story/0,3800091940,20388153,00.htm ROOT /Volumes/Home/www/ScrapBook/macmini2016/ScrapBookHtml/scrap-0-1.html
20130113183950 /Volumes/Home/www/ScrapBook/macmini2016/ScrapBook/data/20130113183950/index.html Windows8%20%E3%82%A2%E3%83%83%E3%83%97%E3%82%B0%E3%83%AC%E3%83%BC%E3%83%89%E5%BE%8C%E3%81%ABWindows.old%E3%83%95%E3%82%A9%E3%83%AB%E3%83%80%E3%82%92%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95%20-%20%E5%85%83%E3%80%8C%E3%81%AA%E3%82%93%E3%81%A7%E3%82%82%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E5%B1%8B%E3%80%8D%E3%81%AE%E3%83%80%E3%83%A1%E6%97%A5%E8%A8%98 http://nasunoblog.blogspot.jp/2012/10/windows8-windowsold.html ROOT /Volumes/Home/www/ScrapBook/macmini2016/ScrapBookHtml/scrap-0-1.html
20130602104455 /Volumes/Home/www/ScrapBook/macmini2016/ScrapBook/data/20130602104455/index.html %E7%A4%BE%E4%BC%9A%E3%81%AB%E5%87%BA%E3%82%8B%E5%89%8D%E3%81%AB%E7%9F%A5%E3%81%A3%E3%81%A6%E3%81%8A%E3%81%8F%E3%81%B9%E3%81%8D%20cygwin%201.7%20%E3%81%AE%E3%81%93%E3%81%A8%20-%20BOOLEANLABEL http://d.hatena.ne.jp/fd0/20090131/p1 ROOT /Volumes/Home/www/ScrapBook/macmini2016/ScrapBookHtml/scrap-0-1.html
...

(2020.11.02全面改訂ここまで)

solrsearch.jsはPure Javascript (ECMAScript 2015)以外のフレームワークを使っていません。これでSolrとのajax連携を含む、イベントドリブンモデルのサンプルになっています。
api.cgiは受け取ったクエリをSolrサーバに投げて返った結果を返すだけの単純なShell Scriptです。
bib.cgiが読み込む書誌ファイルであるlistfile.txtはファイル保存日時、HTMLファイルのパス、タイトル、ソースURL、カテゴリ、カテゴリに対応したインデックスHTMLファイルへのパスをTABで区切ったもので、ここでは単純にファイルを上から下まで舐めてます。この動作をこのサンプルでは最大30 (rowsMAX) 並列で投げて結果を待ち合わせて表示しています。これでも数千レコード程度であれば性能的に問題ないです。ちゃんと手間かけるなら書誌DBを作ったほうがいいのでしょうけれど。

(2020.11.02スクリーンショット差替)

このプログラムで実現されるWebブラウザによるクライアントはこのような形です。
2020110202
キーワードを入力して検索すると複数画面に分割して結果を表示します。Sub Queriesは、語をカンマで区切るとその区切りだけ絞り込み検索します。
2020110201
▼部をマウスオーヴァすることによりヒット箇所のハイライト表示も可能。
2020110203

(2020.11.02スクリーンショット差替ここまで)

|

« ScrapBook及びSave Page WEで保存したWebページをApache Solrで検索できるようにしてみた(その1) | トップページ | Dockerを使ったJupyter Labの環境を用意する »

パソコン・インターネット」カテゴリの記事

コメント

コメントを書く



(ウェブ上には掲載しません)




« ScrapBook及びSave Page WEで保存したWebページをApache Solrで検索できるようにしてみた(その1) | トップページ | Dockerを使ったJupyter Labの環境を用意する »