hiroki0008 commited on
Commit
1012ab1
·
verified ·
1 Parent(s): 3fa0fec

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +88 -113
app.py CHANGED
@@ -13,7 +13,7 @@ from bs4 import BeautifulSoup
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,6 +45,7 @@ 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
  for candidate in ["代表地番", "代表地番のみ", "代表地番シート"]:
49
  if candidate in xl.sheet_names:
50
  return candidate
@@ -78,56 +79,66 @@ def download_one(session: requests.Session, url: str, outdir: str, pref: str) ->
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
- # ✅ ここを修正:通常の iter_content ループに
82
  for chunk in r.iter_content(chunk_size=1 << 15):
83
  if chunk:
84
  f.write(chunk)
85
  return path
86
-
 
 
 
 
 
 
 
 
 
 
87
  def choose_names_from_multiindex(mi: pd.MultiIndex) -> list[str]:
88
  """
89
- 3段ヘッダ(MultiIndex: 大,中,小)から列名を選ぶ。
90
- 優先順: 中分類(第2段) → 小分類(第3段) → 大分類(第1段)
91
- 空/NaN/空白は無視。重複は .1, .2… を付与。
 
92
  """
93
- def clean(x) -> str:
94
- if x is None:
95
- return ""
96
- s = str(x).strip()
97
- return "" if s.lower() == "nan" else s
98
-
99
- # 優先で選択
100
- picked = []
101
  for tpl in mi:
102
- a = clean(tpl[0]) if len(tpl) >= 1 else "" # 大
103
- b = clean(tpl[1]) if len(tpl) >= 2 else "" # 中
104
- c = clean(tpl[2]) if len(tpl) >= 3 else "" #
105
- name = b or c or a or "col" # ★ 中 > 小 > 大
106
- picked.append(name)
 
 
 
 
 
107
 
108
  # 重複解消
109
  seen = {}
110
- uniq = []
111
- for n in picked:
112
  if n not in seen:
113
  seen[n] = 0
114
- uniq.append(n)
115
  else:
116
  seen[n] += 1
117
- uniq.append(f"{n}.{seen[n]}")
118
- return uniq
119
-
120
-
121
- # ---- 3段ヘッダ → 1枚目のみ採用/他はスキップ行数で読込 ----------------------
122
 
123
- HEADER_ROWS = [1, 2, 3] # 0行目は削除、1/2/3行目を列名として結合
124
- SKIP_ROWS_OTHERS = 4 # 2枚目以降は 0〜3 行目をスキップ
 
 
 
125
 
126
- def load_excel_first(xls_path: str, sheet_pref: str | None) -> tuple[pd.DataFrame, list]:
127
  """
128
- 1枚目: 0行目は使わず、1/2/3行目を列名にする(MultiIndex)。
129
- さらに「一番左の列」を削除して返す。
130
- 戻り値: (df, columns_multiindex)
 
 
131
  """
132
  sheet = pick_sheet_name(xls_path, sheet_pref)
133
  if not sheet:
@@ -139,37 +150,37 @@ def load_excel_first(xls_path: str, sheet_pref: str | None) -> tuple[pd.DataFram
139
  header=HEADER_ROWS,
140
  dtype=str
141
  )
142
- # 左端の余計な列を削除
143
  df = df.iloc[:, 1:]
144
- # 列名を選択(中 > 小 > 大)
 
 
 
 
145
  if isinstance(df.columns, pd.MultiIndex):
146
  chosen = choose_names_from_multiindex(df.columns)
147
  else:
148
- # 単層ヘッダの場合のフォールバック
149
- raw = []
150
- for c in df.columns:
151
- s = "" if c is None else str(c).strip()
152
- raw.append("" if s.lower() == "nan" else s)
153
- raw = [r if r else "col" for r in raw]
154
  seen = {}
155
  chosen = []
156
  for n in raw:
157
- if n in seen:
158
- seen[n] += 1
159
- chosen.append(f"{n}.{seen[n]}")
160
- else:
161
  seen[n] = 0
162
  chosen.append(n)
163
-
 
 
164
  df.columns = chosen
 
165
 
166
-
167
- return df, cols
168
-
169
- def load_excel_other(xls_path: str, sheet_pref: str | None, target_cols: list) -> pd.DataFrame | None:
170
  """
171
- 2枚目以降: 3行目までスキップしてデータのみ読み込み。
172
- 左端列を削除後、1枚目の列(MultiIndex)を適用。
 
 
 
173
  """
174
  sheet = pick_sheet_name(xls_path, sheet_pref)
175
  if not sheet:
@@ -182,58 +193,27 @@ def load_excel_other(xls_path: str, sheet_pref: str | None, target_cols: list) -
182
  skiprows=SKIP_ROWS_OTHERS,
183
  dtype=str
184
  )
185
- # 左端の余計な列を削除
186
  df = df.iloc[:, 1:]
187
  # 前後空白トリム
188
  for c in df.select_dtypes(include=["object"]).columns:
189
  df[c] = df[c].str.strip()
190
 
191
- # 列数が合わない場合は合わせられる範囲で調整(警告付き)
192
  if df.shape[1] != len(target_cols):
193
  print(f"[WARN] 列数不一致: file={os.path.basename(xls_path)} "
194
  f"read={df.shape[1]} vs target={len(target_cols)} -> 自動調整")
195
  if df.shape[1] > len(target_cols):
196
  df = df.iloc[:, :len(target_cols)]
197
  else:
198
- # 足りない場合は欠損列を追加
199
- for _ in range(len(target_cols) - df.shape[1]):
200
- df[pd.util.hash_pandas_object(df).name or f"_pad_{_}"] = None
201
- # 列順を並べ替え
202
  df = df.iloc[:, :len(target_cols)]
203
 
204
  df.columns = target_cols
205
  return df
206
 
207
- # ---- 列名フラット化 ---------------------------------------------------------
208
-
209
- def flatten_columns(cols, sep: str = "_") -> list[str]:
210
- """
211
- MultiIndex 列を '上位_中位_下位' にフラット化。
212
- None / NaN / 空白は除去。重複名は .1, .2... を付与。
213
- """
214
- def as_str(x):
215
- s = "" if x is None else str(x)
216
- s = s.strip()
217
- return "" if s.lower() == "nan" else s
218
-
219
- if isinstance(cols, pd.MultiIndex):
220
- raw = []
221
- for tpl in cols:
222
- parts = [as_str(p) for p in tpl if as_str(p)]
223
- raw.append(sep.join(parts) if parts else "col")
224
- else:
225
- raw = [as_str(c) or "col" for c in cols]
226
-
227
- seen, out = {}, []
228
- for c in raw:
229
- if c not in seen:
230
- seen[c] = 0
231
- out.append(c)
232
- else:
233
- seen[c] += 1
234
- out.append(f"{c}.{seen[c]}")
235
- return out
236
-
237
  def zip_paths(paths: list[str], out_zip: str) -> str:
238
  with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as z:
239
  for p in paths:
@@ -241,21 +221,22 @@ def zip_paths(paths: list[str], out_zip: str) -> str:
241
  z.write(p, arcname=os.path.basename(p))
242
  return out_zip
243
 
244
- # ---- メイン(Gradioから呼び出し) -------------------------------------------
245
 
246
- def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress=gr.Progress(track_tqdm=False)):
247
  progress(0, desc="初期化中…")
248
 
249
  session = requests.Session()
250
  session.headers.update({
251
- "User-Agent": "Mozilla/5.0 (compatible; FITCollector/1.2; +https://huggingface.co/spaces)",
252
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
253
  })
254
 
255
  # 1) リンク収集
256
  links = collect_pref_links(session)
257
  if not links:
258
- return ("都道府県ファイルのリンク検出に失敗しました。", None, None, None, None)
 
259
  if limit and limit > 0:
260
  links = links[:int(limit)]
261
  progress(0.1, desc=f"リンク検出 {len(links)} 件")
@@ -284,8 +265,8 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
284
  if not downloaded:
285
  return ("ダウンロードに失敗しました。", None, None, None, None)
286
 
287
- # 3) 読み込み・1枚目
288
- progress(0.75, desc="1枚目を読み込み(列名生成)")
289
  first_path = downloaded[0]
290
  try:
291
  df0, cols0 = load_excel_first(first_path, sheet_name if sheet_name else None)
@@ -295,7 +276,7 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
295
 
296
  frames = [df0]
297
 
298
- # 4) 読み込み・2枚目以降
299
  for j, p in enumerate(downloaded[1:], start=2):
300
  progress(0.75 + 0.25 * (j - 1) / max(1, len(downloaded) - 1),
301
  desc=f"{j}枚目を読み込み")
@@ -308,11 +289,7 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
308
  # 5) 縦結合
309
  combined = pd.concat(frames, ignore_index=True)
310
 
311
- # 6) 列名のフラット化(既定ON)
312
- if do_flatten:
313
- combined.columns = flatten_columns(combined.columns, sep=sep or "_")
314
-
315
- # 7) 出力
316
  os.makedirs(OUTDIR, exist_ok=True)
