一応 5.0.6 が現時点で CDN にあがってる公式最新。
5.0.6 では動作してない。本日時点では issue tracker には挙がってないし、ワタシも当座は慌てて報告するつもりもない。
細かい話は独立したネタとして書いた。今あなたがみているこのページで書いたコードが影響を受けている話なので、セットで読んでもらえるとありがたい。
PDF ドキュメントダウンロードの例が動作しないなど、5.0 ドキュメントはまだ
不完全なようなので、不安であれば 4.x にとどめておくほうがいいかもね。ともあれ。
4.2.7 から 4.9.3 に乗り換えたが、5.0.6 もやってみて「tooltips はいずれ使えなくなるぜぼうず」警告が出たので 4.9.3 で止めた、てこと。いいねぇ、ちゃんとドキュメントに全部書いてあるわ。ゆえに:
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 import io
37 import os
38 import sys
39 import tempfile
40 import shutil
41 import ssl
42 import re
43 import json
44 import urllib.request
45 from urllib.request import urlretrieve as urllib_urlretrieve
46 from urllib.request import quote as urllib_quote
47
48
49 import bs4 # require: beutifulsoup4
50
51
52 __MYNAME__, _ = os.path.splitext(
53 os.path.basename(sys.modules[__name__].__file__))
54 #
55 __USER_AGENT__ = "\
56 Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
57 AppleWebKit/537.36 (KHTML, like Gecko) \
58 Chrome/91.0.4472.124 Safari/537.36"
59 _htctxssl = ssl.create_default_context()
60 _htctxssl.check_hostname = False
61 _htctxssl.verify_mode = ssl.CERT_NONE
62 https_handler = urllib.request.HTTPSHandler(context=_htctxssl)
63 opener = urllib.request.build_opener(https_handler)
64 opener.addheaders = [('User-Agent', __USER_AGENT__)]
65 urllib.request.install_opener(opener)
66 #
67
68
69 _urlretrieved = dict()
70
71
72 def _urlretrieve(url):
73 if url in _urlretrieved:
74 return _urlretrieved[url]
75
76 def _gettemppath(s):
77 tmptopdir = os.path.join(tempfile.gettempdir(), __MYNAME__)
78 if not os.path.exists(tmptopdir):
79 os.makedirs(tmptopdir)
80 import hashlib, base64
81 ep = base64.urlsafe_b64encode(
82 hashlib.md5(s.encode("utf-8")).digest()
83 ).partition(b"=")[0].decode()
84 return os.path.join(tmptopdir, ep)
85
86 cachefn = _gettemppath(url)
87 if os.path.exists(cachefn):
88 res = cachefn
89 else:
90 res, _ = urllib_urlretrieve(url, filename=cachefn)
91 _urlretrieved[url] = res
92 return res
93
94
95 def _from_wp(actorpagename):
96 baseurl = "https://ja.wikipedia.org/wiki/"
97 pn = urllib_quote(actorpagename, encoding="utf-8")
98 fn = _urlretrieve(baseurl + pn)
99
100 result = {"wikipedia": actorpagename, "名前": [actorpagename.partition("_")[0]]}
101 with io.open(fn, "r", encoding="utf-8") as fi:
102 soup = bs4.BeautifulSoup(fi.read(), features="html.parser")
103 try:
104 categos = [
105 a.text
106 for a in soup.find("div", {"id": "mw-normal-catlinks"}).find_all("a")]
107 except Exception:
108 return result
109 try:
110 trecords = iter(
111 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
112 except Exception:
113 return result
114 tr = next(trecords)
115 result["名前"] += [
116 re.sub(r"\[[^\[\]]+\]", "", sp.text)
117 for sp in tr.find("th").find_all("span")]
118
119 actst = float("inf")
120 for tr in trecords:
121 th, td = tr.find("th"), tr.find("td")
122 if not th or not td:
123 continue
124 k = th.text.replace("\n", "").replace(
125 "誕生日", "生年月日").replace("生誕", "生年月日")
126 v = ""
127 if td:
128 v = re.sub(
129 r"\[[^\[\]]+\]", "", td.text.replace("\n", "")).strip()
130 if k == "活動期間":
131 m = re.search(r"(\d+)年\s*\-", v)
132 if m:
133 actst = min(float(m.group(1)), actst)
134 continue
135 elif k == "生年月日":
136 m1 = re.search(r"\d+-\d+-\d+", v)
137 m2 = re.search(r"(\d+)月(\d+)日", v)
138 if m1:
139 v = m1.group(0)
140 elif m2:
141 v = "0000-{:02d}-{:02d}".format(
142 *list(map(int, m2.group(1, 2))))
143 else:
144 v = ""
145 result[k] = v
146 for dtt in [
147 dt.text
148 for dt in soup.find_all("dt") if re.match(r"\d+年", dt.text)]:
149 actst = min(float(re.search(r"(\d+)年", dtt).group(1)), actst)
150 try:
151 result["actst"] = "{:04d}".format(int(actst))
152 except OverflowError:
153 result["actst"] = "0000"
154 if not result.get("性別"):
155 if any([("男性" in c or "男優" in c) for c in categos]):
156 result["性別"] = "男性"
157 elif any([("女性" in c or "女優" in c) for c in categos]):
158 result["性別"] = "女性"
159 result["性別"] = result["性別"][0]
160 return result
161
162
163 if __name__ == '__main__':
164 def _yrgrp(ymd, actst):
165 y = 0
166 if ymd:
167 y, _, md = ymd.partition("-")
168 y = int(y)
169 if y and md < "04-02":
170 y -= 1
171 return list(
172 map(lambda s: s.replace("0000", "????"),
173 ["{:04d}".format(y), actst, ymd]))
174
175 actorpages = list(
176 map(lambda s: s.strip(),
177 io.open("wppagenames.txt", encoding="utf-8").read().strip().split("\n")))
178 result = []
179 for a in actorpages:
180 inf = _from_wp(a)
181 g = _yrgrp(inf.get("生年月日", ""), inf.get("actst"))
182 result.append((
183 g[0], g[1], g[2],
184 inf.get("性別", ""),
185 ("<a href='https://ja.wikipedia.org/wiki/{pn}' target=_blank>{pn}</a>".format(
186 pn=inf["wikipedia"])),
187 ", ".join(inf["名前"]),
188 inf.get("血液型", "-"),
189 inf.get("出生地", "-"),
190 inf.get("出身地", "-"),
191 inf.get("愛称", "-"),
192 inf.get("身長", "-"),
193 inf.get("事務所", "-"),
194 ))
195 result.sort()
196 with io.open("actor_basinf.html", "w", encoding="utf-8") as fo:
197 coln = [
198 "生誕年度", "活動開始年?", "生年月日",
199 "性別", "wikipedia", "名前",
200 "血液型",
201 "出生地", "出身地", "愛称", "身長", "事務所",
202 ]
203 print("""\
204 <html>
205 <head jang="ja">
206 <meta charset="UTF-8">
207 <link href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/css/tabulator_site.min.css" rel="stylesheet">
208 <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/js/tabulator.min.js"></script>
209
210 <!--
211 {}
212 -->
213 </head>""".format(__doc__), file=fo)
214 print("""\
215 <body>
216 <div id="actor_basinf"></div>
217 <script>
218 function _dt2int(dt) {
219 function _pad(n) {
220 let ns = "" + Math.abs(n);
221 if (ns.length === 1) {
222 ns = "0" + ns;
223 }
224 return ns;
225 }
226 return parseInt(dt.getFullYear() + _pad(dt.getMonth() + 1) + _pad(dt.getDate()));
227 }
228 var nowi = _dt2int(new Date());
229 function _calcage(cell, formatterParams) {
230 var v = cell.getValue().replace(new RegExp("\-", "g"), "");
231 if (v.startsWith("????")) {
232 return "";
233 }
234 v = parseInt(v);
235 return "" + parseInt((nowi - v) / 10000);
236 }
237
238
239 var actor_basinf_data = """, file=fo)
240 json.dump(
241 [dict(zip(coln, row)) for row in result],
242 fo, ensure_ascii=False, indent=4)
243 print("""
244 var table = new Tabulator("#actor_basinf", {
245 "height": "800px",
246 "columnDefaults": {
247 /* 注意: 「tooltips」ではない。「tooltip」である。 */
248 "tooltip": true,
249 },
250 "columns": [
251 {
252 "field": "生誕年度",
253 "title": "生誕年度",
254 "headerFilter": "input",
255 "headerFilterFunc": "regex",
256 },
257 {
258 "field": "活動開始年?",
259 "title": "活動開始年?",
260 "headerFilter": "input",
261 "headerFilterFunc": "regex",
262 },
263 {
264 "field": "生年月日",
265 "title": "生年月日",
266 },
267 {
268 "field": "生年月日",
269 "formatter": _calcage,
270 "headerTooltip": "存命の場合は年齢そのもの。亡くなっている方の場合は「生きていれば~歳」。 "
271 },
272 {
273 "field": "性別",
274 "title": "性別",
275 "headerFilter": "input",
276 },
277 {
278 "field": "名前",
279 "title": "名前",
280 "headerFilter": "input",
281 "headerFilterFunc": "regex",
282 },
283 {
284 "field": "wikipedia",
285 "title": "wikipedia",
286 "headerFilter": "input",
287 "headerFilterFunc": "regex",
288 "formatter": "html",
289 },
290 {
291 "field": "血液型",
292 "title": "血液型",
293 "headerFilter": "input",
294 "headerFilterFunc": "regex",
295 },
296 {
297 "field": "出生地",
298 "title": "出生地",
299 "headerFilter": "input",
300 "headerFilterFunc": "regex",
301 },
302 {
303 "field": "出身地",
304 "title": "出身地",
305 "headerFilter": "input",
306 "headerFilterFunc": "regex",
307 },
308 {
309 "field": "愛称",
310 "title": "愛称",
311 "headerFilter": "input",
312 "headerFilterFunc": "regex",
313 },
314 {
315 "field": "身長",
316 "title": "身長",
317 "headerFilter": "input",
318 "headerFilterFunc": "regex",
319 },
320 {
321 "field": "事務所",
322 "title": "事務所",
323 "headerFilter": "input",
324 "headerFilterFunc": "regex",
325 }
326 ],
327 "layout": "fitColumns",
328 "data": actor_basinf_data
329 });
330 </script>
331 </html>
332 """, file=fo)
オプションの構造に階層を持たせた、てことだね。きっといい設計変更に違いない、と思う。
ちふわけで:
機能をどのくらい活用してるかによって乗り換えの難易度は違うとは思うけれど、ここまでちゃんとマイグレーションについてきちんと書いてくれてれば、まぁそんなに困ることはないと思うね。
ところで、「Tabulator バージョン乗り換え」とは全然無関係に、「没時年齢」も表示したくなった。冗長データとして計算済データを作っておくのではなく、没年月日から計算で求めるフォーマッタで実現する場合、その計算は2つのフィールドを参照することになる。
記憶では以前 Tabulator で遊んでた数年前はもっとこういった実現のための機能を探すのに苦労してた記憶があるのだが、たぶんそのときの記憶が間違いでないなら、かなりドキュメントが使いやすくなってる。たぶん「Component Objects」みたいにまとまった記述は、以前はなかったと思うんだよなぁ。細かなパーマネントリンクがないという点だけは相変わらず不満なままだが、さすがにここまでちゃんとしてると、あまり文句を言わないでおいてあげようか、と、優しい気持ちになる。
やりたいことのための改造は「function _calcage」部分のみ:
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 import io
37 import os
38 import sys
39 import tempfile
40 import shutil
41 import ssl
42 import re
43 import json
44 import urllib.request
45 from urllib.request import urlretrieve as urllib_urlretrieve
46 from urllib.request import quote as urllib_quote
47
48
49 import bs4 # require: beutifulsoup4
50
51
52 __MYNAME__, _ = os.path.splitext(
53 os.path.basename(sys.modules[__name__].__file__))
54 #
55 __USER_AGENT__ = "\
56 Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
57 AppleWebKit/537.36 (KHTML, like Gecko) \
58 Chrome/91.0.4472.124 Safari/537.36"
59 _htctxssl = ssl.create_default_context()
60 _htctxssl.check_hostname = False
61 _htctxssl.verify_mode = ssl.CERT_NONE
62 https_handler = urllib.request.HTTPSHandler(context=_htctxssl)
63 opener = urllib.request.build_opener(https_handler)
64 opener.addheaders = [('User-Agent', __USER_AGENT__)]
65 urllib.request.install_opener(opener)
66 #
67
68
69 _urlretrieved = dict()
70
71
72 def _urlretrieve(url):
73 if url in _urlretrieved:
74 return _urlretrieved[url]
75
76 def _gettemppath(s):
77 tmptopdir = os.path.join(tempfile.gettempdir(), __MYNAME__)
78 if not os.path.exists(tmptopdir):
79 os.makedirs(tmptopdir)
80 import hashlib, base64
81 ep = base64.urlsafe_b64encode(
82 hashlib.md5(s.encode("utf-8")).digest()
83 ).partition(b"=")[0].decode()
84 return os.path.join(tmptopdir, ep)
85
86 cachefn = _gettemppath(url)
87 if os.path.exists(cachefn):
88 res = cachefn
89 else:
90 res, _ = urllib_urlretrieve(url, filename=cachefn)
91 _urlretrieved[url] = res
92 return res
93
94
95 def _from_wp(actorpagename):
96 baseurl = "https://ja.wikipedia.org/wiki/"
97 pn = urllib_quote(actorpagename, encoding="utf-8")
98 fn = _urlretrieve(baseurl + pn)
99
100 result = {"wikipedia": actorpagename, "名前": [actorpagename.partition("_")[0]]}
101 with io.open(fn, "r", encoding="utf-8") as fi:
102 soup = bs4.BeautifulSoup(fi.read(), features="html.parser")
103 try:
104 categos = [
105 a.text
106 for a in soup.find("div", {"id": "mw-normal-catlinks"}).find_all("a")]
107 except Exception:
108 return result
109 try:
110 trecords = iter(
111 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
112 except Exception:
113 return result
114 tr = next(trecords)
115 result["名前"] += [
116 re.sub(r"\[[^\[\]]+\]", "", sp.text)
117 for sp in tr.find("th").find_all("span")]
118
119 actst = float("inf")
120 for tr in trecords:
121 th, td = tr.find("th"), tr.find("td")
122 if not th or not td:
123 continue
124 k = th.text.replace("\n", "").replace(
125 "誕生日", "生年月日").replace("生誕", "生年月日")
126 v = ""
127 if td:
128 v = re.sub(
129 r"\[[^\[\]]+\]", "", td.text.replace("\n", "")).strip()
130 if k == "活動期間":
131 m = re.search(r"(\d+)年\s*\-", v)
132 if m:
133 actst = min(float(m.group(1)), actst)
134 continue
135 elif k in ("生年月日", "没年月日"):
136 m1 = re.search(r"\d+-\d+-\d+", v)
137 m2 = re.search(r"(\d+)月(\d+)日", v)
138 if m1:
139 v = m1.group(0)
140 elif m2:
141 v = "0000-{:02d}-{:02d}".format(
142 *list(map(int, m2.group(1, 2))))
143 else:
144 v = ""
145 result[k] = v
146 for dtt in [
147 dt.text
148 for dt in soup.find_all("dt") if re.match(r"\d+年", dt.text)]:
149 actst = min(float(re.search(r"(\d+)年", dtt).group(1)), actst)
150 try:
151 result["actst"] = "{:04d}".format(int(actst))
152 except OverflowError:
153 result["actst"] = "0000"
154 if not result.get("性別"):
155 if any([("男性" in c or "男優" in c) for c in categos]):
156 result["性別"] = "男性"
157 elif any([("女性" in c or "女優" in c) for c in categos]):
158 result["性別"] = "女性"
159 result["性別"] = result["性別"][0]
160 return result
161
162
163 if __name__ == '__main__':
164 def _yrgrp(ymd, actst):
165 y = 0
166 if ymd:
167 y, _, md = ymd.partition("-")
168 y = int(y)
169 if y and md < "04-02":
170 y -= 1
171 return list(
172 map(lambda s: s.replace("0000", "????"),
173 ["{:04d}".format(y), actst, ymd]))
174
175 actorpages = list(
176 map(lambda s: s.strip(),
177 io.open("wppagenames.txt", encoding="utf-8").read().strip().split("\n")))
178 result = []
179 for a in actorpages:
180 inf = _from_wp(a)
181 g = _yrgrp(inf.get("生年月日", ""), inf.get("actst"))
182 result.append((
183 g[0], g[1], g[2],
184 inf.get("没年月日", ""),
185 inf.get("性別", ""),
186 ("<a href='https://ja.wikipedia.org/wiki/{pn}' target=_blank>{pn}</a>".format(
187 pn=inf["wikipedia"])),
188 ", ".join(inf["名前"]),
189 inf.get("血液型", "-"),
190 inf.get("出生地", "-"),
191 inf.get("出身地", "-"),
192 inf.get("愛称", "-"),
193 inf.get("身長", "-"),
194 inf.get("事務所", "-"),
195 ))
196 result.sort()
197 with io.open("actor_basinf.html", "w", encoding="utf-8") as fo:
198 coln = [
199 "生誕年度", "活動開始年?", "生年月日", "没年月日",
200 "性別", "wikipedia", "名前",
201 "血液型",
202 "出生地", "出身地", "愛称", "身長", "事務所",
203 ]
204 print("""\
205 <html>
206 <head jang="ja">
207 <meta charset="UTF-8">
208 <link href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/css/tabulator_site.min.css" rel="stylesheet">
209 <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/js/tabulator.min.js"></script>
210
211 <!--
212 {}
213 -->
214 </head>""".format(__doc__), file=fo)
215 print("""\
216 <body>
217 <div id="actor_basinf"></div>
218 <script>
219 function _dt2int(dt) {
220 function _pad(n) {
221 let ns = "" + Math.abs(n);
222 if (ns.length === 1) {
223 ns = "0" + ns;
224 }
225 return ns;
226 }
227 return parseInt(dt.getFullYear() + _pad(dt.getMonth() + 1) + _pad(dt.getDate()));
228 }
229 var nowi = _dt2int(new Date());
230 function _calcage(cell, formatterParams) {
231 var v = cell.getValue().replace(new RegExp("\-", "g"), "");
232 if (v.startsWith("????")) {
233 return "";
234 }
235 v = parseInt(v);
236 var result = "" + parseInt((nowi - v) / 10000);
237 result += "歳";
238 var d = cell.getRow().getCell("没年月日").getValue().replace(new RegExp("\-", "g"), "");
239 if (d) {
240 result += " (";
241 result += parseInt((parseInt(d) - v) / 10000);
242 result += "歳没)";
243 }
244 return result;
245 }
246
247
248 var actor_basinf_data = """, file=fo)
249 json.dump(
250 [dict(zip(coln, row)) for row in result],
251 fo, ensure_ascii=False, indent=4)
252 print("""
253 var table = new Tabulator("#actor_basinf", {
254 "height": "800px",
255 "columnDefaults": {
256 /* 注意: 「tooltips」ではない。「tooltip」である。 */
257 "tooltip": true,
258 },
259 "columns": [
260 {
261 "field": "生誕年度",
262 "title": "生誕年度",
263 "headerFilter": "input",
264 "headerFilterFunc": "regex",
265 },
266 {
267 "field": "活動開始年?",
268 "title": "活動開始年?",
269 "headerFilter": "input",
270 "headerFilterFunc": "regex",
271 },
272 {
273 "field": "生年月日",
274 "title": "生年月日",
275 },
276 {
277 "field": "生年月日",
278 "formatter": _calcage,
279 "headerTooltip": "存命の場合は年齢そのもの。亡くなっている方の場合は「生きていれば~歳」。 "
280 },
281 {
282 "field": "没年月日",
283 "title": "没年月日",
284 },
285 {
286 "field": "性別",
287 "title": "性別",
288 "headerFilter": "input",
289 },
290 {
291 "field": "名前",
292 "title": "名前",
293 "headerFilter": "input",
294 "headerFilterFunc": "regex",
295 },
296 {
297 "field": "wikipedia",
298 "title": "wikipedia",
299 "headerFilter": "input",
300 "headerFilterFunc": "regex",
301 "formatter": "html",
302 },
303 {
304 "field": "血液型",
305 "title": "血液型",
306 "headerFilter": "input",
307 "headerFilterFunc": "regex",
308 },
309 {
310 "field": "出生地",
311 "title": "出生地",
312 "headerFilter": "input",
313 "headerFilterFunc": "regex",
314 },
315 {
316 "field": "出身地",
317 "title": "出身地",
318 "headerFilter": "input",
319 "headerFilterFunc": "regex",
320 },
321 {
322 "field": "愛称",
323 "title": "愛称",
324 "headerFilter": "input",
325 "headerFilterFunc": "regex",
326 },
327 {
328 "field": "身長",
329 "title": "身長",
330 "headerFilter": "input",
331 "headerFilterFunc": "regex",
332 },
333 {
334 "field": "事務所",
335 "title": "事務所",
336 "headerFilter": "input",
337 "headerFilterFunc": "regex",
338 }
339 ],
340 "layout": "fitColumns",
341 "data": actor_basinf_data
342 });
343 </script>
344 </html>
345 """, file=fo)
てふわけで:
ちょこまか改善しながら、ついでに気付いた機能があったので、それについてもやっとく。いつから使えたのか調べてはないけど「headerSortTristate:true」に気付いたのである。日常的にこれが気になる人は結構いると思う。ヘッダクリックがソートの昇順降順を指示するのを意味するのであれば、「指定しない」状態に戻せないと困るのだが、「headerSortTristate」してないと「クリックしちゃったら最後…」になってしまう。
てわけで:
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 return os.path.join(tmptopdir, ep)
91
92 cachefn = _gettemppath(url)
93 if os.path.exists(cachefn):
94 res = cachefn
95 else:
96 try:
97 res, _ = urllib_urlretrieve(url, filename=cachefn)
98 except Exception:
99 from urllib.request import unquote as urllib_unquote
100 print(url, repr(urllib_unquote(url)))
101 raise
102 _urlretrieved[url] = res
103 return res
104
105
106 def _from_wp_jp(actorpagename):
107 baseurl = "https://ja.wikipedia.org/wiki/"
108 pn = urllib_quote(actorpagename, encoding="utf-8")
109 fn = _urlretrieve(baseurl + pn)
110
111 result = {"wikipedia": actorpagename, "名前": [actorpagename.partition("_")[0]]}
112 with io.open(fn, "r", encoding="utf-8") as fi:
113 soup = bs4.BeautifulSoup(fi.read(), features="html.parser")
114 entr = soup.find("a", {"class": "interlanguage-link-target", "lang": "en"})
115 if entr:
116 result["wikipedia_en"] = entr.attrs["href"]
117 try:
118 categos = [
119 a.text
120 for a in soup.find("div", {"id": "mw-normal-catlinks"}).find_all("a")]
121 except Exception:
122 return result
123 try:
124 trecords = iter(
125 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
126 except Exception:
127 return result
128 tr = next(trecords)
129 result["名前"] += [
130 re.sub(r"\[[^\[\]]+\]", "", sp.text)
131 for sp in tr.find("th").find_all("span")]
132
133 actst = float("inf")
134 actst_rough = None
135 for tr in trecords:
136 th, td = tr.find("th"), tr.find("td")
137 if not th or not td:
138 continue
139 k = th.text.replace("\n", "").replace(
140 "誕生日", "生年月日").replace("生誕", "生年月日")
141 k = re.sub("身長.*$", "身長/体重", k)
142 if k in ("事務所", "レーベル"):
143 k = "事務所・レーベル"
144 v = ""
145 if td:
146 if td.find("li"):
147 td = "\n".join([li.text for li in td.find_all("li")])
148 elif td.find("br"):
149 td = bs4.BeautifulSoup(
150 re.sub(r"<br\s*/?>", r"\n", str(td)), features="html.parser").text.strip()
151 else:
152 td = td.text
153 v = re.sub(
154 r"\[[^\[\]]+\]", "\n", td).strip()
155 if k == "活動期間":
156 m = re.match(r"(\d+)年(代)?", v)
157 if m:
158 if not m.group(2):
159 actst = min(float(m.group(1)), actst)
160 else:
161 actst_rough = m.group(0)
162 continue
163 elif k in ("生年月日", "没年月日"):
164 m1 = re.search(r"\d+-\d+-\d+", v)
165 m2 = re.search(r"(\d+)月(\d+)日", v)
166 if m1:
167 v = m1.group(0)
168 elif m2:
169 v = "0000-{:02d}-{:02d}".format(
170 *list(map(int, m2.group(1, 2))))
171 else:
172 v = ""
173 v = re.sub(
174 r"(、\s*)+", "、",
175 "、".join(re.sub(r"\n+", r"\n", v).split("\n")))
176 if k in ("デビュー作", "事務所・レーベル", "共同作業者", "ジャンル",):
177 v = "、".join(list(filter(None, [result.get(k), v])))
178 if k == "身長/体重":
179 v = re.sub(r"\s*、\s*cm", " cm", v) # なんで??
180 result[k] = v
181 for stt, sta in (("dt", {}), ("b", {})):
182 for dtt in [
183 dt.text
184 for dt in soup.find_all(stt, sta) if re.match(r"\d+年$", dt.text)]:
185 actst = min(float(re.search(r"(\d+)年", dtt).group(1)), actst)
186 try:
187 result["actst"] = "{:04d}".format(int(actst))
188 except OverflowError:
189 if actst_rough:
190 result["actst"] = actst_rough
191 else:
192 result["actst"] = "0000"
193 if not result.get("生年月日"):
194 result["生年月日"] = "0000-??-??"
195 if not result.get("性別"):
196 if any([("男性" in c or "男優" in c) for c in categos]):
197 result["性別"] = "男性"
198 elif any([("女性" in c or "女優" in c) for c in categos]):
199 result["性別"] = "女性"
200 else:
201 #print(result["名前"])
202 result["性別"] = " "
203 result["性別"] = result["性別"][0]
204 return result
205
206
207 def _from_wp_en(result):
208 # 基本的に日本語版を信じ、欠落のものだけ英語版に頼ることにする。
209 if "0000" not in result["生年月日"]:
210 return result
211 try:
212 fn = _urlretrieve(result["wikipedia_en"])
213 except urllib.error.HTTPError:
214 return result
215 with io.open(fn, "r", encoding="utf-8") as fi:
216 soup = bs4.BeautifulSoup(fi.read(), features="html.parser")
217 try:
218 trecords = iter(
219 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
220 except Exception:
221 return result
222 for tr in trecords:
223 th, td = tr.find("th"), tr.find("td")
224 if not th or not td:
225 continue
226 k_en = th.text.strip()
227 if k_en == "Born":
228 if "0000" in result["生年月日"]:
229 bd = td.find("span", {"class": "bday"})
230 if bd:
231 bd = re.sub(r"\[[^\[\]]+\]", "", bd.text)
232 result["生年月日"] = bd
233 return result
234
235
236 def _from_wp(actorpagename):
237 result = _from_wp_jp(actorpagename)
238 if "wikipedia_en" in result:
239 _from_wp_en(result)
240 return result
241
242
243 if __name__ == '__main__':
244 def _yrgrp(ymd, actst):
245 y = 0
246 if ymd:
247 y, _, md = ymd.partition("-")
248 y = int(y)
249 if y and md < "04-02":
250 y -= 1
251 return list(
252 map(lambda s: s.replace("0000", "????"),
253 ["{:04d}".format(y), actst, ymd]))
254
255 actorpages = list(set(
256 map(lambda s: s.strip(),
257 io.open("wppagenames.txt", encoding="utf-8").read().strip().split("\n"))))
258 result = []
259 for a in filter(None, actorpages):
260 inf = _from_wp(a)
261 g = _yrgrp(inf.get("生年月日", ""), inf.get("actst", "0000"))
262 result.append((
263 g[0], g[1], g[2],
264 inf.get("没年月日", ""),
265 inf.get("性別", ""),
266 inf["wikipedia"],
267 ", ".join(inf["名前"]),
268 inf.get("血液型", "-"),
269 "、".join(list(filter(None, [inf.get("出生地", ""), inf.get("出身地", "")]))),
270 inf.get("愛称", "-"),
271 inf.get("身長/体重", "-"),
272 inf.get("事務所・レーベル", "-"),
273 inf.get("デビュー作", ""),
274 inf.get("共同作業者", ""),
275 ))
276 result.sort()
277 with io.open("actor_basinf.html", "w", encoding="utf-8") as fo:
278 coln = [
279 "by", # 生誕年度
280 "as", # 活動開始年?
281 "bymd", # 生年月日
282 "dymd", # 没年月日
283 "gen", # 性別
284 "wp", # wikipedia
285 "nm", # 名前
286 "bld", # 血液型
287 "bor", # 出生地・出身地
288 "nn", # 愛称
289 "tw", # 身長/体重
290 "bel", # 事務所・レーベル
291 "fst", # デビュー作
292 "tea", # 共同作業者
293 ]
294 print("""\
295 <html>
296 <head jang="ja">
297 <meta charset="UTF-8">
298 <link href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/css/tabulator_site.min.css" rel="stylesheet">
299 <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/js/tabulator.min.js"></script>
300
301 <!--
302 {}
303 -->
304 </head>""".format(__doc__), file=fo)
305 print("""\
306 <body>
307 <div style="margin: 1em 0em">
308 <select id="filter-gender">
309 <option value=""></option>
310 <option value="男">男性のみ</option>
311 <option value="女">女性のみ</option>
312 </select>
313 <select id="filter-alive">
314 <option></option>
315 <option>存命者のみ</option>
316 <option>死没者のみ</option>
317 </select>
318 </div>
319 <div id="actor_basinf"></div>
320 <script>
321 var filter_gender = document.getElementById("filter-gender");
322 function _update_genderfilter() {
323 let i = filter_gender.selectedIndex;
324 let v = filter_gender.options[i].value;
325 let eflts = table.getFilters(true);
326 for (let efi in eflts) {
327 let f = eflts[efi];
328 if (f["field"] == "gen") {
329 table.removeFilter(f["field"], f["type"], f["value"]);
330 break;
331 }
332 }
333 if (v) {
334 table.addFilter("gen", "regex", v);
335 }
336 }
337 filter_gender.addEventListener("change", _update_genderfilter);
338 var filter_alive = document.getElementById("filter-alive");
339 function _update_alivefilter() {
340 let i = filter_alive.selectedIndex;
341 let eflts = table.getFilters(true);
342 for (let efi in eflts) {
343 let f = eflts[efi];
344 if (f["field"] == "dymd") {
345 table.removeFilter(f["field"], f["type"], f["value"]);
346 break;
347 }
348 }
349 if (i == 1) {
350 table.addFilter("dymd", "=", "");
351 } else if (i == 2) {
352 table.addFilter("dymd", "!=", "");
353 }
354 }
355 filter_alive.addEventListener("change", _update_alivefilter);
356
357 function _dt2int(dt) {
358 function _pad(n) {
359 let ns = "" + Math.abs(n);
360 if (ns.length === 1) {
361 ns = "0" + ns;
362 }
363 return ns;
364 }
365 return parseInt(dt.getFullYear() + _pad(dt.getMonth() + 1) + _pad(dt.getDate()));
366 }
367 var nowi = _dt2int(new Date());
368 function _calcage(cell, formatterParams) {
369 var v = cell.getValue().replace(new RegExp("\-", "g"), "");
370 if (v.startsWith("????")) {
371 return "";
372 }
373 v = parseInt(v);
374 var result = "" + parseInt((nowi - v) / 10000);
375 result += "歳";
376 var d = cell.getRow().getCell("dymd").getValue().replace(new RegExp("\-", "g"), "");
377 if (d) {
378 result += " (";
379 result += parseInt((parseInt(d) - v) / 10000);
380 result += "歳没)";
381 }
382 return result;
383 }
384
385
386 var actor_basinf_data = """, file=fo)
387 json.dump(
388 [dict(zip(coln, row)) for row in result],
389 fo, ensure_ascii=False, indent=4)
390 print("""
391 var table = new Tabulator("#actor_basinf", {
392 "height": "800px",
393 "columnDefaults": {
394 /* 注意: 「tooltips」ではない。「tooltip」である。 */
395 "tooltip": true,
396
397 /* これはいつから使えたのかな? かつては「columnDefaults」の外にいたやつ。 */
398 "headerSortTristate": true,
399 },
400 "columns": [
401 {
402 "field": "by",
403 "title": "生誕年度",
404 "headerFilter": "input",
405 "headerFilterFunc": "regex",
406 },
407 {
408 "field": "as",
409 "title": "活動開始年?",
410 "headerFilter": "input",
411 "headerFilterFunc": "regex",
412 },
413 {
414 "field": "bymd",
415 "title": "生年月日",
416 },
417 {
418 "field": "bymd",
419 "formatter": _calcage,
420 "headerTooltip": "存命の場合は年齢そのもの。亡くなっている方の場合は「生きていれば~歳」。 "
421 },
422 {
423 "field": "dymd",
424 "title": "没年月日",
425 },
426 {
427 "field": "gen",
428 "title": "性別",
429 },
430 {
431 "field": "nm",
432 "title": "名前",
433 "headerFilter": "input",
434 "headerFilterFunc": "regex",
435 },
436 {
437 "field": "wp",
438 "title": "wikipedia",
439 "headerFilter": "input",
440 "headerFilterFunc": "regex",
441 "formatter": function (cell, formatterParams, onRendered) {
442 let pn = cell.getValue();
443 return "<a href='https://ja.wikipedia.org/wiki/" +
444 pn + "' target=_blank>" + pn + "</a>";
445 },
446 },
447 {
448 "field": "bld",
449 "title": "血液型",
450 "headerFilter": "input",
451 "headerFilterFunc": "regex",
452 },
453 {
454 "field": "bor",
455 "title": "出生地・出身地",
456 "headerFilter": "input",
457 "headerFilterFunc": "regex",
458 },
459 {
460 "field": "nn",
461 "title": "愛称",
462 "headerFilter": "input",
463 "headerFilterFunc": "regex",
464 },
465 {
466 "field": "tw",
467 "title": "身長/体重",
468 "headerFilter": "input",
469 "headerFilterFunc": "regex",
470 },
471 {
472 "field": "bel",
473 "title": "事務所・レーベル",
474 "headerFilter": "input",
475 "headerFilterFunc": "regex",
476 },
477 {
478 "field": "fst",
479 "title": "デビュー作",
480 "headerFilter": "input",
481 "headerFilterFunc": "regex",
482 },
483 {
484 "field": "tea",
485 "title": "共同作業者",
486 "headerFilter": "input",
487 "headerFilterFunc": "regex",
488 },
489 ],
490 "layout": "fitColumns",
491 "data": actor_basinf_data
492 });
493 </script>
494 </html>
495 """, file=fo)