One Liner Enabled

05/31

Index

An Issue for Me

このサイトはHexoで静的コンテンツを生成して
いるのだが、少し不満に思うことがあるので
ちょっと解決していきたい。

Hexo is the best way of blog

まず、Hexoの利点は編集のしやすさ。
Markdown記法で書かれたmdファイルを
ファイル単位で管理できるし、pluginが
豊富にあるので、mermaidで記述することだって
出来るし、jsソースをハイライトで表示すること
だってできる。自分用にカスタマイズもできる
柔軟性が売り。

それとなんと言っても安全。
PHP、Ruby、Perlと言ったサーバサイド
スクリプトを動かさないで済むのがもう
嬉しい。

コメント欄とか掲示板や
WEBから投稿と言ったインタフェースを
作りたいときは流石に必要にはなるけど
やりようによってはどうとでもできるから
サーバには持たないことにしている。

To Post More Easily

先述の安全性を重視したことで
外出先からちょろっと近況の呟きを
投稿と言った身軽さがないのが玉に瑕。
そんな不満を解決したいと思う。

ただ、WEB投稿の仕組みを用意するのは
避けたいので、別の仕組みを用意したい。

To Apply Immediately

思いついた→呟く→Get Posted
この仕組みって正にTwitterなんだけど
Twitterは全く好きになれないので
やっぱりちゃんと一元管理できないと
なんか気持ち悪い。
過去の発言を見直して、「あ、恥ず」
みたいなの訂正できるし。

要件としては

  • 1行1投稿
  • すぐ反映
  • できれば修正も可能
  • スマホ、PCから投稿可能
  • 家からも外出先からも投稿可能
  • Hexoの現在の運用は変えない

こんなところか。
今の使い勝手はそのままに
呟きを見れるようにしたい。
ただ、現在1投稿1ページの
構成はそのままにしたい。
さてと結構面倒くさい要件かも。

How should I do that?

つぶやき専用ページを作ってそこに呟きは
集約してしまう。
そのつぶやきはどんどん追記されていくように
すると。

では投稿の仕組みを考えてみた。
Google Spread Sheetsを使って
つぶやきを管理する。
そこに保存しているつぶやきを
基にMarkdownファイルを生成する。
そしてHexoでgenerateすると。
さらにIFTTTを使って
Google AssistantとSpead Sheetsの
橋渡しを構築する。
軽く図式にするとこんな感じ。

graph TD
  srv1(Google Assistant)
  srv2(Spread Sheets)
  srv3((Web Site))
  srv4(IFTTT)
  svr1[My Server]
  tm1[PC]
  tm2[Smart Phone]
  tm1-->|Edit|srv2
  svr1==>|API Request|srv2
  srv2==>|Response:Cell Values|svr1
  svr1==>|hexo gen|srv3
  tm2-->|OK Google|srv1
  srv1-.->|API|srv4
  srv4-.->|API Request:Edit|srv2
  style tm1 fill:#e99
  style tm2 fill:#e99
  style svr1 fill:#eae9b4
  style srv1 fill:#b2b0af
  style srv4 fill:#afafff
  style srv3 fill:#fff
  style srv2 fill:#96ff96

今回太字のラインを新規構築、
それ以外は既存サービスで実装する予定。
さてやっていこうか。

Ok Google, do post.

さて、呟きの元ネタはGoogle Driveの
中に、Spread Sheets1として保存する。
PCで編集することも出来るし
もちろんスマホでもできる。
ただスマホのアドバンテージとして
Google Assistant2経由で編集できたら
楽かもと思い、IFTTTによる連携も
やってみる。これでGoogle Homeからも
投稿可能になるので、なんか近未来感は
味わえるかなと。でも

外で喋るの恥ずかしいよ、バカなの?

って時もあるかもしれないので
その時はGAをキーボード入力に変えれば
いいだけなので、何も

おっけーぐーぐるっ昼ごはんでミートソースこぼしたドジな私と呟く(ボソ)

みたいなことしなくて良い。
Actions on Googleでも良いのだが
構築しなくてはいけないことが多い気が
してちょっと今回はやめた。