317
  out_xlsx = os.path.join(OUTDIR, "combined_fit.xlsx")
318
  out_parq = os.path.join(OUTDIR, "combined_fit.parquet")
@@ -320,11 +297,11 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
320
  combined.to_excel(w, index=False, sheet_name="combined")
321
  combined.to_parquet(out_parq, index=False)
322
 
323
- # 8) ZIP(取得ファイル一式)
324
  raw_zip = os.path.join(OUTDIR, "raw_excels.zip")
325
  zip_paths(downloaded, raw_zip)
326
 
327
- # 9) プレビュー
328
  preview_csv = os.path.join(OUTDIR, "combined_head.csv")
329
  combined.head(1000).to_csv(preview_csv, index=False)
330
 
@@ -335,21 +312,22 @@ def run_job(sheet_name, sleep_sec, limit, re_download, do_flatten, sep, progress
335
  f"・Parquet: combined_fit.parquet\n"
336
  f"・Raw ZIP: raw_excels.zip\n"
337
  f"・プレビュー: combined_head.csv\n"
338
- f"・列名フラット化: {'ON' if do_flatten else 'OFF'}(区切り: '{sep or '_'}')"
339
  )
340
  return (msg, out_xlsx, out_parq, raw_zip, preview_csv)
341
 
342
- # ---- Gradio UI -------------------------------------------------------------
343
 
344
  with gr.Blocks(title="FIT 公表(都道府県別Excel)一括取得&結合") as demo:
345
  gr.Markdown(
346
  """
347
  # FIT 公表(都道府県別Excel)一括取得 & 結合
348
- **要件に沿った処理**:
349
- - 1枚目のみ「0行目を削除」「1/2/3行目を結合して列名」。
350
- - 2枚目以降は「3行目までスキップ」してデータのみ。
351
- - すべてのファイルで**左端の列を削除**。
352
- - ファイル/シート名などのメタ列は**付与しません**。
 
353
  """
354
  )
355
  with gr.Row():
