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

「的」を消すつもりだった。

これの直接の続きで、「さぁ、ほんとの csv るぞ」と思ってた。

当然「むき身の csv ファイルそのものをごにょろう」と思うならば、html を箱に使う以上は input type=”file”FileReader、もしくは ajax 的アプローチを採るかの二択になるわけだけれど、前者は「ファイルを選択」という行為をユーザに行わせる部分を省略出来ず、ワタシが使いたいものとはかけ離れてしまうし、後者は CORS 問題なんぞも絡んでくるのでそもそもがうっとうしい。

どうしたもんかと思ってたのだが、よくよく考えたら、「javascript や css なら直接取り込めるんだよな」と。つまりワタシのケースの場合なら「データセットアップ部分」だけ独立させて:

data.js
 1 var actor_basinf_data_csv = `
 2 0,1950年代,????????,,1,,"水島 晋, みずしま すすむ",,日本,,,,,,声優、俳優
 3 0,1960年代,????0111,,1,,"高田 竜二, たかだ りゅうじ",,日本,,,,,,
 4    ... (snip) ...
 5 2005,2011,20050601,,2,,"稲葉菜月, いなば なつき",,日本,,,,,,テレビ・映画、声優、女優
 6 2005,2013,20050427,,2,,"原涼子, はら すずこ",,神奈川県横須賀市,,,スペースクラフトジュニア,,,女優(子役)、CM、テレビドラマ、声優、テレビアニメ、映画
 7 2005,2014,20051004,,2,,"遠藤璃菜, えんどう りな",,東京都,,148cm,テアトルアカデミー,,,CM、テレビドラマ、女優(元子役)、声優、テレビアニメ、映画
 8 2012,2017,20120822,,2,,"中村優月, なかむら ゆづき",,東京都,,122cm,テアトルアカデミー,,,声優、子役`;
 9 var actor_basinf_data = Array();
10 actor_basinf_data_csv.trim().split("\n").forEach(function (row, i) {
11     let r = jQuery.csv.toArray(row);
12     let bymd = r[2];
13     if (bymd) {
14         bymd = bymd.slice(0, 4) + "-" + bymd.slice(4, 6) + "-" + bymd.slice(6, 8);
15     }
16     let dymd = r[3];
17     if (dymd) {
18         dymd = dymd.slice(0, 4) + "-" + dymd.slice(4, 6) + "-" + dymd.slice(6, 8);
19     }
20     actor_basinf_data.push({
21         "by": r[0],  /* 生誕年度 */
22         "as": r[1],  /* 活動開始年? */
23         "bymd": bymd,  /* 生年月日 */
24         "dymd": dymd,  /* 没年月日 */
25         "gen": r[4],  /* 性別 */
26         "wp": r[5],  /* wikipedia */
27         "nm": r[6],  /* 名前 */
28         "bld": r[7],  /* 血液型 */
29         "bor": r[8],  /* 出生地・出身地 */
30         "nn": r[9],  /* 愛称 */
31         "tw": r[10],  /* 身長/体重 */
32         "bel": r[11],  /* 事務所・レーベル */
33         "fst": r[12],  /* デビュー作 */
34         "tea": r[13],  /* 共同作業者 */
35         "occ": r[14],  /* 職業/職種/ジャンル */
36     })
37 })

それを取り込めばよいではないか、と:

 1 <html>
 2 <head jang="ja">
 3 <meta charset="UTF-8">
 4 <link href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/css/tabulator_site.min.css" rel="stylesheet">
 5 <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/js/tabulator.min.js"></script>
 6 <script type="text/javascript" src="https://oss.sheetjs.com/sheetjs/xlsx.full.min.js"></script>
 7 
 8 
 9 <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
10 <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-csv/1.0.21/jquery.csv.js"></script>
11 
12 <!-- なにも「csv ファイル」にこだわらなくてもいいじゃないか -->
13 <script type="text/javascript" src="data.js"></script>
14 
15 <!-- ... -->
16 </head>
17 <body>
18 <!-- ... -->
19 <div id="actor_basinf"></div>
20 <!-- ... -->
21 <script>
22 /* ... */
23 var table = new Tabulator("#actor_basinf", {
24     /* ... */
25     "data": actor_basinf_data  /* data.js でセットアップしたデータ */
26 });
27 </script>
28 </html>

絶対に csv ファイルでなければならないという理由はワタシにはないので、結局「的」のままで良いではないか、てことね。

そういうわけで、ほかにもこまごま改善しつつ:

