『node.js での、「http.server@python3」相当』微メモ(3.5) – using ejs, express (2.5)

ひとつ前からの本質的な進化があまりないので追記か新規投稿か悩んだネタ。

「実用を目指す一歩前」だったのが前回ので、今回のは「製品品質には程遠くともオレには割と実用」というものなのでネタの進化ではあるものの、「node.js + ejs + express + python で作る WEB サービス実例という本題」としての進化は些細なのね。しかもこれ、「Docker 向けに使うネタ」の元にするかちょっと迷ってる。いずれオレ向けに、はやるかもしれんけど、「Docker の初心者向け例」に使うにはやや複雑過ぎる気がしてて。

何かつーと「京都大学アーカイブサイトから MSM GPV をダウンロードし、これを pygrib と cartopy で(雨量予測を)可視化する」というもの。ただ cartopy が、本日時点で配布されてる linux distro で必ずしも理想の状態にはなってない(どの distro だったか、パッケージマネージャのものは相当古い)し、そもそも pygrib も cartopy も依存物の複雑さは「最初に扱うものだとするなら十分に複雑すぎる」と思うんだよね。なので、「Docker の最初のネタ」として使おうと目論んで始めたんだけれど、それは保留にして、単なるひとつ前の続きとしちゃおう、というのが今回のタイトルの真意。

「node.js + ejs + express + python で作る WEB サービス実例という本題」の差異は些細、なので、ひとつ前のと比較しながら読んでもらえればすぐにわかると思う。今回のは2つのヘビー級 pythohn スクリプトを逐次実行するようになってる。ひとつが MSM GPV のダウンロードスクリプト、もう一つがそのダウンロードした MSM GPV を入力として雨量の可視化をするスクリプト:

dljmamsmgpv.py
 1 # -*- coding: utf-8 -*-
 2 import io
 3 import os
 4 import shutil
 5 import urllib.request
 6 import bs4
 7 
 8 
 9 _rishurlbase = "http://database.rish.kyoto-u.ac.jp/arch/jmadata/data/gpv/original"
10 _idxurltmpl = _rishurlbase + "/{utcyear}/{utcmonth:02d}/{utcday:02d}"
11 
12 
13 if __name__ == '__main__':
14     import argparse
15     ap = argparse.ArgumentParser()
16     ap.add_argument("year", type=int)
17     ap.add_argument("month", type=int)
18     ap.add_argument("day", type=int)
19     ap.add_argument("type", choices=["pall", "surf"])
20     ap.add_argument("--hour", type=int)
21     ap.add_argument("--fh", help="ex) 00-15")
22     ap.add_argument("--destdir", default=".")
23     args = ap.parse_args()
24     if not os.path.exists(args.destdir):
25         os.makedirs(args.destdir)
26     idxurl = _idxurltmpl.format(
27         utcyear=args.year, utcmonth=args.month, utcday=args.day)
28     ca, _ = urllib.request.urlretrieve(idxurl)
29     cont = io.open(ca, encoding="utf-8").read()
30     table = bs4.BeautifulSoup(cont, features="html.parser").table
31     for tr in table.find_all("tr"):
32         if tr.th:
33             continue
34         td = tr.find_all("td")
35         if "Parent Directory" in str(td):
36             continue
37         fn, tmdl = list(map(lambda e: e.text.strip(), td[1:-2]))
38         if "MSM_GPV" not in fn:
39             continue
40         if args.type not in fn:
41             continue
42         if args.fh and "_FH{}".format(args.fh) not in fn:
43             continue
44         if args.hour is not None and "{utcyear}{utcmonth:02d}{utcday:02d}{utchour:02d}".format(
45                 utcyear=args.year, utcmonth=args.month, utcday=args.day, utchour=args.hour) not in fn:
46             continue
47         print(fn, "...", end="", flush=True)
48         if not os.path.exists(fn):
49             gpvtmpfn, _ = urllib.request.urlretrieve(idxurl + "/" + fn)
50             shutil.move(gpvtmpfn, os.path.join(args.destdir, fn))
51         print("done.", flush=True)
percip_viz.py
 1 # -*- coding: utf-8 -*-
 2 import os
 3 import sys
 4 import datetime
 5 
 6 import pygrib
 7 import numpy as np
 8 import matplotlib.pyplot as plt
 9 from matplotlib.colors import BoundaryNorm, ListedColormap
10 import cartopy.crs as ccrs
11 from cartopy.io.img_tiles import GoogleTiles
12 
13 
14 def _read(srcfn):
15     src = pygrib.open(srcfn)
16     for s in src:
17         yield (s.name, s.parameterName), s.validDate, s.latlons(), s.values
18     src.close()
19 
20 
21 def main(gpv, destdir="."):
22     if not os.path.exists(destdir):
23         os.makedirs(destdir)
24     key = "Total precipitation"
25     _alldata = {}
26     (lats, lons) = None, None
27     for yk, vd, latlons, values in _read(gpv):
28         (lats, lons) = latlons
29         if key in yk:
30             print(vd, flush=True)
31             _alldata[vd] = values
32     if lats is None:
33         sys.exit(0)
34     X, Y = lons[0,:], lats[:,0]
35     #
36     # NHK方式の [0, 1, 5, 10, 20, 30, 50, 80]
37     cmap = ListedColormap(
38         ['white', 'cyan', 'skyblue', 'blue', 'yellow', 'orange', 'red', 'magenta'])
39     clip_min = 0.1
40     norm = BoundaryNorm(
41         [clip_min, 1, 5, 10, 20, 30, 50, 80], cmap.N, extend="max")
42     #
43     tocho = (139.691717, 35.689568)
44     #
45     tiler = GoogleTiles(style='satellite')
46     for vd in _alldata.keys():
47         outname = "{}_{}.png".format(
48             key,
49             str(vd).partition(":")[0].replace(" ", "_"))
50         outname = os.path.join(destdir, outname)
51         if os.path.exists(outname):
52             continue
53         fig = plt.figure()
54         fig.set_size_inches(16.53 * 1.5, 11.69)
55         ax = fig.add_subplot(projection=tiler.crs)
56         ext = [tocho[0] - 8, tocho[0] + 3, tocho[1] - 5, tocho[1] + 5]
57         ax.add_image(tiler, 6)
58         ax.set_extent(ext, ccrs.PlateCarree())
59         v = _alldata[vd].copy()
60         v[np.where(v < clip_min)] = np.nan
61         CS = ax.pcolormesh(
62             X, Y, v,
63             norm=norm,
64             transform=ccrs.PlateCarree(),
65             shading="auto",
66             cmap=cmap, alpha=0.7)
67 
68         ax.coastlines(resolution="10m")
69         ax.gridlines()
70         plt.colorbar(CS, ax=ax)
71         ax.set_title("{} on {} JST\n(v[np.where(v < {})] = np.nan)".format(
72             key, str(vd + datetime.timedelta(hours=9)), clip_min))
73 
74         plt.savefig(outname, bbox_inches="tight")
75         plt.close(fig)
76         print(outname, flush=True)
77 
78 
79 if __name__ == '__main__':
80     # 入力は "Z__C_RJTD_20210520030000_MSM_GPV_Rjp_Lsurf_FH34-39_grib2.bin" など。
81     import argparse
82     ap = argparse.ArgumentParser()
83     ap.add_argument("gpv")
84     ap.add_argument("--destdir", default=".")
85     args = ap.parse_args()
86     main(args.gpv, args.destdir)

ともに単独で十分実用になる(が言い過ぎなら実用とする元ネタになりうる)スクリプトだが、京大のアーカイブページの構造、ファイル名の規則についての知識は不可欠の「知ってる人向け」になってる。例えば初期値時刻としてありえないものを要求すれば単に 404 になるだけだが、スクリプトはそれを防ごうとしてない。(FH についても同じだし、そもそも「13月」という誤った指定すらスクリプトそのものは許容する。)

で、これを前提として:

serv.js
 1 'use strict';
 2 
 3 var listenport = parseInt(process.argv[2], 10) || 8080;
 4 var fs = require('fs');
 5 var ejs = require('ejs');
 6 var express = require('express');
 7 var path = require('path');
 8 var url = require('url');
 9 var app = module.exports = express();
10 //const querystring = require('querystring');
11 const subprocess = require('child_process');
12 const mime = require('mime-types');
13 
14 app.engine('.html', ejs.__express);
15 app.set('views', path.join(__dirname, 'views'));
16 app.set('view engine', 'html');
17 
18 //
19 app.get("/:year/:month/:day/:hour/:fh/Total%20precipitation", (req, res) => {
20     console.log(req.url);
21     const p = req.params;
22     const year = `${String(parseInt(p.year)).padStart(4, '0')}`;
23     const month = `${String(parseInt(p.month)).padStart(2, '0')}`;
24     const day = `${String(parseInt(p.day)).padStart(2, '0')}`;
25     const hour = `${String(parseInt(p.hour)).padStart(2, '0')}`;
26     const fh = `${p.fh}`;
27     const resfile = `${year}${month}${day}${hour}-${fh}.json`;
28     res.render("result.html", {resfile: "/" + resfile});
29 
30     // download MSM GPV
31     const py1res = subprocess.spawn("py", [
32         "dljmamsmgpv.py",
33         year, month, day, "surf",
34         `--hour=${hour}`, `--fh=${fh}`,
35         "--destdir=_gpv_rawdata",
36     ]);
37     const gpvfn =
38           `_gpv_rawdata/Z__C_RJTD_${year}${month}${day}${hour}0000_MSM_GPV_Rjp_Lsurf_FH${fh}_grib2.bin`;
39     console.log(gpvfn);
40     const imgdir = `_result_imgs/${year}${month}${day}${hour}-${fh}/Total precipitation`;
41     py1res.stdout.on("data", (data) => { console.log(data.toString()); });
42     py1res.stderr.on("data", (data) => { console.log(data.toString()); });
43     py1res.on("close", (code) => {
44         console.log(`dljmamsmgpv.py exited(${code}).`);
45 
46         // visualize
47         const py2res = subprocess.spawn("py", [
48             "percip_viz.py",
49             gpvfn,
50             `--destdir=${imgdir}`,
51         ]);
52         py2res.stdout.on("data", (data) => { console.log(data.toString()); });
53         py2res.stderr.on("data", (data) => { console.log(data.toString()); });
54         py2res.on("close", (code2) => {
55             console.log(`percip_viz.py exited(${code2}).`);
56             fs.readdir(imgdir, (err, files) => {
57                 let reslist = [];
58                 for (const f of files) {
59                     reslist.push({img: "/" + imgdir + "/" +  f});
60                 }
61                 fs.writeFile(resfile, JSON.stringify(reslist), (err) => {});
62             });
63         });
64     });
65 });
66 app.get("*", (req, res) => {
67     console.log(`GET ${req.url}`);
68     fs.readFile(
69         "." + decodeURI(url.parse(req.url, true).pathname), function(err, data) {
70         if (!err) {
71             res.writeHead(200, {'Content-Type': mime.lookup(req.url)});
72             res.write(data);
73         } else {
74             console.log(err);
75             res.writeHead(404, {'Content-Type': 'text/html'});
76             res.write("Not Found.");
77         }
78         return res.end();
79     });
80 });
81 
82 /* istanbul ignore next */
83 if (!module.parent) {
84     app.listen(listenport);
85     console.log('Express started on port ' + listenport);
86 }
views/result.html
 1 <!DOCTYPE html>
 2 <html>
 3   <head>
 4     <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
 5     <link
 6       href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.2.2/css/tabulator_site.min.css"
 7       rel="stylesheet">
 8     <script
 9       type="text/javascript"