@@ -358,9 +336,6 @@ with gr.Blocks(title="FIT 公表(都道府県別Excel)一括取得&結合"
358
  with gr.Row():
359
  limit = gr.Number(value=None, precision=0, label="先頭N県のみ(テスト用・空欄は全県)")
360
  reget = gr.Checkbox(label="既存ファイルがあっても再ダウンロードする", value=False)
361
- with gr.Accordion("列名オプション", open=True):
362
- do_flatten = gr.Checkbox(label="列名を1段にフラット化(推奨)", value=True)
363
- sep = gr.Textbox(label="フラット化セパレータ", value="_", placeholder="例)_, /, | など")
364
 
365
  run_btn = gr.Button("実行", variant="primary")
366
  out_msg = gr.Markdown()
@@ -371,7 +346,7 @@ with gr.Blocks(title="FIT 公表(都道府県別Excel)一括取得&結合"
371
 
372
  run_btn.click(
373
  fn=run_job,
374
- inputs=[sheet, sleep, limit, reget, do_flatten, sep],
375
  outputs=[out_msg, out_xlsx, out_parq, out_zip, out_preview]
376
  )
377
 
 
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
+ # 一般的に「代表地番」を優先
49
  for candidate in ["代表地番", "代表地番のみ", "代表地番シート"]:
50
  if candidate in xl.sheet_names:
51
  return candidate
 
79
  fname = guess_filename_from_headers(r, f"{pref}_{file_id}.xlsx")
80
  path = os.path.join(outdir, fname)
81
  with open(path, "wb") as f:
 
82
  for chunk in r.iter_content(chunk_size=1 << 15):
83
  if chunk:
84
  f.write(chunk)
85
  return path
86
+
87
+ # -------------------- 列名選択: 小分類 > 中分類 > 大分類 --------------------
88
+
89
+ def _clean_cell(x) -> str:
90
+ if x is None:
91
+ return ""
92
+ s = str(x).strip()
93
+ if s.lower() == "nan":
94
+ return ""
95
+ return s
96
+
97
  def choose_names_from_multiindex(mi: pd.MultiIndex) -> list[str]:
98
  """
99
+ 3段ヘッダ(MultiIndex)から列名を選ぶ。
100
+ ルール: 小分類(第3段)に値があればそれ、無ければ中分類(第2段)
101
+ それも無ければ大分類(第1段)。すべて空なら 'col'。
102
+ 最後に重複を .1, .2… で解消。
103
  """
104
+ names = []
 
 
 
 
 
 
 
105
  for tpl in mi:
106
+ # tpl (大, 中, 小) 想定
107
+ if len(tpl) < 3:
108
+ # 念のため不足時の安全対策
109
+ a = _clean_cell(tpl[0]) if len(tpl) >= 1 else ""
110
+ b = _clean_cell(tpl[1]) if len(tpl) >= 2 else ""
111
+ c = ""
112
+ else:
113
+ a, b, c = (_clean_cell(tpl[0]), _clean_cell(tpl[1]), _clean_cell(tpl[2]))
114
+ name = c or b or a or "col"
115
+ names.append(name)
116
 
117
  # 重複解消
118
  seen = {}
119
+ out = []
120
+ for n in names:
121
  if n not in seen:
122
  seen[n] = 0
123
+ out.append(n)
124
  else:
125
  seen[n] += 1
126
+ out.append(f"{n}.{seen[n]}")
127
+ return out
 
 
 
128
 
129
+ # -------------------- 読み込みルール --------------------
130
+ # 0行目は削除し、1/2/3行目をヘッダ(= header=[1,2,3])
131
+ HEADER_ROWS = [1, 2, 3]
132
+ # 2枚目以降は 0〜3行目をスキップ(= skiprows=4)、header=None でデータのみ
133
+ SKIP_ROWS_OTHERS = 4
134
 
135
+ def load_excel_first(xls_path: str, sheet_pref: str | None) -> tuple[pd.DataFrame, list[str]]:
136
  """
137
+ 1枚目:
138
+ - header=[1,2,3] で3段ヘッダを読み込み(0行目は自動的に使われない)
139
+ - 左端の列を削除
140
+ - MultiIndex から列名を「小>中>大」の優先で単一行に変換
141
+ 戻り値: (df, chosen_names)
142
  """
143
  sheet = pick_sheet_name(xls_path, sheet_pref)
144
  if not sheet:
 
150
  header=HEADER_ROWS,
151
  dtype=str
152
  )
153
+ # 左端の列を削除
154
  df = df.iloc[:, 1:]
155
+ # 前後空白トリム
156
+ for c in df.select_dtypes(include=["object"]).columns:
157
+ df[c] = df[c].str.strip()
158
+
159
+ # 列名を選択
160
  if isinstance(df.columns, pd.MultiIndex):
161
  chosen = choose_names_from_multiindex(df.columns)
162
  else:
163
+ # 念のため単層だった場合もクリーニング&重複解消
164
+ raw = [_clean_cell(c) or "col" for c in df.columns]
 
 
 
 
165
  seen = {}
166
  chosen = []
167
  for n in raw:
168
+ if n not in seen:
 
 
 
169
  seen[n] = 0
170
  chosen.append(n)
171
+ else:
172
+ seen[n] += 1
173
+ chosen.append(f"{n}.{seen[n]}")
174
  df.columns = chosen
175
+ return df, chosen
176
 
177
+ def load_excel_other(xls_path: str, sheet_pref: str | None, target_cols: list[str]) -> pd.DataFrame | None:
 
 
 
178
  """
179
+ 2枚目以降:
180
+ - skiprows=4, header=None でデータのみ
181
+ - 左端の列を削除
182
+ - 列数が合わなければ切り詰め/ダミー列追加で合わせる
183
+ - 列名を 1枚目の chosen に置換
184
  """
185
  sheet = pick_sheet_name(xls_path, sheet_pref)
186
  if not sheet:
 
193
  skiprows=SKIP_ROWS_OTHERS,
194
  dtype=str
195
  )
196
+ # 左端の列を削除
197
  df = df.iloc[:, 1:]
198
  # 前後空白トリム
199
  for c in df.select_dtypes(include=["object"]).columns:
200
  df[c] = df[c].str.strip()
201
 
202
+ # 列数調整
203
  if df.shape[1] != len(target_cols):
204
  print(f"[WARN] 列数不一致: file={os.path.basename(xls_path)} "
205
  f"read={df.shape[1]} vs target={len(target_cols)} -> 自動調整")
206
  if df.shape[1] > len(target_cols):
207
  df = df.iloc[:, :len(target_cols)]
208
  else:
209
+ # 足りないときは None 列を追加
210
+ for k in range(len(target_cols) - df.shape[1]):
211
+ df[f"_pad_{k}"] = None
 
212
  df = df.iloc[:, :len(target_cols)]
213
 
214
  df.columns = target_cols
215
  return df
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  def zip_paths(paths: list[str], out_zip: str) -> str:
218
  with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as z:
