hiroki0008 commited on
Commit
395503f
·
verified ·
1 Parent(s): 18f56a2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +112 -110
app.py CHANGED
@@ -11,9 +11,9 @@ import pandas as pd
11
  from bs4 import BeautifulSoup
12
 
13
  PUBLIC_URL = "https://www.fit-portal.go.jp/PublicInfo"
14
- OUTDIR = "data_fit" # 保存先
15
 
16
- # -------------------- ユーティリティ --------------------
17
 
18
  def normalize_filename(name: str) -> str:
19
  name = unicodedata.normalize("NFKC", name)
@@ -45,7 +45,6 @@ def pick_sheet_name(xls_path: str, preferred: str | None) -> str | None:
45
  xl = pd.ExcelFile(xls_path)
46
  if preferred and preferred in xl.sheet_names:
47
  return preferred
48
- # 代表地番を優先
49
  for candidate in ["代表地番", "代表地番のみ", "代表地番シート"]:
50
  if candidate in xl.sheet_names:
51
  return candidate
@@ -60,13 +59,9 @@ def collect_pref_links(session: requests.Session) -> list[dict]:
60
  links = []
61
  for a in soup.find_all("a"):
62
  if is_pref_link(a):
63
- links.append({
64
- "pref": extract_pref_name(a),
65
- "href": urljoin(PUBLIC_URL, a.get("href")),
66
- })
67
  # 重複除去
68
- seen = set()
69
- uniq = []
70
  for item in links:
71
  key = (item["pref"], item["href"])
72
  if key not in seen:
@@ -76,7 +71,6 @@ def collect_pref_links(session: requests.Session) -> list[dict]:
76
 
77
  def download_one(session: requests.Session, url: str, outdir: str, pref: str) -> str:
78
  os.makedirs(outdir, exist_ok=True)
79
- from urllib.parse import urlparse, parse_qs
80
  qs = parse_qs(urlparse(url).query)
81
  file_id = (qs.get("file", ["unknown"])[0])[:18]
82
  with session.get(url, timeout=180, stream=True) as r:
@@ -84,87 +78,108 @@ def download_one(session: requests.Session, url: str, outdir: str, pref: str) ->
84
  fname = guess_filename_from_headers(r, f"{pref}_{file_id}.xlsx")
85
  path = os.path.join(outdir, fname)
86
  with open(path, "wb") as f:
87
- for chunk in r.iter_content(chunk_size=1 << 15):
 
88
  if chunk:
89
  f.write(chunk)
90
  return path
91
 
92
- # ---- 3段ヘッダー対応:1枚目のみ利用、他は削除(skiprows) ----
93
- def load_excel(xls_path: str, sheet_pref: str | None, pref_name: str, use_header: bool) -> pd.DataFrame | None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  """
95
- use_header=True のときのみ上3行を列名として使用(MultiIndex)
96
- use_header=False のときは列名なし(skiprows=3)
97
- いずれも先頭にメタ列(都道府県/元ファイル/読込シート)を付加
98
  """
99
  sheet = pick_sheet_name(xls_path, sheet_pref)
100
  if not sheet:
101
  return None
102
- try:
103
- if use_header:
104
- df = pd.read_excel(xls_path, sheet_name=sheet, engine="openpyxl",
105
- header=[0, 1, 2], dtype=str)
106
- # 後でMultiIndex列を維持するため、元の列名を保存
107
- orig_cols = list(df.columns)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  else:
109
- df = pd.read_excel(xls_path, sheet_name=sheet, engine="openpyxl",
110
- header=None, skiprows=3, dtype=str)
111
- orig_cols = None # 後で1枚目の列名を適用
112
-
113
- # 前後空白トリム
114
- for c in df.select_dtypes(include=["object"]).columns:
115
- df[c] = df[c].str.strip()
116
-
117
- # メタ情報列を追加
118
- df.insert(0, "都道府県", pref_name)
119
- df.insert(1, "元ファイル", os.path.basename(xls_path))
120
- df.insert(2, "読込シート", sheet)
121
-
122
- # 1枚目は MultiIndex 列をメタ列も含めて設定しておく
123
- if use_header and isinstance(pd.Index(orig_cols), pd.MultiIndex):
124
- meta = [
125
- ("meta", "都道府県", ""),
126
- ("meta", "元ファイル", ""),
127
- ("meta", "読込シート", ""),
128
- ]
129
- df.columns = pd.MultiIndex.from_tuples(meta + list(orig_cols))
130
- return df
131
- except Exception as e:
132
- print(f"[WARN] 読み込み失敗: {xls_path} ({e})")
133
- return None
134
 