Spread Sheets

Google Driveを開いて
/IFTTT/hexo/HEXO.ods
というSSを作成する。

If This Then That

IFTTTでIf GA Then SSの
連携レシピを設定する。
私は以下のようにした。

ゴッサム で $ と呟く #

と喋ると、SSに1行追記するってことにした。
$は変数として扱われ、その中に
文章が代入され、次のレシピでText Field
として使用できる。
同様に#はNumber Fieldが使える。
Number Fieldはちょっとした
セキュリティ対策として
パスワードの役割として入れてみた。
予め決めといたパスワードに一致してないと
その呟きは投稿しないこととするため。

試しに使ってみる。
手元のPixel 3をギュッと握る。
そして

私: ゴッサム でテストと呟くいちにーさんよん
ぐ: りょ

GAからはうまく行ったら「りょ」と
返すようにした。程なくしてさっき作った
SSに以下の通りに追加されていた。

A B C
1 May 26, 2019 at 05:30PM 1234 で テストと呟く

あれ?$のところが思ってたんと違う。
余計な「で 」と「と呟く」がくっついている。
なんでだろう。
外側スペースから外側のスペースまでを
Text Fieldと認識しているんだろうか。
よく分からない。ま、いいや。
後で文字列操作しないといけないけど。

次。

Spread Sheets API

ここからが本番。SSにアクセスする
仕組みを用意したい。
折角なので、Node.jsで行うことにする。
以前にGoogle Calendar APIを
使ったことがあるので扱いは慣れたものだ。

アクセスは至ってシンプル。
OAuthで認証受けてトークンを
使ってサービスのAPIにアクセスする。

カレンダの時と全く一緒で
いろんな関数が用意されており
今回は中身を取得したいだけなので
ファイル開いて、シート見つけて
セルの範囲を指定して中身を
取り出す。ま、VBAと一緒である。

さてやってみる。

まずnpmで必要なモジュールを入れていく。
今回はone-linerという名前のプロジェクトに
しよう。

$ sudo mkdir /srv/one-liner
$ cd /srv/one-liner
$ npm install date-utils fs readline googleapis@39 --save

あ、それと事前にGoogleアカウントのCloud
Platform ConsoleからSS用のClient側の
Credentialsファイルのダウンロードを忘れずに。
それと、今回編集するSSのIDを取得しておくのも
忘れずに。(jsソース内で使うので)

/**
 *
 * index.js
 *
 */
const dttime = require('date-utils');
const fs = require('fs');
const readline = require('readline');
const {google} = require('googleapis');

/**
 * constants
 */
const postpass='1234';
const sep1=',';
const sep2='at';
const datesep='-'
const timesep=' ';
const replacedatespec='{{ date }}';
const linefeed="\n";
const mdh2='## ';

const BaseDir = '/srv/blog';
const GDriveDir = '/home/user1/GDrive';
const templateMdFile = BaseDir + '/source/_drafts/template-one-liner.md';
const productionMdFile = BaseDir + '/source/_posts/one-liner.md';
const hexoGenerateFile = GDriveDir + '/sync/hexo_generate.txt';

// If modifying these scopes, delete token.json.
const SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
// The file token.json stores the user's access and refresh tokens, and is
// created automatically when the authorization flow completes for the first
// time.
const TOKEN_PATH = 'token.json';

const CREDENTIAL_PATH = 'credentials.json';

/**
 *
 * main
 *
 */
// Load client secrets from a local file.
fs.readFile(CREDENTIAL_PATH, (err, content) => {
    if (err) return console.log('Error loading client secret file:', err);
    // Authorize a client with credentials, then call the Google Sheets API.
    authorize(JSON.parse(content), updateTweet);
});

/**
 *
 * functions
 *
 */
//Month Name to Number 
function setMonth2Num(month) {
    let monthNameArray = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',];
    return (monthNameArray.indexOf(month) + 1);
}

//Optimize Description
function setDescFormat(str){
    let desc=str.substring(2).replace(/ /g, '');
    return desc;
}

