#!/usr/bin/env python3 """シリーズのページに埋め込んでいた Google スプレッドシート・ドキュメントの中身を取ってきて、 サイトに直接載せるためのデータ(src/data/series_docs.json)に書き出す。 - 元は Google 側。サイトは書き出した時点の写しを載せる。Google 側を直したら、これを動かして push する python3 tools/fetch_series_docs.py - 取ってくるのは「ウェブに公開」した版(/pub・/pubhtml)。ログインはいらない - 表は CSV で取る。ドキュメントは公開版の HTML から、見出し・段落・箇条書き・表・太字・リンクだけを残す - デザインノートは Google から写さない(サイトの値が正。src/data/design_note.json と src/lib/sheets.ts) - 載せる場所は src/data/static.json の {"type": "sheet" | "doc", "key": ...} のブロック """ import csv, io, json, re, sys, urllib.request from urllib.parse import urlparse, parse_qs from bs4 import BeautifulSoup, NavigableString OUT = 'src/data/series_docs.json' SHEET = 'https://docs.google.com/spreadsheets/d/e/{id}' DOC = 'https://docs.google.com/document/d/e/{id}/pub?embedded=true' SHEETS = { # key: 公開版の id(2PACX-...)。タブは公開版に出ている順にすべて取る 'hakobu-progress': '2PACX-1vQnqeo6YmdjAz9VQiH8-PYR4OzF9RdlgEOBe-SMtNjYcwdeHkkw2gNTOnLw9jSHBcWmIT3gaxnYnTH6', 'flow-library': '2PACX-1vRpLxXW9hSt-ediJC6AUIt8peUUbRATWB05SL7fdj14ygbsdQM1Hi9LEondS7phSu43Fv1A5MvGwDCT', } DOCS = { 'hakobu-requirements': '2PACX-1vQ8DnpuXG_E-iyE9U0li_i7iwsneP2VpI7DxK-rOP-HFVhuf2ARqWGgiW0gChVb3QgOPcjAaOqS8l9a', 'tsukamu-requirements': '2PACX-1vSTXcuWYVsHofVRjwcR3rqTvrzV6mGnOfUe8MdcCsBpKWfVA2zj-HLmDB3rnQ3MW2_OogYNzHhNDCtR', } def get(url): req = urllib.request.Request(url, headers={'User-Agent': 'p2m-site fetch_series_docs'}) with urllib.request.urlopen(req, timeout=60) as r: return r.read().decode('utf-8') def trim(rows): """後ろの空の行・列を落とす""" while rows and not any(c.strip() for c in rows[-1]): rows.pop() width = max((max((i + 1 for i, c in enumerate(r) if c.strip()), default=0) for r in rows), default=0) return [[c.strip() for c in (r + [''] * width)[:width]] for r in rows] def fetch_sheet(sid): base = SHEET.format(id=sid) html = get(f'{base}/pubhtml?widget=true&headers=false') title = re.search(r'(.*?)', html).group(1) title = BeautifulSoup(title, 'html.parser').get_text().removesuffix(' - Google Drive').strip() tabs = [] for name, gid in re.findall(r'items\.push\(\{name: "((?:[^"\\]|\\.)*)", pageUrl: "[^"]*", gid: "(\d+)"', html): name = json.loads(f'"{name}"') rows = trim(list(csv.reader(io.StringIO(get(f'{base}/pub?gid={gid}&single=true&output=csv'))))) tabs.append({'name': name, 'rows': rows}) if not tabs: raise SystemExit(f'タブが見つからない: {sid}') return {'title': title, 'tabs': tabs} def clean_doc(html): soup = BeautifulSoup(html, 'html.parser') css = ' '.join(s.get_text() for s in soup.find_all('style')) rules = dict(re.findall(r'\.(c\d+)\{([^}]*)\}', css)) def has(el, prop): return any(prop in rules.get(c, '') for c in (el.get('class') or [])) def size(el): for c in el.get('class') or []: m = re.search(r'font-size:(\d+(?:\.\d+)?)pt', rules.get(c, '')) if m: return float(m.group(1)) return 0 body = soup.find(class_='doc-content') out = BeautifulSoup('', 'html.parser') def inline(src, dst): for node in src.children: if isinstance(node, NavigableString): dst.append(str(node)) continue if node.name == 'br': dst.append(out.new_tag('br')) continue if node.name == 'a' and node.get('href'): href = node['href'] if 'google.com/url' in href: href = parse_qs(urlparse(href).query).get('q', [href])[0] a = out.new_tag('a', href=href, target='_blank', rel='noopener') inline(node, a) dst.append(a) continue if node.name == 'span': target = dst if has(node, 'font-weight:700'): b = out.new_tag('strong'); dst.append(b); target = b if has(node, 'font-style:italic'): i = out.new_tag('em'); target.append(i); target = i inline(node, target) continue if node.name in ('img', 'sup'): continue inline(node, dst) def empty(el): return not el.get_text(strip=True) def block(el, parent): name = el.name if name in ('h1', 'h2', 'h3', 'h4'): # ページの見出し(h1・シリーズの見出し h2・ラベル)の下に入るので、3段下げる h = out.new_tag(f'h{min(int(name[1]) + 2, 6)}') inline(el, h); parent.append(h) if not empty(el) else None elif name == 'p': if empty(el): return # 文書のタイトル(大きな文字の段落)は見出しにする。太字の印は見出しには付けない if max([size(el)] + [size(s) for s in el.find_all('span')]) >= 20: h = out.new_tag('h3'); h.string = el.get_text(strip=True); parent.append(h) return p = out.new_tag('p') inline(el, p); parent.append(p) elif name in ('ul', 'ol'): m = re.search(r'lst-kix_[\w]+-(\d+)', ' '.join(el.get('class') or [])) lv = int(m.group(1)) if m else 0 lst = out.new_tag(name) if lv: lst['class'] = f'lv{min(lv, 4)}' for li in el.find_all('li', recursive=False): n = out.new_tag('li'); inline(li, n); lst.append(n) parent.append(lst) elif name == 'table': t = out.new_tag('table') for i, tr in enumerate(el.find_all('tr')): r = out.new_tag('tr') for td in tr.find_all('td', recursive=False): c = out.new_tag('th' if i == 0 else 'td') for k in ('colspan', 'rowspan'): if td.get(k) and td[k] != '1': c[k] = td[k] for j, p in enumerate(td.find_all(['p', 'li'])): if j: c.append(out.new_tag('br')) inline(p, c) r.append(c) t.append(r) wrap = out.new_tag('div', attrs={'class': 'table'}) wrap.append(t); parent.append(wrap) elif name == 'hr': parent.append(out.new_tag('hr')) elif name == 'div': for c in el.children: if getattr(c, 'name', None): block(c, parent) for el in body.children: if getattr(el, 'name', None): block(el, out) html = str(out) # Google の空白の詰め物を整える html = html.replace('\xa0', ' ') return html def main(): data = {'sheets': {}, 'docs': {}} for key, sid in SHEETS.items(): data['sheets'][key] = fetch_sheet(sid) print('sheet', key, [(t['name'], len(t['rows'])) for t in data['sheets'][key]['tabs']]) for key, did in DOCS.items(): data['docs'][key] = {'html': clean_doc(get(DOC.format(id=did)))} print('doc', key, len(data['docs'][key]['html']), 'chars') with open(OUT, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=1) f.write('\n') print('->', OUT) if __name__ == '__main__': sys.exit(main())