syurein commited on
Commit
f1de655
·
1 Parent(s): b69b8cc

main code

Browse files
.gradio/certificate.pem ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
3
+ TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
4
+ cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
5
+ WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
6
+ ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
7
+ MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
8
+ h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
9
+ 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
10
+ A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
11
+ T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
12
+ B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
13
+ B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
14
+ KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
15
+ OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
16
+ jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
17
+ qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
18
+ rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
19
+ HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
20
+ hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
21
+ ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
22
+ 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
23
+ NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
24
+ ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
25
+ TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
26
+ jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
27
+ oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
28
+ 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
29
+ mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
30
+ emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
31
+ -----END CERTIFICATE-----
gmap_scraping_output/html_files/Q001_渋谷 カフェ/R001_N_A_reviews_expanded.html ADDED
The diff for this file is too large to render. See raw diff
 
gmap_scraping_output/scraping_results.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ QueryIndex,OriginalQuery,ResultRank,Status,ExtractedName,ExtractedWebsite,ExtractedPhone,ExtractedAddress,ReviewCount,ReviewsCombined,ExtractionError,PlaceURL,DetailHTMLFilename
2
+ 1,渋谷 カフェ,N/A,Interrupted,Interrupted Query 1,,N/A,N/A,0,,詳細ページ例外処理中に中断リクエスト,N/A,N/A
3
+ 1,渋谷 カフェ,0,Error: Query Level Exception - InvalidSessionIdException,Error (Overall Query 1),,N/A,N/A,0,,,N/A,N/A
maps2.py CHANGED
@@ -15,10 +15,12 @@ import traceback
15
  import io
16
  import contextlib
17
  from datetime import datetime
18
- import threading # スレッド中断のために追加
 
 
19
 
20
  # --- WebDriverの選択 ---
21
- IN_COLAB = 'google.colab' in str(get_ipython()) if 'get_ipython' in globals() else False
22
  if IN_COLAB:
23
  print("Google Colab環境を検出。google_colab_selenium を使用します。")
24
  try: import google_colab_selenium as gs
@@ -36,10 +38,9 @@ else:
36
  ChromeDriverManager = None
37
 
38
  # --- 中断フラグ ---
39
- # スレッドセーフな中断イベントを使用
40
  interrupt_event = threading.Event()
41
 
42
- # --- Helper Functions ---
43
  def find_prefixed_data_string(data_structure):
44
  """データ構造内から ")]}'\n" で始まる文字列を見つける(再帰的検索)"""
45
  if isinstance(data_structure, str) and data_structure.startswith(")]}'\n"):
@@ -127,7 +128,6 @@ def find_details_data_by_id_or_heuristic(data_list, place_id=None):
127
 
128
  return best_candidate
129
 
130
-
131
  def is_domain_like(text):
132
  """文字列がドメイン名らしい形式か簡易的に判定"""
133
  if not isinstance(text, str): return False
@@ -175,6 +175,7 @@ def interruptible_sleep(duration):
175
  # --- HTML抽出関数 (本文抽出を span.wiI7pd 優先に変更、中断チェック追加) ---
176
  def extract_details_and_reviews_from_html(html_content):
177
  """詳細HTMLから基本情報と口コミ情報を抽出 (本文は span.wiI7pd 優先、中断チェックあり)"""
 
178
  print(" [HTML Extractor - Details & Reviews (wiI7pd priority)] 開始")
179
  soup = BeautifulSoup(html_content, 'lxml' if 'lxml' in globals() else 'html.parser')
180
  details = {"name": "N/A", "url": "", "phone": "N/A", "address": "N/A", "links": {}, "reviews": [], "extraction_error": None}
@@ -185,10 +186,10 @@ def extract_details_and_reviews_from_html(html_content):
185
  main_container = soup.select_one(main_container_selector)
186
  search_root = soup # デフォルトはページ全体
187
  if main_container:
188
- print(f" '{main_container_selector}' コンテナ発見。基本情報を抽出。")
189
  search_root = main_container
190
- else:
191
- print(f" 警告: '{main_container_selector}' コンテナが見つかりません。ページ全体から基本情報を抽出。")
192
 
193
  # 名前 (h1タグを探す)
194
  if interrupt_event.is_set(): raise InterruptedError("HTML解析中に中断リクエスト")
@@ -294,15 +295,15 @@ def extract_details_and_reviews_from_html(html_content):
294
  details["url"] = domain_link
295
  elif details["links"]: # linksに何かあれば最初のものをURLとする
296
  details["url"] = next(iter(details["links"].values()))
297
- print(f" 基本情報抽出完了: Name='{details['name']}'")
298
 
299
 
300
  # --- 口コミ情報の抽出 ---
301
- print(" 口コミ情報抽出開始 (span.wiI7pd 優先)...")
302
  review_container_selector = 'div.GHT2ce.NsCY4'
303
  review_container = soup.select_one(review_container_selector)
304
  if review_container:
305
- print(f" '{review_container_selector}' 口コミコンテナ発見。")
306
  # 口コミカードの特定 (jftiEf or MyEned)
307
  review_card_selectors = ['div.jftiEf', 'div.MyEned']
308
  review_cards = []
@@ -310,10 +311,10 @@ def extract_details_and_reviews_from_html(html_content):
310
  if interrupt_event.is_set(): raise InterruptedError("HTML解析中に中断リクエスト")
311
  review_cards = review_container.select(sel)
312
  if review_cards:
313
- print(f" 口コミカードセレクタ '{sel}' で {len(review_cards)} 件発見。")
314
  break
315
- if not review_cards:
316
- print(" 警告: 口コミコンテナ内で口コミカードが見つかりません。")
317
 
318
  extracted_reviews = []
319
  for card_idx, card in enumerate(review_cards):
@@ -351,9 +352,9 @@ def extract_details_and_reviews_from_html(html_content):
351
  extracted_reviews.append({"reviewer": "Error", "rating": "N/A", "text": f"解析エラー: {e_card}"})
352
 
353
  details['reviews'] = extracted_reviews
354
- print(f" 口コミ抽出完了: {len(details['reviews'])} 件")
355
- else:
356
- print(f" 警告: '{review_container_selector}' 口コミコンテナが見つかりません。")
357
 
358
  except InterruptedError as e_interrupt: # 中断エラーをキャッチ
359
  print(f" HTML解析処理が中断されました: {e_interrupt}")
@@ -365,11 +366,11 @@ def extract_details_and_reviews_from_html(html_content):
365
  print(error_trace)
366
  details['extraction_error'] = f"Type: {type(e_extract).__name__}, Msg: {e_extract}\nTrace: {error_trace}"
367
 
368
- print(f" [HTML Extractor - Details & Reviews (wiI7pd priority)] 完了: Name='{details['name']}'")
369
  return details
370
 
371
 
372
- # --- CSV Loading Function (中断チェック追加) ---
373
  def load_queries(csv_path):
374
  """CSVファイルを読み込み、1列目のクエリをリストとして返す(中断チェックあり)"""
375
  queries = []
@@ -454,7 +455,7 @@ def load_queries(csv_path):
454
  return queries
455
 
456
 
457
- # --- Single Query Processing Function (中断チェック強化) ---
458
  def process_single_query_full_list(driver, query, query_index, output_dir, wait_config):
459
  """単一クエリ処理: 検索→リストスクロール→リンク抽出→詳細ページ→口コミタブ→口コミスクロール→「もっと見る」クリック→HTML取得→解析 (中断チェックあり)"""
460
  print(f"\n--- クエリ処理開始 [Index:{query_index}] ---: {query}")
@@ -495,10 +496,6 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
495
  print(" 検索結果リスト表示を確認。")
496
  except TimeoutException as e_timeout:
497
  print(f" エラー: 検索結果リストの表示タイムアウト。URL: {search_url}\n{e_timeout}")
498
- print("--- HTML Snapshot (Timeout) ---")
499
- try: print(driver.page_source[:1000])
500
- except: print(" ページソース取得失敗")
501
- print("--- End Snapshot ---")
502
  results_list.append({'query_index': query_index, 'original_query': query, 'result_rank': 0, 'place_url': search_url, 'html_filename': 'N/A', 'name': f'Error (List Timeout)', 'url': '', 'phone': 'N/A', 'address': 'N/A', 'reviews': [], 'status': f'Error: List Timeout'})
503
  return results_list
504
  except Exception as e_wait:
@@ -525,7 +522,7 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
525
  break
526
  if new_height == last_height:
527
  stuck_count += 1
528
- print(f" 検索リストスクロール高さ変化なし ({stuck_count}回目)。再試行...")
529
  interruptible_sleep(SCROLL_PAUSE_TIME * 1.5) # 中断可能な待機
530
  if interrupt_event.is_set(): raise InterruptedError("検索リストスクロール中に中断リクエスト") # 待機後にもチェック
531
  new_height = driver.execute_script("return arguments[0].scrollHeight", list_container)
@@ -537,7 +534,7 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
537
  last_height = new_height
538
  except Exception as e_scroll:
539
  if interrupt_event.is_set(): raise InterruptedError("検索リストスクロールエラー処理中に中断リクエスト") # エラー処理中もチェック
540
- print(f"★★★★★ 検索リストスクロール中にエラー ★★★★★\n{type(e_scroll).__name__}: {e_scroll}\n--- Traceback ---\n{traceback.format_exc()}\n--- End Traceback ---")
541
  print(" スクロールエラー発生。可能な範囲で続行します。")
542
  scroll_attempts += 1
543
  if scroll_attempts >= MAX_SCROLL_ATTEMPTS:
@@ -553,18 +550,18 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
553
  EC.presence_of_element_located((By.CSS_SELECTOR, list_container_selector))
554
  )
555
  result_cards = list_container_updated.find_elements(By.CSS_SELECTOR, result_card_selector)
556
- print(f" '{result_card_selector}' 要素を {len(result_cards)} 件発見。")
557
 
558
  if not result_cards:
559
- print(f" 警告: '{result_card_selector}' が見つかりません。代替セレクタ 'a.hfpxzc' で試行...")
560
  result_card_selector = 'a.hfpxzc'
561
  result_cards = list_container_updated.find_elements(By.CSS_SELECTOR, result_card_selector)
562
- print(f" 代替セレクタで {len(result_cards)} 件発見。")
563
  if not result_cards:
564
- print(f" 警告: 代替セレクタ 'a.Nv2PK' で試行...")
565
  result_card_selector = 'a.Nv2PK'
566
  result_cards = list_container_updated.find_elements(By.CSS_SELECTOR, result_card_selector)
567
- print(f" 代替セレクタで {len(result_cards)} 件発見。")
568
 
569
  link_extraction_errors = 0
570
  for card_idx, card in enumerate(result_cards):
@@ -629,7 +626,7 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
629
  review_tab_clicked = False
630
  review_scroll_element = None
631
  try:
632
- print(f" {review_tab_text}タブ クリック試行...")
633
  review_tab = WebDriverWait(driver, 10).until(
634
  EC.element_to_be_clickable((By.XPATH, review_tab_xpath))
635
  )
@@ -656,7 +653,7 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
656
  except TimeoutException: print(f" 警告: {review_tab_text}タブまたは口コミコンテナの表示タイムアウト。")
657
  except ElementClickInterceptedException: print(f" 警告: {review_tab_text}タブのクリックが遮られました。")
658
  except NoSuchElementException: print(f" 警告: {review_tab_text}タブが見つかりません。")
659
- except Exception as e_click_review: print(f"★★★★★ {review_tab_text}タブ処理中に予期せぬエラー ★★★★★\n{type(e_click_review).__name__}: {e_click_review}\n--- Traceback ---\n{traceback.format_exc()}\n--- End Traceback ---")
660
 
661
  # --- 口コミエリアのスクロール処理 ---
662
  if review_scroll_element:
@@ -685,7 +682,7 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
685
  review_last_height = review_new_height
686
  except Exception as e_review_scroll:
687
  if interrupt_event.is_set(): raise InterruptedError("口コミスクロールエラー処理中に中断リクエスト")
688
- print(f"★★★★★ 口コミスクロール中にエラー ★★★★★\n{type(e_review_scroll).__name__}: {e_review_scroll}\n--- Traceback ---\n{traceback.format_exc()}\n--- End Traceback ---")
689
  print(" 口コミスクロールエラー発生。可能な範囲で続行します。")
690
  break
691
  review_scroll_attempts += 1
@@ -708,11 +705,11 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
708
  try:
709
  more_buttons = driver.find_elements(By.XPATH, more_buttons_xpath)
710
  if not more_buttons:
711
- if click_attempts == 0: print(" 「もっと見る」ボタンが見つかりませんでした。")
712
- else: print(f" 追加の「もっと見る」ボンは見つかりませんでした (試行 {click_attempts+1}/{max_click_attempts})。")
713
  break
714
 
715
- print(f" 「もっと見る」ボタンを {len(more_buttons)} 個発見 (試行 {click_attempts+1}/{max_click_attempts})。クリック開始...")
716
  for btn_idx, button in enumerate(more_buttons):
717
  if interrupt_event.is_set(): raise InterruptedError("「もっと見る」クリック中に中断リクエスト") # 中断チェック
718
  try:
@@ -729,14 +726,14 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
729
  except StaleElementReferenceException: print(f" ボタン {btn_idx+1} が古くなりました。スキップします。")
730
  except Exception as e_click_more: print(f" ボタン {btn_idx+1} のクリック中にエラー: {e_click_more}")
731
 
732
- print(f" 今回の試行で {buttons_found_this_round} 個の「もっと見る」ボタンをクリックしました。")
733
  if buttons_found_this_round == 0:
734
- print(" これ以上クリックできる「もっと見る」ボタンはありませんでした。")
735
  break
736
 
737
  except Exception as e_find_more:
738
  if interrupt_event.is_set(): raise InterruptedError("「もっと見る」検索エラー処理中に中断リクエスト")
739
- print(f"★★★★★ 「もっと見る」ボタン検索中にエラー ★★★★★\n{type(e_find_more).__name__}: {e_find_more}\n--- Traceback ---\n{traceback.format_exc()}\n--- End Traceback ---")
740
  break
741
  click_attempts += 1
742
  if click_attempts < max_click_attempts:
@@ -744,7 +741,7 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
744
  if interrupt_event.is_set(): raise InterruptedError("「もっと見る」試行間待機中に中断リクエスト")
745
 
746
  if clicked_count > 0: print(f" 合計 {clicked_count} 個の「もっと見る」ボタンをクリックしました。")
747
- else: print(" クリックされた「もっと見る」ボタンはありませんでした。")
748
  interruptible_sleep(WAIT_TIME_BASE * 0.5)
749
  if interrupt_event.is_set(): raise InterruptedError("「もっと見る」クリック後に中断リクエスト")
750
 
@@ -759,12 +756,17 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
759
  except: pass
760
  safe_place_name_part = re.sub(r'[\\/*?:"<>|]', '_', temp_name)[:20].strip() or "no_name"
761
  tab_suffix = "_reviews_expanded" if review_tab_clicked else "_overview"
762
- detail_html_fname = f"Q{query_index:03d}_R{i:03d}_{safe_place_name_part}_{safe_query_part}_detail{tab_suffix}.html"
763
- detail_html_path = os.path.join(output_dir, detail_html_fname)
 
 
 
 
764
  with open(detail_html_path, 'w', encoding='utf-8') as f:
765
  f.write(detail_html_content)
766
- result_details['html_filename'] = detail_html_fname
767
- print(f" HTMLを保存しました: {detail_html_fname}")
 
768
  except Exception as e_save_html:
769
  print(f" HTML取得/保存エラー: {e_save_html}")
770
  result_details['html_filename'] = 'Error Saving HTML'
@@ -787,22 +789,14 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
787
  result_details['status'] = 'Error: Empty HTML Content'
788
 
789
  except TimeoutException as e_timeout_detail:
790
- print(f"★★★★★ 詳細ページ読み込みタイムアウト ★★★★★\nURL: {place_url}\n{e_timeout_detail}")
791
- print("--- HTML Snapshot (Timeout) ---")
792
- try: print(driver.page_source[:1000])
793
- except: print(" ページソース取得失敗")
794
- print("--- End Snapshot ---")
795
  result_details['status'] = f'Error: Detail Page Timeout'; result_details['name'] = f"Error (Timeout R:{i})"
796
  except NoSuchElementException as e_nse:
797
- print(f"★★★★★ 詳細ページで必須要素(h1など)が見つかりません ★★★★★\nURL: {place_url}\n{e_nse}")
798
- print("--- HTML Snapshot (NSE) ---")
799
- try: print(driver.page_source[:1000])
800
- except: print(" ページソース取得失敗")
801
- print("--- End Snapshot ---")
802
  result_details['status'] = f'Error: Detail Page Missing Element (e.g., h1)'; result_details['name'] = f"Error (ElementNotFound R:{i})"
803
  except Exception as e_detail:
804
  if interrupt_event.is_set(): raise InterruptedError("詳細ページ例外処理中に中断リクエスト") # 例外処理中もチェック
805
- print(f"★★★★★ 詳細ページ処理中に予期せぬエラー ★★★★★\nURL: {place_url}\n{type(e_detail).__name__}: {e_detail}\n--- Traceback ---\n{traceback.format_exc()}\n--- End Traceback ---")
806
  result_details['status'] = f'Error: Detail Page Exception - {type(e_detail).__name__}'; result_details['name'] = f"Error (Exception R:{i})"
807
  finally:
808
  # 中断された場合、ステータスを上書き
@@ -824,26 +818,23 @@ def process_single_query_full_list(driver, query, query_index, output_dir, wait_
824
  print(f"--- クエリ処理{status_msg} [Index:{query_index}] - {len(results_list)} 件の結果 ---")
825
  return results_list
826
 
827
- # --- 中断リクエスト用関数 ---
828
  def request_interrupt():
829
  """中断フラグをセットする"""
830
  if not interrupt_event.is_set():
831
  print("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
832
  print("!!! 中断リクエストを受け付けました。 !!!")
833
- print("!!! 現在の処理が完了次第、停止します... !!!")
834
  print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n")
835
  interrupt_event.set()
836
  else:
837
  print("\n--- 中断は既にリクエストされています ---")
838
- # GradioのTextboxに即時反映させるため、ダミーの値を返す
839
- # (clickイベントのoutputsにTextboxを指定する必要があるため)
840
- # 実際にはログは run_scraping 内で更新される
841
  return "[中断リクエスト受信]"
842
 
843
- # --- Gradio Processing Function (中断処理対応) ---
844
  def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
845
  wait_time_base, wait_time_detail, wait_time_search, headless_mode, progress=gr.Progress()):
846
- """Gradioインターフェースから呼び出されるイン処理関数(中断機能付き)"""
847
  log_stream = io.StringIO() # ログ出力用
848
  start_time_total = time.time() # 全体処理時間計測開始
849
  driver = None # WebDriverオブジェクト初期化
@@ -851,6 +842,7 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
851
  total_results_count = 0 # CSV書き込み総行数
852
  total_queries = 0 # 総クエリ数
853
  output_csv_path = None # 出力CSVファイルパス
 
854
  interrupted_flag = False # 処理が中断されたかを示すフラグ
855
 
856
  # --- 中断フラグをリセット ---
@@ -860,19 +852,19 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
860
  # 標準出力と標準エラー出力をログストリームにリダイレクト
861
  with contextlib.redirect_stdout(log_stream), contextlib.redirect_stderr(log_stream):
862
  try:
863
- print("=== 処理開始 ===")
864
  print(f"開始時刻: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
865
  # 入力ファイルチェック
866
  if input_csv_file is None:
867
- print("エラー: CSVファイルが選択されていません。処理を中断します。")
868
- yield log_stream.getvalue(), None # ログと空の結果を返す
869
  return
870
- yield log_stream.getvalue(), None # 初期ログをUIに反映
871
 
872
  # パラメータ設定
873
  SEARCH_QUERIES_CSV_PATH = input_csv_file.name
874
- OUTPUT_DIR = output_dir_name.strip() or "html_reviews_expanded"
875
- OUTPUT_CSV_FILENAME = output_csv_name.strip() or "結果_reviews_expanded.csv"
876
  CSV_ENCODING = csv_encoding
877
  try:
878
  wait_config = {
@@ -882,33 +874,35 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
882
  }
883
  except ValueError:
884
  print("警告: 待機時間に無効な値が入力されました。デフォルト値を使用します。")
885
- wait_config = {'base': 4.0, 'detail': 20.0, 'search': 15.0}
886
  print(f"待機時間設定: 基本={wait_config['base']}秒, 詳細/口コミ={wait_config['detail']}秒, 検索={wait_config['search']}秒")
887
- yield log_stream.getvalue(), None
888
 
889
  # 出力ディレクトリ設定と作成
890
  if not os.path.isabs(OUTPUT_DIR):
891
  OUTPUT_DIR = os.path.join(os.getcwd(), OUTPUT_DIR)
892
- output_csv_path = os.path.join(OUTPUT_DIR, OUTPUT_CSV_FILENAME)
893
- print(f"HTML出力先ディレクトリ: {OUTPUT_DIR}")
 
894
  print(f"CSV出力先ファイル: {output_csv_path}")
895
  os.makedirs(OUTPUT_DIR, exist_ok=True)
896
- yield log_stream.getvalue(), None
 
897
 
898
  # CSVからクエリ読み込み (中断チェックあり)
899
  queries = load_queries(SEARCH_QUERIES_CSV_PATH)
900
- yield log_stream.getvalue(), None
901
  if interrupt_event.is_set(): # 読み込み中に中断されたかチェック
902
  print("CSV読み込み中に中断されたため、処理を終了します。")
903
  interrupted_flag = True
904
  raise InterruptedError("CSV loading interrupted") # 処理を中断フローへ
905
  if not queries:
906
  print("エラー: CSVから処理可能なクエリが見つかりませんでした。処理を終了します。")
907
- yield log_stream.getvalue(), None
908
  return
909
  total_queries = len(queries)
910
  print(f"{total_queries} 件のクエリを処理します。")
911
- yield log_stream.getvalue(), None
912
 
913
  # --- 中断チェック ---
914
  if interrupt_event.is_set(): raise InterruptedError("WebDriver初期化前に中断リクエスト")
@@ -916,7 +910,7 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
916
  # WebDriver初期化
917
  progress(0, desc="WebDriver初期化中...")
918
  print("\nWebDriver初期化中...")
919
- yield log_stream.getvalue(), None
920
  options = Options()
921
  options.add_argument('--no-sandbox')
922
  options.add_argument('--disable-dev-shm-usage')
@@ -966,22 +960,21 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
966
  print(f"エラータイプ: {type(e_wd_init).__name__}")
967
  print(f"エラーメッセージ: {e_wd_init}")
968
  print("--- スタックトレース ---\n", traceback.format_exc(), "\n----------------------")
969
- print("ヒント: ChromeDriverのバージョンとChromeブラウザのバージョンが一致しているか確認してください。")
970
- if not IN_COLAB: print(" `webdriver-manager`がインストールされていない場合は `pip install webdriver-manager` を試してください。")
971
- yield log_stream.getvalue(), None
972
  return
973
- yield log_stream.getvalue(), None
974
 
975
  # --- 中断チェック ---
976
  if interrupt_event.is_set(): raise InterruptedError("CSV処理開始前に中断リクエスト")
977
 
 
978
  csv_header = ['QueryIndex', 'OriginalQuery', 'ResultRank', 'Status', 'ExtractedName',
979
- 'ExtractedWebsite', 'ExtractedPhone', 'ExtractedAddress', 'Reviews',
980
  'ExtractionError', 'PlaceURL', 'DetailHTMLFilename']
981
  file_exists = os.path.exists(output_csv_path)
982
  file_mode = 'a' if file_exists and os.path.getsize(output_csv_path) > 0 else 'w'
983
- print(f"CSVファイルを '{file_mode}' モードで開きます (エンコーディング: {CSV_ENCODING})。")
984
- yield log_stream.getvalue(), None
985
 
986
  try:
987
  with open(output_csv_path, file_mode, newline='', encoding=CSV_ENCODING, errors='replace') as csv_file:
@@ -1003,21 +996,20 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
1003
  progress(i / total_queries, desc=f"クエリ {i}/{total_queries} 処理中: {query[:30]}...")
1004
  start_time_query = time.time()
1005
  print(f"\n===== クエリ {i}/{total_queries} 開始: '{query}' =====")
1006
- yield log_stream.getvalue(), None
1007
 
1008
  results = []
1009
  try:
1010
  # --- 単一クエリのスクレイピング処理実行 (中断例外をキャッチ) ---
1011
- results = process_single_query_full_list(driver, query, i, OUTPUT_DIR, wait_config)
 
1012
  except InterruptedError as e_interrupt_query:
1013
  print(f"クエリ {i} の処理が中断されました: {e_interrupt_query}")
1014
  interrupted_flag = True # メインループに中断を伝える
1015
- # results には中断時点までの結果が入っている可能性がある
1016
  if not any(r['status'] == 'Interrupted' for r in results):
1017
- # results に中断を示すものがなければ追加
1018
  results.append({'query_index': i, 'original_query': query, 'result_rank': 'N/A', 'status': 'Interrupted', 'name': f'Interrupted Query {i}', 'url': '', 'phone': 'N/A', 'address': 'N/A', 'reviews': [], 'extraction_error': str(e_interrupt_query), 'place_url': 'N/A', 'html_filename': 'N/A'})
1019
 
1020
- yield log_stream.getvalue(), None
1021
 
1022
  # --- 取得結果をCSVに書き込み ---
1023
  written_count_query = 0
@@ -1025,30 +1017,39 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
1025
  for result_data in results:
1026
  try:
1027
  reviews_list = result_data.get('reviews', [])
 
1028
  formatted_reviews = ""
1029
  if isinstance(reviews_list, list) and reviews_list:
1030
  review_texts = []
1031
  for idx, review_item in enumerate(reviews_list):
1032
  if isinstance(review_item, dict):
1033
  r_text = str(review_item.get('text', '')).replace('\n', ' ').replace('\r', '')
1034
- review_texts.append(f"[{idx+1}] 投稿者: {review_item.get('reviewer', 'N/A')} | 評価: {review_item.get('rating', 'N/A')} | 本文: {r_text}")
 
 
1035
  elif isinstance(review_item, str):
1036
  review_texts.append(f"[{idx+1}] {review_item.replace('n', ' ').replace('r', '')}")
1037
- formatted_reviews = "\n\n".join(review_texts)
1038
- elif isinstance(reviews_list, str):
 
1039
  formatted_reviews = reviews_list.replace('\n', ' ').replace('\r', '')
1040
 
1041
  extraction_error_msg = result_data.get('extraction_error', '')
1042
  if extraction_error_msg and len(extraction_error_msg) > 500:
1043
  extraction_error_msg = extraction_error_msg[:250] + "..." + extraction_error_msg[-250:]
1044
 
 
1045
  row_data = [
1046
  result_data.get('query_index', i), result_data.get('original_query', query),
1047
  result_data.get('result_rank', 'N/A'), result_data.get('status', 'Unknown'),
1048
  result_data.get('name', 'N/A'), result_data.get('url', ''),
1049
  result_data.get('phone', 'N/A'), result_data.get('address', 'N/A'),
1050
- formatted_reviews, extraction_error_msg,
1051
- result_data.get('place_url', 'N/A'), result_data.get('html_filename', 'N/A')
 
 
 
 
1052
  ]
1053
  writer.writerow(row_data)
1054
  written_count_query += 1
@@ -1063,7 +1064,8 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
1063
  end_time_query = time.time()
1064
  query_status_msg = "中断" if result_data.get('status') == 'Interrupted' else "完了"
1065
  print(f"===== クエリ {i}/{total_queries} {query_status_msg} - {written_count_query}件書き込み, 所要時間: {end_time_query - start_time_query:.2f} 秒 =====")
1066
- yield log_stream.getvalue(), None
 
1067
 
1068
  # 中断フラグが立っていたら、ループを終了
1069
  if interrupted_flag:
@@ -1071,17 +1073,19 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
1071
  break
1072
 
1073
  # --- クエリ間の待機 (中断可能) ---
1074
- if i < total_queries:
1075
  sleep_duration = wait_config['base'] * 1.5 + (hash(query + str(i)) % (wait_config['base'] * 1.5))
1076
  sleep_duration = max(wait_config['base'] * 0.8, min(sleep_duration, wait_config['base'] * 4.0))
1077
  print(f"次のクエリまで {sleep_duration:.2f} 秒待機します...")
1078
- yield log_stream.getvalue(), None
1079
  interruptible_sleep(sleep_duration)
1080
  # 待機後にも中断チェック
1081
  if interrupt_event.is_set():
1082
  print("待機中に中断リクエストを検出。処理を終了します。")
1083
  interrupted_flag = True
1084
  break # ループを抜ける
 
 
1085
  else:
1086
  print("\n全クエリの処理が完了しました。")
1087
 
@@ -1095,14 +1099,12 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
1095
  print(f"エラータイプ: {type(e_csv_loop).__name__}: {e_csv_loop}\n--- Traceback ---\n{traceback.format_exc()}\n----------------------")
1096
 
1097
  except InterruptedError: # run_scraping全体で中断をキャッチ
1098
- print("\n★★★★★ 処理がユーザーによって中断されました ★★★★★")
1099
  interrupted_flag = True # 中断フラグを立てる
1100
- # ここで特別な処理は不要、finallyブロックで終了処理が行われる
1101
  except Exception as e_main:
1102
  print(f"\n★★★★★ メイン処理 (run_scraping) 中に予期せぬエラーが発生しました ★★★★★")
1103
  print(f"エラータイプ: {type(e_main).__name__}: {e_main}")
1104
  print("\n--- スタックトレース ---\n", traceback.format_exc(), "\n----------------------")
1105
- # エラー発生時も、可能な限りログと途中までのCSVを返す
1106
 
1107
  finally:
1108
  # --- 終了処理 ---
@@ -1118,101 +1120,553 @@ def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
1118
  end_time_total = time.time()
1119
  total_duration_seconds = end_time_total - start_time_total
1120
  final_status = "中断" if interrupted_flag else "完了"
1121
- print(f"\n=== 全処理終了 ({final_status}) ===")
1122
  print(f"終了時刻: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
1123
  print(f"処理{final_status}クエリ数: {processed_query_count}/{total_queries if total_queries > 0 else 'N/A'} 件")
1124
  print(f"CSV書き込み総行数: {total_results_count} 件")
1125
  print(f"総処理時間: {total_duration_seconds:.2f} 秒 ({total_duration_seconds/60:.2f} 分)")
1126
  if interrupted_flag:
1127
- print("*** 処理は途中で中断されました ***")
1128
 
1129
  final_log = log_stream.getvalue()
1130
 
1131
  # プログレスバーを完了状態にする
1132
- progress(1.0, desc=f"処理{final_status}")
1133
 
 
 
1134
  if output_csv_path and os.path.exists(output_csv_path) and os.path.getsize(output_csv_path) > 0:
1135
  print(f"結果CSVファイル: {output_csv_path}")
1136
- yield final_log, gr.File(value=output_csv_path, label=f"結果CSVダウンロード ({final_status})")
1137
  elif output_csv_path:
1138
  print(f"警告: 結果CSVファイル '{output_csv_path}' は空または存在しません。")
1139
- yield final_log, None
1140
  else:
1141
  print("結果CSVファイルは生成されませんでした。")
1142
- yield final_log, None
 
 
1143
 
1144
 
1145
- # --- Gradio UI 定義 (中断ボタン追加) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1146
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
1147
- gr.Markdown("# Google Maps スクレイピング (口コミ全件表示試行・中断機能付き)")
1148
  gr.Markdown(
1149
  """
1150
- CSVクエリで検索し、詳細ページで「クチコミ」タブをクリック後、口コミエリアを**最後までスクロール**し、
1151
- さらに**「もっと見る」ボンを全てクリック**して全件表示試みます。
1152
- その後、基本情報と口コミ情報(`span.wiI7pd`優先)を抽出し、CSVに出力します。
1153
- **「処理中断」ボタン**で進行中の処理を安全に停止できます(現在のクエリ完了後)。
1154
-
1155
- **処理フロー:**
1156
- 1. クエリ���索 → リストスクロール → リンク抽出。
1157
- 2. 詳細ページ遷移 → **「クチコミ」タブクリック** → 口コミコンテナ待機。
1158
- 3. **口コミエリアを最後までスクロール**。
1159
- 4. **「もっと見る」ボタンを全てクリック** (複数回試行)。
1160
- 5. HTML取得 → **bs4**で解析 (基本情報: `.aIFcqe`優先, 口コミ本文: `span.wiI7pd`優先)。
1161
- 6. 結果をCSVに出力(HTMLも保存)。
1162
- 7. 各ステップおよび待機中に**中断リクエストをチェック**。
1163
-
1164
- **注意:** ネットワーク状況やサイト構造の変更により時間がかかる、またはエラーが発生する場合があります。
1165
  """
1166
  )
1167
 
1168
- with gr.Row():
1169
- with gr.Column(scale=2):
1170
- gr.Markdown("### 入力ファルと出力設定")
1171
- input_csv_file = gr.File(label="検索クエリCSVファイル (1列目のみ使用)", file_types=[".csv"])
1172
- output_dir_name = gr.Textbox(label="HTML保存先ディレクトリ名", value="html_reviews_expanded")
1173
- output_csv_name = gr.Textbox(label="出力CSVファイ名", value="結果_reviews_expanded.csv")
1174
- csv_encoding = gr.Dropdown(label="出力CSVエコーディング", choices=['utf-8-sig', 'cp932'], value='utf-8-sig')
1175
- headless_mode = gr.Checkbox(label="ヘッドレスモードで実行 (エラー発生時はOFF推奨)", value=True)
1176
- with gr.Column(scale=1):
1177
- gr.Markdown("### ② 待機時間設定 (秒)")
1178
- wait_time_base = gr.Number(label="基本待機", minimum=1, maximum=20, step=0.5, value=4)
1179
- wait_time_detail = gr.Number(label="詳細/口コミ最大待機", minimum=10, maximum=60, step=1, value=25)
1180
- wait_time_search = gr.Number(label="検索リスト最大待機", minimum=5, maximum=60, step=1, value=15)
1181
-
1182
- with gr.Row():
1183
- start_button = gr.Button("処理開始", variant="primary", size="lg", scale=3)
1184
- # --- 中断ボタンを追加 ---
1185
- stop_button = gr.Button("処理中断", variant="stop", size="lg", scale=1)
1186
-
1187
- gr.Markdown("### ③ 処理タスとエラーログ")
1188
- # プログレスバーを追加
1189
- progress_bar = gr.Progress(track_tqdm=True)
1190
- status_textbox = gr.Textbox(label="ログ", lines=25, interactive=False, autoscroll=True, max_lines=2000)
1191
-
1192
- gr.Markdown("### 結果ダウンロード")
1193
- output_csv_download = gr.File(label="結果CSVダウンロード", interactive=False)
1194
-
1195
- # ボタンリック時の動作設定
1196
- # 処理開始ボタン
1197
- start_button.click(
1198
- fn=run_scraping,
1199
- inputs=[input_csv_file, output_dir_name, output_csv_name, csv_encoding,
1200
- wait_time_base, wait_time_detail, wait_time_search, headless_mode],
1201
- outputs=[status_textbox, output_csv_download],
1202
- # progress 引数を渡す
1203
- show_progress='full' # Gradio 組み込みのプグレス表示を使う場合
1204
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1205
 
1206
- # 中断ボタ
1207
- # stop_button.click(fn=request_interrupt, inputs=None, outputs=None, cancels=[start_event]) # Gradioのcancel機能を使う場合
1208
- # cancels 引数を使うには、start_button.click の返り値を変数に受ける必要があるが、
1209
- # 複数出力がある場合はタプルになるなど複雑化する。
1210
- # ここでは、Python側でフラグを立ててチェックする方式を採用。
1211
- # request_interrupt の戻り値を status_textbox に一時的に表示する例
1212
- stop_button.click(fn=request_interrupt, inputs=None, outputs=status_textbox)
 
 
 
 
 
 
 
 
 
 
 
 
 
1213
 
1214
 
1215
  # --- UI起動 ---
1216
  print("Gradio UIを起動します...")
1217
- # queue()で複数ユーザー対応、share=Trueで共有リンク生成
1218
- demo.queue().launch(share=True, debug=False)
 
 
15
  import io
16
  import contextlib
17
  from datetime import datetime
18
+ import threading
19
+ import pandas as pd
20
+ import tempfile
21
 
22
  # --- WebDriverの選択 ---
23
+ IN_COLAB = 'google.colab' in str(globals().get('get_ipython', ''))
24
  if IN_COLAB:
25
  print("Google Colab環境を検出。google_colab_selenium を使用します。")
26
  try: import google_colab_selenium as gs
 
38
  ChromeDriverManager = None
39
 
40
  # --- 中断フラグ ---
 
41
  interrupt_event = threading.Event()
42
 
43
+ # --- Helper Functions (From Script 1) ---
44
  def find_prefixed_data_string(data_structure):
45
  """データ構造内から ")]}'\n" で始まる文字列を見つける(再帰的検索)"""
46
  if isinstance(data_structure, str) and data_structure.startswith(")]}'\n"):
 
128
 
129
  return best_candidate
130
 
 
131
  def is_domain_like(text):
132
  """文字列がドメイン名らしい形式か簡易的に判定"""
133
  if not isinstance(text, str): return False
 
175
  # --- HTML抽出関数 (本文抽出を span.wiI7pd 優先に変更、中断チェック追加) ---
176
  def extract_details_and_reviews_from_html(html_content):
177
  """詳細HTMLから基本情報と口コミ情報を抽出 (本文は span.wiI7pd 優先、中断チェックあり)"""
178
+ # この関数はスクレイピング処理中に呼び出される
179
  print(" [HTML Extractor - Details & Reviews (wiI7pd priority)] 開始")
180
  soup = BeautifulSoup(html_content, 'lxml' if 'lxml' in globals() else 'html.parser')
181
  details = {"name": "N/A", "url": "", "phone": "N/A", "address": "N/A", "links": {}, "reviews": [], "extraction_error": None}
 
186
  main_container = soup.select_one(main_container_selector)
187
  search_root = soup # デフォルトはページ全体
188
  if main_container:
189
+ # print(f" '{main_container_selector}' コンテナ発見。基本情報を抽出。")
190
  search_root = main_container
191
+ # else:
192
+ # print(f" 警告: '{main_container_selector}' コンテナが見つかりません。ページ全体から基本情報を抽出。")
193
 
194
  # 名前 (h1タグを探す)
195
  if interrupt_event.is_set(): raise InterruptedError("HTML解析中に中断リクエスト")
 
295
  details["url"] = domain_link
296
  elif details["links"]: # linksに何かあれば最初のものをURLとする
297
  details["url"] = next(iter(details["links"].values()))
298
+ # print(f" 基本情報抽出完了: Name='{details['name']}'")
299
 
300
 
301
  # --- 口コミ情報の抽出 ---
302
+ # print(" 口コミ情報抽出開始 (span.wiI7pd 優先)...")
303
  review_container_selector = 'div.GHT2ce.NsCY4'
304
  review_container = soup.select_one(review_container_selector)
305
  if review_container:
306
+ # print(f" '{review_container_selector}' 口コミコンテナ発見。")
307
  # 口コミカードの特定 (jftiEf or MyEned)
308
  review_card_selectors = ['div.jftiEf', 'div.MyEned']
309
  review_cards = []
 
311
  if interrupt_event.is_set(): raise InterruptedError("HTML解析中に中断リクエスト")
312
  review_cards = review_container.select(sel)
313
  if review_cards:
314
+ # print(f" 口コミカードセレクタ '{sel}' で {len(review_cards)} 件発見。")
315
  break
316
+ # if not review_cards:
317
+ # print(" 警告: 口コミコンテナ内で口コミカードが見つかりません。")
318
 
319
  extracted_reviews = []
320
  for card_idx, card in enumerate(review_cards):
 
352
  extracted_reviews.append({"reviewer": "Error", "rating": "N/A", "text": f"解析エラー: {e_card}"})
353
 
354
  details['reviews'] = extracted_reviews
355
+ # print(f" 口コミ抽出完了: {len(details['reviews'])} 件")
356
+ # else:
357
+ # print(f" 警告: '{review_container_selector}' 口コミコンテナが見つかりません。")
358
 
359
  except InterruptedError as e_interrupt: # 中断エラーをキャッチ
360
  print(f" HTML解析処理が中断されました: {e_interrupt}")
 
366
  print(error_trace)
367
  details['extraction_error'] = f"Type: {type(e_extract).__name__}, Msg: {e_extract}\nTrace: {error_trace}"
368
 
369
+ # print(f" [HTML Extractor - Details & Reviews (wiI7pd priority)] 完了: Name='{details['name']}'")
370
  return details
371
 
372
 
373
+ # --- CSV Loading Function (From Script 1, 中断チェック追加) ---
374
  def load_queries(csv_path):
375
  """CSVファイルを読み込み、1列目のクエリをリストとして返す(中断チェックあり)"""
376
  queries = []
 
455
  return queries
456
 
457
 
458
+ # --- Single Query Processing Function (From Script 1, 中断チェック強化) ---
459
  def process_single_query_full_list(driver, query, query_index, output_dir, wait_config):
460
  """単一クエリ処理: 検索→リストスクロール→リンク抽出→詳細ページ→口コミタブ→口コミスクロール→「もっと見る」クリック→HTML取得→解析 (中断チェックあり)"""
461
  print(f"\n--- クエリ処理開始 [Index:{query_index}] ---: {query}")
 
496
  print(" 検索結果リスト表示を確認。")
497
  except TimeoutException as e_timeout:
498
  print(f" エラー: 検索結果リストの表示タイムアウト。URL: {search_url}\n{e_timeout}")
 
 
 
 
499
  results_list.append({'query_index': query_index, 'original_query': query, 'result_rank': 0, 'place_url': search_url, 'html_filename': 'N/A', 'name': f'Error (List Timeout)', 'url': '', 'phone': 'N/A', 'address': 'N/A', 'reviews': [], 'status': f'Error: List Timeout'})
500
  return results_list
501
  except Exception as e_wait:
 
522
  break
523
  if new_height == last_height:
524
  stuck_count += 1
525
+ # print(f" 検索リストスクロール高さ変化なし ({stuck_count}回目)。再試行...")
526
  interruptible_sleep(SCROLL_PAUSE_TIME * 1.5) # 中断可能な待機
527
  if interrupt_event.is_set(): raise InterruptedError("検索リストスクロール中に中断リクエスト") # 待機後にもチェック
528
  new_height = driver.execute_script("return arguments[0].scrollHeight", list_container)
 
534
  last_height = new_height
535
  except Exception as e_scroll:
536
  if interrupt_event.is_set(): raise InterruptedError("検索リストスクロールエラー処理中に中断リクエスト") # エラー処理中もチェック
537
+ print(f"★★★★★ 検索リストスクロール中にエラー ★★★★★\n{type(e_scroll).__name__}: {e_scroll}")
538
  print(" スクロールエラー発生。可能な範囲で続行します。")
539
  scroll_attempts += 1
540
  if scroll_attempts >= MAX_SCROLL_ATTEMPTS:
 
550
  EC.presence_of_element_located((By.CSS_SELECTOR, list_container_selector))
551
  )
552
  result_cards = list_container_updated.find_elements(By.CSS_SELECTOR, result_card_selector)
553
+ # print(f" '{result_card_selector}' 要素を {len(result_cards)} 件発見。")
554
 
555
  if not result_cards:
556
+ # print(f" 警告: '{result_card_selector}' が見つかりません。代替セレクタ 'a.hfpxzc' で試行...")
557
  result_card_selector = 'a.hfpxzc'
558
  result_cards = list_container_updated.find_elements(By.CSS_SELECTOR, result_card_selector)
559
+ # print(f" 代替セレクタで {len(result_cards)} 件発見。")
560
  if not result_cards:
561
+ # print(f" 警告: 代替セレクタ 'a.Nv2PK' で試行...")
562
  result_card_selector = 'a.Nv2PK'
563
  result_cards = list_container_updated.find_elements(By.CSS_SELECTOR, result_card_selector)
564
+ # print(f" 代替セレクタで {len(result_cards)} 件発見。")
565
 
566
  link_extraction_errors = 0
567
  for card_idx, card in enumerate(result_cards):
 
626
  review_tab_clicked = False
627
  review_scroll_element = None
628
  try:
629
+ # print(f" {review_tab_text}タブ クリック試行...")
630
  review_tab = WebDriverWait(driver, 10).until(
631
  EC.element_to_be_clickable((By.XPATH, review_tab_xpath))
632
  )
 
653
  except TimeoutException: print(f" 警告: {review_tab_text}タブまたは口コミコンテナの表示タイムアウト。")
654
  except ElementClickInterceptedException: print(f" 警告: {review_tab_text}タブのクリックが遮られました。")
655
  except NoSuchElementException: print(f" 警告: {review_tab_text}タブが見つかりません。")
656
+ except Exception as e_click_review: print(f"★★★★★ {review_tab_text}タブ処理中に予期せぬエラー ★★★★★\n{type(e_click_review).__name__}: {e_click_review}")
657
 
658
  # --- 口コミエリアのスクロール処理 ---
659
  if review_scroll_element:
 
682
  review_last_height = review_new_height
683
  except Exception as e_review_scroll:
684
  if interrupt_event.is_set(): raise InterruptedError("口コミスクロールエラー処理中に中断リクエスト")
685
+ print(f"★★★★★ 口コミスクロール中にエラー ★★★★★\n{type(e_review_scroll).__name__}: {e_review_scroll}")
686
  print(" 口コミスクロールエラー発生。可能な範囲で続行します。")
687
  break
688
  review_scroll_attempts += 1
 
705
  try:
706
  more_buttons = driver.find_elements(By.XPATH, more_buttons_xpath)
707
  if not more_buttons:
708
+ # if click_attempts == 0: print(" 「もっと見る」ボタンが見つかりませんでした。")
709
+ # else: print(f" 追加の「もっと見る」ボ��ンは見つかりませんでした (試行 {click_attempts+1}/{max_click_attempts})。")
710
  break
711
 
712
+ # print(f" 「もっと見る」ボタンを {len(more_buttons)} 個発見 (試行 {click_attempts+1}/{max_click_attempts})。クリック開始...")
713
  for btn_idx, button in enumerate(more_buttons):
714
  if interrupt_event.is_set(): raise InterruptedError("「もっと見る」クリック中に中断リクエスト") # 中断チェック
715
  try:
 
726
  except StaleElementReferenceException: print(f" ボタン {btn_idx+1} が古くなりました。スキップします。")
727
  except Exception as e_click_more: print(f" ボタン {btn_idx+1} のクリック中にエラー: {e_click_more}")
728
 
729
+ # print(f" 今回の試行で {buttons_found_this_round} 個の「もっと見る」ボタンをクリックしました。")
730
  if buttons_found_this_round == 0:
731
+ # print(" これ以上クリックできる「もっと見る」ボタンはありませんでした。")
732
  break
733
 
734
  except Exception as e_find_more:
735
  if interrupt_event.is_set(): raise InterruptedError("「もっと見る」検索エラー処理中に中断リクエスト")
736
+ print(f"★★★★★ 「もっと見る」ボタン検索中にエラー ★★★★★\n{type(e_find_more).__name__}: {e_find_more}")
737
  break
738
  click_attempts += 1
739
  if click_attempts < max_click_attempts:
 
741
  if interrupt_event.is_set(): raise InterruptedError("「もっと見る」試行間待機中に中断リクエスト")
742
 
743
  if clicked_count > 0: print(f" 合計 {clicked_count} 個の「もっと見る」ボタンをクリックしました。")
744
+ # else: print(" クリックされた「もっと見る」ボタンはありませんでした。")
745
  interruptible_sleep(WAIT_TIME_BASE * 0.5)
746
  if interrupt_event.is_set(): raise InterruptedError("「もっと見る」クリック後に中断リクエスト")
747
 
 
756
  except: pass
757
  safe_place_name_part = re.sub(r'[\\/*?:"<>|]', '_', temp_name)[:20].strip() or "no_name"
758
  tab_suffix = "_reviews_expanded" if review_tab_clicked else "_overview"
759
+ # クエリごとのサブディレクトリを作成
760
+ query_subdir = os.path.join(output_dir, f"Q{query_index:03d}_{safe_query_part}")
761
+ os.makedirs(query_subdir, exist_ok=True)
762
+ detail_html_fname = f"R{i:03d}_{safe_place_name_part}{tab_suffix}.html"
763
+ detail_html_path = os.path.join(query_subdir, detail_html_fname)
764
+
765
  with open(detail_html_path, 'w', encoding='utf-8') as f:
766
  f.write(detail_html_content)
767
+ # 相対パスを保存
768
+ result_details['html_filename'] = os.path.join(f"Q{query_index:03d}_{safe_query_part}", detail_html_fname)
769
+ print(f" HTMLを保存しました: {result_details['html_filename']}")
770
  except Exception as e_save_html:
771
  print(f" HTML取得/保存エラー: {e_save_html}")
772
  result_details['html_filename'] = 'Error Saving HTML'
 
789
  result_details['status'] = 'Error: Empty HTML Content'
790
 
791
  except TimeoutException as e_timeout_detail:
792
+ print(f"★★★★★ 詳細ページ読み込みタイムアウト ★★★★★\nURL: {place_url}")
 
 
 
 
793
  result_details['status'] = f'Error: Detail Page Timeout'; result_details['name'] = f"Error (Timeout R:{i})"
794
  except NoSuchElementException as e_nse:
795
+ print(f"★★★★★ 詳細ページで必須要素(h1など)が見つかりません ★★★★★\nURL: {place_url}")
 
 
 
 
796
  result_details['status'] = f'Error: Detail Page Missing Element (e.g., h1)'; result_details['name'] = f"Error (ElementNotFound R:{i})"
797
  except Exception as e_detail:
798
  if interrupt_event.is_set(): raise InterruptedError("詳細ページ例外処理中に中断リクエスト") # 例外処理中もチェック
799
+ print(f"★★★★★ 詳細ページ処理中に予期せぬエラー ★★★★★\nURL: {place_url}\n{type(e_detail).__name__}: {e_detail}")
800
  result_details['status'] = f'Error: Detail Page Exception - {type(e_detail).__name__}'; result_details['name'] = f"Error (Exception R:{i})"
801
  finally:
802
  # 中断された場合、ステータスを上書き
 
818
  print(f"--- クエリ処理{status_msg} [Index:{query_index}] - {len(results_list)} 件の結果 ---")
819
  return results_list
820
 
821
+ # --- 中断リクエスト用関数 (From Script 1) ---
822
  def request_interrupt():
823
  """中断フラグをセットする"""
824
  if not interrupt_event.is_set():
825
  print("\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
826
  print("!!! 中断リクエストを受け付けました。 !!!")
827
+ print("!!! 現在のスクレイピング処理が完了次第、停止します... !!!")
828
  print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n")
829
  interrupt_event.set()
830
  else:
831
  print("\n--- 中断は既にリクエストされています ---")
 
 
 
832
  return "[中断リクエスト受信]"
833
 
834
+ # --- Gradio Processing Function (From Script 1, 中断処理対応, 途中ダウンロード削除) ---
835
  def run_scraping(input_csv_file, output_dir_name, output_csv_name, csv_encoding,
836
  wait_time_base, wait_time_detail, wait_time_search, headless_mode, progress=gr.Progress()):
837
+ """Gradioインターフェースから呼び出されるスクレ処理関数"""
838
  log_stream = io.StringIO() # ログ出力用
839
  start_time_total = time.time() # 全体処理時間計測開始
840
  driver = None # WebDriverオブジェクト初期化
 
842
  total_results_count = 0 # CSV書き込み総行数
843
  total_queries = 0 # 総クエリ数
844
  output_csv_path = None # 出力CSVファイルパス
845
+ html_base_output_dir = None # HTML出力ベースディレクトリ
846
  interrupted_flag = False # 処理が中断されたかを示すフラグ
847
 
848
  # --- 中断フラグをリセット ---
 
852
  # 標準出力と標準エラー出力をログストリームにリダイレクト
853
  with contextlib.redirect_stdout(log_stream), contextlib.redirect_stderr(log_stream):
854
  try:
855
+ print("=== スクレイピング処理開始 ===")
856
  print(f"開始時刻: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
857
  # 入力ファイルチェック
858
  if input_csv_file is None:
859
+ print("エラー: クエリCSVファイルが選択されていません。処理を中断します。")
860
+ yield log_stream.getvalue(), None, None # ログ, 結果CSV, HTMLフォルダパス
861
  return
862
+ yield log_stream.getvalue(), None, None # 初期ログをUIに反映
863
 
864
  # パラメータ設定
865
  SEARCH_QUERIES_CSV_PATH = input_csv_file.name
866
+ OUTPUT_DIR = output_dir_name.strip() or "gmap_scraping_output"
867
+ OUTPUT_CSV_FILENAME = output_csv_name.strip() or "scraping_results.csv"
868
  CSV_ENCODING = csv_encoding
869
  try:
870
  wait_config = {
 
874
  }
875
  except ValueError:
876
  print("警告: 待機時間に無効な値が入力されました。デフォルト値を使用します。")
877
+ wait_config = {'base': 4.0, 'detail': 25.0, 'search': 15.0}
878
  print(f"待機時間設定: 基本={wait_config['base']}秒, 詳細/口コミ={wait_config['detail']}秒, 検索={wait_config['search']}秒")
879
+ yield log_stream.getvalue(), None, None
880
 
881
  # 出力ディレクトリ設定と作成
882
  if not os.path.isabs(OUTPUT_DIR):
883
  OUTPUT_DIR = os.path.join(os.getcwd(), OUTPUT_DIR)
884
+ html_base_output_dir = os.path.join(OUTPUT_DIR, "html_files") # HTML保存用サブディレクトリ
885
+ output_csv_path = os.path.join(OUTPUT_DIR, OUTPUT_CSV_FILENAME) # CSVはメインディレクトリ
886
+ print(f"HTML出力先ベースディレクトリ: {html_base_output_dir}")
887
  print(f"CSV出力先ファイル: {output_csv_path}")
888
  os.makedirs(OUTPUT_DIR, exist_ok=True)
889
+ os.makedirs(html_base_output_dir, exist_ok=True) # HTML用サブディレクトリも作成
890
+ yield log_stream.getvalue(), None, html_base_output_dir # HTMLフォルダパスを返す
891
 
892
  # CSVからクエリ読み込み (中断チェックあり)
893
  queries = load_queries(SEARCH_QUERIES_CSV_PATH)
894
+ yield log_stream.getvalue(), None, html_base_output_dir
895
  if interrupt_event.is_set(): # 読み込み中に中断されたかチェック
896
  print("CSV読み込み中に中断されたため、処理を終了します。")
897
  interrupted_flag = True
898
  raise InterruptedError("CSV loading interrupted") # 処理を中断フローへ
899
  if not queries:
900
  print("エラー: CSVから処理可能なクエリが見つかりませんでした。処理を終了します。")
901
+ yield log_stream.getvalue(), None, html_base_output_dir
902
  return
903
  total_queries = len(queries)
904
  print(f"{total_queries} 件のクエリを処理します。")
905
+ yield log_stream.getvalue(), None, html_base_output_dir
906
 
907
  # --- 中断チェック ---
908
  if interrupt_event.is_set(): raise InterruptedError("WebDriver初期化前に中断リクエスト")
 
910
  # WebDriver初期化
911
  progress(0, desc="WebDriver初期化中...")
912
  print("\nWebDriver初期化中...")
913
+ yield log_stream.getvalue(), None, html_base_output_dir
914
  options = Options()
915
  options.add_argument('--no-sandbox')
916
  options.add_argument('--disable-dev-shm-usage')
 
960
  print(f"エラータイプ: {type(e_wd_init).__name__}")
961
  print(f"エラーメッセージ: {e_wd_init}")
962
  print("--- スタックトレース ---\n", traceback.format_exc(), "\n----------------------")
963
+ yield log_stream.getvalue(), None, html_base_output_dir
 
 
964
  return
965
+ yield log_stream.getvalue(), None, html_base_output_dir
966
 
967
  # --- 中断チェック ---
968
  if interrupt_event.is_set(): raise InterruptedError("CSV処理開始前に中断リクエスト")
969
 
970
+ # CSVヘッダーを定義 (口コミ本文も個別列にするか検討 → 結合文字列でよさそう)
971
  csv_header = ['QueryIndex', 'OriginalQuery', 'ResultRank', 'Status', 'ExtractedName',
972
+ 'ExtractedWebsite', 'ExtractedPhone', 'ExtractedAddress', 'ReviewCount', 'ReviewsCombined',
973
  'ExtractionError', 'PlaceURL', 'DetailHTMLFilename']
974
  file_exists = os.path.exists(output_csv_path)
975
  file_mode = 'a' if file_exists and os.path.getsize(output_csv_path) > 0 else 'w'
976
+ print(f"結果CSVファイルを '{file_mode}' モードで開きます (パス: {output_csv_path}, エンコーディング: {CSV_ENCODING})。")
977
+ yield log_stream.getvalue(), None, html_base_output_dir
978
 
979
  try:
980
  with open(output_csv_path, file_mode, newline='', encoding=CSV_ENCODING, errors='replace') as csv_file:
 
996
  progress(i / total_queries, desc=f"クエリ {i}/{total_queries} 処理中: {query[:30]}...")
997
  start_time_query = time.time()
998
  print(f"\n===== クエリ {i}/{total_queries} 開始: '{query}' =====")
999
+ yield log_stream.getvalue(), None, html_base_output_dir
1000
 
1001
  results = []
1002
  try:
1003
  # --- 単一クエリのスクレイピング処理実行 (中断例外をキャッチ) ---
1004
+ # HTML保存先として html_base_output_dir を渡す
1005
+ results = process_single_query_full_list(driver, query, i, html_base_output_dir, wait_config)
1006
  except InterruptedError as e_interrupt_query:
1007
  print(f"クエリ {i} の処理が中断されました: {e_interrupt_query}")
1008
  interrupted_flag = True # メインループに中断を伝える
 
1009
  if not any(r['status'] == 'Interrupted' for r in results):
 
1010
  results.append({'query_index': i, 'original_query': query, 'result_rank': 'N/A', 'status': 'Interrupted', 'name': f'Interrupted Query {i}', 'url': '', 'phone': 'N/A', 'address': 'N/A', 'reviews': [], 'extraction_error': str(e_interrupt_query), 'place_url': 'N/A', 'html_filename': 'N/A'})
1011
 
1012
+ yield log_stream.getvalue(), None, html_base_output_dir
1013
 
1014
  # --- 取得結果をCSVに書き込み ---
1015
  written_count_query = 0
 
1017
  for result_data in results:
1018
  try:
1019
  reviews_list = result_data.get('reviews', [])
1020
+ review_count = 0
1021
  formatted_reviews = ""
1022
  if isinstance(reviews_list, list) and reviews_list:
1023
  review_texts = []
1024
  for idx, review_item in enumerate(reviews_list):
1025
  if isinstance(review_item, dict):
1026
  r_text = str(review_item.get('text', '')).replace('\n', ' ').replace('\r', '')
1027
+ reviewer = review_item.get('reviewer', 'N/A')
1028
+ rating = review_item.get('rating', 'N/A')
1029
+ review_texts.append(f"[{idx+1}] {reviewer} ({rating}): {r_text}")
1030
  elif isinstance(review_item, str):
1031
  review_texts.append(f"[{idx+1}] {review_item.replace('n', ' ').replace('r', '')}")
1032
+ formatted_reviews = " || ".join(review_texts) # 区切り文字で結合
1033
+ review_count = len(reviews_list)
1034
+ elif isinstance(reviews_list, str): # 文字列の場合(エラーメッセージなど)
1035
  formatted_reviews = reviews_list.replace('\n', ' ').replace('\r', '')
1036
 
1037
  extraction_error_msg = result_data.get('extraction_error', '')
1038
  if extraction_error_msg and len(extraction_error_msg) > 500:
1039
  extraction_error_msg = extraction_error_msg[:250] + "..." + extraction_error_msg[-250:]
1040
 
1041
+ # CSVヘッダーに合わせてデータを準備
1042
  row_data = [
1043
  result_data.get('query_index', i), result_data.get('original_query', query),
1044
  result_data.get('result_rank', 'N/A'), result_data.get('status', 'Unknown'),
1045
  result_data.get('name', 'N/A'), result_data.get('url', ''),
1046
  result_data.get('phone', 'N/A'), result_data.get('address', 'N/A'),
1047
+ review_count, # レビュー数
1048
+ formatted_reviews, # 結合されたレビュー文字列
1049
+ extraction_error_msg,
1050
+ result_data.get('place_url', 'N/A'),
1051
+ # HTMLファイル名は output_dir からの相対パス
1052
+ result_data.get('html_filename', 'N/A')
1053
  ]
1054
  writer.writerow(row_data)
1055
  written_count_query += 1
 
1064
  end_time_query = time.time()
1065
  query_status_msg = "中断" if result_data.get('status') == 'Interrupted' else "完了"
1066
  print(f"===== クエリ {i}/{total_queries} {query_status_msg} - {written_count_query}件書き込み, 所要時間: {end_time_query - start_time_query:.2f} 秒 =====")
1067
+ # ここで部分的なCSVを yield しないように変更
1068
+ yield log_stream.getvalue(), None, html_base_output_dir
1069
 
1070
  # 中断フラグが立っていたら、ループを終了
1071
  if interrupted_flag:
 
1073
  break
1074
 
1075
  # --- クエリ間の待機 (中断可能) ---
1076
+ if i < total_queries and not interrupted_flag:
1077
  sleep_duration = wait_config['base'] * 1.5 + (hash(query + str(i)) % (wait_config['base'] * 1.5))
1078
  sleep_duration = max(wait_config['base'] * 0.8, min(sleep_duration, wait_config['base'] * 4.0))
1079
  print(f"次のクエリまで {sleep_duration:.2f} 秒待機します...")
1080
+ yield log_stream.getvalue(), None, html_base_output_dir
1081
  interruptible_sleep(sleep_duration)
1082
  # 待機後にも中断チェック
1083
  if interrupt_event.is_set():
1084
  print("待機中に中断リクエストを検出。処理を終了します。")
1085
  interrupted_flag = True
1086
  break # ループを抜ける
1087
+ elif interrupted_flag:
1088
+ pass # 中断されたら待機しない
1089
  else:
1090
  print("\n全クエリの処理が完了しました。")
1091
 
 
1099
  print(f"エラータイプ: {type(e_csv_loop).__name__}: {e_csv_loop}\n--- Traceback ---\n{traceback.format_exc()}\n----------------------")
1100
 
1101
  except InterruptedError: # run_scraping全体で中断をキャッチ
1102
+ print("\n★★★★★ スクレイピング処理がユーザーによって中断されました ★★★★★")
1103
  interrupted_flag = True # 中断フラグを立てる
 
1104
  except Exception as e_main:
1105
  print(f"\n★★★★★ メイン処理 (run_scraping) 中に予期せぬエラーが発生しました ★★★★★")
1106
  print(f"エラータイプ: {type(e_main).__name__}: {e_main}")
1107
  print("\n--- スタックトレース ---\n", traceback.format_exc(), "\n----------------------")
 
1108
 
1109
  finally:
1110
  # --- 終了処理 ---
 
1120
  end_time_total = time.time()
1121
  total_duration_seconds = end_time_total - start_time_total
1122
  final_status = "中断" if interrupted_flag else "完了"
1123
+ print(f"\n=== スクレイピング全処理終了 ({final_status}) ===")
1124
  print(f"終了時刻: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
1125
  print(f"処理{final_status}クエリ数: {processed_query_count}/{total_queries if total_queries > 0 else 'N/A'} 件")
1126
  print(f"CSV書き込み総行数: {total_results_count} 件")
1127
  print(f"総処理時間: {total_duration_seconds:.2f} 秒 ({total_duration_seconds/60:.2f} 分)")
1128
  if interrupted_flag:
1129
+ print("*** スクレイピング処理は途中で中断されました ***")
1130
 
1131
  final_log = log_stream.getvalue()
1132
 
1133
  # プログレスバーを完了状態にする
1134
+ progress(1.0, desc=f"スクレイピング処理 {final_status}")
1135
 
1136
+ # 最終的なCSVファイルのパスを返す
1137
+ final_csv_output = None
1138
  if output_csv_path and os.path.exists(output_csv_path) and os.path.getsize(output_csv_path) > 0:
1139
  print(f"結果CSVファイル: {output_csv_path}")
1140
+ final_csv_output = gr.File(value=output_csv_path, label=f"スクレイピング結果CSV ({final_status})")
1141
  elif output_csv_path:
1142
  print(f"警告: 結果CSVファイル '{output_csv_path}' は空または存在しません。")
 
1143
  else:
1144
  print("結果CSVファイルは生成されませんでした。")
1145
+
1146
+ # HTMLフォルダパスも返す
1147
+ yield final_log, final_csv_output, html_base_output_dir
1148
 
1149
 
1150
+ # --- Helper Functions (From Script 2) ---
1151
+ def normalize_folder_path(folder_path):
1152
+ """フォルダパスを正規化する関数"""
1153
+ try:
1154
+ if not isinstance(folder_path, str): return None
1155
+ # 引用符や余分なスペースを削除
1156
+ folder_path = folder_path.strip().strip('"').strip("'")
1157
+ # バックスラッシュをスラッシュに変換し、正規化
1158
+ folder_path = os.path.normpath(folder_path).replace("\\", "/")
1159
+ return folder_path
1160
+ except Exception as e:
1161
+ print(f"フォルダパス正規化エラー: {e}")
1162
+ return None
1163
+
1164
+ def extract_shop_name_from_html_filename(filename):
1165
+ """HTMLファイル名から店名を抽出する関数 (拡張)"""
1166
+ try:
1167
+ # 例: R001_店舗名_reviews_expanded.html
1168
+ # 例: R001_店舗名_overview.html
1169
+ # 例: Q001_R001_店舗名_クエリ_detail_reviews_expanded.html (古い形式も考慮)
1170
+ base = os.path.basename(filename)
1171
+ # まず拡張子と既知の接尾辞を削除
1172
+ base = re.sub(r'(_reviews_expanded|_overview)?\.html$', '', base)
1173
+ # ランキング部分 (Rxxx_ または Qxxx_Rxxx_) を削除
1174
+ base = re.sub(r'^(Q\d+_)?R\d+_', '', base)
1175
+ # 古い形式の可能性のある接尾辞を削除
1176
+ base = re.sub(r'_detail_overview$|_detail_reviews_expanded$|_detailRESS$', '', base)
1177
+ # 残った部分を店名とする (前後のアンダースコアや空白トリム)
1178
+ shop_name = base.replace('_', ' ').strip()
1179
+ return shop_name if shop_name else filename
1180
+ except:
1181
+ return filename # エラー時は元のファイル名を返す
1182
+
1183
+ def collect_reviews_from_html(folder_path, progress=gr.Progress()):
1184
+ """指定フォルダ内のHTMLから口コミデータを収集する関数"""
1185
+ reviews_data = []
1186
+ log_stream = io.StringIO()
1187
+
1188
+ # フォルダパスを正規化
1189
+ folder_path = normalize_folder_path(folder_path)
1190
+ if not folder_path:
1191
+ print("エラー: 無効なHTMLフォルダパスです。", file=log_stream)
1192
+ return pd.DataFrame(), log_stream.getvalue()
1193
+
1194
+ # フォルダの存在確認
1195
+ if not os.path.exists(folder_path):
1196
+ print(f"エラー: フォルダ '{folder_path}' が見つかりません。", file=log_stream)
1197
+ return pd.DataFrame(), log_stream.getvalue()
1198
+
1199
+ # フォルダ内のすべてのHTMLファイルを処理対象とする
1200
+ try:
1201
+ all_files = []
1202
+ # 再帰的にサブディレクトリも探索
1203
+ for root, _, files in os.walk(folder_path):
1204
+ for filename in files:
1205
+ if filename.lower().endswith(".html"):
1206
+ all_files.append(os.path.join(root, filename))
1207
+
1208
+ if not all_files:
1209
+ print(f"警告: '{folder_path}' 以下にHTMLファイルが見つかりません。", file=log_stream)
1210
+ return pd.DataFrame(), log_stream.getvalue()
1211
+
1212
+ print(f"処理対象のHTMLファイル数: {len(all_files)}", file=log_stream)
1213
+
1214
+ total_files = len(all_files)
1215
+ for i, file_path in enumerate(all_files):
1216
+ progress(i / total_files, desc=f"HTML解析中 {i}/{total_files}")
1217
+ filename = os.path.basename(file_path)
1218
+ relative_path = os.path.relpath(file_path, folder_path) # ベースフォルダからの相対パス
1219
+
1220
+ # 店名をファイル名から抽出
1221
+ shop_name = extract_shop_name_from_html_filename(filename)
1222
+
1223
+ # HTMLファイルを読み込む
1224
+ try:
1225
+ with open(file_path, "r", encoding="utf-8") as file:
1226
+ html_content = file.read()
1227
+ except Exception as e:
1228
+ print(f"ファイル '{relative_path}' の読み込みエラー: {e}", file=log_stream)
1229
+ continue
1230
+
1231
+ # BeautifulSoupでパース
1232
+ soup = BeautifulSoup(html_content, 'lxml' if 'lxml' in globals() else 'html.parser')
1233
+
1234
+ # 基本情報も念のため抽出 (h1があれば店名として優先)
1235
+ h1_tag = soup.find('h1')
1236
+ if h1_tag:
1237
+ shop_name = h1_tag.get_text(strip=True)
1238
+
1239
+ # 口コミカードを取得 (Script 1の抽出ロジックに合わせる: jftiEf or MyEned)
1240
+ review_card_selectors = ['div.jftiEf', 'div.MyEned']
1241
+ review_cards = []
1242
+ for sel in review_card_selectors:
1243
+ review_cards = soup.select(sel)
1244
+ if review_cards:
1245
+ break
1246
+
1247
+ if not review_cards:
1248
+ # print(f"ファイル '{relative_path}' に口コミデータが見つかりません (jftiEf/MyEned)。", file=log_stream)
1249
+ # 口コミがなくても、店舗情報だけは記録するかもしれない(オプション)
1250
+ # reviews_data.append({
1251
+ # "ファイル名": relative_path, "店名": shop_name, "投稿者": "N/A",
1252
+ # "投稿者情報": "N/A", "評価": "N/A", "投稿時期": "N/A",
1253
+ # "口コミ本文": "口コミなし", "オーナーからの返信": "N/A"
1254
+ # })
1255
+ continue
1256
+
1257
+ for card_idx, card in enumerate(review_cards):
1258
+ try:
1259
+ # 投稿者名 (.d4r55)
1260
+ reviewer_el = card.select_one('.d4r55')
1261
+ reviewer_name = reviewer_el.get_text(strip=True) if reviewer_el else "不明"
1262
+
1263
+ # 投稿者情報(ローカルガイド情報など .RfnDt)
1264
+ reviewer_info_el = card.select_one('.RfnDt')
1265
+ reviewer_details = reviewer_info_el.get_text(strip=True) if reviewer_info_el else "なし"
1266
+
1267
+ # 評価 (.kvMYJc aria-label)
1268
+ rating_el = card.select_one('.kvMYJc')
1269
+ rating_value = "不明"
1270
+ if rating_el and rating_el.get('aria-label'):
1271
+ match = re.search(r'星 (\d+(\.\d+)?)', rating_el['aria-label'])
1272
+ if match: rating_value = f"星 {match.group(1)}" # "星 X.X" 形式で保存
1273
+
1274
+ # 投稿時期 (.rsqaWe)
1275
+ post_time_el = card.select_one('.rsqaWe')
1276
+ post_time_value = post_time_el.get_text(strip=True) if post_time_el else "不明"
1277
+
1278
+ # 口コミ本文 (span.wiI7pd 優先)
1279
+ review_text_el = card.select_one('span.wiI7pd')
1280
+ if not review_text_el:
1281
+ review_text_el = card.select_one('span[jscontroller="MZnM8e"]') # フォールバック
1282
+ review_content = review_text_el.get_text(strip=True) if review_text_el else "なし"
1283
+
1284
+ # オーナーからの返信 (.CDe7pd) - 注意: これは古いセレクタかもしれない
1285
+ # 新しい構造では返信は別のdiv構造になっている可能性がある
1286
+ # 簡単のため、一旦 .CDe7pd を試す
1287
+ owner_response_el = card.select_one('.CDe7pd')
1288
+ owner_response_text = owner_response_el.get_text(strip=True) if owner_response_el else "なし"
1289
+
1290
+ # データを辞書形式で保存
1291
+ review_data = {
1292
+ "ファイル名": relative_path,
1293
+ "店名": shop_name,
1294
+ "投稿者": reviewer_name,
1295
+ "投稿者情報": reviewer_details,
1296
+ "評価": rating_value,
1297
+ "投稿時期": post_time_value,
1298
+ "口コミ本文": review_content,
1299
+ "オーナーからの返信": owner_response_text
1300
+ }
1301
+ reviews_data.append(review_data)
1302
+ except Exception as e_card:
1303
+ print(f"ファイル '{relative_path}' の口コミカード {card_idx+1} の解析中にエラー: {e_card}", file=log_stream)
1304
+
1305
+ progress(1.0, desc="HTML解析完了")
1306
+
1307
+ except Exception as e_folder:
1308
+ print(f"フォルダ '{folder_path}' の処理中に予期せぬエラー: {e_folder}", file=log_stream)
1309
+ print(traceback.format_exc(), file=log_stream)
1310
+ return pd.DataFrame(), log_stream.getvalue()
1311
+
1312
+ # DataFrameに変換
1313
+ df = pd.DataFrame(reviews_data)
1314
+ if df.empty:
1315
+ print("警告: 口コミデータが収集できませんでした。", file=log_stream)
1316
+ else:
1317
+ print(f"収集された口コミデータ件数: {len(df)}", file=log_stream)
1318
+ # print("DataFrameの列:", df.columns.tolist(), file=log_stream) # デバッグ用
1319
+
1320
+ return df, log_stream.getvalue()
1321
+
1322
+ # --- Functions for Review Search Tab ---
1323
+
1324
+ def get_csv_columns_safe(csv_file_obj):
1325
+ """アップロードされたCSVファイルから安全に列名を取得する"""
1326
+ if csv_file_obj is None:
1327
+ return gr.Dropdown(choices=[], label="検索対象の列 (CSVをアップロードしてください)")
1328
+ try:
1329
+ # pandasで読み込んで列名を取得
1330
+ # TODO: エンコーディング自動判別を追加した方が良いかも
1331
+ df_peek = pd.read_csv(csv_file_obj.name, nrows=5) # 先頭数行だけ読む
1332
+ columns = df_peek.columns.tolist()
1333
+ # "ReviewsCombined" や "口コミ" など、検索に適した列をデフォルトで選択させる候補
1334
+ default_col = next((c for c in columns if c.lower() in ['reviewscombined', '口コミ', '口コミ本文', 'text', 'review']), columns[0] if columns else None)
1335
+ return gr.Dropdown(choices=columns, value=default_col, label="検索対象の列")
1336
+ except Exception as e:
1337
+ print(f"CSV列名取得エラー: {e}")
1338
+ return gr.Dropdown(choices=[], label=f"列名取得エラー: {e}")
1339
+
1340
+ def search_reviews_controller(search_source, html_folder_path, uploaded_csv_file, search_column, keyword, progress=gr.Progress()):
1341
+ """口コミ検索のコントローラー関数"""
1342
+ log_stream = io.StringIO()
1343
+ df = pd.DataFrame()
1344
+ search_results_df = pd.DataFrame()
1345
+ temp_csv_path = None
1346
+ results_text = ""
1347
+
1348
+ print(f"検索ソース: {search_source}", file=log_stream)
1349
+
1350
+ try:
1351
+ if search_source == "HTMLフォルダから検索":
1352
+ if not html_folder_path:
1353
+ results_text = "エラー: HTMLフォルダパスを入力してください。"
1354
+ return results_text, None, None, log_stream.getvalue()
1355
+
1356
+ print(f"HTMLフォルダから口コミを収集中: {html_folder_path}", file=log_stream)
1357
+ df, collect_log = collect_reviews_from_html(html_folder_path, progress)
1358
+ log_stream.write(collect_log)
1359
+ search_col_actual = "口コミ本文" # HTMLからの場合はこの列を検索
1360
+ if df.empty:
1361
+ results_text = f"エラー: フォルダ '{html_folder_path}' から口コミデータが収集できませんでした。"
1362
+ elif search_col_actual not in df.columns:
1363
+ results_text = f"エラー: 収集したデータに '{search_col_actual}' 列が見つかりません。"
1364
+
1365
+
1366
+ elif search_source == "CSVファイルから検索":
1367
+ if uploaded_csv_file is None:
1368
+ results_text = "エラー: 検索対象のCSVファイルをアップロードしてください。"
1369
+ return results_text, None, None, log_stream.getvalue()
1370
+ if not search_column:
1371
+ results_text = "エラー: 検索対象の列を選択してください。"
1372
+ return results_text, None, None, log_stream.getvalue()
1373
+
1374
+ print(f"アップロードされたCSVから検索: {os.path.basename(uploaded_csv_file.name)}, 列: {search_column}", file=log_stream)
1375
+ try:
1376
+ # TODO: エンコーディングを考慮
1377
+ df = pd.read_csv(uploaded_csv_file.name)
1378
+ search_col_actual = search_column
1379
+ if search_col_actual not in df.columns:
1380
+ results_text = f"エラー: アップロードされたCSVに列 '{search_col_actual}' が見つかりません。"
1381
+ except Exception as e_csv:
1382
+ results_text = f"エラー: CSVファイルの読み込みに失敗しました。ファイル形式やエンコーディングを確認してください。\n{e_csv}"
1383
+
1384
+ else:
1385
+ results_text = "エラー: 不明な検索ソースです。"
1386
+
1387
+ # データフレームと検索列が有効かチェック
1388
+ if results_text: # 上記のいずれかでエラーが発生した場合
1389
+ pass
1390
+ elif df.empty:
1391
+ if search_source == "HTMLフォルダから検索":
1392
+ results_text = "情報: 収集された口コミデータがありませんでした。"
1393
+ else:
1394
+ results_text = "エラー: CSVからデータを読み込めませんでした。"
1395
+ elif not keyword or keyword.strip() == "":
1396
+ results_text = "情報: キーワードが入力されていません。全件表示します。"
1397
+ search_results_df = df # キーワード空欄時は全件
1398
+ else:
1399
+ keyword = keyword.strip()
1400
+ print(f"キーワード '{keyword}' で列 '{search_col_actual}' を検索中...", file=log_stream)
1401
+ try:
1402
+ # NaNを空文字列に変換してから検索
1403
+ search_results_df = df[df[search_col_actual].fillna('').astype(str).str.contains(keyword, case=False, na=False)]
1404
+ count = len(search_results_df)
1405
+ if count > 0:
1406
+ results_text = f"キーワード '{keyword}' を含む口コミが {count} 件見つかりました。"
1407
+ print(results_text, file=log_stream)
1408
+ else:
1409
+ results_text = f"キーワード '{keyword}' を含む口コミは見つかりませんでした。"
1410
+ print(results_text, file=log_stream)
1411
+ except KeyError:
1412
+ results_text = f"エラー: DataFrameに検索対象列 '{search_col_actual}' が見つかりません。"
1413
+ print(results_text, file=log_stream)
1414
+ except Exception as e_search:
1415
+ results_text = f"検索中にエラーが発生しました: {e_search}"
1416
+ print(results_text, file=log_stream)
1417
+ print(traceback.format_exc(), file=log_stream)
1418
+
1419
+ # 結果をCSVに保存 (検索結果がある場合)
1420
+ if not search_results_df.empty:
1421
+ try:
1422
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".csv", mode="w", encoding="utf-8-sig") as temp_file:
1423
+ search_results_df.to_csv(temp_file.name, index=False)
1424
+ temp_csv_path = temp_file.name
1425
+ print(f"検索結果を一時CSVファイルに保存しました: {temp_csv_path}", file=log_stream)
1426
+ except Exception as e_csv_save:
1427
+ print(f"検索結果のCSV保存中にエラー: {e_csv_save}", file=log_stream)
1428
+ results_text += "\n警告: 検索結果のCSV保存に失敗しました。"
1429
+
1430
+ # テーブル表示用に列を絞る(存在しない列は無視)
1431
+ display_columns = ['店名', '投稿者', '評価', '投稿時期', '口コミ本文', 'オーナーからの返信', 'ファイル名']
1432
+ if search_source == "CSVファイルから検索" and not search_results_df.empty:
1433
+ # CSVからの場合、元の列名を優先しつつ、なければHTML由来の列名も試す
1434
+ available_cols = search_results_df.columns.tolist()
1435
+ display_columns = [col for col in available_cols if col in display_columns or col == search_column] # 検索列も表示
1436
+
1437
+ # 存在しない列を除外してDataFrameを返す
1438
+ display_df = search_results_df[[col for col in display_columns if col in search_results_df.columns]] if not search_results_df.empty else pd.DataFrame()
1439
+
1440
+
1441
+ except Exception as e_controller:
1442
+ error_msg = f"口コミ検索コントローラーで予期せぬエラー: {e_controller}"
1443
+ print(error_msg, file=log_stream)
1444
+ print(traceback.format_exc(), file=log_stream)
1445
+ results_text = error_msg
1446
+
1447
+ return results_text, display_df, gr.File(value=temp_csv_path if temp_csv_path else None), log_stream.getvalue()
1448
+
1449
+
1450
+ def export_all_reviews_controller(search_source, html_folder_path, uploaded_csv_file, progress=gr.Progress()):
1451
+ """全口コミデータをCSVにエクスポートするコントローラー関数"""
1452
+ log_stream = io.StringIO()
1453
+ df = pd.DataFrame()
1454
+ temp_csv_path = None
1455
+ results_text = ""
1456
+
1457
+ print(f"全件エクスポート開始。ソース: {search_source}", file=log_stream)
1458
+
1459
+ try:
1460
+ if search_source == "HTMLフォルダから検索":
1461
+ if not html_folder_path:
1462
+ results_text = "エラー: HTMLフォルダパスを入力してください。"
1463
+ return results_text, None, log_stream.getvalue()
1464
+
1465
+ print(f"HTMLフォルダから全口コミを収集中: {html_folder_path}", file=log_stream)
1466
+ df, collect_log = collect_reviews_from_html(html_folder_path, progress)
1467
+ log_stream.write(collect_log)
1468
+ if df.empty:
1469
+ results_text = f"情報: フォルダ '{html_folder_path}' から収集できる口コミデータがありませんでした。"
1470
+
1471
+ elif search_source == "CSVファイルから検索":
1472
+ if uploaded_csv_file is None:
1473
+ results_text = "エラー: 対象のCSVファイルをアップロードしてください。"
1474
+ return results_text, None, log_stream.getvalue()
1475
+
1476
+ print(f"アップロードされたCSVをエクスポート対象として読み込み中: {os.path.basename(uploaded_csv_file.name)}", file=log_stream)
1477
+ try:
1478
+ # アップロードされたCSVをそのままデータフレームとする
1479
+ df = pd.read_csv(uploaded_csv_file.name)
1480
+ if df.empty:
1481
+ results_text = "情報: アップロードされたCSVは空です。"
1482
+ except Exception as e_csv:
1483
+ results_text = f"エラー: CSVファイルの読み込みに失敗しました。\n{e_csv}"
1484
+
1485
+ else:
1486
+ results_text = "エラー: 不明な検索ソースです。"
1487
+
1488
+ # データフレームが有効で、空でない場合にCSVエクスポート
1489
+ if not df.empty:
1490
+ try:
1491
+ with tempfile.NamedTemporaryFile(delete=False, suffix="_all.csv", mode="w", encoding="utf-8-sig") as temp_file:
1492
+ df.to_csv(temp_file.name, index=False)
1493
+ temp_csv_path = temp_file.name
1494
+ results_text = f"全 {len(df)} 件のデータをCSVファイルにエクスポートしました。"
1495
+ print(results_text, file=log_stream)
1496
+ print(f"エクスポートファイル: {temp_csv_path}", file=log_stream)
1497
+ except Exception as e_csv_save:
1498
+ results_text = f"全件CSVエクスポート中にエラー: {e_csv_save}"
1499
+ print(results_text, file=log_stream)
1500
+ print(traceback.format_exc(), file=log_stream)
1501
+
1502
+ except Exception as e_controller:
1503
+ error_msg = f"全件エクスポートコントローラーで予期せぬエラー: {e_controller}"
1504
+ print(error_msg, file=log_stream)
1505
+ print(traceback.format_exc(), file=log_stream)
1506
+ results_text = error_msg
1507
+
1508
+ return results_text, gr.File(value=temp_csv_path if temp_csv_path else None), log_stream.getvalue()
1509
+
1510
+
1511
+ # --- Gradio UI 定義 (統合版) ---
1512
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
1513
+ gr.Markdown("# Google Maps スクレイピング & 口コミ検索ツール")
1514
  gr.Markdown(
1515
  """
1516
+ **タブ1:** Google Mapsから店舗情報をスクレイピングし、結果をCSVとHTMLファイルに出力します。
1517
+ **タブ2:** スクレイピングで保存されたHTMLフォルダ、またはアップロードたCSVファイルから口コミ情報検索・エクスポートします。
 
 
 
 
 
 
 
 
 
 
 
 
 
1518
  """
1519
  )
1520
 
1521
+ with gr.Tabs():
1522
+ with gr.TabItem("① スクレイピング実行"):
1523
+ gr.Markdown("### Google Maps スクレピング設定")
1524
+ gr.Markdown(
1525
+ """
1526
+ CSVクエリで検索し、詳細ページで「クチコミ」タブをクリック後、口コミエリアを**最後までスクロー**し、
1527
+ さらに**「もっと見る」ボタを全てクリック**して全件表示を試みます。
1528
+ その後、基本情報と口コミ情報を抽出し、結果CSVとHTMLファイル群を出力します。
1529
+ HTMLファイルはクエリごとにサブディレクトリに保存されます。
1530
+ **「処理中断」ボタン**で進行中のスクレイピング処理を安全に停止できます(現在のクエリ完了後)。
1531
+ """
1532
+ )
1533
+ with gr.Row():
1534
+ with gr.Column(scale=2):
1535
+ gr.Markdown("#### 入力ファイルと出力設定")
1536
+ input_csv_file_scrape = gr.File(label="検索クエリCSVファイル (1列目のみ使用)", file_types=[".csv"])
1537
+ output_dir_name_scrape = gr.Textbox(label="出力先ベースディレクトリ名", value="gmap_scraping_output")
1538
+ output_csv_name_scrape = gr.Textbox(label="出力CSVファイル名 (ベースディレクトリ内)", value="scraping_results.csv")
1539
+ csv_encoding_scrape = gr.Dropdown(label="出力CSVエンコーディング", choices=['utf-8-sig', 'cp932'], value='utf-8-sig')
1540
+ headless_mode_scrape = gr.Checkbox(label="ヘッドレドで実行 (エラー発生時はOFF推奨)", value=True)
1541
+ with gr.Column(scale=1):
1542
+ gr.Markdown("#### 待機時間設定 (秒)")
1543
+ wait_time_base_scrape = gr.Number(label="基本待機", minimum=1, maximum=20, step=0.5, value=4)
1544
+ wait_time_detail_scrape = gr.Number(label="詳細/口コミ最大待機", minimum=10, maximum=60, step=1, value=25)
1545
+ wait_time_search_scrape = gr.Number(label="検索リスト最大待機", minimum=5, maximum=60, step=1, value=15)
1546
+
1547
+ with gr.Row():
1548
+ start_button_scrape = gr.Button("スレイピング開始", variant="primary", size="lg", scale=3)
1549
+ stop_button_scrape = gr.Button("処理中断", variant="stop", size="lg", scale=1)
1550
+
1551
+ gr.Markdown("#### 処理ステータスとエラーログ")
1552
+ progress_bar_scrape = gr.Progress(track_tqdm=True)
1553
+ status_textbox_scrape = gr.Textbox(label="ログ", lines=15, interactive=False, autoscroll=True, max_lines=2000)
1554
+
1555
+ gr.Markdown("#### 結果")
1556
+ output_csv_download_scrape = gr.File(label="結果CSVダウンード", interactive=False)
1557
+ # HTMLフォルダパスを表示するためのテキストボックス (読み取り専用)
1558
+ html_output_folder_path_display = gr.Textbox(label="HTML保存先フォルダパス (口コミ検索タブで使用)", interactive=False)
1559
+
1560
+ with gr.TabItem("② 口コミ検索"):
1561
+ gr.Markdown("### 口コミ検索・エクスポート")
1562
+ gr.Markdown(
1563
+ """
1564
+ **検索ソース**を選択し、HTMLフォルダパスまたはCSVファイルを指定して口コミを検索・エクスポートします。
1565
+ - **HTMLフォルダから検索:** タブ1で出力されたHTMLファイル群が含まれる**ベースディレクトリ内の `html_files` フォルダ**、または他のHTMLファイル群を含むフォルダを指定してください。
1566
+ - **CSVファイルから検索:** タブ1で出力された結果CSV、または同様の形式のCSVファイルをアップロードしてください。
1567
+ """
1568
+ )
1569
+ with gr.Row():
1570
+ with gr.Column(scale=1):
1571
+ search_source_review = gr.Radio(
1572
+ choices=["HTMLフォルダから検索", "CSVファイルから検索"],
1573
+ label="検索ソースを選択",
1574
+ value="HTMLフォルダから検索"
1575
+ )
1576
+ html_folder_path_review = gr.Textbox(
1577
+ label="HTMLフォルダパス",
1578
+ placeholder="例: gmap_scraping_output/html_files",
1579
+ visible=True # 初期表示
1580
+ )
1581
+ uploaded_csv_review = gr.File(
1582
+ label="検索対象CSVファイル",
1583
+ file_types=[".csv"],
1584
+ visible=False # 初期非表示
1585
+ )
1586
+ search_column_review = gr.Dropdown(
1587
+ label="検索対象の列 (CSV選択時)",
1588
+ choices=[],
1589
+ interactive=True,
1590
+ visible=False # 初期非表示
1591
+ )
1592
+ keyword_review = gr.Textbox(label="検索キーワード (空欄で全件)")
1593
+ search_button_review = gr.Button("検索実行", variant="primary")
1594
+ export_all_button_review = gr.Button("全件CSVエクスポート")
1595
+
1596
+ with gr.Column(scale=2):
1597
+ gr.Markdown("#### 検索/エクスポート結果")
1598
+ status_textbox_review = gr.Textbox(label="処理状況", lines=5, interactive=False)
1599
+ output_table_review = gr.Dataframe(label="検索結果(テーブル)")
1600
+ search_csv_output_review = gr.File(label="検索結果CSVダウンロード", interactive=False)
1601
+ all_reviews_csv_output_review = gr.File(label="全件エクスポートCSVダウンロード", interactive=False)
1602
+ progress_bar_review = gr.Progress(track_tqdm=True) # 口コミ収集/エクスポート用
1603
+
1604
+ # --- イベントハンドラ定義 ---
1605
+
1606
+ # --- タブ1: スクレイピング ---
1607
+ start_button_scrape.click(
1608
+ fn=run_scraping,
1609
+ inputs=[input_csv_file_scrape, output_dir_name_scrape, output_csv_name_scrape, csv_encoding_scrape,
1610
+ wait_time_base_scrape, wait_time_detail_scrape, wait_time_search_scrape, headless_mode_scrape],
1611
+ outputs=[status_textbox_scrape, output_csv_download_scrape, html_output_folder_path_display],
1612
+ # progress 引数は Gradio 側で自動的に渡される (show_progress='full' の場合)
1613
+ )
1614
+
1615
+ stop_button_scrape.click(fn=request_interrupt, inputs=None, outputs=status_textbox_scrape) # ログに中断リクエストを表示
1616
+
1617
+ # --- タブ2: 口コミ検索 ---
1618
+
1619
+ # 検索ソースの選択に応じてUI表示を切り替え
1620
+ def update_review_source_ui(source):
1621
+ if source == "HTMLフォルダから検索":
1622
+ return {
1623
+ html_folder_path_review: gr.Textbox(visible=True),
1624
+ uploaded_csv_review: gr.File(visible=False, value=None), # クリア
1625
+ search_column_review: gr.Dropdown(visible=False, value=None, choices=[]) # クリア
1626
+ }
1627
+ elif source == "CSVファイルから検索":
1628
+ return {
1629
+ html_folder_path_review: gr.Textbox(visible=False, value=""), # クリア
1630
+ uploaded_csv_review: gr.File(visible=True),
1631
+ search_column_review: gr.Dropdown(visible=True) # 列選択を表示
1632
+ }
1633
+ else:
1634
+ return { # デフォルト
1635
+ html_folder_path_review: gr.Textbox(visible=True),
1636
+ uploaded_csv_review: gr.File(visible=False, value=None),
1637
+ search_column_review: gr.Dropdown(visible=False, value=None, choices=[])
1638
+ }
1639
+
1640
+ search_source_review.change(
1641
+ fn=update_review_source_ui,
1642
+ inputs=search_source_review,
1643
+ outputs=[html_folder_path_review, uploaded_csv_review, search_column_review]
1644
+ )
1645
 
1646
+ # CSVアップロード時に列名を取得してドロップダウを更新
1647
+ uploaded_csv_review.upload(
1648
+ fn=get_csv_columns_safe,
1649
+ inputs=uploaded_csv_review,
1650
+ outputs=search_column_review
1651
+ )
1652
+
1653
+ # 検索ボタンのクリック
1654
+ search_button_review.click(
1655
+ fn=search_reviews_controller,
1656
+ inputs=[search_source_review, html_folder_path_review, uploaded_csv_review, search_column_review, keyword_review],
1657
+ outputs=[status_textbox_review, output_table_review, search_csv_output_review, status_textbox_scrape] # 最後のログはデバッグ用に入れておく
1658
+ )
1659
+
1660
+ # 全件エクスポートボタンのクリック
1661
+ export_all_button_review.click(
1662
+ fn=export_all_reviews_controller,
1663
+ inputs=[search_source_review, html_folder_path_review, uploaded_csv_review],
1664
+ outputs=[status_textbox_review, all_reviews_csv_output_review, status_textbox_scrape] # 最後のログはデバッグ用
1665
+ )
1666
 
1667
 
1668
  # --- UI起動 ---
1669
  print("Gradio UIを起動します...")
1670
+ # queue()で複数ユーザー対応、share=Trueで共有リンク生成 (Colabでは自動的に共有リンク)
1671
+ # launch() debug=True をつけるとリロードなどが有効になるが、不安定になることもある
1672
+ demo.queue().launch(share=False, debug=False)