wikipedia をスクレイプして「声優世代表のおとも html」、な、ver 10、だと思われる
  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 csv
 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 from urllib.request import unquote as urllib_unquote
 54 
 55 
 56 import bs4  # require: beutifulsoup4
 57 
 58 
 59 __MYNAME__, _ = os.path.splitext(
 60     os.path.basename(sys.modules[__name__].__file__))
 61 #
 62 __USER_AGENT__ = "\
 63 Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
 64 AppleWebKit/537.36 (KHTML, like Gecko) \
 65 Chrome/91.0.4472.124 Safari/537.36"
 66 _htctxssl = ssl.create_default_context()
 67 _htctxssl.check_hostname = False
 68 _htctxssl.verify_mode = ssl.CERT_NONE
 69 https_handler = urllib.request.HTTPSHandler(context=_htctxssl)
 70 opener = urllib.request.build_opener(https_handler)
 71 opener.addheaders = [('User-Agent', __USER_AGENT__)]
 72 urllib.request.install_opener(opener)
 73 #
 74 
 75 
 76 def _str(s):
 77     return s.encode("cp932", errors="xmlcharrefreplace").decode("cp932")
 78 
 79 
 80 _urlretrieved = dict()
 81 
 82 
 83 def _urlretrieve(url):
 84     if url in _urlretrieved:
 85         return _urlretrieved[url]
 86 
 87     def _gettemppath(s):
 88         tmptopdir = os.path.join(tempfile.gettempdir(), __MYNAME__)
 89         if not os.path.exists(tmptopdir):
 90             os.makedirs(tmptopdir)
 91         import hashlib, base64
 92         ep = base64.urlsafe_b64encode(
 93             hashlib.md5(s.encode("utf-8")).digest()
 94         ).partition(b"=")[0].decode()
 95         flat = os.path.join(tmptopdir, ep)
 96         p1, p2 = ep[0], ep[1:]
 97         d1 = os.path.join(tmptopdir, p1)
 98         if not os.path.exists(d1):
 99             os.makedirs(d1)
