MIF_E31231226/scraper/scraping_pipeline.py

996 lines
36 KiB
Python

# -*- coding: utf-8 -*-
import argparse
import os
import tempfile
from networkx import config
import undetected_chromedriver as uc
from jmespath import Options
from pandas import options
from selenium import webdriver
if 'PATH' not in os.environ:
os.environ['PATH'] = r"C:\Windows\System32;C:\Windows"
import json
import re
import sys
import time
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from urllib import error, parse, request
# import pymysql
import mysql.connector
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
BASE_DIR = Path(__file__).resolve().parents[1]
def log(level, message):
print(f"[{level}] {message}", flush=True)
def fail(message, code=1):
log("ERROR", message)
sys.exit(code)
def parse_args():
parser = argparse.ArgumentParser(description="Scrape ulasan Google Maps untuk rentang tanggal tertentu.")
parser.add_argument("--start-date", help="Tanggal awal format YYYY-MM-DD. Default: awal bulan berjalan.")
parser.add_argument("--end-date", help="Tanggal akhir format YYYY-MM-DD. Default: hari ini.")
parser.add_argument(
"--wisata",
default="all",
help="Nama wisata yang akan diambil. Gunakan 'all' atau kosong untuk semua lokasi.",
)
return parser.parse_args()
def parse_date(value, fallback):
if not value:
return fallback
try:
return datetime.strptime(value, "%Y-%m-%d")
except ValueError:
fail(f"Format tanggal tidak valid: {value}. Gunakan YYYY-MM-DD.")
def read_laravel_env():
env = {}
env_file = BASE_DIR / ".env"
if not env_file.exists():
return env
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env.setdefault(key.strip(), value.strip().strip('"').strip("'"))
return env
def env_value(env, key, default=""):
# .env file dibaca DULUAN, baru fallback ke os.getenv, lalu default
value = env.get(key) or os.getenv(key) or default
if value in {None, "", "null", "None"}:
return default
return value
def env_bool(env, key, default=False):
value = str(env_value(env, key, str(default))).strip().lower()
return value in {"1", "true", "yes", "on"}
def db_config():
env = read_laravel_env()
connection = env_value(env, "DB_CONNECTION", "mysql")
if connection == "sqlite":
database = env_value(env, "DB_DATABASE", str(BASE_DIR / "database" / "database.sqlite"))
database_path = Path(database)
if not database_path.is_absolute():
database_path = BASE_DIR / database
return {
"connection": connection,
"database": str(database_path),
}
if connection not in {"mysql", "mariadb"}:
fail(f"DB_CONNECTION={connection} belum didukung oleh scraping_pipeline.py. Gunakan sqlite/mysql/mariadb.")
return {
"connection": connection,
"host": env_value(env, "DB_HOST", "127.0.0.1"),
"port": int(env_value(env, "DB_PORT", "3306")),
"database": env_value(env, "DB_DATABASE", "sistem_analisis"),
"user": env_value(env, "DB_USERNAME", "root"),
"password": env_value(env, "DB_PASSWORD", ""),
}
def scraper_config():
env = read_laravel_env()
return {
"timezone": env_value(env, "APP_TIMEZONE", "Asia/Jakarta"),
"provider": env_value(env, "SCRAPER_PROVIDER", "selenium").lower(),
"headless": env_bool(env, "SCRAPER_HEADLESS", True),
"user_data_dir": env_value(env, "CHROME_USER_DATA_DIR", ""),
"profile": env_value(env, "CHROME_PROFILE", ""),
"scroll_limit": int(env_value(env, "SELENIUM_SCROLL_LIMIT", "20")),
"manual_login_timeout": int(env_value(env, "SELENIUM_MANUAL_LOGIN_TIMEOUT", "180")),
"filter_date_range": env_bool(env, "SCRAPER_FILTER_DATE_RANGE", True),
"require_all_destinations": env_bool(env, "SCRAPER_REQUIRE_ALL_DESTINATIONS", False),
"apify_token": env_value(env, "APIFY_TOKEN", ""),
"apify_actor_id": env_value(env, "APIFY_ACTOR_ID", "compass/google-maps-reviews-scraper"),
"apify_max_reviews": int(env_value(env, "APIFY_MAX_REVIEWS", "100")),
"apify_language": env_value(env, "APIFY_LANGUAGE", "id"),
"apify_timeout": int(env_value(env, "APIFY_TIMEOUT_SECONDS", "360")),
}
def is_sqlite_connection(conn):
return isinstance(conn, sqlite3.Connection)
def prepare_sql(conn, sql):
if is_sqlite_connection(conn):
return sql.replace("%s", "?").replace("NOW()", "CURRENT_TIMESTAMP")
return sql
def execute(cursor, conn, sql, params=()):
cursor.execute(prepare_sql(conn, sql), params)
def make_connection(config):
if config["connection"] == "sqlite":
return sqlite3.connect(config["database"])
return mysql.connector.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
charset="utf8mb4",
)
def normalize_rating(value):
if value is None:
return None
match = re.search(r"([1-5])", str(value))
return int(match.group(1)) if match else None
def subtract_months(value, months):
month_index = value.month - 1 - months
year = value.year + month_index // 12
month = month_index % 12 + 1
days_in_month = [31, 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28,
31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
day = min(value.day, days_in_month[month - 1])
return value.replace(year=year, month=month, day=day)
def estimate_review_date(text, now=None):
if not text:
return None
now = now or datetime.now()
raw = str(text).strip().lower()
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%d/%m/%Y", "%d-%m-%Y"):
try:
return datetime.strptime(raw[:19], fmt)
except ValueError:
pass
if any(word in raw for word in ["baru saja", "hari ini", "sekarang", "just now"]):
return now
if "kemarin" in raw or "yesterday" in raw:
return now - timedelta(days=1)
match = re.search(r"(\d+|se)\s*(menit|jam|hari|minggu|bulan|tahun|minute|hour|day|week|month|year)", raw)
if not match:
return None
amount = 1 if match.group(1) == "se" else int(match.group(1))
unit = match.group(2)
if unit in {"menit", "minute"}:
return now - timedelta(minutes=amount)
if unit in {"jam", "hour"}:
return now - timedelta(hours=amount)
if unit in {"hari", "day"}:
return now - timedelta(days=amount)
if unit in {"minggu", "week"}:
return now - timedelta(weeks=amount)
if unit in {"bulan", "month"}:
return subtract_months(now, amount)
if unit in {"tahun", "year"}:
return subtract_months(now, amount * 12)
return None
def is_current_period_review(tanggal_text, periode_bulan, periode_tahun):
estimated = estimate_review_date(tanggal_text)
return estimated is not None and estimated.month == periode_bulan and estimated.year == periode_tahun
def is_review_in_date_range(tanggal_text, start_date, end_date, now=None):
estimated = estimate_review_date(tanggal_text, now)
if estimated is None:
return False
estimated_date = estimated.date()
return start_date.date() <= estimated_date <= end_date.date()
def normalized_review_date(tanggal_text):
estimated = estimate_review_date(tanggal_text)
return estimated.strftime("%Y-%m-%d") if estimated else str(tanggal_text or "")
def get_or_create_period(cursor, conn, start_date, end_date):
bulan = start_date.month
tahun = start_date.year
nama_bulan = [
"Januari", "Februari", "Maret", "April", "Mei", "Juni",
"Juli", "Agustus", "September", "Oktober", "November", "Desember",
]
nama = f"{nama_bulan[bulan - 1]} {tahun}"
execute(
cursor,
conn,
"SELECT id, nama FROM periode_analisis WHERE bulan = %s AND tahun = %s LIMIT 1",
(bulan, tahun),
)
periode = cursor.fetchone()
if periode:
return periode[0], periode[1]
execute(
cursor,
conn,
"""
INSERT INTO periode_analisis (nama, bulan, tahun, created_at, updated_at)
VALUES (%s, %s, %s, NOW(), NOW())
""",
(nama, bulan, tahun),
)
conn.commit()
return cursor.lastrowid, nama
DESTINATIONS = {
"Pantai Papuma": "https://www.google.com/maps/place/Pantai+Papuma/@-8.4310054,113.5508204,16z/data=!4m7!3m6!1s0x2dd682a6a4b5cd8d:0xb9c242f3a09e2d2e!4b1!8m2!3d-8.4300871!4d113.5536464!16s%2Fg%2F11bwy_gg5k?authuser=0&entry=ttu&g_ep=EgoyMDI2MDUxMy4wIKXMDSoASAFQAw%3D%3D",
"Pantai Watu Ulo": "https://www.google.com/maps/place/Pantai+Watu+Ulo/@-8.4252967,113.5515972,15z/data=!4m8!3m7!1s0x2dd69d2fffffffff:0x6f3d7097accf3209!8m2!3d-8.425297!4d113.561897!9m1!1b1!16s%2Fg%2F120m19h0?authuser=0&entry=ttu&g_ep=EgoyMDI2MDUxMy4wIKXMDSoASAFQAw%3D%3D",
"Teluk Love": "https://www.google.com/maps/place/Teluk+Love/@-8.4410549,113.581323,17z/data=!3m1!4b1!4m6!3m5!1s0x2dd6942520c45715:0x3cabb1f5dd90a01b!8m2!3d-8.4410549!4d113.5838979!16s%2Fg%2F11ckkqq_1b?authuser=0&entry=ttu&g_ep=EgoyMDI2MDUxMy4wIKXMDSoASAFQAw%3D%3D",
"Kebun Teh Gunung Gambir": "https://www.google.com/maps/place/Wisata+kebun+teh+gunung+gambir/@-8.0351906,113.4389961,17z/data=!4m7!3m6!1s0x2dd6f551f90c0c8f:0x3d1a62be1e269d12!4b1!8m2!3d-8.0351906!4d113.441571!16s%2Fg%2F11tjmzk404?authuser=0&entry=ttu&g_ep=EgoyMDI2MDUxMy4wIKXMDSoASAFQAw%3D%3D",
}
def selected_destinations(wisata_name):
if not wisata_name or wisata_name.strip().lower() in {"all", "semua", "semua destinasi", "semua lokasi"}:
return DESTINATIONS
normalized = wisata_name.strip().lower()
for name, url in DESTINATIONS.items():
if name.lower() == normalized:
return {name: url}
fail(
"Wisata tidak dikenal: "
+ wisata_name
+ ". Pilihan tersedia: all, "
+ ", ".join(DESTINATIONS.keys())
)
def build_chrome_options(config):
from selenium.webdriver.chrome.options import Options
import tempfile
options = Options()
# options = uc.ChromeOptions()
options.binary_location = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
options.add_argument("--lang=id")
options.add_argument("--accept-language=id-ID,id")
options.add_argument("--disable-blink-features=AutomationControlled")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--no-sandbox")
options.add_argument("--no-first-run")
options.add_argument("--no-default-browser-check")
options.add_argument("--disable-gpu")
options.add_argument("--disable-software-rasterizer")
options.add_argument("--remote-debugging-port=9222")
options.add_argument("--disable-extensions")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-gpu")
options.add_argument(f"--user-data-dir={tempfile.mkdtemp()}")
# User agent supaya dianggap browser asli
options.add_argument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
if config.get("headless"):
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
else:
options.add_argument("--start-maximized")
# if config["user_data_dir"]:
# options.add_argument(f"--user-data-dir={config['user_data_dir']}")
# if config["profile"]:
# options.add_argument(f"--profile-directory={config['profile']}")
return options
driver = uc.Chrome(options=options)
def is_login_or_consent_page(driver):
current_url = driver.current_url.lower()
login_url_markers = [
"accounts.google.com",
"signin",
]
consent_url_markers = [
"consent.google.com",
]
return any(marker in current_url for marker in login_url_markers + consent_url_markers)
def wait_for_manual_login(driver, timeout_seconds):
if not is_login_or_consent_page(driver):
return True
log(
"WARNING",
f"Google membuka halaman login/consent. Silakan login manual di Chrome. Menunggu maksimal {timeout_seconds} detik.",
)
deadline = time.time() + timeout_seconds
while time.time() < deadline:
time.sleep(3)
if not is_login_or_consent_page(driver):
log("INFO", "Login/consent selesai, scraping dilanjutkan.")
return True
return False
def click_possible_consent(driver):
from selenium.webdriver.common.by import By
labels = [
"Terima semua",
"Saya setuju",
"Setuju",
"Accept all",
"I agree",
]
for label in labels:
try:
buttons = driver.find_elements(
By.XPATH,
f"//button[contains(., '{label}')] | //div[@role='button'][contains(., '{label}')]",
)
for button in buttons:
if button.is_displayed():
driver.execute_script("arguments[0].click();", button)
time.sleep(2)
log("INFO", f"Tombol consent diklik: {label}")
return True
except Exception:
continue
return False
def click_first_search_result_if_needed(driver):
from selenium.webdriver.common.by import By
try:
results = driver.find_elements(By.XPATH, "//a[contains(@href, '/maps/place/')]")
for result in results:
if result.is_displayed():
driver.execute_script("arguments[0].click();", result)
time.sleep(10)
log("INFO", "Hasil pencarian Google Maps pertama dibuka.")
return True
except Exception as exc:
log("WARNING", f"Gagal membuka hasil pencarian pertama: {exc}")
return False
def first_value(data, keys, default=""):
for key in keys:
value = data.get(key)
if value not in {None, ""}:
return value
return default
RATING_ONLY_REVIEW_TEXT = "[Tanpa teks]"
def normalize_actor_id(actor_id):
return actor_id.strip().replace("/", "~")
def fetch_apify_reviews(wisata, url, config, start_date, end_date):
if not config["apify_token"]:
fail("APIFY_TOKEN belum diisi di .env.")
actor_id = normalize_actor_id(config["apify_actor_id"])
query = parse.urlencode({"token": config["apify_token"]})
endpoint = f"https://api.apify.com/v2/acts/{actor_id}/run-sync-get-dataset-items?{query}"
payload = {
"startUrls": [{"url": url}],
"maxReviews": config["apify_max_reviews"],
"language": config["apify_language"],
}
body = json.dumps(payload).encode("utf-8")
req = request.Request(
endpoint,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
log("INFO", f"Apify: mengambil {wisata} maksimal {config['apify_max_reviews']} review")
with request.urlopen(req, timeout=config["apify_timeout"]) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw) if raw else []
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
fail(f"Apify gagal HTTP {exc.code}: {detail}")
except error.URLError as exc:
fail(f"Apify gagal diakses: {exc}")
except json.JSONDecodeError as exc:
fail(f"Response Apify bukan JSON valid: {exc}")
if not isinstance(data, list):
fail("Response Apify tidak berbentuk list dataset items.")
rows = []
try:
scrape_now = datetime.now(ZoneInfo(config["timezone"])).replace(tzinfo=None)
except Exception:
scrape_now = datetime.now()
for item in data:
if not isinstance(item, dict):
continue
ulasan = str(first_value(item, ["text", "reviewText", "textTranslated", "snippet"], "")).strip()
rating = normalize_rating(first_value(item, ["stars", "rating", "score"], None))
if not ulasan and rating is None:
continue
if not ulasan:
ulasan = RATING_ONLY_REVIEW_TEXT
tanggal_text = str(first_value(item, ["publishedAtDate", "publishAt", "publishedAt", "date"], ""))
estimated_date = estimate_review_date(tanggal_text, scrape_now)
if config["filter_date_range"] and not is_review_in_date_range(tanggal_text, start_date, end_date, scrape_now):
continue
rows.append(
{
"wisata": wisata,
"reviewer": first_value(item, ["name", "reviewerName", "authorName", "reviewer"], "anonymous"),
"rating": rating,
"ulasan": ulasan,
"tanggal": estimated_date.strftime("%Y-%m-%d") if estimated_date else str(tanggal_text or ""),
}
)
return rows
def insert_reviews(cursor, conn, reviews, periode_id):
saved = 0
skipped_duplicate = 0
for review in reviews:
execute(
cursor,
conn,
"""
SELECT id FROM ulasan
WHERE wisata = %s AND reviewer = %s AND ulasan = %s AND tanggal = %s
LIMIT 1
""",
(review["wisata"], review["reviewer"], review["ulasan"], review["tanggal"]),
)
if cursor.fetchone():
skipped_duplicate += 1
continue
execute(
cursor,
conn,
"""
INSERT INTO ulasan
(wisata, reviewer, rating, ulasan, tanggal, scraping_date, periode_id, sentimen, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, NOW(), NOW())
""",
(
review["wisata"],
review["reviewer"],
review["rating"],
review["ulasan"],
review["tanggal"],
datetime.now(),
periode_id,
),
)
saved += 1
conn.commit()
return saved, skipped_duplicate
def count_reviews_for_wisata(cursor, conn, wisata, periode_id):
execute(
cursor,
conn,
"SELECT COUNT(*) FROM ulasan WHERE wisata = %s AND periode_id = %s",
(wisata, periode_id),
)
result = cursor.fetchone()
return int(result[0] if result else 0)
def purge_out_of_range_reviews(cursor, conn, periode_id, start_date, end_date):
execute(cursor, conn, "SELECT id, wisata, tanggal FROM ulasan WHERE periode_id = %s", (periode_id,))
rows = cursor.fetchall()
deleted = 0
for row in rows:
review_id, wisata, tanggal = row
if not is_review_in_date_range(tanggal, start_date, end_date):
execute(cursor, conn, "DELETE FROM ulasan WHERE id = %s", (review_id,))
deleted += 1
log("INFO", f"Hapus ulasan luar rentang dari periode aktif: {wisata} ({tanggal})")
if deleted:
conn.commit()
log("INFO", f"Pembersihan ulasan luar rentang: {deleted} baris dihapus.")
def validate_all_destinations_have_data(cursor, conn, periode_id, destinations, require_all=False):
missing = []
total_existing = 0
for wisata in destinations:
total = count_reviews_for_wisata(cursor, conn, wisata, periode_id)
total_existing += total
log("INFO", f"Validasi data {wisata}: {total} ulasan pada periode_id={periode_id}")
if total == 0:
missing.append(wisata)
if missing and require_all:
fail(
"Scraping belum mengambil semua wisata. Belum ada data untuk: "
+ ", ".join(missing)
+ ". Jalankan Ambil Data lagi atau cek apakah halaman Google Maps tempat tersebut membuka tab ulasan."
)
if missing:
log("WARNING", "Belum ada ulasan periode aktif untuk: " + ", ".join(missing))
return total_existing
def validate_all_destinations_processed(processed, destinations, require_all=False):
missing = [wisata for wisata in destinations if wisata not in processed]
if missing and require_all:
fail(
"Scraper belum berhasil membuka/memproses semua lokasi: "
+ ", ".join(missing)
+ ". Cek URL atau selector Google Maps untuk lokasi tersebut."
)
if missing:
log("WARNING", "Scraper belum berhasil memproses lokasi: " + ", ".join(missing))
def first_review_text(review, xpaths, attr=None):
for xpath in xpaths:
try:
elements = review.find_elements("xpath", xpath)
for element in elements:
value = element.get_attribute(attr) if attr else element.text
value = str(value or "").strip()
if value:
return value
except Exception:
continue
return ""
def scrape_with_apify(cursor, conn, periode_id, start_date, end_date, config, destinations):
total_saved = 0
total_skipped_duplicate = 0
processed = set()
for wisata, url in destinations.items():
reviews = fetch_apify_reviews(wisata, url, config, start_date, end_date)
saved, skipped_duplicate = insert_reviews(cursor, conn, reviews, periode_id)
processed.add(wisata)
total_saved += saved
total_skipped_duplicate += skipped_duplicate
log("OK", f"{wisata}: Apify dapat {len(reviews)}, simpan {saved}, duplikat dilewati {skipped_duplicate}")
log("OK", f"Scraping Apify selesai. Total simpan {total_saved}, duplikat dilewati {total_skipped_duplicate}.")
validate_all_destinations_processed(processed, destinations, config["require_all_destinations"])
total_existing = validate_all_destinations_have_data(cursor, conn, periode_id, destinations, config["require_all_destinations"])
if total_existing == 0:
fail("Scraping selesai tetapi tidak ada ulasan yang berhasil disimpan.")
def click_sort_newest(driver):
from selenium.webdriver.common.by import By
try:
for button in driver.find_elements(By.XPATH, "//button"):
label = (button.get_attribute("aria-label") or button.text or "").lower()
if "urutkan" in label or "sort" in label:
driver.execute_script("arguments[0].click();", button)
time.sleep(1)
break
for option in driver.find_elements(By.XPATH, "//*[contains(text(),'Terbaru') or contains(text(),'Newest')]"):
if option.is_displayed():
driver.execute_script("arguments[0].click();", option)
time.sleep(3)
log("INFO", "Ulasan diurutkan dari yang terbaru.")
return True
except Exception as exc:
log("WARNING", f"Gagal mengurutkan ulasan terbaru: {exc}")
return False
def click_reviews_tab(driver, wait, wisata, manual_login_timeout=0):
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
# candidates = [
# "//div[@role='tab'][contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ulasan')]",
# "//div[@role='tab'][contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'review')]",
# "//button[@role='tab'][contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ulasan')]",
# "//button[@role='tab'][contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'review')]",
# "//button[contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ulasan') and not(contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'tulis')) and not(contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'write'))]",
# "//button[contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'review') and not(contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'write'))]",
# ]
possible_xpaths = [
"//button[contains(@aria-label, 'Ulasan untuk')]",
"//button[contains(., 'Ulasan')]",
"//div[@role='tab'][contains(., 'Ulasan')]",
"//button[contains(@aria-label, 'Reviews')]"
]
try:
# wait.until(EC.presence_of_all_elements_located((By.XPATH, "//button | //div[@role='tab']")))
all_btns = driver.find_elements(By.XPATH, "//button | //div[@role='tab']")
time.sleep(3)
except Exception:
pass
def try_click_reviews_tab():
for xpath in possible_xpaths:
try:
elements = driver.find_elements(By.XPATH, xpath)
for element in elements:
if element.is_displayed():
label = element.get_attribute("aria-label") or element.text or xpath
normalized_label = label.strip().lower()
if "tulis ulasan" in normalized_label or "write a review" in normalized_label:
continue
driver.execute_script("arguments[0].click();", element)
time.sleep(4)
log("INFO", f"{wisata}: tab ulasan dibuka lewat {label}")
return True
except Exception:
continue
return False
if try_click_reviews_tab():
return True
page_text = driver.find_element(By.TAG_NAME, "body").text.lower()
if "tampilan terbatas" in page_text or "limited view" in page_text:
if manual_login_timeout <= 0:
fail(
"Google Maps menampilkan tampilan terbatas sehingga tab Ulasan tidak tersedia. "
"Jalankan Selenium dalam mode visible lalu login Google di jendela Chrome yang terbuka."
)
log(
"WARNING",
"Google Maps menampilkan tampilan terbatas. Silakan login Google di jendela Chrome yang terbuka. "
f"Menunggu maksimal {manual_login_timeout} detik.",
)
deadline = time.time() + manual_login_timeout
while time.time() < deadline:
time.sleep(3)
if try_click_reviews_tab():
return True
fail("Login Google belum selesai atau tab Ulasan masih tidak tersedia setelah menunggu.")
return False
def find_reviews_scroll_container(driver, wait):
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
xpaths = [
"//div[@role='region']",
"//div[contains(@aria-label, 'Ulasan')]",
"//div[contains(@aria-label, 'Reviews')]",
"//div[.//div[@data-review-id]]",
]
# xpaths = [
# "//div[contains(@class, 'm6U624-v79jQ-le0U9b')]", # Class kontainer ulasan umum
# "//div[@role='main' and contains(@aria-label, 'Ulasan')]",
# "//div[contains(@aria-label, 'Ulasan untuk')]"
# ]
for xpath in xpaths:
try:
return wait.until(EC.presence_of_element_located((By.XPATH, xpath)))
except Exception:
continue
return None
def scrape_with_selenium(cursor, conn, periode_id, start_date, end_date, config, destinations):
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
driver = None
log(
"INFO",
"Chrome mode: "
+ ("headless" if config["headless"] else "visible")
+ (f", profile={config['profile']}" if config["profile"] else ""),
)
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
# driver = webdriver.Chrome(
# service = Service(r"C:\Users\Mufrida Farah\Downloads\chromedriver-win64\chromedriver-win64\chromedriver.exe")
# driver = webdriver.Chrome(service=service, options=build_chrome_options(config))
# options=build_chrome_options(config)
# )
service = Service(r"C:\Users\Mufrida Farah\Downloads\chromedriver-win64\chromedriver-win64\chromedriver.exe")
opts = build_chrome_options(config)
driver = webdriver.Chrome(service=service, options=opts)
total_saved = 0
total_skipped_duplicate = 0
try:
scrape_now = datetime.now(ZoneInfo(config["timezone"])).replace(tzinfo=None)
except Exception:
scrape_now = datetime.now()
processed = set()
try:
for wisata, url in destinations.items():
log("INFO", f"Scraping: {wisata}")
main_window = driver.window_handles[0]
for handle in driver.window_handles[1:]:
driver.switch_to.window(handle)
driver.close()
driver.switch_to.window(main_window)
driver.get(url)
wait = WebDriverWait(driver, 25)
time.sleep(5)
click_possible_consent(driver)
click_first_search_result_if_needed(driver)
if not wait_for_manual_login(driver, config["manual_login_timeout"]):
fail(
"Google Maps masih berada di halaman login/consent setelah waktu tunggu habis. "
"Login manual di Chrome profile khusus, lalu klik Ambil Data lagi."
)
if not click_reviews_tab(driver, wait, wisata, 0 if config["headless"] else config["manual_login_timeout"]):
log("WARNING", f"Tombol/tab ulasan tidak ditemukan untuk {wisata}")
continue
time.sleep(2)
all_windows = driver.window_handles
if len(all_windows) > 1:
driver.switch_to.window(all_windows[-1])
log("INFO", f"{wisata}: pindah ke tab baru")
click_sort_newest(driver)
click_sort_newest(driver)
scrollable_div = find_reviews_scroll_container(driver, wait)
if scrollable_div is None:
log("WARNING", f"Container ulasan tidak ditemukan untuk {wisata}")
continue
last_height = 0
driver.execute_script("arguments[0].focus();", scrollable_div)
for i in range(config["scroll_limit"]):
try:
driver.execute_script("arguments[0].scrollTop = arguments[0].scrollHeight", scrollable_div)
time.sleep(2)
new_height = driver.execute_script("return arguments[0].scrollHeight", scrollable_div)
log("INFO", f"{wisata}: scroll ke-{i + 1}")
if new_height == last_height:
break
last_height = new_height
except Exception as e:
log("ERROR", f"{wisata}: Error occurred while scrolling: {e}")
scrollable_div = find_reviews_scroll_container(driver, wait)
if scrollable_div is None:
break
continue
for button in driver.find_elements(By.XPATH, "//button[contains(text(),'Selengkapnya')]"):
try:
driver.execute_script("arguments[0].click();", button)
except Exception:
pass
time.sleep(2)
items = []
reviews = driver.find_elements(By.XPATH, "//div[contains(@class,'jftiEf') and @data-review-id]")
log("INFO", f"{wisata}: {len(reviews)} review ditemukan")
for review in reviews:
rating_label = first_review_text(
review,
[
".//*[@role='img' and contains(@aria-label,'bintang')]",
".//*[@role='img' and contains(@aria-label,'star')]",
],
"aria-label",
)
rating = normalize_rating(rating_label)
ulasan = first_review_text(
review,
[
".//span[contains(@class,'wiI7pd')]",
".//span[contains(@class,'MyEned')]",
".//div[contains(@class,'MyEned')]//span",
".//span[@lang]",
],
)
if not ulasan and rating is None:
continue
if not ulasan:
ulasan = RATING_ONLY_REVIEW_TEXT
tanggal_text = first_review_text(
review,
[
".//span[contains(@class,'rsqaWe')]",
".//span[contains(@class,'xRkPPb')]",
],
)
estimated_date = estimate_review_date(tanggal_text, scrape_now)
if config["filter_date_range"] and not is_review_in_date_range(tanggal_text, start_date, end_date, scrape_now):
log("INFO", f"{wisata}: skip ulasan luar rentang ({tanggal_text})")
continue
reviewer = first_review_text(
review,
[
".//div[contains(@class,'d4r55')]",
".//button[contains(@class,'WEBjve')]",
],
) or "anonymous"
items.append(
{
"wisata": wisata,
"reviewer": reviewer,
"rating": rating,
"ulasan": ulasan,
"tanggal": estimated_date.strftime("%Y-%m-%d") if estimated_date else str(tanggal_text or ""),
}
)
saved, skipped_duplicate = insert_reviews(cursor, conn, items, periode_id)
processed.add(wisata)
total_saved += saved
total_skipped_duplicate += skipped_duplicate
log("OK", f"{wisata}: simpan {saved}, duplikat dilewati {skipped_duplicate}")
log("OK", f"Scraping Selenium selesai. Total simpan {total_saved}, duplikat dilewati {total_skipped_duplicate}.")
validate_all_destinations_processed(processed, destinations, config["require_all_destinations"])
total_existing = validate_all_destinations_have_data(cursor, conn, periode_id, destinations, config["require_all_destinations"])
if total_existing == 0:
fail("Scraping selesai tetapi tidak ada ulasan yang berhasil disimpan. Cek apakah tab ulasan Google Maps terbuka dan filter tanggal tidak terlalu ketat.")
finally:
driver.quit()
def main():
args = parse_args()
now = datetime.now()
default_start = now.replace(day=1)
default_end = now
start_date = parse_date(args.start_date, default_start)
end_date = parse_date(args.end_date, default_end)
if end_date.date() < start_date.date():
fail("end-date tidak boleh lebih kecil dari start-date.")
if start_date.strftime("%Y-%m") != end_date.strftime("%Y-%m"):
fail("Rentang tanggal harus berada dalam bulan yang sama karena periode_analisis disimpan per bulan.")
config = db_config()
scraper = scraper_config()
destinations = selected_destinations(args.wisata)
conn = make_connection(config)
cursor = conn.cursor()
try:
periode_id, periode_nama = get_or_create_period(cursor, conn, start_date, end_date)
log("INFO", f"Scraping untuk periode {periode_nama} (periode_id={periode_id})")
log("INFO", f"Provider scraping: {scraper['provider']}")
log("INFO", "Lokasi scraping: " + ", ".join(destinations.keys()))
if scraper["filter_date_range"]:
log("INFO", f"Filter aktif: ulasan {start_date.strftime('%Y-%m-%d')} sampai {end_date.strftime('%Y-%m-%d')}")
# purge_out_of_range_reviews(cursor, conn, periode_id, start_date, end_date)
if scraper["provider"] == "apify":
scrape_with_apify(cursor, conn, periode_id, start_date, end_date, scraper, destinations)
elif scraper["provider"] == "selenium":
scrape_with_selenium(cursor, conn, periode_id, start_date, end_date, scraper, destinations)
else:
fail("SCRAPER_PROVIDER harus bernilai apify atau selenium.")
except Exception as exc:
conn.rollback()
fail(f"Scraping gagal: {exc}")
finally:
conn.close()
if __name__ == "__main__":
main()