Fix scraper import and analysis flow

This commit is contained in:
el 2026-05-12 21:32:20 +08:00
parent 638ae703c0
commit 9e9b44d042
3 changed files with 299 additions and 94 deletions

View File

@ -122,7 +122,6 @@ public function importRiwayat(Request $request)
]); ]);
$periodeBulan = Carbon::createFromFormat('Y-m', $validated['periode_bulan']); $periodeBulan = Carbon::createFromFormat('Y-m', $validated['periode_bulan']);
$periodeId = $this->getOrCreatePeriode($periodeBulan);
$rows = $this->readCsvRows($request->file('file')->getRealPath()); $rows = $this->readCsvRows($request->file('file')->getRealPath());
if (empty($rows)) { if (empty($rows)) {
@ -140,12 +139,20 @@ public function importRiwayat(Request $request)
$inserted = 0; $inserted = 0;
$skipped = 0; $skipped = 0;
$periodeIds = [];
DB::transaction(function () use ($rows, $periodeId, &$inserted, &$skipped) { DB::transaction(function () use ($rows, $periodeBulan, &$inserted, &$skipped, &$periodeIds) {
foreach ($rows as $row) { foreach ($rows as $index => $row) {
$wisata = trim((string) ($row['wisata'] ?? '')); $wisata = trim((string) ($row['wisata'] ?? ''));
$ulasan = trim((string) ($row['ulasan'] ?? '')); $ulasan = trim((string) ($row['ulasan'] ?? ''));
$tanggal = $this->normalizeCsvDate($row['tanggal'] ?? null); $tanggal = $this->normalizeCsvDate($row['tanggal'] ?? null);
$rating = $this->normalizeRating($row['rating'] ?? null);
$isRatingOnly = false;
if ($ulasan === '' && $rating !== null) {
$ulasan = '[Tanpa teks]';
$isRatingOnly = true;
}
if ($wisata === '' || $ulasan === '' || !$tanggal) { if ($wisata === '' || $ulasan === '' || !$tanggal) {
$skipped++; $skipped++;
@ -153,7 +160,11 @@ public function importRiwayat(Request $request)
} }
$reviewer = trim((string) ($row['reviewer'] ?? 'anonymous')) ?: 'anonymous'; $reviewer = trim((string) ($row['reviewer'] ?? 'anonymous')) ?: 'anonymous';
$rating = $this->normalizeRating($row['rating'] ?? null); if ($isRatingOnly && $reviewer === 'anonymous') {
$reviewer = 'anonymous-csv-' . ($index + 1);
}
$periodeTanggal = Carbon::parse($tanggal)->startOfMonth();
$periodeId = $this->getOrCreatePeriode($periodeTanggal ?: $periodeBulan);
$exists = DB::table('ulasan') $exists = DB::table('ulasan')
->where('wisata', $wisata) ->where('wisata', $wisata)
@ -180,6 +191,7 @@ public function importRiwayat(Request $request)
'updated_at' => now(), 'updated_at' => now(),
]); ]);
$periodeIds[$periodeId] = $periodeId;
$inserted++; $inserted++;
} }
}); });
@ -189,17 +201,26 @@ public function importRiwayat(Request $request)
->with('error', 'Import selesai, tetapi tidak ada data baru yang masuk. Baris kosong/duplikat dilewati: ' . $skipped . '.'); ->with('error', 'Import selesai, tetapi tidak ada data baru yang masuk. Baris kosong/duplikat dilewati: ' . $skipped . '.');
} }
$analisis = $this->runPythonScript(base_path('scraper/analisis.py'), 900, [ foreach ($periodeIds as $periodeId) {
'--periode-id', (string) $periodeId, $analisis = $this->runPythonScript(base_path('scraper/analisis.py'), 900, [
]); '--periode-id', (string) $periodeId,
]);
if (!$analisis['success']) { if (!$analisis['success']) {
return redirect()->route('riwayat.index') return redirect()->route('riwayat.index')
->with('error', 'Import berhasil, tetapi analisis gagal: ' . $analisis['output']); ->with('error', 'Import berhasil, tetapi analisis gagal: ' . $analisis['output']);
}
} }
$periodeLabels = DB::table('periode_analisis')
->whereIn('id', array_values($periodeIds))
->orderBy('tahun')
->orderBy('bulan')
->pluck('nama')
->implode(', ');
return redirect()->route('riwayat.index') return redirect()->route('riwayat.index')
->with('success', 'Import CSV historis berhasil. Data masuk: ' . $inserted . ', dilewati: ' . $skipped . ', periode: ' . $periodeBulan->translatedFormat('F Y') . '.'); ->with('success', 'Import CSV historis berhasil. Data masuk: ' . $inserted . ', dilewati: ' . $skipped . ', periode: ' . $periodeLabels . '.');
} }
public function ambilData(Request $request) public function ambilData(Request $request)

View File

@ -3,6 +3,7 @@ import argparse
import os import os
import re import re
import sys import sys
import sqlite3
from pathlib import Path from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
@ -67,10 +68,22 @@ def db_config():
env = read_laravel_env() env = read_laravel_env()
connection = env_value(env, "DB_CONNECTION", "mysql") 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"}: if connection not in {"mysql", "mariadb"}:
fail(f"DB_CONNECTION={connection} belum didukung oleh analisis.py. Gunakan mysql/mariadb.") fail(f"DB_CONNECTION={connection} belum didukung oleh analisis.py. Gunakan sqlite/mysql/mariadb.")
return { return {
"connection": connection,
"host": env_value(env, "DB_HOST", "127.0.0.1"), "host": env_value(env, "DB_HOST", "127.0.0.1"),
"port": int(env_value(env, "DB_PORT", "3306")), "port": int(env_value(env, "DB_PORT", "3306")),
"database": env_value(env, "DB_DATABASE", "sentara"), "database": env_value(env, "DB_DATABASE", "sentara"),
@ -80,6 +93,12 @@ def db_config():
def make_connections(config): def make_connections(config):
if config["connection"] == "sqlite":
engine = create_engine(f"sqlite:///{config['database']}")
conn = sqlite3.connect(config["database"])
conn.row_factory = sqlite3.Row
return engine, conn
engine_url = ( engine_url = (
"mysql+pymysql://" "mysql+pymysql://"
f"{quote_plus(config['user'])}:{quote_plus(config['password'])}" f"{quote_plus(config['user'])}:{quote_plus(config['password'])}"
@ -98,6 +117,29 @@ def make_connections(config):
return engine, conn return engine, conn
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 table_columns(cursor, conn, table):
if is_sqlite_connection(conn):
cursor.execute(f"PRAGMA table_info({table})")
return {row["name"] for row in cursor.fetchall()}
cursor.execute(f"SHOW COLUMNS FROM {table}")
return {row["Field"] for row in cursor.fetchall()}
SLANG_MAP = { SLANG_MAP = {
"ga": "tidak", "ga": "tidak",
"gak": "tidak", "gak": "tidak",
@ -214,16 +256,19 @@ def apply_rating_priority(row, model_classes=None):
def main(): def main():
args = parse_args() args = parse_args()
config = db_config() config = db_config()
log("INFO", f"Menggunakan database {config['database']} di {config['host']}:{config['port']}") if config["connection"] == "sqlite":
log("INFO", f"Menggunakan database SQLite {config['database']}")
else:
log("INFO", f"Menggunakan database {config['database']} di {config['host']}:{config['port']}")
engine, raw_conn = make_connections(config) engine, raw_conn = make_connections(config)
cursor = raw_conn.cursor() cursor = raw_conn.cursor()
try: try:
if args.periode_id: if args.periode_id:
cursor.execute("SELECT id, nama FROM periode_analisis WHERE id = %s LIMIT 1", (args.periode_id,)) execute(cursor, raw_conn, "SELECT id, nama FROM periode_analisis WHERE id = %s LIMIT 1", (args.periode_id,))
else: else:
cursor.execute(""" execute(cursor, raw_conn, """
SELECT p.id, p.nama SELECT p.id, p.nama
FROM periode_analisis p FROM periode_analisis p
WHERE EXISTS ( WHERE EXISTS (
@ -241,8 +286,11 @@ def main():
log("INFO", f"Analisis periode terbaru: {periode_nama} (periode_id={periode_id})") log("INFO", f"Analisis periode terbaru: {periode_nama} (periode_id={periode_id})")
df = pd.read_sql( df = pd.read_sql(
"SELECT id, wisata, reviewer, rating, ulasan, tanggal, periode_id " prepare_sql(
"FROM ulasan WHERE periode_id = %s", raw_conn,
"SELECT id, wisata, reviewer, rating, ulasan, tanggal, periode_id "
"FROM ulasan WHERE periode_id = %s",
),
engine, engine,
params=(periode_id,), params=(periode_id,),
) )
@ -322,11 +370,10 @@ def main():
log("INFO", "Evaluasi memakai pseudo-label dari rating/rule otomatis, bukan label manual.") log("INFO", "Evaluasi memakai pseudo-label dari rating/rule otomatis, bukan label manual.")
cursor.execute("DELETE FROM hasil_analisis WHERE periode_id = %s", (periode_id,)) execute(cursor, raw_conn, "DELETE FROM hasil_analisis WHERE periode_id = %s", (periode_id,))
cursor.execute("DELETE FROM evaluasi_model WHERE periode_id = %s", (periode_id,)) execute(cursor, raw_conn, "DELETE FROM evaluasi_model WHERE periode_id = %s", (periode_id,))
cursor.execute("SHOW COLUMNS FROM hasil_analisis") hasil_columns = table_columns(cursor, raw_conn, "hasil_analisis")
hasil_columns = {row["Field"] for row in cursor.fetchall()}
insert_columns = [ insert_columns = [
"wisata", "wisata",
"ulasan_asli", "ulasan_asli",
@ -360,10 +407,12 @@ def main():
if "ulasan_terolah" in hasil_columns: if "ulasan_terolah" in hasil_columns:
values.insert(3, str(row["ulasan_bersih"])) values.insert(3, str(row["ulasan_bersih"]))
cursor.execute(insert_hasil, tuple(values)) execute(cursor, raw_conn, insert_hasil, tuple(values))
weighted = report.get("weighted avg", {}) weighted = report.get("weighted avg", {})
cursor.execute( execute(
cursor,
raw_conn,
""" """
INSERT INTO evaluasi_model INSERT INTO evaluasi_model
(`precision`, `recall`, f1_score, accuracy, tp, tn, fp, fn, periode_id, created_at, updated_at) (`precision`, `recall`, f1_score, accuracy, tp, tn, fp, fn, periode_id, created_at, updated_at)

View File

@ -5,6 +5,7 @@ import json
import re import re
import sys import sys
import time import time
import sqlite3
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -80,10 +81,22 @@ def env_bool(env, key, default=False):
def db_config(): def db_config():
env = read_laravel_env() env = read_laravel_env()
connection = env_value(env, "DB_CONNECTION", "mysql") 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"}: if connection not in {"mysql", "mariadb"}:
fail(f"DB_CONNECTION={connection} belum didukung oleh scraping_pipeline.py. Gunakan mysql/mariadb.") fail(f"DB_CONNECTION={connection} belum didukung oleh scraping_pipeline.py. Gunakan sqlite/mysql/mariadb.")
return { return {
"connection": connection,
"host": env_value(env, "DB_HOST", "127.0.0.1"), "host": env_value(env, "DB_HOST", "127.0.0.1"),
"port": int(env_value(env, "DB_PORT", "3306")), "port": int(env_value(env, "DB_PORT", "3306")),
"database": env_value(env, "DB_DATABASE", "sentara"), "database": env_value(env, "DB_DATABASE", "sentara"),
@ -112,6 +125,34 @@ def scraper_config():
} }
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 pymysql.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
charset="utf8mb4",
)
def normalize_rating(value): def normalize_rating(value):
if value is None: if value is None:
return None return None
@ -197,7 +238,9 @@ def get_or_create_period(cursor, conn, start_date, end_date):
] ]
nama = f"{nama_bulan[bulan - 1]} {tahun}" nama = f"{nama_bulan[bulan - 1]} {tahun}"
cursor.execute( execute(
cursor,
conn,
"SELECT id, nama FROM periode_analisis WHERE bulan = %s AND tahun = %s LIMIT 1", "SELECT id, nama FROM periode_analisis WHERE bulan = %s AND tahun = %s LIMIT 1",
(bulan, tahun), (bulan, tahun),
) )
@ -205,7 +248,9 @@ def get_or_create_period(cursor, conn, start_date, end_date):
if periode: if periode:
return periode[0], periode[1] return periode[0], periode[1]
cursor.execute( execute(
cursor,
conn,
""" """
INSERT INTO periode_analisis (nama, bulan, tahun, created_at, updated_at) INSERT INTO periode_analisis (nama, bulan, tahun, created_at, updated_at)
VALUES (%s, %s, %s, NOW(), NOW()) VALUES (%s, %s, %s, NOW(), NOW())
@ -349,6 +394,9 @@ def first_value(data, keys, default=""):
return default return default
RATING_ONLY_REVIEW_TEXT = "[Tanpa teks]"
def normalize_actor_id(actor_id): def normalize_actor_id(actor_id):
return actor_id.strip().replace("/", "~") return actor_id.strip().replace("/", "~")
@ -399,9 +447,12 @@ def fetch_apify_reviews(wisata, url, config, start_date, end_date):
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
ulasan = first_value(item, ["text", "reviewText", "textTranslated", "snippet"]) ulasan = str(first_value(item, ["text", "reviewText", "textTranslated", "snippet"], "")).strip()
if not str(ulasan).strip(): rating = normalize_rating(first_value(item, ["stars", "rating", "score"], None))
if not ulasan and rating is None:
continue continue
if not ulasan:
ulasan = RATING_ONLY_REVIEW_TEXT
tanggal_text = str(first_value(item, ["publishedAtDate", "publishAt", "publishedAt", "date"], "")) tanggal_text = str(first_value(item, ["publishedAtDate", "publishAt", "publishedAt", "date"], ""))
estimated_date = estimate_review_date(tanggal_text, scrape_now) estimated_date = estimate_review_date(tanggal_text, scrape_now)
@ -412,8 +463,8 @@ def fetch_apify_reviews(wisata, url, config, start_date, end_date):
{ {
"wisata": wisata, "wisata": wisata,
"reviewer": first_value(item, ["name", "reviewerName", "authorName", "reviewer"], "anonymous"), "reviewer": first_value(item, ["name", "reviewerName", "authorName", "reviewer"], "anonymous"),
"rating": normalize_rating(first_value(item, ["stars", "rating", "score"], None)), "rating": rating,
"ulasan": str(ulasan).strip(), "ulasan": ulasan,
"tanggal": estimated_date.strftime("%Y-%m-%d") if estimated_date else str(tanggal_text or ""), "tanggal": estimated_date.strftime("%Y-%m-%d") if estimated_date else str(tanggal_text or ""),
} }
) )
@ -426,7 +477,9 @@ def insert_reviews(cursor, conn, reviews, periode_id):
skipped_duplicate = 0 skipped_duplicate = 0
for review in reviews: for review in reviews:
cursor.execute( execute(
cursor,
conn,
""" """
SELECT id FROM ulasan SELECT id FROM ulasan
WHERE wisata = %s AND reviewer = %s AND ulasan = %s AND tanggal = %s WHERE wisata = %s AND reviewer = %s AND ulasan = %s AND tanggal = %s
@ -438,7 +491,9 @@ def insert_reviews(cursor, conn, reviews, periode_id):
skipped_duplicate += 1 skipped_duplicate += 1
continue continue
cursor.execute( execute(
cursor,
conn,
""" """
INSERT INTO ulasan INSERT INTO ulasan
(wisata, reviewer, rating, ulasan, tanggal, scraping_date, periode_id, sentimen, created_at, updated_at) (wisata, reviewer, rating, ulasan, tanggal, scraping_date, periode_id, sentimen, created_at, updated_at)
@ -460,8 +515,10 @@ def insert_reviews(cursor, conn, reviews, periode_id):
return saved, skipped_duplicate return saved, skipped_duplicate
def count_reviews_for_wisata(cursor, wisata, periode_id): def count_reviews_for_wisata(cursor, conn, wisata, periode_id):
cursor.execute( execute(
cursor,
conn,
"SELECT COUNT(*) FROM ulasan WHERE wisata = %s AND periode_id = %s", "SELECT COUNT(*) FROM ulasan WHERE wisata = %s AND periode_id = %s",
(wisata, periode_id), (wisata, periode_id),
) )
@ -470,14 +527,14 @@ def count_reviews_for_wisata(cursor, wisata, periode_id):
def purge_out_of_range_reviews(cursor, conn, periode_id, start_date, end_date): def purge_out_of_range_reviews(cursor, conn, periode_id, start_date, end_date):
cursor.execute("SELECT id, wisata, tanggal FROM ulasan WHERE periode_id = %s", (periode_id,)) execute(cursor, conn, "SELECT id, wisata, tanggal FROM ulasan WHERE periode_id = %s", (periode_id,))
rows = cursor.fetchall() rows = cursor.fetchall()
deleted = 0 deleted = 0
for row in rows: for row in rows:
review_id, wisata, tanggal = row review_id, wisata, tanggal = row
if not is_review_in_date_range(tanggal, start_date, end_date): if not is_review_in_date_range(tanggal, start_date, end_date):
cursor.execute("DELETE FROM ulasan WHERE id = %s", (review_id,)) execute(cursor, conn, "DELETE FROM ulasan WHERE id = %s", (review_id,))
deleted += 1 deleted += 1
log("INFO", f"Hapus ulasan luar rentang dari periode aktif: {wisata} ({tanggal})") log("INFO", f"Hapus ulasan luar rentang dari periode aktif: {wisata} ({tanggal})")
@ -486,10 +543,12 @@ def purge_out_of_range_reviews(cursor, conn, periode_id, start_date, end_date):
log("INFO", f"Pembersihan ulasan luar rentang: {deleted} baris dihapus.") log("INFO", f"Pembersihan ulasan luar rentang: {deleted} baris dihapus.")
def validate_all_destinations_have_data(cursor, periode_id, destinations, require_all=False): def validate_all_destinations_have_data(cursor, conn, periode_id, destinations, require_all=False):
missing = [] missing = []
total_existing = 0
for wisata in destinations: for wisata in destinations:
total = count_reviews_for_wisata(cursor, wisata, periode_id) 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}") log("INFO", f"Validasi data {wisata}: {total} ulasan pada periode_id={periode_id}")
if total == 0: if total == 0:
missing.append(wisata) missing.append(wisata)
@ -504,16 +563,36 @@ def validate_all_destinations_have_data(cursor, periode_id, destinations, requir
if missing: if missing:
log("WARNING", "Belum ada ulasan periode aktif untuk: " + ", ".join(missing)) log("WARNING", "Belum ada ulasan periode aktif untuk: " + ", ".join(missing))
return total_existing
def validate_all_destinations_processed(processed, destinations):
def validate_all_destinations_processed(processed, destinations, require_all=False):
missing = [wisata for wisata in destinations if wisata not in processed] missing = [wisata for wisata in destinations if wisata not in processed]
if missing: if missing and require_all:
fail( fail(
"Scraper belum berhasil membuka/memproses semua lokasi: " "Scraper belum berhasil membuka/memproses semua lokasi: "
+ ", ".join(missing) + ", ".join(missing)
+ ". Cek URL atau selector Google Maps untuk lokasi tersebut." + ". 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): def scrape_with_apify(cursor, conn, periode_id, start_date, end_date, config, destinations):
total_saved = 0 total_saved = 0
@ -529,8 +608,10 @@ def scrape_with_apify(cursor, conn, periode_id, start_date, end_date, config, de
log("OK", f"{wisata}: Apify dapat {len(reviews)}, simpan {saved}, duplikat dilewati {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}.") log("OK", f"Scraping Apify selesai. Total simpan {total_saved}, duplikat dilewati {total_skipped_duplicate}.")
validate_all_destinations_processed(processed, destinations) validate_all_destinations_processed(processed, destinations, config["require_all_destinations"])
validate_all_destinations_have_data(cursor, periode_id, 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): def click_sort_newest(driver):
@ -556,18 +637,17 @@ def click_sort_newest(driver):
return False return False
def click_reviews_tab(driver, wait, wisata): def click_reviews_tab(driver, wait, wisata, manual_login_timeout=0):
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
candidates = [ candidates = [
"//button[contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ulasan')]",
"//button[contains(translate(@aria-label, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'review')]",
"//button[.//*[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ulasan')]]",
"//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ulasan')]",
"//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'review')]",
"//div[@role='tab'][contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ulasan')]", "//div[@role='tab'][contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ulasan')]",
"//div[@role='tab'][contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'review')]", "//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'))]",
] ]
try: try:
@ -575,18 +655,47 @@ def click_reviews_tab(driver, wait, wisata):
except Exception: except Exception:
pass pass
for xpath in candidates: def try_click_reviews_tab():
try: for xpath in candidates:
elements = driver.find_elements(By.XPATH, xpath) try:
for element in elements: elements = driver.find_elements(By.XPATH, xpath)
if element.is_displayed(): for element in elements:
label = element.get_attribute("aria-label") or element.text or xpath if element.is_displayed():
driver.execute_script("arguments[0].click();", element) label = element.get_attribute("aria-label") or element.text or xpath
time.sleep(4) normalized_label = label.strip().lower()
log("INFO", f"{wisata}: tab ulasan dibuka lewat {label}") if "tulis ulasan" in normalized_label or "write a review" in normalized_label:
return True continue
except Exception: driver.execute_script("arguments[0].click();", element)
continue 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 return False
@ -650,7 +759,7 @@ def scrape_with_selenium(cursor, conn, periode_id, start_date, end_date, config,
"Login manual di Chrome profile khusus, lalu klik Ambil Data lagi." "Login manual di Chrome profile khusus, lalu klik Ambil Data lagi."
) )
if not click_reviews_tab(driver, wait, wisata): 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}") log("WARNING", f"Tombol/tab ulasan tidak ditemukan untuk {wisata}")
continue continue
@ -679,32 +788,62 @@ def scrape_with_selenium(cursor, conn, periode_id, start_date, end_date, config,
time.sleep(2) time.sleep(2)
items = [] items = []
reviews = driver.find_elements(By.XPATH, "//div[@data-review-id]") reviews = driver.find_elements(By.XPATH, "//div[contains(@class,'jftiEf') and @data-review-id]")
log("INFO", f"{wisata}: {len(reviews)} review ditemukan") log("INFO", f"{wisata}: {len(reviews)} review ditemukan")
for review in reviews: for review in reviews:
try: rating_label = first_review_text(
ulasan = review.find_element(By.XPATH, ".//span[contains(@class,'wiI7pd')]").text.strip() review,
if not ulasan: [
continue ".//*[@role='img' and contains(@aria-label,'bintang')]",
tanggal_text = review.find_element(By.XPATH, ".//span[contains(@class,'rsqaWe')]").text.strip() ".//*[@role='img' and contains(@aria-label,'star')]",
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): "aria-label",
log("INFO", f"{wisata}: skip ulasan luar rentang ({tanggal_text})") )
continue rating = normalize_rating(rating_label)
items.append( ulasan = first_review_text(
{ review,
"wisata": wisata, [
"reviewer": review.find_element(By.XPATH, ".//div[contains(@class,'d4r55')]").text.strip(), ".//span[contains(@class,'wiI7pd')]",
"rating": normalize_rating( ".//span[contains(@class,'MyEned')]",
review.find_element(By.XPATH, ".//span[@role='img']").get_attribute("aria-label") ".//div[contains(@class,'MyEned')]//span",
), ".//span[@lang]",
"ulasan": ulasan, ],
"tanggal": estimated_date.strftime("%Y-%m-%d") if estimated_date else str(tanggal_text or ""), )
} if not ulasan and rating is None:
) continue
except Exception as exc: if not ulasan:
log("WARNING", f"{wisata}: skip review karena {exc}") 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) saved, skipped_duplicate = insert_reviews(cursor, conn, items, periode_id)
processed.add(wisata) processed.add(wisata)
@ -713,8 +852,10 @@ def scrape_with_selenium(cursor, conn, periode_id, start_date, end_date, config,
log("OK", f"{wisata}: simpan {saved}, duplikat dilewati {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}.") log("OK", f"Scraping Selenium selesai. Total simpan {total_saved}, duplikat dilewati {total_skipped_duplicate}.")
validate_all_destinations_processed(processed, destinations) validate_all_destinations_processed(processed, destinations, config["require_all_destinations"])
validate_all_destinations_have_data(cursor, periode_id, 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: finally:
driver.quit() driver.quit()
@ -734,13 +875,7 @@ def main():
config = db_config() config = db_config()
scraper = scraper_config() scraper = scraper_config()
destinations = selected_destinations(args.wisata) destinations = selected_destinations(args.wisata)
conn = pymysql.connect( conn = make_connection(config)
host='localhost',
port=3306,
user='root',
password='', # isi password kalau ada
database='sistem_analisis',
)
cursor = conn.cursor() cursor = conn.cursor()
try: try:
@ -767,4 +902,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()