135
- # ---- 3段 → 1段 へフラット化 ----
136
  def flatten_columns(cols, sep: str = "_") -> list[str]:
137
  """
138
- MultiIndex 列を '上位_中位_下位' の1段に変換。
139
  None / NaN / 空白は除去。重複名は .1, .2... を付与。
140
  """
141
- # 1) 一旦すべて文字列へ
142
  def as_str(x):
143
  s = "" if x is None else str(x)
144
  s = s.strip()
145
  return "" if s.lower() == "nan" else s
146
 
147
- flat = []
148
  if isinstance(cols, pd.MultiIndex):
 
149
  for tpl in cols:
150
- parts = [as_str(p) for p in tpl]
151
- parts = [p for p in parts if p] # 空除去
152
- name = sep.join(parts) if parts else "col"
153
- flat.append(name)
154
  else:
155
- flat = [as_str(c) or "col" for c in cols]
156
 
157
- # 2) 重複名に連番を付与
158
- seen = {}
159
- uniq = []
160
- for c in flat:
161
  if c not in seen:
162
  seen[c] = 0
163
- uniq.append(c)
164
  else:
165
  seen[c] += 1
166
- uniq.append(f"{c}.{seen[c]}")
167
- return uniq
168
 
169
  def zip_paths(paths: list[str], out_zip: str) -> str:
170
  with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as z:
@@ -173,34 +188,23 @@ def zip_paths(paths: list[str], out_zip: str) -> str:
173
  z.write(p, arcname=os.path.basename(p))
174
  return out_zip
175
 
176
- # -------------------- メイン実行(Gradioから呼ぶ) --------------------
177
 
178
  def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress=gr.Progress(track_tqdm=False)):
179
- """
180
- sheet_name: "代表地番" 等。空欄なら自動
181
- sleep_sec: ダウンロード間隔
182
- limit: 先頭N件のみ(テスト用)
183
- re_download: 既存ファイルがあっても再取得する
184
- do_flatten: 列名を1段にフラット化する
185
- sep: フラット化時のセパレータ
186
- """
187
  progress(0, desc="初期化中…")
188
 
189
  session = requests.Session()
190
  session.headers.update({
191
- "User-Agent": "Mozilla/5.0 (compatible; FITCollector/1.1; +https://huggingface.co/spaces)",
192
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
193
  })
194
 
195
  # 1) リンク収集
196
  links = collect_pref_links(session)
197
  if not links:
198
- return ("都道府県ファイルのリンク検出に失敗しました。ページ構成の変更や一時的なブロックの可能性があります。",
199
- None, None, None, None)
200
-
201
  if limit and limit > 0:
202
  links = links[:int(limit)]
203
-
204
  progress(0.1, desc=f"リンク検出 {len(links)} 件")
205
 
206
  # 2) ダウンロード
@@ -209,7 +213,6 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
209
  progress(0.1 + 0.6 * i / max(1, len(links)),
210
  desc=f"ダウンロード {i}/{len(links)}: {item['pref']}")
211
  try:
212
- # 既存ファイルを利用(高速化)
213
  existing = None
214
  if not re_download and os.path.isdir(OUTDIR):
215
  for fn in os.listdir(OUTDIR):
@@ -221,44 +224,42 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
221
  else:
222
  path = download_one(session, item["href"], OUTDIR, item["pref"])
223
  time.sleep(float(sleep_sec))
224
- downloaded.append({"pref": item["pref"], "path": path})
225
  except Exception as e:
226
  print(f"[WARN] ダウンロード失敗: {item['pref']} {e}")
227
 
228
  if not downloaded:
229
- return ("ダウンロードに失敗しました。ネットワークやサイト側制限をご確認ください。",
 
 
 
 
 
 
 
 
230
  None, None, None, None)
231
 
232
- # 3) 読み込み
233
- frames = []
234
- for i, it in enumerate(downloaded, start=1):
235
- progress(0.72 + 0.18 * i / max(1, len(downloaded)),
236
- desc=f"読み込み {i}/{len(downloaded)}: {os.path.basename(it['path'])}")
237
- df = load_excel(
238
- it["path"],
239
- sheet_name if sheet_name else None,
240
- it["pref"],
241
- use_header=(i == 1) # 1枚目だけ上3行を列名に
242
- )
243
  if df is not None and len(df) > 0:
244
  frames.append(df)
 
 
245
 
246
- if not frames:
247
- return ("Excelは取得できましたが、読み込めるデータがありませんでした(シート名の指定を見直してください)。",
248
- None, None, None, None)
249
-
250
- # 2枚目以降は列名が無いので、1枚目の列名を適用
251
- if len(frames) > 1:
252
- frames[1:] = [f.set_axis(frames[0].columns, axis=1) for f in frames[1:]]
253
-
254
- # 4) 縦結合
255
  combined = pd.concat(frames, ignore_index=True)
256
 
257
- # 5) 列名のフラット化(推奨)
258
  if do_flatten:
259
  combined.columns = flatten_columns(combined.columns, sep=sep or "_")
260
 
261
- # 6) 出力
262
  os.makedirs(OUTDIR, exist_ok=True)
263
  out_xlsx = os.path.join(OUTDIR, "combined_fit.xlsx")
264
  out_parq = os.path.join(OUTDIR, "combined_fit.parquet")
@@ -266,16 +267,15 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
266
  combined.to_excel(w, index=False, sheet_name="combined")
267
  combined.to_parquet(out_parq, index=False)
268
 
269
- # 7) 生ファイル一式のZIP
270
  raw_zip = os.path.join(OUTDIR, "raw_excels.zip")
271
- zip_paths([it["path"] for it in downloaded], raw_zip)
272
 
273
- # 8) プレビュー
274
  preview_csv = os.path.join(OUTDIR, "combined_head.csv")
275
  combined.head(1000).to_csv(preview_csv, index=False)
276
 
277
  progress(1.0, desc=f"完了({len(combined):,} 行)")
278
-
279
  msg = (
280
  f"✅ 結合完了: 行数 = {len(combined):,}\n"
281
  f"・Excel: combined_fit.xlsx\n"
@@ -284,17 +284,19 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
284
  f"・プレビュー: combined_head.csv\n"
285
  f"・列名フラット化: {'ON' if do_flatten else 'OFF'}(区切り: '{sep or '_'}')"
286
  )
287
-
288
  return (msg, out_xlsx, out_parq, raw_zip, preview_csv)
289
 
290
- # -------------------- Gradio UI --------------------
291
 
292
  with gr.Blocks(title="FIT 公表(都道府県別Excel)一括取得&結合") as demo:
293
  gr.Markdown(
294
  """
295
  # FIT 公表(都道府県別Excel)一括取得 & 結合
296
- - 公表ページから都道府県別のExcelを取得し、**1枚目のみ上3行を列名**として採用、**2枚目以降は列名を削除**して縦結合します。
297
- - 列名はオプションで**フラット化**(例:`大分類_中分類_小分類`)できます(推奨)。
 
 
 
298
  """
299
  )
300
  with gr.Row():
 
11
  from bs4 import BeautifulSoup
12
 
13
  PUBLIC_URL = "https://www.fit-portal.go.jp/PublicInfo"
14
+ OUTDIR = "data_fit"
15
 
16
+ # ---- ユーティリティ ---------------------------------------------------------
17
 
18
  def normalize_filename(name: str) -> str:
19
  name = unicodedata.normalize("NFKC", name)
 
45
  xl = pd.ExcelFile(xls_path)
46
  if preferred and preferred in xl.sheet_names:
47
  return preferred
 
48
  for candidate in ["代表地番", "代表地番のみ", "代表地番シート"]:
49
  if candidate in xl.sheet_names:
50
  return candidate
 
59
  links = []
60
  for a in soup.find_all("a"):
61
  if is_pref_link(a):
62
+ links.append({"pref": extract_pref_name(a), "href": urljoin(PUBLIC_URL, a.get("href"))})
 
 
 
63
  # 重複除去
64
+ seen, uniq = set(), []
 
65
  for item in links:
66
  key = (item["pref"], item["href"])
67
  if key not in seen:
 
71
 
72
  def download_one(session: requests.Session, url: str, outdir: str, pref: str) -> str:
73
  os.makedirs(outdir, exist_ok=True)
 
74
  qs = parse_qs(urlparse(url).query)
75
  file_id = (qs.get("file", ["unknown"])[0])[:18]
76
  with session.get(url, timeout=180, stream=True) as r:
 
78
  fname = guess_filename_from_headers(r, f"{pref}_{file_id}.xlsx")
79
  path = os.path.join(outdir, fname)
80
  with open(path, "wb") as f:
81
+ for chunk in r.iter_iterable = r.iter_content(chunk_size=1 << 15)
82
+ for chunk in iter_iterable:
83
  if chunk:
84
  f.write(chunk)
85
  return path
86
 
87
+ # ---- 3段ヘッダ → 1枚目のみ採用/他はスキップ行数で読込 ----------------------
88
+
89
+ HEADER_ROWS = [1, 2, 3] # 0行目は削除、1/2/3行目を列名として結合
90
+ SKIP_ROWS_OTHERS = 4 # 2枚目以降は 0〜3 行目をスキップ
91
+
92
+ def load_excel_first(xls_path: str, sheet_pref: str | None) -> tuple[pd.DataFrame, list]:
93
+ """
94
+ 1枚目: 0行目は使わず、1/2/3行目を列名にする(MultiIndex)。
95
+ さらに「一番左の列」を削除して返す。
96
+ 戻り値: (df, columns_multiindex)
97
+ """
98
+ sheet = pick_sheet_name(xls_path, sheet_pref)
99
+ if not sheet:
100
+ raise RuntimeError("シートが見つかりません")
101
+ df = pd.read_excel(
102
+ xls_path,
103
+ sheet_name=sheet,
104
+ engine="openpyxl",
105
+ header=HEADER_ROWS,
106
+ dtype=str
107
+ )
108
+ # 左端の余計な列を削除
109
+ df = df.iloc[:, 1:]
110
+ # 前後空白トリム
111
+ for c in df.select_dtypes(include=["object"]).columns:
112
+ df[c] = df[c].str.strip()
113
+ cols = list(df.columns) # MultiIndex のまま保持
114
+ return df, cols
115
+
116
+ def load_excel_other(xls_path: str, sheet_pref: str | None, target_cols: list) -> pd.DataFrame | None:
117
  """
118
+ 2枚目以降: 3行目までスキップしてデータのみ読み込み。
119
+ 左端列を削除後、1枚目の列(MultiIndex)を適用。
 
120
  """
121
  sheet = pick_sheet_name(xls_path, sheet_pref)
122
  if not sheet:
123
  return None
124
+ df = pd.read_excel(
125
+ xls_path,
126
+ sheet_name=sheet,
127
+ engine="openpyxl",
128
+ header=None,
129
+ skiprows=SKIP_ROWS_OTHERS,
130
+ dtype=str
131
+ )
132
+ # 左端の余計な列を削除
133
+ df = df.iloc[:, 1:]
134
+ # 前後空白トリム
135
+ for c in df.select_dtypes(include=["object"]).columns:
136
+ df[c] = df[c].str.strip()
137
+
138
+ # 列数が合わない場合は合わせられる範囲で調整(警告付き)
139
+ if df.shape[1] != len(target_cols):
140
+ print(f"[WARN] 列数不一致: file={os.path.basename(xls_path)} "
141
+ f"read={df.shape[1]} vs target={len(target_cols)} -> 自動調整")
142
+ if df.shape[1] > len(target_cols):
143
+ df = df.iloc[:, :len(target_cols)]
144
  else:
145
+ # 足りない場合は欠損列を追加
146
+ for _ in range(len(target_cols) - df.shape[1]):
147
+ df[pd.util.hash_pandas_object(df).name or f"_pad_{_}"] = None
148
+ # 列順を並べ替え
149
+ df = df.iloc[:, :len(target_cols)]
150
+
151
+ df.columns = target_cols
152
+ return df
153
+
154
+ # ---- 列名フラット化 ---------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
 
156
  def flatten_columns(cols, sep: str = "_") -> list[str]:
157
  """
158
+ MultiIndex 列を '上位_中位_下位' にフラット化。
159
  None / NaN / 空白は除去。重複名は .1, .2... を付与。
160
  """
 
161
  def as_str(x):
162
  s = "" if x is None else str(x)
163
  s = s.strip()
164
  return "" if s.lower() == "nan" else s
165
 
 
166
  if isinstance(cols, pd.MultiIndex):
167
+ raw = []
168
  for tpl in cols:
169
+ parts = [as_str(p) for p in tpl if as_str(p)]
170
+ raw.append(sep.join(parts) if parts else "col")
 
 
171
  else:
172
+ raw = [as_str(c) or "col" for c in cols]
173
 
174
+ seen, out = {}, []
175
+ for c in raw:
 
 
176
  if c not in seen:
177
  seen[c] = 0
178
+ out.append(c)
179
  else:
180
  seen[c] += 1
181
+ out.append(f"{c}.{seen[c]}")
182
+ return out
183
 
184
  def zip_paths(paths: list[str], out_zip: str) -> str:
185
  with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as z:
 
188
  z.write(p, arcname=os.path.basename(p))
189
  return out_zip
190
 
191
+ # ---- メイン(Gradioから呼び出し) -------------------------------------------
192
 
193
  def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress=gr.Progress(track_tqdm=False)):
 
 
 
 
 
 
 
 
194
  progress(0, desc="初期化中…")
195
 
196
  session = requests.Session()
197
  session.headers.update({
198
+ "User-Agent": "Mozilla/5.0 (compatible; FITCollector/1.2; +https://huggingface.co/spaces)",
199
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
200
  })
201
 
202
  # 1) リンク収集
203
  links = collect_pref_links(session)
204
  if not links:
205
+ return ("都道府県ファイルのリンク検出に失敗しました。", None, None, None, None)
 
 
206
  if limit and limit > 0:
207
  links = links[:int(limit)]
 
208
  progress(0.1, desc=f"リンク検出 {len(links)} 件")
209
 
210
  # 2) ダウンロード
 
213
  progress(0.1 + 0.6 * i / max(1, len(links)),
214
  desc=f"ダウンロード {i}/{len(links)}: {item['pref']}")
215
  try:
 
216
  existing = None
217
  if not re_download and os.path.isdir(OUTDIR):
218
  for fn in os.listdir(OUTDIR):
 
224
  else:
225
  path = download_one(session, item["href"], OUTDIR, item["pref"])
226
  time.sleep(float(sleep_sec))
227
+ downloaded.append(path)
228
  except Exception as e:
229
  print(f"[WARN] ダウンロード失敗: {item['pref']} {e}")
230
 
231
  if not downloaded:
232
+ return ("ダウンロードに失敗しました。", None, None, None, None)
233
+
234
+ # 3) 読み込み・1枚目
235
+ progress(0.75, desc="1枚目を読み込み(列名生成)")
236
+ first_path = downloaded[0]
237
+ try:
238
+ df0, cols0 = load_excel_first(first_path, sheet_name if sheet_name else None)
239
+ except Exception as e:
240
+ return (f"1枚目の読み込みに失敗しました: {os.path.basename(first_path)} / {e}",
241
  None, None, None, None)
242
 
243
+ frames = [df0]
244
+
245
+ # 4) 読み込み・2枚目以降
246
+ for j, p in enumerate(downloaded[1:], start=2):
247
+ progress(0.75 + 0.25 * (j - 1) / max(1, len(downloaded) - 1),
248
+ desc=f"{j}枚目を読み込み")
249
+ df = load_excel_other(p, sheet_name if sheet_name else None, cols0)
 
 
 
 
250
  if df is not None and len(df) > 0:
251
  frames.append(df)
252
+ else:
253
+ print(f"[WARN] 読み込みスキップ: {os.path.basename(p)}")
254
 
255
+ # 5) 縦結合
 
 
 
 
 
 
 
 
256
  combined = pd.concat(frames, ignore_index=True)
257
 
258
+ # 6) 列名のフラット化(既定ON)
259
  if do_flatten:
260
  combined.columns = flatten_columns(combined.columns, sep=sep or "_")
261
 
262
+ # 7) 出力
263
  os.makedirs(OUTDIR, exist_ok=True)
264
  out_xlsx = os.path.join(OUTDIR, "combined_fit.xlsx")
265
  out_parq = os.path.join(OUTDIR, "combined_fit.parquet")
 
267
  combined.to_excel(w, index=False, sheet_name="combined")
268
  combined.to_parquet(out_parq, index=False)
269
 
270
+ # 8) ZIP(取得ファイル一式)
271
  raw_zip = os.path.join(OUTDIR, "raw_excels.zip")
272
+ zip_paths(downloaded, raw_zip)
273
 
274
+ # 9) プレビュー
275
  preview_csv = os.path.join(OUTDIR, "combined_head.csv")
276
  combined.head(1000).to_csv(preview_csv, index=False)
277
 
278
  progress(1.0, desc=f"完了({len(combined):,} 行)")
 
279
  msg = (
280
  f"✅ 結合完了: 行数 = {len(combined):,}\n"
281
  f"・Excel: combined_fit.xlsx\n"
 
284
  f"・プレビュー: combined_head.csv\n"
285
  f"・列名フラット化: {'ON' if do_flatten else 'OFF'}(区切り: '{sep or '_'}')"
286
  )
 
287
  return (msg, out_xlsx, out_parq, raw_zip, preview_csv)
288
 
289
+ # ---- Gradio UI -------------------------------------------------------------
290
 
291
  with gr.Blocks(title="FIT 公表(都道府県別Excel)一括取得&結合") as demo:
292
  gr.Markdown(
293
  """
294
  # FIT 公表(都道府県別Excel)一括取得 & 結合
295
+ **要件に沿った処理**:
296
+ - 1枚目のみ「0行目を削除」「1/2/3行目を結合して列名」。
297
+ - 2枚目以降は「3行目までスキップ」してデータのみ。
298
+ - すべてのファイルで**左端の列を削除**。
299
+ - ファイル/シート名などのメタ列は**付与しません**。
300
  """
301
  )
302
  with gr.Row():