//Optimize Password
function setPassFormat(str){
    let isValid=false;
    let validatestr=('0000'+str).slice(-4);
    //console.log(validatestr);
    if(validatestr==postpass){
        isValid=true;
    }
    return isValid;
}

//Optimize Date
function setDateFormat(dt){
    let date1=dt.substring(dt.indexOf(sep1) + 2, dt.indexOf(sep2) - 1)
        +datesep
        +dt.substring(0,dt.indexOf(sep1)).replace(/ /,datesep)
        +timesep
        +dt.substring(dt.indexOf(sep2) + 3);
    let monthbyStr=date1.substring(5,8);
    let monthbyNum=setMonth2Num(monthbyStr);
    date1=date1.replace(/[AP]M/,'');
    date1=date1.replace(monthbyStr,monthbyNum);
    //console.log(new Date().toFormat(date1));
    return new Date().toFormat(date1);
}

//check if file exists
function checkFileExist(filespec){
    let isExist = false;
    try{
        fs.statSync(filespec);
        isExist = true;
    }catch(err){
        isExist = false;
    }
    return isExist;
}

/**
 * Create an OAuth2 client with the given credentials, and then execute the
 * given callback function.
 * @param {Object} credentials The authorization client credentials.
 * @param {function} callback The callback to call with the authorized client.
 */
function authorize(credentials, callback) { 
    const {client_secret, client_id, redirect_uris} = credentials.installed;
    const oAuth2Client = new google.auth.OAuth2(
        client_id, client_secret, redirect_uris[0]);

    // Check if we have previously stored a token.
    fs.readFile(TOKEN_PATH, (err, token) => {
        if (err) return getNewToken(oAuth2Client, callback);
        oAuth2Client.setCredentials(JSON.parse(token));
        callback(oAuth2Client);
    });
}

/**
 * Get and store new token after prompting for user authorization, and then
 * execute the given callback with the authorized OAuth2 client.
 * @param {google.auth.OAuth2} oAuth2Client The OAuth2 client to get token for.
 * @param {getEventsCallback} callback The callback for the authorized client.
 */
function getNewToken(oAuth2Client, callback) {
    const authUrl = oAuth2Client.generateAuthUrl({
        access_type: 'offline',
        scope: SCOPES,
    });
    console.log('Authorize this app by visiting this url:', authUrl);
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout,
    });
    rl.question('Enter the code from that page here: ', (code) => {
        rl.close();
        oAuth2Client.getToken(code, (err, token) => {
            if (err) return console.error('Error while trying to retrieve access token', err);
            oAuth2Client.setCredentials(token);
            // Store the token to disk for later program executions
            fs.writeFile(TOKEN_PATH, JSON.stringify(token), (err) => {
                if (err) return console.error(err);
                console.log('Token stored to', TOKEN_PATH);
            });
            callback(oAuth2Client);
        });
    });
}

/**
 *
 * Post Tweeting
 *
 */
function updateTweet(auth) {
    const sheets = google.sheets({version: 'v4', auth});
    sheets.spreadsheets.values.get({
        spreadsheetId: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
        range: 'Sheet1!A1:C',
    }, (err, res) => {
        if (err) return console.log('The API returned an error: ' + err);
        const rows = res.data.values;
        let tweetData={date:[], valid:[], description:[], rawdata: []};
        if (rows.length) {
            //console.log('Date, Pass, RawData');
            rows.map((row) => {
                //console.log(`${row[0]}, ${row[1]}, ${row[2]}`);
                tweetData.date.unshift(setDateFormat(row[0]));
                tweetData.valid.unshift(setPassFormat(row[1]));
                tweetData.description.unshift(setDescFormat(row[2]));
                tweetData.rawdata.unshift(row[2]);
            });
            if(tweetData.date.length>0){
                //console.log(tweetData);
                doPost(tweetData);
            }
        } else {
            console.log('No data found.');
        }
    });
}

/**
 *
 * Post
 *
 */
function doPost(tweetData){
    if(checkFileExist(templateMdFile)){
        fs.readFile(templateMdFile, (err, databuf)=>{
            //console.log(tweetData);
            for(let i=0;i<20;i++){
                if(tweetData.valid[i]){
                    databuf+=linefeed
                        +mdh2
                        +tweetData.date[i]
                        +linefeed
                        +linefeed
                        +tweetData.description[i]
                        +linefeed;
                }
            }
            databuf=databuf.replace(replacedatespec, tweetData.date[0]);
            //console.log(databuf);
            fs.readFile(productionMdFile, (err, postbuf)=>{
                if(postbuf==databuf){
                    console.log('nothing was updated.');
                }else{
                    fs.writeFile(productionMdFile, databuf, ()=>{
                        console.log('production file['+productionMdFile+'] was generated.');
                        fs.writeFile(hexoGenerateFile, '', ()=>{
                            console.log('hexo trigger file was generated.');
                        });
                    });
                }
            });
        });
    }else{
        console.log('template file['+templateMdFile+'] was not found.');
        return false;
    }
}

私のコーディングスキルの稚拙さなんだろうけど
どんどんネストが深くなっていく。
タスキリレーのようにどんどん奥底へ行く感覚。

思えば遠くへ来たもんだ

と歌いたくなる。

因みにSSへのアクセスをするための
サンプルが用意されているのでそのソースを変えただけ。
ただここから投稿を修正する仕組みを入れるとなると
途端に難易度が上がる。今回は修正作業をしたいときは
SSを直接修正することにする。

あとはhexo用のテンプレートファイルを
用意してそこに呟きをはめ込んでいき
投稿用ファイルとして上書き保存する。
テンプレートは以下のような感じ。

---
title: talking to myself
date: {{ date }}
tags:
- diary
cover_index: /assets/conversation.jpg
cover_detail: /assets/conversation.jpg
toc: true
---

# Index
<!-- toc -->

最後はトリガーとなるファイルを
作成して完了。このサイトでは
投稿の仕組みは紹介していないのだが
トリガーファイルを監視するスクリプトが
動いていて、そのファイルがあれば
hexo gが動きだす仕組みが別途仕込んでる。
ま、簡単なShell Scriptなので
ここでは割愛。

Check Every 2 Minutes

さてこのjsスクリプトを定期的に実行
することに。あまりに回数を多くすると
たしかGoogle Cloud APIの回数制限に
引っかかるので、2分に1回ぐらいがいいかな。

!/bin/bash

PATH=$PATH:/usr/local/node/bin

cd /srv/one-liner
node .
exit 0

こんなのをone-liner.shのような
ファイルで保存する。
そしてcronさまのお世話になる。

00-59/2 * * * * $HOME/bin/one-liner.sh

Evaluate

Pixel 3をギュッと握る。

私: ゴッサム でスタバで働いてみたい とつぶやくいちにーさんよん
ぐ: りょ

しばらくしたらこのサイトの
http://superpack.yf-19.net/2019/05/29/one-liner/
というURLで投稿が完了する。
日付の新しいものから20件が降順で
表示されている。

コレコレ。
これをやりたかった。

Complain

不満もやはり出てきた。

2分置きにスクリプトが動くのでSSを編集したら
即反映されてしまうのは気をつけないといけない。

日付が変わって呟くとURLが変わるので
投稿ページが埋もれていなくなることは
ないが、ブックマークは出来ない。
ま、こんなサイトを購読して見ていただける
奇特な方は毎回トップページから
アクセスしていただければと。

あと、GAからだと本当に短い文章しか
認識してくれない。何回も呟き直すことがあった。
まどろっこしい。

上のjsの実装の問題で
例えば

ゴッサム でYou Sexy Motherfucker とつぶやく1234

って呟いたとして(本当は日本語で喋るので
こんなのは無理だけど)、実際の投稿内容は

YouSexyMotherfucker

となる。つまり半角スペースが消える。
ま、ほぼ日本語でしか喋らないから
困るようなことってないと思うからいいんだけど。

投稿時間が24時間表記でないのは問題だな。
これはそのうち直そうっと。


  1. 1.以降、SSとする。
  2. 2.以降、GAとする。

コメント: