Tabulator で csv 「的」データを入力にしたい、兼「声優世代表のおとも」

ワレ的には「ついに」てネタ。

先延ばしてたんだよね、思ってはいたけれど。ここ数日の「Tabulator のバージョンを最新に乗り換える」話とは一応独立したネタ。

個人的ニーズはテレビ朝日の番組「声優パーク建設計画VR部」内のコーナー「声優世代表を作ろう!」のおともに使える表が欲しい、そして、そのアプローチとして「Python で wikipedia をスクレイプして、その結果を Tabulator な html に仕立て上げる」としたわけだ。で、(1)(2)(3) (→ (4))の、「技術ネタ」の本題は「Tabulator の新しいバージョンへの乗り換え」だったのだが、技術ネタとともにワタシには「声優世代表のおとも、そのもの」も本題なわけで、なのでスクリプトの進化といっしょに、扱う声優の数もガンガン増やしていた。

当然の話だが、量が増えてくればそれにともなう問題も出てくる。スクリプトの処理時間がかかる問題は(どうせオフラインなので)あまり気にしないとして、ブラウザも重くなることもあるが、一番最初に致命傷になったのが、ワタシのケースでは「Wordpress の制限」だった。あなたが今目にしているこのページは WordPress で管理していて、こんなふう:

に編集しているのだが、この絵のテキストボックス内のテキストが、ワタシのこのケースではロリポップサーバ内に配置された MySQL データベースのカラムとして格納される。今回初めて知ったのだがこの列サイズの制限にひっかかると、編集画面にはなんのエラー報告もなく空っぽのページを生成してしまうらしい。「Tabulator 実例を完全な形のものとして紹介する」ための手法としてワタシが採っているのがキャプチャ画面でみえている通り生成した html ページ全体を data-uri にしてしまってワタシのブログ管理を簡単にする、というアプローチなので、今回の例のように生成 HTML が大きくなれば、MySQL に放り込まれるサイズもそれにともなって大きくなっていき、そしてカラムサイズ制限にひっかかる、てわけだ。

この、ワタシに固有の問題の有無に関わらず、いずれにしても「データ量との闘い」は必要である。似た話を、少なくとも3回書いている: 36進数名前の切り詰めの話数値表現の切り詰め。名前の切り詰めは、既に施してある(最初の版では「field」と「title」が同じだった)。また、「男性/女性」という文字列でなくコード化したり、生誕年度を文字列でなく数値にしたりもしてる。データの冗長さはなくせるならなくそうと少し努力もした(「名前」)。そしてまぁおよそほぼ「最後の一手」として今回のケースで効くのは無論、2つめのところで書いている通り、「key-value が全ての世界を救う…わけないのであって、適材適所、TPO」であり、今の場合、「コンパクトな csv 的なのでいいんだよなぁ」て話。「的」といってるのは、「key-value ではなくただのリスト」て意味ね、「カンマセパレーティッドな形式のファイルを入力とする」ことはひとまず置いといて、まずは単に javascript としてのリストが既存で、それを Tabuletor への入力とする話ね。「key がかさばるんだよ」てことだよ、わかるよね?

ただねぇ…。