100         d2 = os.path.join(tmptopdir, p1, p2)
101         if os.path.exists(flat) and not os.path.exists(d2):
102             os.rename(flat, d2)
103         return d2
104 
105     cachefn = _gettemppath(url)
106     if os.path.exists(cachefn):
107         res = cachefn
108     else:
109         try:
110             res, _ = urllib_urlretrieve(url, filename=cachefn)
111         except Exception:
112             print(url, repr(urllib_unquote(url)))
113             raise
114     _urlretrieved[url] = res
115     return res
116 
117 
118 def _norm(s):
119     for f, r in (("(", "("), (")", ")"), ("・", "・"), (" ", " ")):
120         s = s.replace(f, r)
121     return s.strip()
122 
123 
124 def _try_get_actst(doc, actst, actst_rough):
125     # 活動開始をどうにか特定したい、ので、ヘディングで「1999年」としてるならまずはそれ、
126     # また、ヘディングが「出演」の配下にあるリスト項目の「~(1999年、…)」みたいな羅列
127     # を拾いたい、と思うわけだが、「html なのでこれは」なので(特に後者が)ひたすらに
128     # めんどいのよ。
129     validymin = 1900  # 日本の声優第一号の生誕から得るのがよかろうが、わからんのでてきとう
130     for stt, sta in (("dt", {}), ("span", {"class": "mw-headline"}), ("td", {}), ("b", {})):
131         # ヘディングから。<b>をヘディングにしてるのがいて迷惑…。
132         for dtt in [
133                 dt.text
134                 for dt in doc.find_all(stt, sta)
135                 if not re.search(r"(生年月日|誕生日|生誕)", dt.parent.text)]:
136             m = re.match(r"(\d{4})年(代|\d+月(?:\d+日)?)?$", dtt.strip())
137             if not m:
138                 continue
139             if not m.group(2) or m.group(2) != "代":
140                 v = int(m.group(1))
141                 if v > validymin:
142                     actst = min(v, actst)
143             else:
144                 actst_rough.append(m.group(0))
145     # if actst < 9999:
146     #     print(re.sub(r"\s\-\s.*$", "", _str(doc.find("title").text)), actst, actst_rough)
147     if actst == 9999:
148         docs = str(doc)
149         m = re.search(r'<h2.* id="(?:主な)?(出演|活動歴).*</h2>', docs)
150         if m:
151             _, rngs = m.span()
152             m = re.search(r'<h2.*.*</h2>', docs[rngs:])
153             if not m or "案内メニュー" in m.group(0):
154                 m = re.search(r'<div class="printfooter"', docs[rngs:])
155             if m:
156                 rnge, _ = m.span()
157                 sect = bs4.BeautifulSoup(docs[rngs:rngs + rnge], features="html.parser")
158                 s = sect.text
159                 for ys in list(
160                         re.findall(r"\(.*?(\d{4}[年.]代?)", s)) + list(
161                             re.findall(r"\(.*?(\d{4})[~-]\d{4}年\b", s)):
162                     if ys[-1] == "代":
163                         actst_rough.append(ys)
164                     else:
165                         v = int(re.sub(r"\D", "", ys))
166                         if v > validymin:
167                             actst = min(v, actst)
168                 print(re.sub(r"\s\-\s.*$", "", _str(doc.find("title").text)), actst, actst_rough)
169     if actst == 9999:
170         # ほんとにこれは最後の最後の手段。出典の参照日や出典の発行日は当該人物が
171         # 世に知られるよりも前の日付であることがありえない、て考え。さすがにいくら
172         # 著名人でも
173         #   南野誰蔵は2015年3月17日に誕生予定である[1]2015年3月12日
174         # なんてのはなかろう。絶対とはいわんけど。(というか実在したとしてもそれは
175         # wikipedia の本来のポリシーとして出典としては認められないだろうよ、だから
176         # そのような記述がなされたとしてもいずれ消される。)
177         rl = doc.find_all("div", {"class": "reflist"})
178         if not rl:
179             rl = doc.find_all("ol", {"class": "references"})
180         if rl:
181             for rle in rl:
182                 for ys in re.findall(r"(\d{4})年(?:\d{1,2}月)?(?:\d{1,2}日)?", rle.text):
183                     v = int(ys)
184                     if v > validymin:
185                         actst = min(v, actst)
186             print(re.sub(r"\s\-\s.*$", "", _str(doc.find("title").text)), actst, actst_rough)
187     return actst, actst_rough
188 
189 
190 def _from_abspara(doc):
191     for p in doc.find_all("p"):
192         ptxt = re.sub(r"\[[^\[\]]+\]", "", p.text)
193         m = re.match(r"""[^。、]+\(([^。]+)\)は、?[^。、]+?の([^。]+)。""", ptxt)
194         if m:
195             return m.group(1), re.sub(r"である$", "", m.group(2))
196     return "", ""
197 
198 
199 def _citation_notice(doc):
200     for mts in doc.find_all("div", {"class": "mbox-text-span"}):
201         m = re.search(r"出典が([^。]+)。", mts.text)
202         if m:
203             if "全くありません" in m.group(1):
204                 return -2
205             else:
206                 return -1
207     return 0
208 
209 
210 def _redirect(doc):
211     e = doc.find("span", {"class": "mw-redirectedfrom"})
212     if e:
213         e = e.find("a", {"class": "mw-redirect"})
214         if e:
215             # 「~より転送」の「~」はワレが指定したページであり、
216             # 知りたいのは「左遷先」すなわち開いているページ名。
217             # つまりこれは違う: e.attrs["title"].strip()
218             e = doc.find("head").find("link", {"rel": "canonical"})
219             if e:
220                 return urllib_unquote(
221                     re.sub(r"^.*/([^/]+)$", r"\1", e.attrs["href"]))
222             e = doc.find("head").find("link", {"title": "編集"})
223             if e:
224                 return urllib_unquote(
225                     re.sub(r"^.*/index\.php\?title=(.*)&action=edit$", r"\1", e.attrs["href"]))
226     return ""
227 
228 
229 def _from_wp_jp(actorpagename):
230     baseurl = "https://ja.wikipedia.org/wiki/"
231     pn = urllib_quote(actorpagename, encoding="utf-8")
232     fn = _urlretrieve(baseurl + pn)
233 
234     result = {"wikipedia": actorpagename, "名前": [actorpagename.partition("_")[0]]}
235     with io.open(fn, "r", encoding="utf-8") as fi:
236         soup = bs4.BeautifulSoup(_norm(fi.read()), features="html.parser")
237         bd_fromabs, occ_fromabs = _from_abspara(soup)
238         result["occ_fromabs"] = occ_fromabs
239         result["citation_notice"] = _citation_notice(soup)
240         result["redirect"] = _redirect(soup)
241         if result["redirect"]:
242             print(_str(actorpagename), _str(result["redirect"]), sep=" → ")
243 
244         entr = soup.find("a", {"class": "interlanguage-link-target", "lang": "en"})
245         if entr:
246             result["wikipedia_en"] = entr.attrs["href"]
247         try:
248             categos = [
249                 a.text
250                 for a in soup.find("div", {"id": "mw-normal-catlinks"}).find_all("a")]
251         except Exception:
252             categos = []
253         trecords = []
254         try:
255             trecords = iter(
256                 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
257         except Exception:
258             pass
259         if trecords:
260             tr = next(trecords)
261             if tr.find("th"):
262                 tr = bs4.BeautifulSoup(
263                     re.sub(r"<br\s*/?>", r"\n", str(tr)), features="html.parser")
264                 for sp in tr.find("th").find_all("span"):
265                     s = re.sub(r"\[[^\[\]]+\]", "", sp.text.strip())
266                     if "\n" in s:
267                         result["名前"].extend(s.split("\n"))
268                     else:
269                         result["名前"].append(s)
270 
271         actst = 9999
272         actst_rough = ["9999年代"]
273         for tr in trecords:
274             th, td = tr.find("th"), tr.find("td")
275             if not th or not td:
276                 continue
277             k = th.text.replace("\n", "").replace(
278                 "誕生日", "生年月日").replace("生誕", "生年月日")
279             k = re.sub("身長.*$", "身長/体重", k)
280             if k in ("事務所", "レーベル"):
281                 k = "事務所・レーベル"
282             elif k in ("職業", "職種", "ジャンル",):
283                 k = "occ"
284             v = ""
285             if td:
286                 if k in ("出生地", "出身地"): 
287                     td = bs4.BeautifulSoup(
288                         re.sub(r'<a [^<>]*title="日本(?:の旗)?">日本(?:の旗)?</a>[・\s,、]?', r"", str(td)),
289                         features="html.parser")
290                 if td.find("li"):
291                     td = "\n".join([li.text for li in td.find_all("li")])
292                 elif td.find("br"):
293                     td = bs4.BeautifulSoup(
294                         re.sub(r"<br\s*/?>", r"\n", str(td)), features="html.parser").text.strip()
295                 else:
296                     td = td.text
297                 v = re.sub(
298                     r"\[[^\[\]]+\]", "\n", td).strip()
299                 if k == "活動期間":
300                     m = re.search(r"(\d{4})年(代)?", v)
301                     if m:
302                         if not m.group(2):
303                             actst = min(int(m.group(1)), actst)
304                         else:
305                             actst_rough.append(m.group(0))
306                     continue
307                 elif k in ("生年月日", "没年月日"):
308                     m1 = re.search(r"\d+-\d+-\d+", v)
309                     m2 = re.search(r"(\d+)年(\d+)月(\d+)日", v)
310                     m3 = re.search(r"(\d+)月(\d+)日", v)
311                     if m1:
312                         v = m1.group(0)
313                     elif m2:
314                         v = "{:04d}-{:02d}-{:02d}".format(
315                             *list(map(int, m2.group(1, 2, 3))))
316                     elif m3:
317                         v = "0000-{:02d}-{:02d}".format(
318                             *list(map(int, m3.group(1, 2))))
319                     else:
320                         v = ""
321             v = re.sub(
322                 r"(、\s*)+", "、",
323                 "、".join(re.sub(r"\n+", r"\n", v).split("\n")))
324             if k in ("デビュー作", "事務所・レーベル", "共同作業者",):
325                 v = "、".join(list(filter(None, [result.get(k), v])))
326             elif k == "occ":
327                 v = "、".join(
328                     list(
329                         filter(
330                             None,
331                             re.split(r"[、,\n]", "、".join(
332                                 [result.get(k, ""), v])))))
333             elif k == "身長/体重":
334                 v = re.sub(r"\s*、\s*cm", " cm", v)  # なんで??
335             if not (k == "身長/体重" and ("cm" in result.get(k, "") or "kg" in result.get(k, ""))):
336                 result[k] = v
337         actst_orig = actst
338         actst_rough_min_orig = list(sorted(actst_rough))[0]
339         armv_orig = int(re.match(r"(\d+)", actst_rough_min_orig).group(1))
340         actst, actst_rough = _try_get_actst(soup, actst, actst_rough)
341         actst_rough_min = list(sorted(actst_rough))[0]
342         armv = int(re.match(r"(\d+)", actst_rough_min).group(1))
343         if actst_orig < 9999 and armv_orig == 9999:
344             # 「活動期間」に「~年」で明記
345             result["actst"] = "{:04d}".format(actst_orig)
346             if actst // 10 < armv // 10 and actst < actst_orig:
347                 result["actst"] = "{:04d}?".format(actst)
348             elif actst_orig // 10 > armv // 10:
349                 result["actst"] = actst_rough_min + "?"
350         elif actst_orig == 9999 and armv_orig < 9999:
351             # 「活動期間」に「~年代」で明記
352             result["actst"] = actst_rough_min_orig
353             if actst // 10 < armv // 10 and actst // 10 < armv_orig // 10:
354                 result["actst"] = "{:04d}?".format(actst)
355             elif armv < armv_orig:
356                 result["actst"] = actst_rough_min + "?"
357         elif actst_orig < 9999 and armv_orig < 9999:
358             # 「活動期間」に「~年」「~年代」双方形式明記
359             # (声優活動が 2003 年、俳優活動が 1990年代、という具合)
360             if armv_orig // 10 <= actst_orig // 10:
361                 # ex. 声優活動 2003 年、俳優活動が 1990年代 -> 1990年代を採る
362                 result["actst"] = actst_rough_min_orig
363                 if actst // 10 < armv // 10 and actst // 10 < armv_orig // 10:
364                     result["actst"] = "{:04d}?".format(actst)
365                 elif armv < armv_orig:
366                     result["actst"] = actst_rough_min + "?"
367             else:
368                 # ex. 声優活動 2001 年、俳優活動が 2010年代 -> 2001年を採る
369                 result["actst"] = "{:04d}".format(actst_orig)
370                 if actst // 10 < armv // 10 and actst < actst_orig:
371                     result["actst"] = "{:04d}?".format(actst)
372                 elif actst_orig // 10 > armv // 10:
373                     result["actst"] = actst_rough_min + "?"
374         else:  # 「活動期間」記載なし
375             if armv // 10 > actst // 10:
376                 result["actst"] = "{:04d}?".format(actst)
377             elif armv < 9999:
378                 result["actst"] = actst_rough_min + "?"
379         if "actst" not in result:
380             result["actst"] = "0000"
381         if not result.get("生年月日"):
382             result["生年月日"] = "0000-??-??"
383             if bd_fromabs:
384                 # infobox なしなのに律儀に生年月日は書いてる、てのが結構ある。
385                 m = re.search(
386                     r"[^、。()\n]+、(\d{4}年)?(\d+月)?(\d+日)?[^()]*",
387                     bd_fromabs)
388                 if m:
389                     yy, mm, dd = m.group(1, 2, 3)
390                     if yy:
391                         yy = int(yy[:-1])
392                     else:
393                         yy = 0
394                     if mm and dd:
395                         result["生年月日"] = "{:04d}-{:02d}-{:02d}".format(
396                             yy, int(mm[:-1]), int(dd[:-1]))
397                     elif mm:
398                         result["生年月日"] = "{:04d}-{:02d}-??".format(
399                             yy, int(mm[:-1]))
400                     else:
401                         result["生年月日"] = "{:04d}-??-??".format(yy)
402         if not result.get("性別"):
403             if any([("男性" in c or "男優" in c) for c in categos]):
404                 result["性別"] = 1
405             elif any([("女性" in c or "女優" in c) for c in categos]):
406                 result["性別"] = 2
407             else:
408                 result["性別"] = 0
409         else:
410             result["性別"] = 1 if ("男" in result["性別"]) else 2
411     return result
412 
413 
414 def _from_wp_en(result):
415     # 基本的に日本語版を信じ、欠落のものだけ英語版に頼ることにする。
416     if "生年月日" in result and "0000" not in result["生年月日"]:
417         return result
418     try:
419         fn = _urlretrieve(result["wikipedia_en"])
420     except urllib.error.HTTPError:
421         return result
422     with io.open(fn, "r", encoding="utf-8") as fi:
423         soup = bs4.BeautifulSoup(fi.read(), features="html.parser")
424         try:
425             trecords = iter(
426                 soup.find("table", {"class": "infobox"}).find("tbody").find_all("tr"))
427         except Exception:
428             return result
429         for tr in trecords:
430             th, td = tr.find("th"), tr.find("td")
431             if not th or not td:
432                 continue
433             k_en = th.text.strip()
434             if k_en == "Born":
435                 if "生年月日" not in result or "0000" in result["生年月日"]:
436                     bd = td.find("span", {"class": "bday"})
437                     if bd:
438                         bd = re.sub(r"\[[^\[\]]+\]", "", bd.text)
439                         result["生年月日"] = bd
440     return result
441 
442 
443 def _from_wp(actorpagename):
444     result = _from_wp_jp(actorpagename)
445     if "wikipedia_en" in result:
446         _from_wp_en(result)
447     return result
448 
449 
450 if __name__ == '__main__':
451     def _yrgrp(ymd, actst):
452         y = 0
453         if ymd:
454             y, _, md = ymd.partition("-")
455             y = int(y)
456             if y and md < "04-02":
457                 y -= 1
458         return [
459             y,
460             actst.replace("0000", "????"),
461             ymd.replace("0000", "????")]
462     def _names(inf):
463         # inf["名前"]
464         # 通常は
465         #   0: wikipediaページ名からあいまい回避サフィクスを取り除いた名前
466         #   1: 2のよみ
467         #   2: 姓+スペース+名
468         # ページによってこれがバラつく。0しか取れないものは結構ある。外国人名は「姓+スペース+名」
469         # に従わない。通常ケースでは 2、1 をとって 0 を捨てるのが使いやすい。
470         nms = inf["名前"]
471         if len(nms) == 3:
472             if nms[0] == re.sub(r"\s+", "", nms[2]) or nms[0] == nms[1]:
473                 return ", ".join([nms[2], nms[1]]), nms[2]
474             return ", ".join([nms[0], nms[2], nms[1]]), nms[0]
475         elif len(nms) == 1:
476             return nms[0], nms[0]
477         else:  # ふりがながないケース、と思う。
478             if nms[0] != re.sub(r"\s+", "", nms[1]):
479                 return ", ".join([nms[0], nms[1]]), nms[0]
480             return nms[1], nms[1]
481         
482     actorpages = list(filter(None, list(set(
483         map(lambda s: s.strip(),
484             io.open("wppagenames.txt", encoding="utf-8").read().strip().split("\n"))))))
485     result = []
486     for i, a in enumerate(actorpages):
487         inf = _from_wp(a)
488         if not inf:
489             continue
490         g = _yrgrp(inf.get("生年月日", ""), inf.get("actst", "0000"))
491         nms_disp, n0 = _names(inf)
492         wp = inf["wikipedia"]
493         if n0.replace(" ", "") == wp:
494             wp = ""
495         result.append((
496             g[0], g[1], g[2].replace("-", ""),
497             inf.get("没年月日", "").replace("-", ""),
498             inf.get("性別", 0),
499             wp,
500             inf["redirect"],
501             nms_disp,
502             re.sub(r"\s*型", "", inf.get("血液型", "")),
503             "、".join(list(filter(None, [inf.get("出生地", ""), inf.get("出身地", "")]))),
504             re.sub(r"、\s*([((])", r"\1", inf.get("愛称", "")),
505             re.sub(r"(\d)\s+([a-z])", r"\1\2", inf.get("身長/体重", "")),
506             re.sub(r"、\s*([((])", r"\1", inf.get("事務所・レーベル", "")),
507             re.sub(r"、\s*([((])", r"\1", inf.get("デビュー作", "")),
508             inf.get("共同作業者", ""),
509             inf.get("occ", ""),
510             inf.get("occ_fromabs", ""),
511             inf["citation_notice"],
512         ))
513         print("{}/{}".format(i + 1, len(actorpages)))
514     result.sort()
515 
516     # 出力は「データセットアップするだけの js」+ html。csv ファイルとして独立させることを最初
517     # 考えたが、よくよく考えたら「js を link」が最も手っ取いよね。
518     with io.open("actor_basinf_data.js", "w", encoding="utf-8") as fo:  # データ部
519         print("""\
520 var actor_basinf_data_csv = `""", file=fo)
521         def _esc(c):
522             return re.sub(r'''([$`])''', r"\\\1", str(c))
523         tmpobj = io.StringIO()
524         writer = csv.writer(tmpobj)
525         writer.writerows(((_esc(col) for col in row) for row in result))
526         print(tmpobj.getvalue().strip().replace("\r", ""), file=fo, end="")
527         print("""`;
528 var actor_basinf_data = Array();
529 actor_basinf_data_csv.trim().split("\\n").forEach(function (row, i) {
530     let r = jQuery.csv.toArray(row);
531     let bymd = r[2];
532     if (bymd) {
533         bymd = bymd.slice(0, 4) + "-" + bymd.slice(4, 6) + "-" + bymd.slice(6, 8);
534     }
535     let dymd = r[3];
536     if (dymd) {
537         dymd = dymd.slice(0, 4) + "-" + dymd.slice(4, 6) + "-" + dymd.slice(6, 8);
538     }
539     actor_basinf_data.push({
540         "by": r[0],  /* 生誕年度 */
541         "as": r[1],  /* 活動開始年? */
542         "bymd": bymd,  /* 生年月日 */
543         "dymd": dymd,  /* 没年月日 */
544         "gen": r[4],  /* 性別 */
545         "wp": r[5],  /* wikipedia */
546         "redi": r[6],  /* リダイレクト先 */
547         "nm": r[7],  /* 名前 */
548         "bld": r[8],  /* 血液型 */
549         "bor": r[9],  /* 出生地・出身地 */
550         "nn": r[10],  /* 愛称 */
551         "tw": r[11],  /* 身長/体重 */
552         "bel": r[12],  /* 事務所・レーベル */
553         "fst": r[13],  /* デビュー作 */
554         "tea": r[14],  /* 共同作業者 */
555         "occ": r[15],  /* 職業/職種/ジャンル */
556         "occ2": r[16],  /* 本文最初のセンテンスの要約に書かれている職種 */
557         "cin": r[17],  /* 「出典」の過不足: 0:警告なし, -1:「不足」, -2:「皆無」 */
558     })
559 })""", file=fo)
560     #
561     with io.open("actor_basinf.html", "w", encoding="utf-8") as fo:  # html 本体
562         print("""\
563 <html>
564 <head jang="ja">
565 <meta charset="UTF-8">
566 <link href="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/css/tabulator_site.min.css" rel="stylesheet">
567 <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/tabulator/5.0.6/js/tabulator.min.js"></script>
568 <script type="text/javascript" src="https://oss.sheetjs.com/sheetjs/xlsx.full.min.js"></script>
569 
570 
571 <!-- Tabulator ではなく csv parser として jquery-csv を使いたくて、のための jquery -->
572 <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
573 <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-csv/1.0.21/jquery.csv.js"></script>
574 
575 <script type="text/javascript" src="actor_basinf_data.js"></script>
576 
577 <!--
578 {}
579   -->
580 </head>""".format(__doc__), file=fo)
581         print("""\
582 <body>
583 <div style="margin: 1em 0em">
584 <select id="filter-gender">
585   <option value=""></option>
586   <option value="1">男性のみ</option>
587   <option value="2">女性のみ</option>
588 </select>
589 <select id="filter-alive">
590   <option></option>
591   <option>存命者のみ</option>
592   <option>死没者のみ</option>
593 </select>
594 <select id="filter-occ2">
595   <option></option>
596   <option>wikipedia本文冒頭要約が声優に言及</option>
597   <option>wikipedia本文冒頭要約が声優に言及していない</option>
598 </select>
599 <select id="filter-citenote">
600   <option></option>
601   <option>「出典皆無」を除外</option>
602   <option>「出典皆無」と「出典不足」を除外</option>
603 </select>
604 </div>
605 <div id="actor_basinf"></div>
606 <div style="margin: 1em 0em">
607   <button id="download-csv">Download CSV</button>
608   <button id="download-json">Download JSON</button>
609   <button id="download-xlsx">Download XLSX</button>
610   <button id="download-html">Download HTML</button>
611 </div>
612 <script>
613 var filter_gender = document.getElementById("filter-gender");
614 function _update_genderfilter() {
615     let i = filter_gender.selectedIndex;
616     let v = filter_gender.options[i].value;
617     let eflts = table.getFilters(true);
618     for (let efi in eflts) {
619         let f = eflts[efi];
620         if (f["field"] == "gen") {
621             table.removeFilter(f["field"], f["type"], f["value"]);
622             break;
623         }
624     }
625     if (v) {
626         table.addFilter("gen", "regex", v);
627     }
628 }
629 filter_gender.addEventListener("change", _update_genderfilter);
630 var filter_alive = document.getElementById("filter-alive");
631 function _update_alivefilter() {
632     let i = filter_alive.selectedIndex;
633     let eflts = table.getFilters(true);
634     for (let efi in eflts) {
635         let f = eflts[efi];
636         if (f["field"] == "dymd") {
637             table.removeFilter(f["field"], f["type"], f["value"]);
638             break;
639         }
640     }
641     if (i == 1) {
642         table.addFilter("dymd", "=", "");
643     } else if (i == 2) {
644         table.addFilter("dymd", "!=", "");
645     }
646 }
647 filter_alive.addEventListener("change", _update_alivefilter);
648 var filter_occ2 = document.getElementById("filter-occ2");
649 function _occ2filter(data, para) {
650     if (para == 1) {
651         return data["occ2"].includes("声優");
652     } else {
653         return !data["occ2"].includes("声優");
654     }
655 }
656 var last_filter_occ2_selectedIndex = 0;
657 function _update_occ2filter() {
658     let i = filter_occ2.selectedIndex;
659     let eflts = table.getFilters();
660     for (let efi in eflts) {
661         let f = eflts[efi];
662         if (f["field"] === _occ2filter) {
663             table.removeFilter(_occ2filter, last_filter_occ2_selectedIndex);
664             break;
665         }
666     }
667     if (i > 0) {
668         table.addFilter(_occ2filter, i);
669     }
670     last_filter_occ2_selectedIndex = i;
671 }
672 filter_occ2.addEventListener("change", _update_occ2filter);
673 var filter_citenote = document.getElementById("filter-citenote");
674 function _citenotefilter(data, para) {
675     if (para == 1) {
676         return data["cin"] > -2;
677     } else {
678         return data["cin"] > -1;
679     }
680 }
681 var last_filter_citenote_selectedIndex = 0;
682 function _update_citenotefilter() {
683     let i = filter_citenote.selectedIndex;
684     let eflts = table.getFilters();
685     for (let efi in eflts) {
686         let f = eflts[efi];
687         if (f["field"] === _citenotefilter) {
688             table.removeFilter(_citenotefilter, last_filter_citenote_selectedIndex);
689             break;
690         }
691     }
692     if (i > 0) {
693         table.addFilter(_citenotefilter, i);
694     }
695     last_filter_citenote_selectedIndex = i;
696 }
697 filter_citenote.addEventListener("change", _update_citenotefilter);
698 
699 document.getElementById("download-csv").addEventListener("click", function() {
700     table.download("csv", "data.csv");
701 });
702 document.getElementById("download-json").addEventListener("click", function() {
703     table.download("json", "data.json");
704 });
705 document.getElementById("download-xlsx").addEventListener("click", function() {
706     table.download("xlsx", "data.xlsx", {sheetName:"My Data"});
707 });
708 document.getElementById("download-html").addEventListener("click", function() {
709     table.download("html", "data.html", {style: true});
710 });
711 
712 function _dt2int(dt) {
713     function _pad(n) {
714         let ns = "" + Math.abs(n);
715         if (ns.length === 1) {
716             ns = "0" + ns;
717         }
718         return ns;
719     }
720     return parseInt(dt.getFullYear() + _pad(dt.getMonth() + 1) + _pad(dt.getDate()));
721 }
722 var nowi = _dt2int(new Date());
723 function _calcage(cell, formatterParams) {
724     var v = cell.getValue().replace(new RegExp("\-", "g"), "");
725     if (v.startsWith("????")) {
726         return "";
727     }
728     v = parseInt(v.replace(/\?/g, "0"));
729     var result = "" + parseInt((nowi - v) / 10000);
730     result += "歳";
731     let unk = cell.getValue().includes("-??");
732     if (unk) {
733         result += "?";
734     }
735     var d = cell.getRow().getCell("dymd").getValue().replace(
736         "?", "0").replace(new RegExp("\-", "g"), "");
737     if (d) {
738         result += " (";
739         result += parseInt((parseInt(d) - v) / 10000);
740         result += "歳";
741         if (unk) {
742             result += "?";
743         }
744         result += "没)";
745     }
746     return result;
747 }
748 
749 var table = new Tabulator("#actor_basinf", {
750     "height": "800px",
751     "columnDefaults": {
752         /* 注意: 「tooltips」ではない。「tooltip」である。 */
753         "tooltip": true,
754 
755         /* これはいつから使えたのかな? かつては「columnDefaults」の外にいたやつ。 */
756         "headerSortTristate": true,
757     },
758     "columns": [
759         {
760             "field": "by",
761             "title": "生誕年度",
762             "headerFilter": "input",
763             "headerFilterFunc": "regex",
764             "formatter": function (cell, formatterParams, onRendered) {
765                 let v = parseInt(cell.getValue());
766                 return !v ? "????" : v;
767             },
768         },
769         {
770             "field": "as",
771             "title": "活動開始年?",
772             "headerFilter": "input",
773             "headerFilterFunc": "regex",
774             "headerTooltip": "「最低でもこの年には存命」としての用途を想定。wikipedia で断定しているもののほか、ページ内に現れる年の最小値も拾っている。"
775         },
776         {
777             "field": "bymd",
778             "title": "生年月日",
779             "headerFilter": "input",
780             "headerFilterFunc": "regex",
781         },
782         {
783             "field": "bymd",
784             "formatter": _calcage,
785             "headerTooltip": "存命の場合は年齢そのもの。亡くなっている方の場合は「生きていれば~歳」。 "
786         },
787         {
788             "field": "dymd",
789             "title": "没年月日",
790         },
791         {
792             "field": "gen",
793             "title": "性別",
794             "formatter": function (cell, formatterParams, onRendered) {
795                 let g = cell.getValue();
796                 if (g == 1) {
797                     return "男";
798                 } else if (g == 2) {
799                     return "女";
800                 }
801                 return "";
802             },
803         },
804         {
805             "field": "nm",
806             "title": "名前",
807             "headerFilter": "input",
808             "headerFilterFunc": "regex",
809         },
810         {
811             "field": "wp",
812             "title": "wikipedia",
813             "formatter": function (cell, formatterParams, onRendered) {
814                 let pn = cell.getValue();
815                 if (!pn) {
816                     let n0 = cell.getRow().getCell("nm").getValue();
817                     n0 = n0.replace(new RegExp(", .*$"), "");
818                     pn = n0.replace(new RegExp("\\\\s+", "g"), "");
819                 }
820                 return "<a href='https://ja.wikipedia.org/wiki/" +
821                     pn + "' target=_blank>" + pn + "</a>";
822             },
823         },
824         {
825             "field": "redi",
826             "headerFilter": "input",
827             "headerFilterFunc": "regex",
828             "formatter": function (cell, formatterParams, onRendered) {
829                 let pn = cell.getValue();
830                 return "<a href='https://ja.wikipedia.org/wiki/" +
831                     pn + "' target=_blank>" + pn + "</a>";
832             },
833             "headerTooltip": "転送された場合、転送先。"
834         },
835         {
836             "field": "bld",
837             "title": "血液型",
838             "headerFilter": "input",
839             "headerFilterFunc": "regex",
840         },
841         {
842             "field": "bor",
843             "title": "出生地・出身地",
844             "headerFilter": "input",
845             "headerFilterFunc": "regex",
846         },
847         {
848             "field": "nn",
849             "title": "愛称",
850             "headerFilter": "input",
851             "headerFilterFunc": "regex",
852         },
853         {
854             "field": "tw",
855             "title": "身長/体重",
856             "headerFilter": "input",
857             "headerFilterFunc": "regex",
858         },
859         {
860             "field": "bel",
861             "title": "事務所・レーベル",
862             "headerFilter": "input",
863             "headerFilterFunc": "regex",
864         },
865         {
866             "field": "fst",
867             "title": "デビュー作",
868             "headerFilter": "input",
869             "headerFilterFunc": "regex",
870         },
871         {
872             "field": "tea",
873             "title": "共同作業者",
874             "headerFilter": "input",
875             "headerFilterFunc": "regex",
876         },
877         {
878             "field": "occ",
879             "title": "職業/職種/ジャンル",
880             "headerFilter": "input",
881             "headerFilterFunc": "regex",
882         },
883         {
884             "field": "occ2",
885             "title": "wikipediaページ本文冒頭での要約に書かれている職業",
886             "headerFilter": "input",
887             "headerFilterFunc": "regex",
888         },
889     ], 
890     "layout": "fitColumns",
891     "data": actor_basinf_data
892 });
893 </script>
894 </html>
895 """, file=fo)

今回の出力結果は actor_basinf.html と actor_basinf_data.js のふたつになり、data-uri の形で html 内に埋め込むと actor_basinf_data.js をワタシのロリポップサーバーにアップロード(して管理)する手間があってワタシがダルいので、今回は zip でまとめたものを:

展開して html をブラウザで開きなはれ。

あ、スクリプトも同梱したけれど、もし動かしたいなら、処理時間に無頓着に作ってるんで注意。html 取得部分のキャッシュだけはやってるけど、ページの解析をして情報収集する部分は毎度処理してるんで、今の 3800人くらいを処理するのに結構かかる。(コンビニ行って買い物して帰ってくるまでには終わらない、というくらい。)



Related Posts