Tabulator の「download」、壊れてる?、兼「声優世代表のおとも」

一つ前ので少し言ったけれど。

インタラクティブデモ」を動かしてみると、PDF ダウンロードはエラーで動かず、HTML ダウンロードは「うんともすんとも言わない」。これは Tabulator 本体の問題なのか、はたまた「インタラクティブデモ」としての問題なのか、どっちなんだ、と。

ひとまず:

wikipedia をスクレイプして「声優世代表のおとも html」、な、きっと ver 7
  1 # -*- coding: utf-8 -*-
  2 # require: python 3, bs4
  3 """
  4 「wikipedia声優ページ一覧」(など)を入力として、およそ世代関係を把握しやすい
  5 html テーブルを生成する。
  6 
  7 該当アーティストが情報を公開しているかどうかと wikipedia 調べがすべて一致する
  8 かどうかは誰も保証出来ない。正しい公開情報が真実である保証もない。という、元
  9 データそのものについての注意はあらかじめしておく。そして、wikipedia における
 10 ペンディング扱い(「要出典」など)やその他補足情報はこのスクリプトは無視している
 11 ので、「確実かそうでないか」がこの道具の結果だけでは判別出来ないことにも注意。
 12 (wikipedia 本体を読んでいる限りは、正しくない可能性がまだ残っている場合、
 13 そうであるとわかることが多い。)
 14 
 15 ワタシの目下の目的が「声優世代表のおとも」なので、「生年月日非公開/不明」が困る。
 16 なので、wikipedia が管理しようとしている「活動期間」を補助的に使おうと考えた。
 17 これが活用出来る場合は「生誕年=活動開始-20年くらい」みたいな推測に使える。
 18 
 19 この情報の精度には、当たり前ながらかなりのバラつきがあるし、要約の仕方も統一感
 20 がない。たとえば「田中ちえ美」の声優活動は最低でも「サクラクエスト」の2017年に
 21 始まっているが、当該ページ執筆者が「声優アーティスト活動開始」の定義に基いて
 22 「音楽活動の活動期間は2021年-」と記述してしまっていて、かつ、声優活動としての
 23 開始を記述していない。ゆえにこの情報だけを拾うと「田中ちえ美の活動期間は2021年-」
 24 であると誤って判断してしまいかねない。そもそもが「サクラクエスト」にてキャラク
 25 ターソングを出しているので、定義次第では「音楽活動の活動期間は2021年-」も誤って
 26 いる。また、「2011年」のように年を特定出来ずに「2010年代」と要約しているページ
 27 も多く、これも結構使いにくい。
 28 
 29 ので、「テレビアニメ」などの配下の「2012年」みたいな年ごとまとめ見出しの最小値を、
 30 活動期間の情報として補助的に拾ってる。
 31 
 32 活動期間は年齢の推測にも使えるけれど、「声優世代表のおとも」として考える場合は、
 33 生年月日と活動期間の両方が既知でこその面白さがある。たとえば黒沢ともよ、宮本侑芽、
 34 諸星すみれ、浪川大輔などの子役出身者の例。あるいは逆に「遅咲き」と言われる役者。
 35 
 36 なお、日本語版では不明となっているのに英語版には記載されていることがあり、この
 37 スクリプトはそれも拾うが、これがアーティスト自身の公開非公開選択の方針とはより
 38 ズレる可能性があることには一応注意。(日本語版では判明情報を本人方針尊重で隠して
 39 いるのに、英語版ではそれが届いていない、のようなこと。)
 40 """
 41 import io
 42 import os
 43 import sys
 44 import tempfile
 45 import shutil
 46 import ssl
 47 import re
 48 import json
 49 import urllib
 50 import urllib.request
 51 from urllib.request import urlretrieve as urllib_urlretrieve
 52 from urllib.request import quote as urllib_quote
 53 
 54 
 55 import bs4  # require: beutifulsoup4
 56 
 57 
 58 __MYNAME__, _ = os.path.splitext(
 59     os.path.basename(sys.modules[__name__].__file__))
 60 #
 61 __USER_AGENT__ = "\
 62 Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
 63 AppleWebKit/537.36 (KHTML, like Gecko) \
 64 Chrome/91.0.4472.124 Safari/537.36"
 65 _htctxssl = ssl.create_default_context()
 66 _htctxssl.check_hostname = False
 67 _htctxssl.verify_mode = ssl.CERT_NONE
 68 https_handler = urllib.request.HTTPSHandler(context=_htctxssl)
 69 opener = urllib.request.build_opener(https_handler)
 70 opener.addheaders = [('User-Agent', __USER_AGENT__)]
 71 urllib.request.install_opener(opener)
 72 #
 73 
 74 
 75 _urlretrieved = dict()
 76 
 77 
 78 def _urlretrieve(url):
 79     if url in _urlretrieved:
 80         return _urlretrieved[url]
 81 
 82     def _gettemppath(s):
 83         tmptopdir = os.path.join(tempfile.gettempdir(), __MYNAME__)
 84         if not os.path.exists(tmptopdir):
 85             os.makedirs(tmptopdir)
 86         import hashlib, base64
 87         ep = base64.urlsafe_b64encode(
 88             hashlib.md5(s.encode("utf-8")).digest()
 89         ).partition(b"=")[0].decode()
 90         flat = os.path.join(tmptopdir, ep)
 91         p1, p2 = ep[0], ep[1:]
 92         d1 = os.path.join(tmptopdir, p1)
 93         if not os.path.exists(d1):
 94             os.makedirs(d1)
 95         d2 = os.path.join(tmptopdir, p1, p2)
 96         if os.path.exists(flat) and not os.path.exists(d2):
 97             os.rename(flat, d2)
 98         return d2
 99 
