一つ前ので少し言ったけれど。
「インタラクティブデモ」を動かしてみると、PDF ダウンロードはエラーで動かず、HTML ダウンロードは「うんともすんとも言わない」。これは Tabulator 本体の問題なのか、はたまた「インタラクティブデモ」としての問題なのか、どっちなんだ、と。
ひとまず:
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 追記: 書いた。)