1"""Japan Corporate Numbers (NTA houjin-bangou) Actor.
2
3国税庁 法人番号公表サイトの全件ダウンロード (zenken) から
4法人番号・法人名を検索する。POST token 方式で ZIP 取得後、
5CP932 CSV をパースして部分一致検索。
6"""
7from __future__ import annotations
8
9import asyncio
10import csv
11import io
12import json
13import re
14import zipfile
15from typing import Any
16
17import httpx
18from apify import Actor
19
20BASE = "https://www.houjin-bangou.nta.go.jp"
21ZENKEN_URL = f"{BASE}/download/zenken/"
22POST_URL = f"{BASE}/download/zenken/index.html"
23TOKEN_FIELD = "jp.go.nta.houjin_bangou.framework.web.common.CNSFWTokenProcessor.request.token"
24
25PREFS = ["北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県",
26 "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県",
27 "新潟県", "富山県", "石川県", "福井県", "山梨県", "長野県", "岐阜県",
28 "静岡県", "愛知県", "三重県", "滋賀県", "京都府", "大阪府", "兵庫県",
29 "奈良県", "和歌山県", "鳥取県", "島根県", "岡山県", "広島県", "山口県",
30 "徳島県", "香川県", "愛媛県", "高知県", "福岡県", "佐賀県", "長崎県",
31 "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県"]
32
33
34def _parse_file_nos(html: str) -> dict[str, list[str]]:
35 """zenken ページから都道府県名→ファイル番号リストを抽出。"""
36 pairs: dict[str, list[str]] = {}
37 chunks = re.split(r"<dt[^>]*>", html)
38 pref_re = re.compile(r"([^<]{2,8})</dt>")
39 dl_re = re.compile(r"doDownload\((\d+)\)")
40 for ch in chunks[1:]:
41 pm = pref_re.search(ch)
42 dm = dl_re.search(ch)
43 if pm and dm:
44 pref = pm.group(1).strip()
45 pairs.setdefault(pref, []).append(dm.group(1))
46 return pairs
47
48
49async def _download_zip(client: httpx.AsyncClient, file_no: str) -> bytes:
50 """zenken ページの token を取得して ZIP を POST ダウンロード。"""
51 r = await client.get(ZENKEN_URL)
52 r.raise_for_status()
53 m = re.search(r'name="' + re.escape(TOKEN_FIELD) + r'" value="([^"]+)"', r.text)
54 if not m:
55 raise RuntimeError("token not found on zenken page")
56 token = m.group(1)
57 pr = await client.post(
58 POST_URL,
59 data={TOKEN_FIELD: token, "event": "download", "selDlFileNo": file_no},
60 headers={"Referer": ZENKEN_URL},
61 )
62 pr.raise_for_status()
63 if pr.content[:2] != b"PK":
64 raise RuntimeError(f"not a zip: content-type={pr.headers.get('content-type')}")
65 return pr.content
66
67
68async def main() -> None:
69 async with Actor:
70 run_input = await Actor.get_input() or {}
71 query = (run_input.get("query") or run_input.get("name") or "").strip()
72 prefecture = (run_input.get("prefecture") or "").strip()
73 limit = int(run_input.get("limit") or 20)
74
75 if not query and not prefecture:
76 raise ValueError("query か prefecture のいずれかは必須")
77
78 headers = {"User-Agent": "Mozilla/5.0"}
79 async with httpx.AsyncClient(timeout=300, follow_redirects=True,
80 headers=headers) as client:
81 r = await client.get(ZENKEN_URL)
82 r.raise_for_status()
83 pairs = _parse_file_nos(r.text)
84
85
86 if prefecture and prefecture in pairs:
87 file_nos = pairs[prefecture][:1]
88 elif prefecture:
89 raise ValueError(f"unknown prefecture: {prefecture}")
90 else:
91 file_nos = pairs.get("全国", [])[:1]
92
93 all_rows: list[dict[str, str]] = []
94 for fno in file_nos:
95 Actor.log.info(f"Downloading fileNo={fno}")
96 content = await _download_zip(client, fno)
97 zf = zipfile.ZipFile(io.BytesIO(content))
98 csv_name = next(n for n in zf.namelist() if n.endswith(".csv"))
99 Actor.log.info(f"Parsing {csv_name}")
100 with zf.open(csv_name) as f:
101 reader = csv.reader(io.TextIOWrapper(f, encoding="cp932"))
102 for row in reader:
103
104 if len(row) < 12:
105 continue
106 num, name = row[1], row[6]
107 if query and query not in name and query not in num:
108 continue
109 all_rows.append({
110 "corporateNumber": num,
111 "name": name,
112 "kind": row[2],
113 "prefecture": row[9],
114 "city": row[10],
115 "addressRemainder": row[11],
116 "furigana": row[28] if len(row) > 28 else "",
117 })
118 if len(all_rows) >= limit:
119 break
120 if len(all_rows) >= limit:
121 break
122
123 Actor.log.info(f"Got {len(all_rows)} corporations")
124 if not all_rows:
125 all_rows = [{"__meta__": "1", "query": query, "prefecture": prefecture, "count": "0"}]
126 await Actor.push_data(all_rows)
127 Actor.log.info(f"Pushed {len(all_rows)} rows")
128
129
130if __name__ == "__main__":
131 asyncio.run(main())