探してはみるも、Tabulator は「csv を入力とする」ことをサポートしてはくれてないみたいなんだよね。ひとまずはこんなふうに自力でコンバートすりゃぁ、そらうまくはいく:

 1 <!-- ... snip -->
 2 <script>
 3 /* ... snip ... */
 4 var actor_basinf_data_csv = 
 5 [
 6 [
 7 "1920",
 8 "1938",
 9 "1920-08-03",
10 "",
11 1,
12 "高杉哲平",
13 "高杉哲平, たかすぎ てっぺい",
14 "",
15 "東京都",
16 "",
17 "",
18 "",
19 "",
20 ""
21 ],
22 [
23 "1922",
24 "1960年代",
25 "1922-11-09",
26 "2012-06-27",
27 2,
28 "高村章子",
29 "高村章子, たかむら あきこ, 高村 章子",
30 "O",
31 "長野県岡谷市",
32 "",
33 "153 cm / 60 kg",
34 "アドヴァンスプロモーション",
35 "",
36 ""
37 ],
38 /* ... snip ... */
39 ]
40 /* ... snip ... */
41 var actor_basinf_data = Array();
42 actor_basinf_data_csv.forEach(function (r, i) {
43     actor_basinf_data.push({
44         "by": r[0],  /* 生誕年度 */
45         "as": r[1],  /* 活動開始年? */
46         "bymd": r[2],  /* 生年月日 */
47         "dymd": r[3],  /* 没年月日 */
48         "gen": r[4],  /* 性別 */
49         "wp": r[5],  /* wikipedia */
50         "nm": r[6],  /* 名前 */
51         "bld": r[7],  /* 血液型 */
52         "bor": r[8],  /* 出生地・出身地 */
53         "nn": r[9],  /* 愛称 */
54         "tw": r[10],  /* 身長/体重 */
55         "bel": r[11],  /* 事務所・レーベル */
56         "fst": r[12],  /* デビュー作 */
57         "tea": r[13],  /* 共同作業者 */
58     })
59 })
60 
61 
62 var table = new Tabulator("#actor_basinf", {
63     /* ... snip ... */
64     "data": actor_basinf_data
65 });
66 </script>

ダサいよね、とは思うが、これしか思い浮かばない。

というわけでこれを生成するスクリプト全体としてはこうした(列もひとつ追加してる):

wikipedia をスクレイプして「声優世代表のおとも html」、な、ver 8、かな?
  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 _norm(s):