10       src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.2.2/js/tabulator.min.js">
11     </script>
12     <script>
13       var timerId = null;
14       var table = null;
15       function waitData() {
16           let datapath = "<%= resfile %>";
17           $.ajax(
18               {
19                   url: datapath,
20                   success: function(result) {
21                       console.log(result);
22                       table.setData(result);
23                       clearInterval(timerId);
24                   },
25                   error: function(xhr) {
26                       console.log(xhr);
27                       if (!timerId) {
28                           timerId = setInterval(waitData, 1000);
29                       }
30                   }
31               });
32       }
33       function setupPlaylist() {
34           table = new Tabulator("#playlist", {
35               "selectable": 1,
36               "columns": [
37                   {
38                       "field": "img",
39                       "title": "img",
40                       "formatter": "image",
41                       "formatterParams": {"width": 52, "height": 26},
42                   },
43               ]
44           });
45           table.on("tableBuilt", function () {
46               waitData();
47           });
48           table.on("rowSelected", function (row) {
49               $('#selimg').html(`<img src="${row.getData().img}" height="540"/>`);
50           });
51       }
52       $(document).ready(setupPlaylist);
53     </script>
54   </head>
55   <body>
56     <table border=0>
57       <tr>
58         <td>
59           <div id="playlist" style="max-width: 20em"></div>
60         </td>
61         <td>
62           <div id="selimg"></div>
63         </td>
64       </tr>
65     </table>
66   </body>
67 </html>


serv.js と result.html、特に後者の前回からの代わり映えのなさよ。そう、結局 python 部分が「一応ちゃんと目的のことをする複雑なものになった」だけがほとんど全て。

「node.js + ejs + express + python で作る WEB サービス実例という本題の些細な差異」はハイライトした行。画像が「/2022/5/21/0/00-15/Total%20precipitation」をリクエストしてて、serv.js はこれを「/:year/:month/:day/:hour/:fh/Total%20precipitation」でルーティング。「:year」で「req.params.year」を取れる、てことね。(なお、Express 公式サイトの実例集にはこのパラメータ取得部分のもう少し凝った例があるので読んでおくと良い。)あと細かいけど「ファイル名に空白を含む」ケースね、これ。decodeURI が必要になったのはそれが理由。


にしても塩梅が難しい。pygrib、cartopy ともに「Windows だととても大変」なものなので「Docker でハッピーさ」のネタとして楽しいものになりうるのは確かなのだけれど、ワタシが今目指してるのは「単純すぎず複雑過ぎず、なおかつ嘘くささのないちゃんとした「使える」実例」なので…。「複雑過ぎず」になるならないのが既にわかっちゃってるんだよなぁこれ。(最低でも linux distro によってはパッケージマネージャを利用しない構築になりうることがわかってる。)

今回のはだから、ワタシのサイトに真面目に追従して頑張ってる人か Windows ユーザ以外にとっては割とそのまま簡単に(「py」問題を措置する程度で)動かせるんだけれど、そうでない人が今からこれを動かすようにするのは、おそらく数日がかりの作業になるほど大変なんだよね。


ところで、かなりどうでもいい蛇足。

「padStart」に気づいた人がいるかもしれない。しかもワタシのちょっと前のネタでは実はこれが使えるのを知ってて使うのを避けてる。その心は?

padStart は割と最近の ECMASCRIPT で使えるようになった新しい機能。だからこれの使用を避ける動機はわかるだろう。けど、ワタシが避けたネタと避けてない今回のネタでの違いは? これが「同じ javascript だけど、プロセッサがブラウザかサーバサイド node.js かの違い」によるものね。ブラウザは「無限にある」と考えればコントロール出来ず、「サーバはオレのもの」(レンタルサーバなどによる制約を除けば)なのでコントロール下にある、の違いね。「ユーザが古いブラウザを使ってるかもしれない」ということに備える必要があっても、「使う node.js はコントロール出来る/または使ってる node.js を把握してる」てこと。

クライアントサイドとサーバサイドの言語が同じだとこれがわかりにくくなっちゃうよなぁと。同じだけど違う、と思った、てハナシ。そして、これも「クライアントサイドだけで完結せずにサーバサイドの助けも借りれると世界が広がる」の理由の一つでもある、てことね。



Related Posts