Edited at

Qiita記事解析システムを作ろう その1

More than 1 year has passed since last update.


概要

 Qiitaの記事をGoogle Cloud Natural Languageで解析してみるという企画。コンソールアプリで作っても面白くないので、意味も無くWebシステムで構築するのだ!

項目
内容

VPS
Google Compute Engine

OS
CentOS7

WebServer
Nginx

APServer
PHP-FPM

Database
PostgreSQL

 今回もページ遷移無しのSPA(SinglePageApplication)縛り。

 縛り好きだけど、SでもMでもナイヨ!


ソースコード

 https://github.com/mofon001/QiitaAnalyzer

 ソースはGitHubにあるのかい?

 そぉっす


フレームワークの構築

 本当のことを言おう。今回の企画、実はQiitaの記事を解析することがメインの目的では無いのだ。

 そう、SPAのフレームワーク構築、これが真の目的なのだ!

 さあ、バリバリ必要なモノを作っていくぞ


 1.モジュール

 PHPのプログラム、ファイルを分散させた時に、いちいちrequireとかinclude書くの面倒くせぇ

 毎回使う機能じゃないから呼び出すタイミングを絞りたいけど、それを書くのもめんどくせぇ

 息をするのもめんどく・・・アベシ 

 ということで、クラス名とファイル名を対応づけておくことで、クラスが呼び出されると勝手にファイルを読み込む構造を作る。

 詳しくは過去記事参照 JavaScriptからPHP関数を直接呼ぶ感覚で使えるアダプタ

 フレームワーク上でサーバ側の機能を追加する場合は、モジュール専用ディレクトリにPHPのファイルを置く。必要な時に勝手に対処する。これで呼吸を忘れてしまう人間でも心配する必要がなくなった。

image.png

 クラスに情報を埋め込んでおくと、一覧で確認できるのだ


 2.データベース関連

 ローカルデータベースとメインデータベースの二刀流

 ローカルデータベースはSQLiteを使い、緊急用のローカル管理ユーザやメインデータベースへの接続情報、セッション情報などを管理

 メインデータベースは通常のユーザ情報や、今回のシステムだとQiitaの記事をキャッシュ

image.png

 当然、これの設定が終わるまでメイン機能は使えない


 3.セッション

 C/S間のやりとりでは権限の問題が絡むので、セッション機能が必要だ。何故なら、オレオレ言われても誰なのか分からない。俺が俺であることを俺が証明するというオレオレ証明書では困るのだ。

 ちなみにページ遷移が前提のシステムだと、セッションのキーとなる情報はクッキーに保存されることが多い。しかしSPAはJavaScript自体がデータを持っていれば良いだけなのでクッキーなど不要なのだ。何でもかんでもクッキーを食わせていたら、肥え太って仕方が無い。ちなみに最近は安物のクッキーやパンにはバターが入っていないものが多い。スーパーへ行って成分表を確認してみると良い。食パンなんか入っている方がレアなぐらいだ。それからブラウザのリロードでセッション情報の喪失を恐れるならsessionStorageにぶち込んでおこう。

 セッションは初回アクセス時に必ず発行。ログインしていなくても必ず発行。ログインしたら、セッションとユーザ情報を結びつける。また、今回は初期ユーザやデータベースの接続情報などはハードコーディングしない、設定ファイルも用意しないという縛りまで用意している。必要な情報は必要な時に設定画面を出し、その場でローカルデータベースに記憶するのだ。つまり管理者がいなかった場合、お前が管理者になれと強制する画面が表示されるということだ。


 4.ユーザ管理

 ユーザやグループ管理機能、ちなみにグループの管理画面しかスクリーンショットが無い。何故なら、ユーザ設定に個人情報が含まれていて、マスクするのが面倒くさいからだ。

image.png


 5.ログ機能

 あるとデバッグするのに楽。


具体的な組み方

 PHP側のプログラムは関数の頭に「JS_」を付ければ、そのままJavaScriptとやりとり可能な命令に変身する。そしてJSというキーワードで不埒な想像をした者は、捕まる前に更生を図った方が良いだろう。


Users.php

<?php