115     for f, r in (("(", "("), (")", ")"), ("・", "・")):
116         s = s.replace(f, r)
117     return s
118 
119 
120 def _try_get_actst(doc, actst, actst_rough):
121     # 活動開始をどうにか特定したい、ので、ヘディングで「1999年」としてるならまずはそれ、
122     # また、ヘディングが「出演」の配下にあるリスト項目の「~(1999年、…)」みたいな羅列
123     # を拾いたい、と思うわけだが、「html なのでこれは」なので(特に後者が)ひたすらに
124     # めんどいのよ。
125     validymin = 1900  # 日本の声優第一号の生誕から得るのがよかろうが、わからんのでてきとう
126     for stt, sta in (("dt", {}), ("b", {})):
127         # ヘディングから。<b>をヘディングにしてるのがいて迷惑…。
128         for dtt in [
129                 dt.text
130                 for dt in doc.find_all(stt, sta) if re.match(r"\d{4}年代?$", dt.text)]:
131             m = re.match(r"(\d+)年(代)?", dtt)
132             if not m.group(2):
133                 v = int(m.group(1))
134                 if v > validymin:
135                     actst = min(v, actst)
136             else:
137                 actst_rough.append(m.group(0))
138     if actst == 9999:
139         docs = str(doc)
140         m = re.search(r'<h2.* id="出演.*</h2>', docs)
141         if m:
142             _, rngs = m.span()
143             m = re.search(r'<h2.*.*</h2>', docs[rngs:])
144             if m:
145                 rnge, _ = m.span()
146                 sect = bs4.BeautifulSoup(docs[rngs:rngs + rnge], features="html.parser")
147                 s = sect.text
148                 for ys in re.findall(r"\((\d{4}[年.]代?)", s):
149                     if ys[-1] == "代":
150                         actst_rough.append(ys)
151                     else:
152                         v = int(ys[:-1])
153                         if v > validymin:
154                             actst = min(v, actst)
155     return actst, actst_rough
156 
157 
158 def _from_wp_jp(actorpagename):
159     baseurl = "https://ja.wikipedia.org/wiki/"
160     pn = urllib_quote(actorpagename, encoding="utf-8")
161     fn = _urlretrieve(baseurl + pn)
162 
163     result = {"wikipedia": actorpagename, "名前": [actorpagename.partition("_")[0]]}
164     with io.open(fn, "r", encoding="utf-8") as fi:
165         soup = bs4.BeautifulSoup(_norm(fi.read()), features="html.parser")
166         entr = soup.find("a", {"class": "interlanguage-link-target", "lang": "en"})
167         if entr:
168             result["wikipedia_en"] = entr.attrs["href"]
169         try:
170             categos = [
171                 a.text
172                 for a in soup.find("div", {"id": "mw-normal-catlinks"}).find_all("a")]
173         except Exception:
174             categos = []
175         trecords = []
176         try:
177             trecords = iter(
178                 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
179         except Exception:
180             pass
181         if trecords:
182             tr = next(trecords)
183             if tr.find("th"):
184                 tr = bs4.BeautifulSoup(
185                     re.sub(r"<br\s*/?>", r"\n", str(tr)), features="html.parser")
186                 for sp in tr.find("th").find_all("span"):
187                     s = re.sub(r"\[[^\[\]]+\]", "", sp.text)
188                     if "\n" in s:
189                         result["名前"].extend(s.split("\n"))
190                     else:
191                         result["名前"].append(s)
192 
193         actst = 9999
194         actst_rough = ["9999年代"]
195         for tr in trecords:
196             th, td = tr.find("th"), tr.find("td")
197             if not th or not td:
198                 continue
199             k = th.text.replace("\n", "").replace(
200                 "誕生日", "生年月日").replace("生誕", "生年月日")
201             k = re.sub("身長.*$", "身長/体重", k)
202             if k in ("事務所", "レーベル"):
203                 k = "事務所・レーベル"
204             elif k in ("職業", "職種", "ジャンル",):
205                 k = "occ"
206             v = ""
207             if td:
208                 if td.find("li"):
209                     td = "\n".join([li.text for li in td.find_all("li")])
210                 elif td.find("br"):
211                     td = bs4.BeautifulSoup(
212                         re.sub(r"<br\s*/?>", r"\n", str(td)), features="html.parser").text.strip()
213                 else:
214                     td = td.text
215                 v = re.sub(
216                     r"\[[^\[\]]+\]", "\n", td).strip()
217                 if k == "活動期間":
218                     m = re.match(r"(\d+)年(代)?", v)
219                     if m:
220                         if not m.group(2):
221                             actst = min(int(m.group(1)), actst)
222                         else:
223                             actst_rough.append(m.group(0))
224                     continue
225                 elif k in ("生年月日", "没年月日"):
226                     m1 = re.search(r"\d+-\d+-\d+", v)
227                     m2 = re.search(r"(\d+)年(\d+)月(\d+)日", v)
228                     m3 = re.search(r"(\d+)月(\d+)日", v)
229                     if m1:
230                         v = m1.group(0)
231                     elif m2:
232                         v = "{:04d}-{:02d}-{:02d}".format(
233                             *list(map(int, m2.group(1, 2, 3))))
234                     elif m3:
235                         v = "0000-{:02d}-{:02d}".format(
236                             *list(map(int, m3.group(1, 2))))
237                     else:
238                         v = ""
239             v = re.sub(
240                 r"(、\s*)+", "、",
241                 "、".join(re.sub(r"\n+", r"\n", v).split("\n")))
242             if k in ("デビュー作", "事務所・レーベル", "共同作業者",):
243                 v = "、".join(list(filter(None, [result.get(k), v])))
244             elif k == "occ":
245                 v = "、".join(
246                     list(
247                         set("、".join(list(filter(None, [result.get(k, ""), v]))).split("、"))))
248             elif k == "身長/体重":
249                 v = re.sub(r"\s*、\s*cm", " cm", v)  # なんで??
250             result[k] = v
251         actst, actst_rough = _try_get_actst(soup, actst, actst_rough)
252         actst_rough_min = list(sorted(actst_rough))[0]
253         armv = int(re.match(r"(\d+)", actst_rough_min).group(1))
254         if actst < 9999 and armv // 10 > actst // 10:
255             result["actst"] = "{:04d}".format(actst)
256         elif armv < 9999:
257             result["actst"] = actst_rough_min
258         else:
259             result["actst"] = "0000"
260         if not result.get("生年月日"):
261             # infobox なしなのに律儀に生年月日は書いてる、てのが結構ある。
262             m = re.search(
263                 r"[^、。()\n]+\s*\([^、。()\n]+、(\d{4}年)?(\d+月)?(\d+日)?[^()]*\)は、日本の",
264                 soup.text)
265             if m:
266                 yy, mm, dd = m.group(1, 2, 3)
267                 if yy:
268                     yy = int(yy[:-1])
269                 else:
270                     yy = 0
271                 if mm and dd:
272                     result["生年月日"] = "{:04d}-{:02d}-{:02d}".format(
273                         yy, int(mm[:-1]), int(dd[:-1]))
274                 elif mm:
275                     result["生年月日"] = "{:04d}-{:02d}-??".format(
276                         yy, int(mm[:-1]))
277                 else:
278                     result["生年月日"] = "{:04d}-??-??".format(yy)
279             else:
280                 result["生年月日"] = "0000-??-??"
281         if not result.get("性別"):
282             if any([("男性" in c or "男優" in c) for c in categos]):
283                 result["性別"] = 1
284             elif any([("女性" in c or "女優" in c) for c in categos]):
285                 result["性別"] = 2
286             else:
287                 result["性別"] = 0
288         else:
289             result["性別"] = 1 if ("男" in result["性別"]) else 2
290     return result
291 
292 
293 def _from_wp_en(result):
294     # 基本的に日本語版を信じ、欠落のものだけ英語版に頼ることにする。
295     if "生年月日" in result and "0000" not in result["生年月日"]:
296         return result
297     try:
298         fn = _urlretrieve(result["wikipedia_en"])
299     except urllib.error.HTTPError:
300         return result
301     with io.open(fn, "r", encoding="utf-8") as fi:
302         soup = bs4.BeautifulSoup(fi.read(), features="html.parser")
303         try:
304             trecords = iter(
305                 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
306         except Exception:
307             return result
308         for tr in trecords:
309             th, td = tr.find("th"), tr.find("td")
310             if not th or not td:
311                 continue
312             k_en = th.text.strip()
313             if k_en == "Born":
314                 if "生年月日" not in result or "0000" in result["生年月日"]:
315                     bd = td.find("span", {"class": "bday"})
316                     if bd:
317                         bd = re.sub(r"\[[^\[\]]+\]", "", bd.text)
318                         result["生年月日"] = bd
319     return result
320 
321 
322 def _from_wp(actorpagename):
323     result = _from_wp_jp(actorpagename)
324     if "wikipedia_en" in result:
325         _from_wp_en(result)
326     return result
327 
328 
329 if __name__ == '__main__':
330     def _yrgrp(ymd, actst):
331         y = 0
332         if ymd:
333             y, _, md = ymd.partition("-")
334             y = int(y)
335             if y and md < "04-02":
336                 y -= 1
337         return [
338             y,
339             actst.replace("0000", "????"),
340             ymd.replace("0000", "????")]
341     def _names(inf):
342         # inf["名前"]
343         # 通常は
344         #   0: wikipediaページ名からあいまい回避サフィクスを取り除いた名前
345         #   1: 2のよみ
346         #   2: 姓+スペース+名
347         # ページによってこれがバラつく。0しか取れないものは結構ある。外国人名は「姓+スペース+名」
348         # に従わない。通常ケースでは 2、1 をとって 0 を捨てるのが使いやすい。
349         nms = inf["名前"]
350         if len(nms) == 3:
351             if nms[0] == re.sub(r"\s+", "", nms[2]) or nms[0] == nms[1]:
352                 return ", ".join([nms[2], nms[1]]), nms[2]
353             return ", ".join([nms[0], nms[2], nms[1]]), nms[0]
354         elif len(nms) == 1:
355             return nms[0], nms[0]
356         else:  # ふりがながないケース、と思う。
357             if nms[0] != re.sub(r"\s+", "", nms[1]):
358                 return ", ".join([nms[0], nms[1]]), nms[0]
359             return nms[1], nms[1]
360         
361     actorpages = list(set(
362         map(lambda s: s.strip(),
363             io.open("wppagenames.txt", encoding="utf-8").read().strip().split("\n"))))
364     result = []
365     for a in filter(None, actorpages):
366         inf = _from_wp(a)
367         g = _yrgrp(inf.get("生年月日", ""), inf.get("actst", "0000"))
368         nms_disp, n0 = _names(inf)
369         wp = inf["wikipedia"]
370         if n0.replace(" ", "") == wp:
371             wp = ""
372         result.append((
373             g[0], g[1], g[2].replace("-", ""),
374             inf.get("没年月日", "").replace("-", ""),
375             inf.get("性別", 0),
376             wp,
377             nms_disp,
378             re.sub(r"\s*型", "", inf.get("血液型", "")),
379             re.sub(
380                 r"\b日本・", "",
381                 "、".join(list(filter(None, [inf.get("出生地", ""), inf.get("出身地", "")])))),
382             re.sub(r"、\s*([((])", r"\1", inf.get("愛称", "")),
383             re.sub(r"(\d)\s+([a-z])", r"\1\2", inf.get("身長/体重", "")),
384             re.sub(r"、\s*([((])", r"\1", inf.get("事務所・レーベル", "")),
385             re.sub(r"、\s*([((])", r"\1", inf.get("デビュー作", "")),
386             inf.get("共同作業者", ""),
387             inf.get("occ", ""),
388         ))
389     result.sort()
390     with io.open("actor_basinf.html", "w", encoding="utf-8") as fo:
391         print("""\
392 <html>
393 <head jang="ja">
394 <meta charset="UTF-8">
395 <link href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/css/tabulator_site.min.css" rel="stylesheet">
396 <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/js/tabulator.min.js"></script>
397 <script type="text/javascript" src="https://oss.sheetjs.com/sheetjs/xlsx.full.min.js"></script>
398 
399 <!--
400 {}
401   -->
402 </head>""".format(__doc__), file=fo)
403         print("""\
404 <body>
405 <div style="margin: 1em 0em">
406 <select id="filter-gender">
407   <option value=""></option>
408   <option value="1">男性のみ</option>
409   <option value="2">女性のみ</option>
410 </select>
411 <select id="filter-alive">
412   <option></option>
413   <option>存命者のみ</option>
414   <option>死没者のみ</option>
415 </select>
416 </div>
417 <div id="actor_basinf"></div>
418 <div style="margin: 1em 0em">
419   <button id="download-csv">Download CSV</button>
420   <button id="download-json">Download JSON</button>
421   <button id="download-xlsx">Download XLSX</button>
422   <button id="download-html">Download HTML</button>
423 </div>
424 <script>
425 var filter_gender = document.getElementById("filter-gender");
426 function _update_genderfilter() {
427     let i = filter_gender.selectedIndex;
428     let v = filter_gender.options[i].value;
429     let eflts = table.getFilters(true);
430     for (let efi in eflts) {
431         let f = eflts[efi];
432         if (f["field"] == "gen") {
433             table.removeFilter(f["field"], f["type"], f["value"]);
434             break;
435         }
436     }
437     if (v) {
438         table.addFilter("gen", "regex", v);
439     }
440 }
441 filter_gender.addEventListener("change", _update_genderfilter);
442 var filter_alive = document.getElementById("filter-alive");
443 function _update_alivefilter() {
444     let i = filter_alive.selectedIndex;
445     let eflts = table.getFilters(true);
446     for (let efi in eflts) {
447         let f = eflts[efi];
448         if (f["field"] == "dymd") {
449             table.removeFilter(f["field"], f["type"], f["value"]);
450             break;
451         }
452     }
453     if (i == 1) {
454         table.addFilter("dymd", "=", "");
455     } else if (i == 2) {
456         table.addFilter("dymd", "!=", "");
457     }
458 }
459 filter_alive.addEventListener("change", _update_alivefilter);
460 
461 document.getElementById("download-csv").addEventListener("click", function() {
462     table.download("csv", "data.csv");
463 });
464 document.getElementById("download-json").addEventListener("click", function() {
465     table.download("json", "data.json");
466 });
467 document.getElementById("download-xlsx").addEventListener("click", function() {
468     table.download("xlsx", "data.xlsx", {sheetName:"My Data"});
469 });
470 document.getElementById("download-html").addEventListener("click", function() {
471     table.download("html", "data.html", {style: true});
472 });
473 
474 function _dt2int(dt) {
475     function _pad(n) {
476         let ns = "" + Math.abs(n);
477         if (ns.length === 1) {
478             ns = "0" + ns;
479         }
480         return ns;
481     }
482     return parseInt(dt.getFullYear() + _pad(dt.getMonth() + 1) + _pad(dt.getDate()));
483 }
484 var nowi = _dt2int(new Date());
485 function _calcage(cell, formatterParams) {
486     var v = cell.getValue().replace(new RegExp("\-", "g"), "");
487     if (v.startsWith("????")) {
488         return "";
489     }
490     v = parseInt(v.replace(/\?/g, "0"));
491     var result = "" + parseInt((nowi - v) / 10000);
492     result += "歳";
493     let unk = cell.getValue().includes("-??");
494     if (unk) {
495         result += "?";
496     }
497     var d = cell.getRow().getCell("dymd").getValue().replace(
498         "?", "0").replace(new RegExp("\-", "g"), "");
499     if (d) {
500         result += " (";
501         result += parseInt((parseInt(d) - v) / 10000);
502         result += "歳";
503         if (unk) {
504             result += "?";
505         }
506         result += "没)";
507     }
508     return result;
509 }
510 
511 
512 var actor_basinf_data_csv = """, file=fo)
513         jds = json.dumps(
514             result,
515             ensure_ascii=False, indent=0)
516         print(jds, file=fo)
517         print("""
518 var actor_basinf_data = Array();
519 actor_basinf_data_csv.forEach(function (r, i) {
520     let bymd = r[2];
521     if (bymd) {
522         bymd = bymd.slice(0, 4) + "-" + bymd.slice(4, 6) + "-" + bymd.slice(6, 8);
523     }
524     let dymd = r[3];
525     if (dymd) {
526         dymd = dymd.slice(0, 4) + "-" + dymd.slice(4, 6) + "-" + dymd.slice(6, 8);
527     }
528     actor_basinf_data.push({
529         "by": r[0],  /* 生誕年度 */
530         "as": r[1],  /* 活動開始年? */
531         "bymd": bymd,  /* 生年月日 */
532         "dymd": dymd,  /* 没年月日 */
533         "gen": r[4],  /* 性別 */
534         "wp": r[5],  /* wikipedia */
535         "nm": r[6],  /* 名前 */
536         "bld": r[7],  /* 血液型 */
537         "bor": r[8],  /* 出生地・出身地 */
538         "nn": r[9],  /* 愛称 */
539         "tw": r[10],  /* 身長/体重 */
540         "bel": r[11],  /* 事務所・レーベル */
541         "fst": r[12],  /* デビュー作 */
542         "tea": r[13],  /* 共同作業者 */
543         "occ": r[14],  /* 職業/職種/ジャンル */
544     })
545 })
546 
547 
548 var table = new Tabulator("#actor_basinf", {
549     "height": "800px",
550     "columnDefaults": {
551         /* 注意: 「tooltips」ではない。「tooltip」である。 */
552         "tooltip": true,
553 
554         /* これはいつから使えたのかな? かつては「columnDefaults」の外にいたやつ。 */
555         "headerSortTristate": true,
556     },
557     "columns": [
558         {
559             "field": "by",
560             "title": "生誕年度",
561             "headerFilter": "input",
562             "headerFilterFunc": "regex",
563             "formatter": function (cell, formatterParams, onRendered) {
564                 let v = cell.getValue();
565                 return !v ? "????" : v;
566             },
567         },
568         {
569             "field": "as",
570             "title": "活動開始年?",
571             "headerFilter": "input",
572             "headerFilterFunc": "regex",
573         },
574         {
575             "field": "bymd",
576             "title": "生年月日",
577             "headerFilter": "input",
578             "headerFilterFunc": "regex",
579         },
580         {
581             "field": "bymd",
582             "formatter": _calcage,
583             "headerTooltip": "存命の場合は年齢そのもの。亡くなっている方の場合は「生きていれば~歳」。 "
584         },
585         {
586             "field": "dymd",
587             "title": "没年月日",
588         },
589         {
590             "field": "gen",
591             "title": "性別",
592             "formatter": function (cell, formatterParams, onRendered) {
593                 let g = cell.getValue();
594                 if (g == 1) {
595                     return "男";
596                 } else if (g == 2) {
597                     return "女";
598                 }
599                 return "";
600             },
601         },
602         {
603             "field": "nm",
604             "title": "名前",
605             "headerFilter": "input",
606             "headerFilterFunc": "regex",
607         },
608         {
609             "field": "wp",
610             "title": "wikipedia",
611             "formatter": function (cell, formatterParams, onRendered) {
612                 let pn = cell.getValue();
613                 if (!pn) {
614                     let n0 = cell.getRow().getCell("nm").getValue();
615                     n0 = n0.replace(new RegExp(", .*$"), "");
616                     pn = n0.replace(new RegExp("\\\\s+", "g"), "");
617                 }
618                 return "<a href='https://ja.wikipedia.org/wiki/" +
619                     pn + "' target=_blank>" + pn + "</a>";
620             },
621         },
622         {
623             "field": "bld",
624             "title": "血液型",
625             "headerFilter": "input",
626             "headerFilterFunc": "regex",
627         },
628         {
629             "field": "bor",
630             "title": "出生地・出身地",
631             "headerFilter": "input",
632             "headerFilterFunc": "regex",
633         },
634         {
635             "field": "nn",
636             "title": "愛称",
637             "headerFilter": "input",
638             "headerFilterFunc": "regex",
639         },
640         {
641             "field": "tw",
642             "title": "身長/体重",
643             "headerFilter": "input",
644             "headerFilterFunc": "regex",
645         },
646         {
647             "field": "bel",
648             "title": "事務所・レーベル",
649             "headerFilter": "input",
650             "headerFilterFunc": "regex",
651         },
652         {
653             "field": "fst",
654             "title": "デビュー作",
655             "headerFilter": "input",
656             "headerFilterFunc": "regex",
657         },
658         {
659             "field": "tea",
660             "title": "共同作業者",
661             "headerFilter": "input",
662             "headerFilterFunc": "regex",
663         },
664         {
665             "field": "occ",
666             "title": "職業/職種/ジャンル",
667             "headerFilter": "input",
668             "headerFilterFunc": "regex",
669         },
670   ], 
671   "layout": "fitColumns",
672   "data": actor_basinf_data
673 });
674 </script>
675 </html>
676 """, file=fo)

「columns」定義部分で「field」を指定してるのだから、ここに配列のインデクスを指定したらおkなのでは、と期待してみたけど、それはどうやらダメ。残念。

ともあれ、ここまでやればかなりの人数の声優を(このワタシの MySQL 管理の制限内でも)こうやって貼り付けられる: