make the smart speaker smarter 21

01/11

Index

Let Me Know Days of Gabbage Collection

先日紹介したネタ帳の中でゴミ収集の日(ゴミの日)を知りたいってのを書いたがちょっとやってみたらそれらしいのが出来たので紹介。

How do I make

わが町札幌市はゴミ収集日カレンダーを市民に提供していて、これを見れば誰でも確認できるようにはなっているのだが、これがPDFで提供されている。

私がちょっと調べた限りではPDFを解析するものがnode.jsで用意されてはいるもののうまく動かなかった。
そこで上記ページをよく見ると音声読み上げ用ページってのが用意されていた。すべてテキスト情報として見ることができるので、これを解析したらよいだろうと思いこのページをスクレイピングしてカレンダー情報をDB登録してGoogle Home mini(Nest mini)から以下のような問い合わせができる仕組みを用意する。

私:オーケーグーグル、今日は何のゴミの日?
SS:お調べします。
SS:今日はプラスチックの日です。

ま、プラごみと燃えるごみはまだ予想できるのでいいとして、枝・葉・草とか燃やせないごみの日、雑がみの日はちょっと厄介なので毎週調べている。本当面倒なので、これがあると朝の作業能率が上がる。

Construction

簡単に仕組みを紹介。といっても、前回の購買履歴のときと大して変わらない。

graph TD
  ss[Google Home mini]
  gs((Google Assistant))
  srv1((IFTTT))
  p[Person]
  db{DataBase}
  js1(SYNC.js)
  js2(API.js)
  srv2((Gabbage Collection Page))

  style srv1 color:#eee,fill:#222
  style srv2 color:#111,fill:#e4e
  style ss fill:#4fa
  style gs fill:#4fa
  style js1 fill:#ddd,color:#373
  style db fill:#e83,color:#333
  style js2 fill:#ddd,color:#373
  style p color:#eee,fill:#34f

  js1-.->|1.request page|srv2
  srv2-->|2.response page|js1
  js1-->|3.insert records|db
  p-->|4.ask gc day|gs
  gs-->|5.trigger phrase|srv1
  srv1-->|6.request api|js2
  js2-->|7.query records|db
  db-->|8.return records|js2
  js2-->|9.request phrase|ss
  ss-->|10.answer gc day|p

  linkStyle 0 stroke:#666
  linkStyle 1 stroke:#666
  linkStyle 2 stroke:#666
  linkStyle 3 stroke:#666
  linkStyle 4 stroke:#666
  linkStyle 5 stroke:#666
  linkStyle 6 stroke:#666
  linkStyle 7 stroke:#666
  linkStyle 8 stroke:#666
  linkStyle 9 stroke:#666
  linkStyle 0 stroke:#666

札幌市ごみ収集カレンダーのサイトから音声読み上げ用ページから日付とごみの種類を拾い上げてDBにinsertするスクリプトが一つ。
IFTTTからアクセスされるAPIのスクリプトが一つ。既存のスクリプトの改修。

analyze web page

さて、札幌市のサイトを見ると読み上げに特化した話し口調で句読点などが考慮された文章が月毎にブロック分けされている。
これを解析してみる。

見ると、「○日です」「○日と○日です」みたいな日にち指定の表現と、「毎週○曜日です」みたいなon days of weekのな表現、あとは「○○の収集はありません」の3種類になっている。

そこで、これらの表現をそれぞれのごみの種類で1年分抜き出して、それらを9/1〜8/311分当てはまる日にちを見つけていく。

最後はそれをDBにいれていく。日付型で入れることができればいくらでもクエリ検索できるので大変便利って話。

まずは準備。

$ cd /srv
$ sudo mkdir gabbage_collection && sudo chown pi:users gabbage_collection
$ cd gabbage_collection
$ npm init
$ npm install then-request jsdom dateformat --save

んで、index.jsが以下の通り。

/**
 *
 * sync gabbage collection with DB
 *
 */
const request = require("then-request");
const {
  JSDOM
} = require('jsdom');
const dateformat=require("dateformat");
const jdg = require('../google-home-notifier/Judge');

//constrants
const URL="https://www.city.sapporo.jp/seiso/kaisyu/yomiage/carender/01chuo/01chuo1.html";
const METHOD="GET";
const ENC='utf8';

//functions

/**
 * 
 * download page 
 * 
 */
var gabbagecollect=(callback, subcallback)=>{
    let gabbagephrase=[];
    let _t='';
    console.log('downloading...');
    try {
        let res=request(METHOD, URL).getBody(ENC).done((res)=>{;
            let dom = new JSDOM(res);
            let p = dom.window.document.getElementsByTagName("p");
            console.log('web scraping...');
            let txttemp;
            for(let i=0;i<p.length;i++){
                    txttemp=p[i].textContent.replace(/[\r\n]/g,'');
                if(!!txttemp&&txttemp.indexOf('年')>-1&&txttemp.indexOf('月の')>-1){
                    _t+=txttemp;
                }   
            }   
            console.log('splitting...');
            gabbagephrase=_t.split('令和');

            callback(gabbagephrase, subcallback);
            return res.statusCode;
        }); 
    
    }catch(err){
        console.log(err);
        process.exit(1);
    };  
}

/**
 * 
 * Collect and Analyze
 *
 *  
 */
var collectGabbageCollectionDay=async (gcphrase, callback)=>{
    console.log('collecting...');
    let gcarray=[];
    let gcindex=0;
    for(let i=0;i<gcphrase.length;i++){
        if(gcphrase[i].indexOf('月の')>-1){
            gcarray[gcindex]={};
            gcarray[gcindex].processyear=gcphrase[i].replace(/^.*([元0-90-9]{1,3})年[0-90-9]{1,2}月の.*$/,"$1");
            gcarray[gcindex].processmonth=gcphrase[i].replace(/^.*年([0-90-9]{1,2})月の.*$/,"$1");
            gcarray[gcindex].burnableday=gcphrase[i].replace(/^.*燃やせるごみは、([^。]*)です。.*$/,"$1");
            gcarray[gcindex].recyclableday=gcphrase[i].replace(/^.*びん・缶・ペットボトルは([^、]*)、.*$/,"$1");
            gcarray[gcindex].plasticday=gcphrase[i].replace(/^.*容器包装プラスチックは([^。]*)です。.*$/,"$1");
            gcarray[gcindex].paperday=gcphrase[i].replace(/^.*雑がみは([^。]*)です。.*$/,"$1");
            gcarray[gcindex].nonburnableday=gcphrase[i].replace(/^.*燃やせないごみは([^。]*)です。.*$/,"$1");
            gcarray[gcindex].branchleafgrassday=gcphrase[i].replace(/^.*枝・葉・草は([^。]*)です。.*$/,"$1");
            if(gcphrase[i].indexOf('雑がみの収集はありません')>-1){
                gcarray[gcindex].paperday='';
            }
            if(gcphrase[i].indexOf('燃やせないごみの収集はありません')>-1){
              gcarray[gcindex].nonburnableday='';
            }
            if(gcphrase[i].indexOf('草の収集はありません')>-1){
                gcarray[gcindex].branchleafgrassday='';
            }
            if(gcarray[gcindex].processyear.indexOf('元')>-1){
                gcarray[gcindex].processyear=2019;
            }else{
                gcarray[gcindex].processyear=2018+Number(gcarray[gcindex].processyear);
            }
            gcarray[gcindex].processmonth=Number(gcarray[gcindex].processmonth);
            gcindex++;
        }else{
            console.log('somthing is wrong');
        }
    }
    return callback(gcarray);
}

/**
 * 
 * Generate Calendar and Sync with db
 * 
 */
var mappingGabbageCollectionSchedule=(gcarray)=>{
    console.log('mapping schedule...');
    let gcschedule=[];
    let gcindex=0;
    for(let i=0;i<gcarray.length;i++){
        gcschedule[gcindex]={
            processdate: gcarray[i].processyear + '/' + ('000'+gcarray[i].processmonth).substr(-2,2),
            burnableday: [],
            recyclableday: [],
            plasticday: [],
            paperday: [],
            nonburnableday: [],
            branchleafgrassday: []
        };
        gcschedule[gcindex].burnableday=getscheduledays(gcschedule[gcindex].processdate, gcarray[i].burnableday);
        gcschedule[gcindex].recyclableday=getscheduledays(gcschedule[gcindex].processdate, gcarray[i].recyclableday);
        gcschedule[gcindex].plasticday=getscheduledays(gcschedule[gcindex].processdate, gcarray[i].plasticday);
        gcschedule[gcindex].paperday=getscheduledays(gcschedule[gcindex].processdate, gcarray[i].paperday);
        gcschedule[gcindex].nonburnableday=getscheduledays(gcschedule[gcindex].processdate, gcarray[i].nonburnableday);
        gcschedule[gcindex].branchleafgrassday=getscheduledays(gcschedule[gcindex].processdate, gcarray[i].branchleafgrassday);
        gcindex++;
    }
    
    console.log('inserting into db...');
    let j=new jdg();
    j.insertGabbageCollectionDay(gcschedule);
}

/**
 *
 * Translate Weekday in 3-letters abbr. from Wamei Weekday
 *
 */
var getdddfromweekday=(weekdayspec)=>{
    let weekdayarray=[
        '日',
        '月',
        '火',
        '水',
        '木',
        '金',
        '土',
    ];
    let dddarray=[
        'Sun',
        'Mon',
        'Tue',
        'Wed',
        'Thu',
        'Fri',
        'Sat'
    ];
    let ret;
    for(let i=0;i<weekdayarray.length;i++){
        if(weekdayspec==weekdayarray[i]){
            return dddarray[i];
        }
    }
    return null;
}

/**
 * 
 * generate days array by phrase
 * 
 */
var getscheduledays=(datespec, gcset)=>{
    let regex1=/([0-90-9]{1,2})日/g;
    let regex2=/([日月火水木金土])曜日/g;
    let gcbufarray=[];
    let gcarray=[];
    let m='';
    if(gcset.indexOf('毎週')<0&&gcset.indexOf('日')>-1){
        while(m=regex1.exec(gcset)){
            gcbufarray.push(m[1]);
        }
        try {
            for(let i=0;i<gcbufarray.length;i++){
                let datebuf=new Date(datespec+'/'+('00'+gcbufarray[i]).substr(-2,2));
                gcarray.push(dateformat(datebuf, 'yyyy/mm/dd'));
            }
        }catch(e){
            console.log('no such date format:'+datebuf);
        }
    }
    if(gcset.indexOf('毎週')>-1){
        while(m=regex2.exec(gcset)){
            gcbufarray.push(m[1]);
        }
        try {
            for(let i=0;i<gcbufarray.length;i++){
                for(let j=1;j<32;j++){
                    let datebuf=new Date(datespec+'/'+('00'+j).substr(-2,2));
                    if(dateformat(datebuf, 'ddd')==getdddfromweekday(gcbufarray[i])){
                        gcarray.push(dateformat(datebuf, 'yyyy/mm/dd'));
                    }
                }
            }
        }catch(e){
            console.log('no such date format:'+datebuf);
        }
    }
    return gcarray;
}

//main
gabbagecollect(collectGabbageCollectionDay, mappingGabbageCollectionSchedule);

DBへの登録は、既存APIであるJudge.jsを改修する。insertGabbageCollectionDay()ってメソッドを作っている。
ちなみに今回のテーブルのDDLだが至極簡素にしている。ま、大したこともしていないので。

-- gabbage collection
create table gabbage_collection(
    dt_created date not null, 
    val_gc varchar(64) not null, 
    primary key (dt_created, val_gc));

仮にダブリが発生したとしても重複レコードは追加しないようにする。
あ、で今回ゴミの種類をコードで管理している。

{
    0 : '燃やせるごみ',
    1 : 'びん・缶・ペットボトル',
    2 : '容器・包装・プラスチック',
    3 : '雑がみ',
    4 : '燃やせないごみ',
    5 : '枝・草・葉'
}

ま、本当に大したことはしていない。
あとはGoogle Assistant経由でIFTTTが我がサーバのAPIにアクセスしてきたらgoogle-home-notifierでGoogle Home miniに渡してやる。
以下、その抜粋。

var getGabbageCollection=async () => {
    let now=new Date();
    let j=await new jdg();
    let textMessage='';
    let gc=[];
    gc=await j.getGabbageCollectionDay();
    for(let i=0;i<gc.length;i++){
        if(gc[i].dt_created.toFormat('YYYY-MM-DD')===now.toFormat('YYYY-MM-DD')){
            if(textMessage===''){
                textMessage='今日は';
            }else{
                textMessage+='と';
            }   
            textMessage+=await j.getGabbageCollectionType(gc[i].val_gc);
        }   
    }   
    if(textMessage===''){
        textMessage='今日は収集はありません。';
    }else{
        textMessage+='の日です';
    }   
    const gs = new googleSpeak();
    gs.meow(textMessage, (result)=>{
        console.log(textMessage);
        console.log(result);
    }); 
}   
getGabbageCollection();

res.status(200).send('POST request accepted.');
res.end();

expressモジュールを使用しており、resはHTTP Responseオブジェクト。
googleSpeak()はgoogle-home-notifierを簡易的に扱うためのクラス。
meow()にtextを渡せばネットワーク内のGoogle Home全部がtextを喋る。

今回課題だったのが、どの端末を使ってGoogle Assistantを使用したかAPIが分からないこと。
例えば居間のGoogle Home miniを使って「今日は何のゴミの日?」って聞いてるのに、寝室のGoogle Homeが「今日は収集はありません。」って答えても困る。

そこでいろいろ考えた。
事前にどのGoogle Homeから聞かれているかを知るには。
もしかしたらネットワーク内になんかのパケットをブロードキャストしてるのでは?とか、ネット経由で音声をダウンロードしているはずだから家のFWで横取りしてIPを調べてみる?とか。
おそらくやってやれないことはないんだろうけど、レスポンススピードを考えると現時点でも結構体感的に待たされてる感じがするので割り切って全部のGoogle Homeに喋らせることにした。
そのメソッドが.meow()。
メインは書斎のGoogle Homeなので、.bowwow()ってのを使うと書斎だけ、.meow()を使うと全部。ってことにした。
ワンが一台、ミャオは複数台。(ご主人に忠実なわんこと自由奔放なにゃんこ)

Experience

実際に使ってみた感想としては、結構便利。
今までスマホ開いてPDFをダウンロードするためにページ開いて、ダウンロードしてPDF開いて、今日のカレンダーを探して、、、ってやってたのがたった一言「おーけーぐーぐる、今日は何のゴミの日」って言うだけで良い。
進歩の何者でもない。
まさに、Made the smart speaker smarter. I made it.

満足。


  1. 1.正確にはカレンダーは2019/09/01〜2020/09/30までの1年と1ヶ月分掲載されている。

コメント: