経緯
このteratail の過去遺産をもっと活用したいという質問に触発されて、特定のユーザーが投稿した質問や回答を検索するWebページを作ってみました。
site:teratail.com
つけてgoogleで検索すればいいじゃん!という意見はご尤もなんですけどね…作りたかったんです。
公開サイトはここ
勉強もかねて、bluemixで環境を構築してみました。laravelで作ったアプリケーションをbluemixにデプロイする時にはいろいろとフォルダの構成や環境ファイルを作る必要があるので四苦八苦しましたが、公式ブログと先人の知恵を借りて何とかデプロイ完了。
環境
・ laravel5.5(勉強がてら最新のフレームワークで作成。といっても新しい機能は使っていませんが…)
・ bootstrap(Umi)(日本語フォントが好きなので…)
・ teratailAPI(そりゃそうだ)
画面イメージ
検索画面
回答からの検索画面
質問からの検索画面
主だった修正点
Controller
APIをページネーションしながら全記事を取得しています。
(記事を一度に取得できる件数は100件が限度の為)
<?php
namespace App\Http\Controllers\search;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cookie;
use App\Facades\teratailAPI;
use App\Facades\utils;
/**
* teratailの記事の内容から検索したい内容を取り出す
*/
class GetMyArticlesController extends Controller
{
public function searchArticle(Request $request) {
$requestParam = $request->all();
$validator = Validator::make($requestParam,[
'searchWord' => 'required',
'accesstoken'=> 'required',
'userID'=> 'required',
]);
$getArticle = $this->getMyArticles($requestParam);
$validator->after(function($validator) use($getArticle){
if(!is_array($getArticle)){
$validator->errors()->add('searchWord','不正なリクエストです:'.$getArticle);
}
});
if($validator->fails()){
return redirect('/')->withErrors($validator)->withInput();
}
$response = new Response(view('searchResult', compact('getArticle','requestParam')));
if(isset($requestParam['saveCookie'])){
$response->withCookie(cookie()->forever('accesstoken', $requestParam['accesstoken']));
$response->withCookie(cookie()->forever('userID', $requestParam['userID']));
}else{
Cookie::queue(Cookie::forget('accesstoken'));
Cookie::queue(Cookie::forget('userID'));
}
return $response;
}
/**
* TeratailAPIをページネーションしながら呼び出し、全ての記事を取得する。
* @param array $requestParam
* @return type
*/
private function getMyArticles(Array $requestParam){
$category = $requestParam['answerOrQuestion']==='isAnswer'?'replies':'questions';
$url = 'https://teratail.com/api/v1/users/'. $requestParam['userID'] .'/'.$category.'?limit=100';
$resultArray = teratailAPI::callAPI($requestParam,$url);
if($resultArray['meta']['message'] != 'success'){ //metaの内容を確認してエラーがあったら終了
return $resultArray['meta']['message'];
}
$searchArray = explode(' ',$requestParam['searchWord']);
$articles = utils::searchWord($resultArray[$category], $searchArray);
for ($i=2;$i<=$resultArray['meta']['total_page'];$i++){//2ページ目から最終ページまでを取得する
$nextResultArray = teratailAPI::callAPI($requestParam,$url,$i);
if($nextResultArray['meta']['message'] != 'success'){ //metaの内容を確認してエラーがあったら終了
return $nextResultArray['meta']['message'];
}
$articles = array_merge($articles, utils::searchWord($nextResultArray[$category], $searchArray));
}
return $articles;
}
}
実際にAPIをコールしているServiceProvider
ここでは、アクセストークンの仕様が分からずにちょっとだけハマりました。
公式のリファレンスに
リクエストメソッドは現在GETとOPTIONSのみで、HTTPSのみに対応しています。アクセストークンを持っている場合は、OAuthの仕様に基づき Authorizationヘッダーに入れてリクエストを送ります。$ curl -X GET "https://teratail.com/api/v1/users/11" -H "Authorization: Bearer 0123456789abcdef0123456789abcdef0123456789"
ってあるのをちゃんと読めなかったのが敗因。
<?php
namespace App\Services;
class teratailAPI{
/**
* 実際にAPIを実行する処理。取得結果を配列にデコードして返却
* @param array $requestParam ユーザID,アクセストークンを利用
* @param string $url
* @param int $page
* @return type
*/
public function callAPI(Array $requestParam, string $url,int $page=0) {
$access_token = $requestParam['accesstoken'];
if($page){
$url .= '&page='.$page;
}
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); // 証明書の検証を行わない
curl_setopt($curl, CURLOPT_HTTPHEADER, array('Authorization: Bearer '.$access_token));
$result = curl_exec($curl);
curl_close($curl);
return json_decode($result,true);
}
}
検索結果を返すview
ここでは、質問を取得するAPIが返すIDの名前が'id'で回答が返す名前が'question_id'という事で、若干、無駄な分岐を入れることに…
あと、回答のAPIには質問のタイトルが無く…泣く泣く、質問IDをタイトルにしてしまいました。
回答側のレスポンスがもうちょっと、充実されるといいんですけどね…
question_idをキーに質問詳細を取得する事も考えましたが、だったらリンクさせれば良いだけな気もして、無駄にAPIをコールする事も無いか、という事でこの仕様に…
@extends('layout')
@section('content')
<form action="GetMyArticles" method="POST">
{{ csrf_field() }}
<input type="hidden" value="{{$requestParam['accesstoken']}}" name="accesstoken">
<div class="container form-group">
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" name="searchWord" value="{{$requestParam['searchWord']}}" required>
</div>
<div class="col-md-2">
<input type="submit" class="form-control btn-success col-md-2" value="再検索">
</div>
<div class="col-md-2">
<input type="text" value="{{$requestParam['userID']}}" name="userID" class="form-control" required>
</div>
</div>
<div class="row form-group">
<label for="answer" class="form-check-label">
<input type="radio" class="form-check-input" name="answerOrQuestion" value="isAnswer" id ="answer" checked>
回答から探す
</label>
<label for="question" class="form-check-label">
<input type="radio" class="form-check-input" name="answerOrQuestion" value="isQuestion" id="question">
質問から探す
</label>
</div>
</div>
</form>
@if(count($getArticle) > 0)
<div class="container">
@foreach ($getArticle as $article)
<div class="row row-eq-height well">
<div class="col-md-10">
@if ($requestParam['answerOrQuestion']=='isAnswer')
<a href="https://teratail.com/questions/{{$article['question_id']}}" target="blank">
<h2>{{$article['question_id']}}</h2>
</a>
@else
<a href="https://teratail.com/questions/{{$article['id']}}" target="blank">
<h2>{{$article['title']}}</h2>
</a>
@endif
{!! $article['body_str'] !!}
</div>
</div>
@endforeach
</div>
@else
<h3>検索内容に該当がありませんでした。</h3>
@endif
@stop
実際に文字列を検索しているところ
大文字小文字にかかわらず、また、全半角が異なってもマッチするように組んだつもり。
<?php
namespace App\Services;
class utils{
/**
* 配列内から検索ワードに該当する質問IDを配列で返却
* 全角・半角、大文字・小文字の違いは無視する。
* @param array $articles
* @param Array $searchWords
* @return array
*/
public function searchWord(Array $articles,Array $searchWords) {
$filter = function($searchObject) use ($searchWords){
foreach ($searchWords as $searchWord) {
if(mb_stripos(mb_convert_kana($searchObject['body']),mb_convert_kana($searchWord))===false){
return false;
}
}
return true;
};
return array_filter($articles, $filter);
}
}
まとめ
ここまでやってみましたが、下記の点が課題かな~と。
・ 回答を検索したときのタイトルがあまりにもヒドイ
・ 検索したときの文字列検索が遅そう…
・ 全記事を取得する時に何度もコールするのが本当に良いのか…
久々に組んで楽しかったので、まぁ、良しとします。
ちなみに、ソースはここ。