219
  for p in paths:
 
221
  z.write(p, arcname=os.path.basename(p))
222
  return out_zip
223
 
224
+ # -------------------- メイン実行(Gradioから呼ぶ) --------------------
225
 
226
+ def run_job(sheet_name, sleep_sec, limit, re_download, progress=gr.Progress(track_tqdm=False)):
227
  progress(0, desc="初期化中…")
228
 
229
  session = requests.Session()
230
  session.headers.update({
231
+ "User-Agent": "Mozilla/5.0 (compatible; FITCollector/1.3; +https://huggingface.co/spaces)",
232
  "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
233
  })
234
 
235
  # 1) リンク収集
236
  links = collect_pref_links(session)
237
  if not links:
238
+ return ("都道府県ファイルのリンク検出に失敗しました。ページ構成の変更/一時的な制限の可能性があります。",
239
+ None, None, None, None)
240
  if limit and limit > 0:
241
  links = links[:int(limit)]
242
  progress(0.1, desc=f"リンク検出 {len(links)} 件")
 
265
  if not downloaded:
266
  return ("ダウンロードに失敗しました。", None, None, None, None)
267
 
268
+ # 3) 読み込み(1枚目で列名確定)
269
+ progress(0.75, desc="1枚目を読み込み(列名を確定)")
270
  first_path = downloaded[0]
271
  try:
272
  df0, cols0 = load_excel_first(first_path, sheet_name if sheet_name else None)
 
276
 
277
  frames = [df0]
278
 
279
+ # 4) 読み込み(2枚目以降)
280
  for j, p in enumerate(downloaded[1:], start=2):
281
  progress(0.75 + 0.25 * (j - 1) / max(1, len(downloaded) - 1),
282
  desc=f"{j}枚目を読み込み")
 
289
  # 5) 縦結合
290
  combined = pd.concat(frames, ignore_index=True)
291
 
292
+ # 6) 出力
 
 
 
 
293
  os.makedirs(OUTDIR, exist_ok=True)
294
  out_xlsx = os.path.join(OUTDIR, "combined_fit.xlsx")
295
  out_parq = os.path.join(OUTDIR, "combined_fit.parquet")
 
297
  combined.to_excel(w, index=False, sheet_name="combined")
298
  combined.to_parquet(out_parq, index=False)
299
 
300
+ # 7) ZIP(取得ファイル一式)
301
  raw_zip = os.path.join(OUTDIR, "raw_excels.zip")
302
  zip_paths(downloaded, raw_zip)
303
 
304
+ # 8) プレビュー
305
  preview_csv = os.path.join(OUTDIR, "combined_head.csv")
306
  combined.head(1000).to_csv(preview_csv, index=False)
307
 
 
312
  f"・Parquet: combined_fit.parquet\n"
313
  f"・Raw ZIP: raw_excels.zip\n"
314
  f"・プレビュー: combined_head.csv\n"
315
+ f"・列名は『小分類>中分類>大分類』の優先で単一行化(結合は不実施)"
316
  )
317
  return (msg, out_xlsx, out_parq, raw_zip, preview_csv)
318
 
319
+ # -------------------- Gradio UI --------------------
320
 
321
  with gr.Blocks(title="FIT 公表(都道府県別Excel)一括取得&結合") as demo:
322
  gr.Markdown(
323
  """
324
  # FIT 公表(都道府県別Excel)一括取得 & 結合
325
+ **列名ポリシー**:
326
+ - 1枚目: 0行目を使わず、1/2/3行目をヘッダとして読み込み(3段)。
327
+ - 列名は **小分類に値があれば小分類、無ければ中分類のみ**(結合しません)。
328
+ - 2枚目以降: 0〜3行目をスキップし、データのみ読み込み。
329
+ - すべてのファイルで **左端の列は削除**。
330
+ - ファイル名/シート名などのメタ列は付与しません。
331
  """
332
  )
333
  with gr.Row():
 
336
  with gr.Row():
337
  limit = gr.Number(value=None, precision=0, label="先頭N県のみ(テスト用・空欄は全県)")
338
  reget = gr.Checkbox(label="既存ファイルがあっても再ダウンロードする", value=False)
 
 
 
339
 
340
  run_btn = gr.Button("実行", variant="primary")
341
  out_msg = gr.Markdown()
 
346
 
347
  run_btn.click(
348
  fn=run_job,
349
+ inputs=[sheet, sleep, limit, reget],
350
  outputs=[out_msg, out_xlsx, out_parq, out_zip, out_preview]
351
  )
352