100     cachefn = _gettemppath(url)
101     if os.path.exists(cachefn):
102         res = cachefn
103     else:
104         try:
105             res, _ = urllib_urlretrieve(url, filename=cachefn)
106         except Exception:
107             from urllib.request import unquote as urllib_unquote
108             print(url, repr(urllib_unquote(url)))
109             raise
110     _urlretrieved[url] = res
111     return res
112 
113 
114 def _from_wp_jp(actorpagename):
115     baseurl = "https://ja.wikipedia.org/wiki/"
116     pn = urllib_quote(actorpagename, encoding="utf-8")
117     fn = _urlretrieve(baseurl + pn)
118 
119     result = {"wikipedia": actorpagename, "名前": [actorpagename.partition("_")[0]]}
120     with io.open(fn, "r", encoding="utf-8") as fi:
121         soup = bs4.BeautifulSoup(fi.read(), features="html.parser")
122         entr = soup.find("a", {"class": "interlanguage-link-target", "lang": "en"})
123         if entr:
124             result["wikipedia_en"] = entr.attrs["href"]
125         try:
126             categos = [
127                 a.text
128                 for a in soup.find("div", {"id": "mw-normal-catlinks"}).find_all("a")]
129         except Exception:
130             categos = []
131         try:
132             trecords = iter(
133                 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
134         except Exception:
135             return result
136         tr = next(trecords)
137 
138         if tr.find("th"):
139             result["名前"] += [
140                 re.sub(r"\[[^\[\]]+\]", "", sp.text)
141                 for sp in tr.find("th").find_all("span")]
142 
143         actst = 9999
144         actst_rough = ["9999年代"]
145         for tr in trecords:
146             th, td = tr.find("th"), tr.find("td")
147             if not th or not td:
148                 continue
149             k = th.text.replace("\n", "").replace(
150                 "誕生日", "生年月日").replace("生誕", "生年月日")
151             k = re.sub("身長.*$", "身長/体重", k)
152             if k in ("事務所", "レーベル"):
153                 k = "事務所・レーベル"
154             v = ""
155             if td:
156                 if td.find("li"):
157                     td = "\n".join([li.text for li in td.find_all("li")])
158                 elif td.find("br"):
159                     td = bs4.BeautifulSoup(
160                         re.sub(r"<br\s*/?>", r"\n", str(td)), features="html.parser").text.strip()
161                 else:
162                     td = td.text
163                 v = re.sub(
164                     r"\[[^\[\]]+\]", "\n", td).strip()
165                 if k == "活動期間":
166                     m = re.match(r"(\d+)年(代)?", v)
167                     if m:
168                         if not m.group(2):
169                             actst = min(int(m.group(1)), actst)
170                         else:
171                             actst_rough.append(m.group(0))
172                     continue
173                 elif k in ("生年月日", "没年月日"):
174                     m1 = re.search(r"\d+-\d+-\d+", v)
175                     m2 = re.search(r"(\d+)月(\d+)日", v)
176                     if m1:
177                         v = m1.group(0)
178                     elif m2:
179                         v = "0000-{:02d}-{:02d}".format(
180                             *list(map(int, m2.group(1, 2))))
181                     else:
182                         v = ""
183             v = re.sub(
184                 r"(、\s*)+", "、",
185                 "、".join(re.sub(r"\n+", r"\n", v).split("\n")))
186             if k in ("デビュー作", "事務所・レーベル", "共同作業者", "ジャンル",):
187                 v = "、".join(list(filter(None, [result.get(k), v])))
188             if k == "身長/体重":
189                 v = re.sub(r"\s*、\s*cm", " cm", v)  # なんで??
190             result[k] = v
191         for stt, sta in (("dt", {}), ("b", {})):
192             for dtt in [
193                     dt.text
194                     for dt in soup.find_all(stt, sta) if re.match(r"\d+年$", dt.text)]:
195                 actst = min(int(re.search(r"(\d+)年", dtt).group(1)), actst)
196         actst_rough_min = list(sorted(actst_rough))[0]
197         armv = int(re.match(r"(\d+)", actst_rough_min).group(1))
198         if actst < 9999 and armv // 10 > actst // 10:
199             result["actst"] = "{:04d}".format(actst)
200         elif armv < 9999:
201             result["actst"] = actst_rough_min
202         else:
203             result["actst"] = "0000"
204         if not result.get("生年月日"):
205             result["生年月日"] = "0000-??-??"
206         if not result.get("性別"):
207             if any([("男性" in c or "男優" in c) for c in categos]):
208                 result["性別"] = 1
209             elif any([("女性" in c or "女優" in c) for c in categos]):
210                 result["性別"] = 2
211             else:
212                 result["性別"] = 0
213         else:
214             result["性別"] = 1 if ("男" in result["性別"]) else 2
215     return result
216 
217 
218 def _from_wp_en(result):
219     # 基本的に日本語版を信じ、欠落のものだけ英語版に頼ることにする。
220     if "0000" not in result["生年月日"]:
221         return result
222     try:
223         fn = _urlretrieve(result["wikipedia_en"])
224     except urllib.error.HTTPError:
225         return result
226     with io.open(fn, "r", encoding="utf-8") as fi:
227         soup = bs4.BeautifulSoup(fi.read(), features="html.parser")
228         try:
229             trecords = iter(
230                 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
231         except Exception:
232             return result
233         for tr in trecords:
234             th, td = tr.find("th"), tr.find("td")
235             if not th or not td:
236                 continue
237             k_en = th.text.strip()
238             if k_en == "Born":
239                 if "0000" in result["生年月日"]:
240                     bd = td.find("span", {"class": "bday"})
241                     if bd:
242                         bd = re.sub(r"\[[^\[\]]+\]", "", bd.text)
243                         result["生年月日"] = bd
244     return result
245 
246 
247 def _from_wp(actorpagename):
248     result = _from_wp_jp(actorpagename)
249     if "wikipedia_en" in result:
250         _from_wp_en(result)
251     return result
252 
253 
254 if __name__ == '__main__':
255     def _yrgrp(ymd, actst):
256         y = 0
257         if ymd:
258             y, _, md = ymd.partition("-")
259             y = int(y)
260             if y and md < "04-02":
261                 y -= 1
262         return list(
263             map(lambda s: s.replace("0000", "????"),
264                 ["{:04d}".format(y), actst, ymd]))
265 
266     actorpages = list(set(
267         map(lambda s: s.strip(),
268             io.open("wppagenames.txt", encoding="utf-8").read().strip().split("\n"))))
269     result = []
270     for a in filter(None, actorpages):
271         inf = _from_wp(a)
272         g = _yrgrp(inf.get("生年月日", ""), inf.get("actst", "0000"))
273         result.append((
274             g[0], g[1], g[2],
275             inf.get("没年月日", ""),
276             inf.get("性別", 0),
277             inf["wikipedia"],
278             ", ".join(inf["名前"]),
279             re.sub(r"\s*型", "", inf.get("血液型", "")),
280             re.sub(
281                 r"\b日本・", "",
282                 "、".join(list(filter(None, [inf.get("出生地", ""), inf.get("出身地", "")])))),
283             re.sub(r"、\s*([((])", r"\1", inf.get("愛称", "")),
284             inf.get("身長/体重", ""),
285             re.sub(r"、\s*([((])", r"\1", inf.get("事務所・レーベル", "")),
286             re.sub(r"、\s*([((])", r"\1", inf.get("デビュー作", "")),
287             inf.get("共同作業者", ""),
288         ))
289     result.sort()
290     with io.open("actor_basinf.html", "w", encoding="utf-8") as fo:
291         coln = [
292             "by",  # 生誕年度
293             "as",  # 活動開始年?
294             "bymd",  # 生年月日
295             "dymd",  # 没年月日
296             "gen",  # 性別
297             "wp",  # wikipedia
298             "nm",  # 名前
299             "bld",  # 血液型
300             "bor",  # 出生地・出身地
301             "nn",  # 愛称
302             "tw",  # 身長/体重
303             "bel",  # 事務所・レーベル
304             "fst",  # デビュー作
305             "tea",  # 共同作業者
306             ]
307         print("""\
308 <html>
309 <head jang="ja">
310 <meta charset="UTF-8">
311 <link href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/css/tabulator_site.min.css" rel="stylesheet">
312 <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/js/tabulator.min.js"></script>
313 <script type="text/javascript" src="https://oss.sheetjs.com/sheetjs/xlsx.full.min.js"></script>
314 
315 <!--
316 {}
317   -->
318 </head>""".format(__doc__), file=fo)
319         print("""\
320 <body>
321 <div style="margin: 1em 0em">
322 <select id="filter-gender">
323   <option value=""></option>
324   <option value="1">男性のみ</option>
325   <option value="2">女性のみ</option>
326 </select>
327 <select id="filter-alive">
328   <option></option>
329   <option>存命者のみ</option>
330   <option>死没者のみ</option>
331 </select>
332 </div>
333 <div id="actor_basinf"></div>
334 <div style="margin: 1em 0em">
335   <button id="download-csv">Download CSV</button>
336   <button id="download-json">Download JSON</button>
337   <button id="download-xlsx">Download XLSX</button>
338   <button id="download-html">Download HTML</button>
339 </div>
340 <script>
341 var filter_gender = document.getElementById("filter-gender");
342 function _update_genderfilter() {
343     let i = filter_gender.selectedIndex;
344     let v = filter_gender.options[i].value;
345     let eflts = table.getFilters(true);
346     for (let efi in eflts) {
347         let f = eflts[efi];
348         if (f["field"] == "gen") {
349             table.removeFilter(f["field"], f["type"], f["value"]);
350             break;
351         }
352     }
353     if (v) {
354         table.addFilter("gen", "regex", v);
355     }
356 }
357 filter_gender.addEventListener("change", _update_genderfilter);
358 var filter_alive = document.getElementById("filter-alive");
359 function _update_alivefilter() {
360     let i = filter_alive.selectedIndex;
361     let eflts = table.getFilters(true);
362     for (let efi in eflts) {
363         let f = eflts[efi];
364         if (f["field"] == "dymd") {
365             table.removeFilter(f["field"], f["type"], f["value"]);
366             break;
367         }
368     }
369     if (i == 1) {
370         table.addFilter("dymd", "=", "");
371     } else if (i == 2) {
372         table.addFilter("dymd", "!=", "");
373     }
374 }
375 filter_alive.addEventListener("change", _update_alivefilter);
376 
377 document.getElementById("download-csv").addEventListener("click", function() {
378     table.download("csv", "data.csv");
379 });
380 document.getElementById("download-json").addEventListener("click", function() {
381     table.download("json", "data.json");
382 });
383 document.getElementById("download-xlsx").addEventListener("click", function() {
384     table.download("xlsx", "data.xlsx", {sheetName:"My Data"});
385 });
386 document.getElementById("download-html").addEventListener("click", function() {
387     table.download("html", "data.html", {style: true});
388 });
389 
390 function _dt2int(dt) {
391     function _pad(n) {
392         let ns = "" + Math.abs(n);
393         if (ns.length === 1) {
394             ns = "0" + ns;
395         }
396         return ns;
397     }
398     return parseInt(dt.getFullYear() + _pad(dt.getMonth() + 1) + _pad(dt.getDate()));
399 }
400 var nowi = _dt2int(new Date());
401 function _calcage(cell, formatterParams) {
402     var v = cell.getValue().replace(new RegExp("\-", "g"), "");
403     if (v.startsWith("????")) {
404         return "";
405     }
406     v = parseInt(v);
407     var result = "" + parseInt((nowi - v) / 10000);
408     result += "歳";
409     var d = cell.getRow().getCell("dymd").getValue().replace(new RegExp("\-", "g"), "");
410     if (d) {
411         result += " (";
412         result += parseInt((parseInt(d) - v) / 10000);
413         result += "歳没)";
414     }
415     return result;
416 }
417 
418 
419 var actor_basinf_data = """, file=fo)
420         jds = json.dumps(
421             [dict(zip(coln, row)) for row in result],
422             ensure_ascii=False, indent=0)
423         jds = re.sub(r'"([^"]+)": ', r'\1:', jds)
424         print(jds, file=fo)
425         print("""
426 var table = new Tabulator("#actor_basinf", {
427     "height": "800px",
428     "columnDefaults": {
429         /* 注意: 「tooltips」ではない。「tooltip」である。 */
430         "tooltip": true,
431 
432         /* これはいつから使えたのかな? かつては「columnDefaults」の外にいたやつ。 */
433         "headerSortTristate": true,
434     },
435     "columns": [
436         {
437             "field": "by",
438             "title": "生誕年度",
439             "headerFilter": "input",
440             "headerFilterFunc": "regex",
441         },
442         {
443             "field": "as",
444             "title": "活動開始年?",
445             "headerFilter": "input",
446             "headerFilterFunc": "regex",
447         },
448         {
449             "field": "bymd",
450             "title": "生年月日",
451         },
452         {
453             "field": "bymd",
454             "formatter": _calcage,
455             "headerTooltip": "存命の場合は年齢そのもの。亡くなっている方の場合は「生きていれば~歳」。 "
456         },
457         {
458             "field": "dymd",
459             "title": "没年月日",
460         },
461         {
462             "field": "gen",
463             "title": "性別",
464             "formatter": function (cell, formatterParams, onRendered) {
465                 let g = cell.getValue();
466                 if (g == 1) {
467                     return "男";
468                 } else if (g == 2) {
469                     return "女";
470                 }
471                 return "";
472             },
473         },
474         {
475             "field": "nm",
476             "title": "名前",
477             "headerFilter": "input",
478             "headerFilterFunc": "regex",
479         },
480         {
481             "field": "wp",
482             "title": "wikipedia",
483             "headerFilter": "input",
484             "headerFilterFunc": "regex",
485             "formatter": function (cell, formatterParams, onRendered) {
486                 let pn = cell.getValue();
487                 return "<a href='https://ja.wikipedia.org/wiki/" +
488                     pn + "' target=_blank>" + pn + "</a>";
489             },
490         },
491         {
492             "field": "bld",
493             "title": "血液型",
494             "headerFilter": "input",
495             "headerFilterFunc": "regex",
496         },
497         {
498             "field": "bor",
499             "title": "出生地・出身地",
500             "headerFilter": "input",
501             "headerFilterFunc": "regex",
502         },
503         {
504             "field": "nn",
505             "title": "愛称",
506             "headerFilter": "input",
507             "headerFilterFunc": "regex",
508         },
509         {
510             "field": "tw",
511             "title": "身長/体重",
512             "headerFilter": "input",
513             "headerFilterFunc": "regex",
514         },
515         {
516             "field": "bel",
517             "title": "事務所・レーベル",
518             "headerFilter": "input",
519             "headerFilterFunc": "regex",
520         },
521         {
522             "field": "fst",
523             "title": "デビュー作",
524             "headerFilter": "input",
525             "headerFilterFunc": "regex",
526         },
527         {
528             "field": "tea",
529             "title": "共同作業者",
530             "headerFilter": "input",
531             "headerFilterFunc": "regex",
532         },
533   ], 
534   "layout": "fitColumns",
535   "data": actor_basinf_data
536 });
537 </script>
538 </html>
539 """, file=fo)

これらダウンロード機能がワタシが欲しいものであるかどうかは別として、少なくとも自分でこうやって使ってみる限り、「html ダウンロードがうんともすんとも言わない」ことはない:


つまり html については「インタラクティブデモ」の問題であって、Tabulator 本体の問題ではない。

PDF については、例をコピーして動かしてみると、「インタラクティブデモ」で起こってるのと同じエラーで動かない。こちらは本体の問題てことだな。いつからなんだろうか? PDF ダウンロードが公式に取り込まれたのは 3.5 のはずだが、その時点でもワタシはトライしてないからなぁ、わからん。

気力があれば issue に投げるとかするかもしれんけど、今はあんまりその気じゃない。そもそも既に issue に挙がってるかどうか調べるのすら億劫になってるし。全然必要ないんだよねワタシには、PDF。ゆえ、まぁどうしても欲しい人は頑張って要望あげてみてくれ、と思う。


2021-11-03追記:
探せば色々ありそうなのだが、少なくとももう一つ問題を見つけた。issue tracker に挙げるかは迷ってるとこ。Initial Header Filter Values も動作しなくなってる。声優世代表のおとも ver 10(?)で「リダイレクトで飛ばされるページを初期状態では除外」としたくて:

 1 <!-- ... -->
 2 <script>
 3 /* ... */
 4 var table = new Tabulator("#actor_basinf", {
 5     "height": "800px",
 6     "columnDefaults": {
 7         "tooltip": true,
 8         "headerSortTristate": true,
 9     },
10     "headerFilterLiveFilterDelay": 800,
11 
12     "initialHeaderFilter": [
13         {
14             "field": "redi",
15             "value": "^$",
16         },
17     ],
18     /* ... */
19     "columns": [
20         /* ... */
21         {
22             "field": "redi",
23             "headerFilter": "input",
24             "headerFilterFunc": "regex",
25             "formatter": function (cell, formatterParams, onRendered) {
26                 let pn = cell.getValue();
27                 return "<a href='https://ja.wikipedia.org/wiki/" +
28                     pn + "' target=_blank>" + pn + "</a>";
29             },
30             "headerTooltip": "転送された場合、転送先。"
31         },
32         /* ... */
33     ], 
34     "layout": "fitColumns",
35     "data": actor_basinf_data
36 });
37 /* ... */
38 </script>
39 <!-- ... -->

とすると、これがエラーで動かない。で、試しに Tabulator のバージョンを 4.2.7 に変えてみたところ、これは今のワタシのコードが使っている「cell.getRow().getCell(filed).getValue()」が 4.2 では動作しないのでこれを書き換えるる必要はあったものの、initialHeaderFilter はドキュメントに書かれている通りに動いた。

ワタシが挙げなくてもすぐに誰かが気付きそうな気がするので、ちょっとしばらく待ってみようかと思う。それよりもオレ的ネタとしては「「cell.getRow().getCell(filed).getValue()」が 4.2 では動作しない」との格闘をいったんネタにしたいと思う。これは別のネタとして。(→ 2021-11-03 19:30 追記: 書いた。)



Related Posts