class Users{
static $JS_ENABLE;
const INFO = [
"NAME"=>"ユーザ管理プラグイン",
"VERSION"=>1.00,
"DESCRIPTION"=>"ユーザ情報の管理を行う",
"AUTHOR"=>"SoraKumo",
"TABLES"=>[["users",1,1]]
];
public static function initModule(){
if(MG::DB()->isConnect() && !MG::DB()->isTable("users")){
MG::DB()->exec(
"create table IF NOT EXISTS users(users_id SERIAL PRIMARY KEY,users_enable BOOLEAN,
users_mail TEXT,users_password TEXT,users_name TEXT,users_info TEXT,UNIQUE(users_mail));
create table user_group(user_group_id SERIAL PRIMARY KEY,user_group_enable boolean,user_group_name TEXT,user_group_info TEXT);
insert into user_group values(default,true,'SYSTEM_ADMIN','管理者グループ');
insert into user_group values(default,true,'GUEST','ゲストグループ');
create table user_group_bind(users_id INTEGER references users(users_id),
user_group_id INTEGER references user_group(user_group_id),
user_group_bind_value INTEGER,PRIMARY KEY(users_id,user_group_id));"
);
}
}
public static function getUserCount(){
return MG::DB()->get("select count(*) from users");
}
public static function getUsers(){
return MG::DB()->queryData("select * from users order by users_id");
}
public static function addUser(){
return MG::DB()->get("insert into users values(default,true,null,null,'新規ユーザ','') returning users_id");
}
public static function delUser($id){
return MG::DB()->exec("delete from users where users_id=?",$id);
}
public static function setUser($id,$enable,$name,$mail,$pass,$info){
$keys = ["users_id"];
$values = ["users_id"=>$id];
if($enable !== null) $values["users_enable"] = $enable;
if($name !== null) $values["users_name"] = $name;
if($mail !== null) $values["users_mail"] = $mail;
if($pass !== null) $values["users_password"] = $pass;
if($info !== null) $values["users_info"] = $info;
return MG::DB()->replace("users",$keys,$values);
}
public static function getGroupNames($userId){
if(!MG::DB()->isConnect())
return null;
$values = MG::DB()->queryData2("select user_group_name from user_group where user_group_id in ".
"(select user_group_id from user_group_bind where users_id = (select users_id from users where users_mail=?))",
$userId);
$result = [];
foreach($values as $value)
$result[] = $value[0];
return $result;
}
public static function getGroups($userId){
return MG::DB()->queryData("select * from user_group where user_group_id in ".
"(select user_group_id from user_group_bind where users_id=?)",
$userId);
}
public static function getGroupUserCount($groupName){
if($groupName === null)
return MG::DB()->get("select count(*) from user_group_bind");
else
return MG::DB()->get(
"select count(*) from user_group_bind where user_group_id in ".
"(select user_group_id from user_group where user_group_name=?)",
$groupName);
}
public static function JS_getUsers(){
if(!MG::isAdmin())
return false;
return Self::getUsers();
}
public static function JS_addUser(){
if(!MG::isAdmin())
return false;
return Self::addUser();
}
public static function JS_delUser($userId){
if(!MG::isAdmin())
return false;
return Self::delUser($userId);
}
public static function JS_setUser($id,$enable=null,$name=null,$mail=null,$pass=null,$info=null){
if(!MG::isAdmin())
return ["result"=>0,"message"=>"ユーザーデータの設定 権限エラー"];
Self::setUser($id,$enable,$name,$mail,$pass,$info);
$result = ["result"=>1,"message"=>"ユーザーデータの設定"];
return $result;
}
public static function JS_getGroups(){
if(!MG::isAdmin())
return false;
return MG::DB()->queryData("select *,0 from user_group order by user_group_id");
}
public static function JS_setGroup($id,$enable,$name,$info){
if(!MG::isAdmin())
return false;

$keys = ["user_group_id"];
$values = ["user_group_id"=>$id];
if($enable !== null)
$values["user_group_enable"] = $enable;
if($name !== null)
$values["user_group_name"] = $name;
if($info !== null)
$values["user_group_info"] = $info;
return MG::DB()->replace("user_group",$keys,$values);
}
public static function JS_delGroup($id){
if(!MG::isAdmin())
return false;
return MG::DB()->exec("delete from user_group where user_group_id=?",$id);
}
public static function JS_addGroup(){
if(!MG::isAdmin())
return false;
return MG::DB()->get("insert into user_group values(default,true,'新規グループ','') returning user_group_id");
}
public static function JS_addGroupUser($groupId,$userId){
if(!MG::isAdmin())
return false;
return MG::DB()->exec("insert into user_group_bind values(?,?)",$userId,$groupId) === 1;
}
public static function JS_delGroupUser($groupId,$userId){
if(!MG::isAdmin())
return false;
return MG::DB()->exec("delete from user_group_bind where users_id=? and user_group_id=?",$userId,$groupId) === 1;
}
public static function JS_getUserGroups($userId){
if(!MG::isAdmin())
return false;
return Self::getGroups($userId);
}
}


 

 

 下のソースが、ユーザやグループ管理用のJavaScriptのコード。Win32APIがらみのプログラムをした経験がある者は、なにか懐かしさを感じるのでは無いだろうか?基本的なコントロールがウインドウを単位としている。

 ちなみにAngularJS vue.js React等のように、パラメータで何かを記述するような発想はない。全てきっちり命令を書くのだ。甘えは許されない。ウインドウの表示は補助するが、DOMをちょちょいと簡単に操作する機能など用意してはいないのだ。

 リストビューやツリービューは標準で用意している。これがWin32APIの臭いの原因だ。


UserView.js

function createUserView(){

var win = GUI.createWindow();

if(!SESSION.isAuthority('SYSTEM_ADMIN')){
GUI.createMessageBox("エラー","権限がありません",["OK"]);
return win;
}

var separate = GUI.createSeparate(400,"ns");
win.addChild(separate,"client");

var panel = GUI.createPanel();
separate.getChild(0).addChild(panel,"top");
panel.getClient().innerHTML = "ユーザ設定 <BUTTON>追加</BUTTON><BUTTON>削除</BUTTON>";

var buttons = panel.getClient().querySelectorAll("button");
for(var i=0;i<buttons.length;i++){
buttons[i].addEventListener("click",onButtonClick);
}
function onButtonClick(e){
switch(e.srcElement.textContent){
case "追加":
ADP.exec("Users.addUser").on = function(r){
if(r)
win.loadUser();
}
break;
case "削除":
var index = listView.getSelectIndex();
var id = listView.getItemText(index,0);
ADP.exec("Users.delUser",id).on = function(r){
if(r)
win.loadUser();
}
break;
}
}

var listView = GUI.createListView();
listView.addHeader("ID",80);
listView.addHeader("ENABLE",100);
listView.addHeader("NAME",200);
listView.addHeader("MAIL",200);
listView.addHeader("PASS",120);
listView.addHeader("INFO",200);
separate.getChild(0).addChild(listView,"client");

win.loadUser = function(){
listView.clearItem();
listGroup.clearItem();
ADP.exec("Users.getUsers").on = function(values){
if(values!==null){
for(var i=0;i<values.length;i++){
var value = values[i];
var index = listView.addItem(value['users_id']);
listView.setItem(index,1,value['users_enable']);
listView.setItem(index,2,value['users_name']);
listView.setItem(index,3,value['users_mail']);
listView.setItem(index,4,'********');
listView.setItem(index,5,value['users_info']);
}
}
}
}

listView.addEvent("itemClick",function(e){
var index = e.itemIndex;
var subIndex = e.itemSubIndex;
win.loadGroup();
if(subIndex <= 0)
return;

var id = listView.getItemText(index,0);
var area = this.getItemArea(index,subIndex);
var enable = ['true','false'];
switch(subIndex){
case 1:
var select = GUI.createSelectView();
for(var i in enable)
select.addText(enable[i]);
select.setSize(area.width,200);
select.setPos(area.x,area.y);
select.addEvent("select",function(e){
ADP.exec("Users.setUser",id,e.value).on = function(r){
if(r != null && r.result){
listView.setItem(index,subIndex,e.value);
}
}
});
break;
case 2:
case 3:
case 4:
case 5:
var edit = listView.editText(index,subIndex);
edit.addEvent("enter",function(e){
var p = [];
p[subIndex - 2] = subIndex == 4 ? CryptoJS.MD5(e.value).toString():e.value;
ADP.exec("Users.setUser",id,null,p[0],p[1],p[2],p[3]).on=function(r){
if(r != null && r.result){
if(subIndex!=4)
listView.setItem(index,subIndex,e.value);
}
}
});
break;
}
});

var panel = GUI.createPanel();
separate.getChild(1).addChild(panel, "top");
panel.getClient().innerHTML = "グループ設定 <BUTTON>追加</BUTTON><BUTTON>削除</BUTTON>";

var listGroup = GUI.createListView();
listGroup.addHeader("ID",80);
listGroup.addHeader("NAME",300);
separate.getChild(1).addChild(listGroup, "client");

var buttons = panel.getClient().querySelectorAll("button");
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener("click", onButtonGroup);
}
function onButtonGroup(e){
switch (e.srcElement.textContent) {
case "追加":
var w = createGroupSelect(function(value){
if (listView.getSelectIndex() < 0)
return;
var id = listView.getItemText(listView.getSelectIndex(), 0);
ADP.exec("Users.addGroupUser", value,id).on = function (r) {
if (r){
w.close();
win.loadGroup();
}
}
});
break;
case "削除":
if (listView.getSelectIndex() < 0 || listGroup.getSelectIndex() < 0)
return;
var userId = listView.getItemText(listView.getSelectIndex(), 0);
var groupId = listGroup.getItemText(listGroup.getSelectIndex(), 0);
ADP.exec("Users.delGroupUser", groupId, userId).on = function (r) {
if (r)
win.loadGroup();
}
break;
}
}
win.loadGroup = function () {
listGroup.clearItem();
if (listView.getSelectIndex() < 0)
return;
var id = listView.getItemText(listView.getSelectIndex(), 0);
ADP.exec("Users.getUserGroups",id).on = function (values) {
if (values) {
for (var i = 0; i < values.length; i++) {
var value = values[i];
var index = listGroup.addItem(value['user_group_id']);
listGroup.setItem(index, 1, value['user_group_name']);
}
}
}
}

win.loadUser();
return win;
}
function createGroupSelect(func){
var win = GUI.createFrameWindow();
win.setTitle("グループ選択");
var listView = GUI.createListView();
listView.addHeader("ID",80);
listView.addHeader("ENABLE", 100);
listView.addHeader("NAME",200);
listView.addHeader("INFO",200);
win.addChild(listView,"client");
win.setPos();

listView.addEvent("itemDblClick",function(e){
func(this.getItemText(e.itemIndex,0));
});

win.load = function(){
listView.clearItem();
ADP.exec("Users.getGroups").on = function (values) {
if (values === null)
return;
for (var i = 0; i < values.length; i++) {
var value = values[i];
var index = listView.addItem(value['user_group_id']);
listView.setItem(index, 1, value['user_group_enable']);
listView.setItem(index, 2, value['user_group_name']);
listView.setItem(index, 3, value['user_group_info']);
}
}
}

win.load();
return win;
}


 

 

 下がJavaScriptのメイン部分。

 ツリービューの表示と各機能への遷移を担当。


Main.js

(function(){

//PHP通信用アダプタの作成(グローバル)
ADP = AFL.createAdapter("./",sessionStorage.getItem("sessionHash"));
document.addEventListener("DOMContentLoaded",onLoad);

//プログラム開始動作
function onLoad(){
//認証処理後、onStartを呼び出す
SESSION.requestSession(onStart,false);
}
System = {};
function onStart(r){
GUI.rootWindow.removeChildAll();

//画面上部を作成
var top = GUI.createWindow();
top.setSize(0, 60);
top.setChildStyle("top");
top.setClientClass("LayoutTop");

var title = document.createElement("div");
top.getClient().appendChild(title);
title.textContent = "Qiita記事解析システム";

var login = document.createElement("div");
System.login = login;
login.className = "menuItem";
top.getClient().appendChild(login);
login.addEventListener("click", function () {
SESSION.createLoginWindow(onStart);
});

//画面分割(横)
var separate = GUI.createSeparate(200,"we");
separate.setChildStyle("client");

//ツリーメニューの作成
var treeMenu = GUI.createTreeView();
separate.addSeparateChild(0, treeMenu,"client");
treeMenu.addEvent("select",function(){
var parent = separate.getChild(1);
parent.removeChildAll();
var proc = treeMenu.getSelectValue();
if(proc){
var w = proc();
separate.addSeparateChild(1,w,"client");
}

});

var rootItem = treeMenu.getRootItem();
rootItem.setItemText("Menu一覧");

var item,subItem;
item = rootItem.addItem("システム");
item.addItem("モジュール確認").setItemValue(createPluginView);
item.addItem("データベース設定").setItemValue(createDatabaseView);
item.addItem("ユーザ設定").setItemValue(createUserView);
item.addItem("グループ設定").setItemValue(createGroupView);
item.addItem("ログ").setItemValue(createLog);

item = rootItem.addItem("Qiitaデータ関連");
item.addItem("データ取得").setItemValue(createQiitaView);
item.addItem("データ確認").setItemValue(createQiitaTitleView);

item = rootItem.addItem("Google CNL");
item.addItem("構文分析");
item.addItem("感情分析");

System.login.textContent = r.user.name;

}
})();



ようやく記事解析部分

 解析する前に記事をキャッシュする必要がある。無い袖は振れない。

image.png

 読み込んだ記事は、紐付いているユーザ情報やタグも含めて、複数のテーブルに整理して保存。

 無駄な労力である。

image.png

 ちなみにダブルクリックすると、フレームウインドウがポップアップして記事内容を表示される。

 このままだと完全にQiitaの記事Viewerだ。

image.png


あれ、記事解析は?

 ここまで来るのに疲れたよ、パトラッシュ。

 ということで次回に続く。