From fa79880771d3e7579daa6dc3a362f939a04c81e8 Mon Sep 17 00:00:00 2001 From: Vysyr Date: Mon, 31 Aug 2026 17:28:05 +0200 Subject: [PATCH] initial code --- __pycache__/reviews.cpython-310.pyc | Bin 0 -> 9572 bytes google_reviews_ratings.csv | 166 ++++++++ reviews.py | 585 ++++++++++++++++++++++++++++ 3 files changed, 751 insertions(+) create mode 100644 __pycache__/reviews.cpython-310.pyc create mode 100644 google_reviews_ratings.csv create mode 100644 reviews.py diff --git a/__pycache__/reviews.cpython-310.pyc b/__pycache__/reviews.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47c9092153a574bcee09e20c84da2ea8b27b614e GIT binary patch literal 9572 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 5: + print("Panel opinii otwarty.") + return True + + except Exception as e: + print("Nie udało się kliknąć:", e) + + except: + continue + + # Fallback dla role=button + print("Próbuję alternatywnego selektora...") + + candidates = page.locator( + '[role="button"][aria-label*="opini"], ' + '[role="button"][aria-label*="recenz"], ' + '[role="button"][aria-label*="review"]' + ) + + for i in range(candidates.count()): + try: + el = candidates.nth(i) + + if not el.is_visible(): + continue + + aria = el.get_attribute("aria-label") or "" + + if not re.search(r"\d+", aria): + continue + + print("Klikam:", repr(aria)) + + el.scroll_into_view_if_needed() + el.click(timeout=3000) + + time.sleep(3) + + cards = page.locator('div[data-review-id]') + + if cards.count() > 5: + print("Panel opinii otwarty.") + return True + + except: + continue + + return False + + +# ============================================================ +# ZNAJDOWANIE SCROLLOWANEGO PANELU +# ============================================================ + +def get_scrollable_parent(card): + try: + return card.evaluate_handle(""" + el => { + let p = el.parentElement; + + while (p) { + const style = getComputedStyle(p); + + if ( + (style.overflowY === 'auto' || + style.overflowY === 'scroll') && + p.scrollHeight > p.clientHeight + ) { + return p; + } + + p = p.parentElement; + } + + return null; + } + """) + except: + return None + + +# ============================================================ +# OCENA GWIAZDKOWA +# ============================================================ + +def extract_rating(card): + selectors = [ + '[role="img"][aria-label*="gwiazd"]', + '[aria-label*="gwiazd"]', + '[role="img"][aria-label*="star"]', + '[aria-label*="star"]', + 'span.kvMYJc[aria-label]', + ] + + for selector in selectors: + try: + el = card.locator(selector).first + + if not el.count(): + continue + + label = el.get_attribute("aria-label") or "" + + match = re.search(r"([1-5](?:[.,]\d+)?)", label) + + if match: + value = match.group(1).replace(",", ".") + return int(float(value)) + + except: + pass + + return None + + +# ============================================================ +# DATA OPINII +# ============================================================ + +def extract_date_text(card): + selectors = [ + ".rsqaWe", + "span.rsqaWe", + ] + + for selector in selectors: + try: + el = card.locator(selector).first + + if el.count(): + text = el.inner_text().strip() + + if text: + return text + + except: + pass + + return None + + +# ============================================================ +# ŁADOWANIE WSZYSTKICH OPINII +# ============================================================ + +def count_unique_reviews(cards): + """Policz opinie, a nie zduplikowane elementy DOM Google Maps.""" + try: + return cards.evaluate_all(""" + elements => new Set( + elements + .map(el => el.getAttribute('data-review-id')) + .filter(Boolean) + ).size + """) + except: + return 0 + + +def load_all_reviews(page): + previous_count = 0 + empty_scrolls = 0 + + print("\nŁadowanie opinii...") + + while empty_scrolls < MAX_EMPTY_SCROLLS: + cards = page.locator('div[data-review-id]') + count = count_unique_reviews(cards) + + print( + f"\rZaładowane opinie: {count}", + end="", + flush=True + ) + + if count > previous_count: + previous_count = count + empty_scrolls = 0 + else: + empty_scrolls += 1 + + if count == 0: + page.mouse.wheel(0, 5000) + time.sleep(SCROLL_DELAY) + continue + + last_card = cards.last + + scrollable = get_scrollable_parent(last_card) + + if scrollable: + try: + scrollable.evaluate(""" + el => { + el.scrollTop = el.scrollHeight; + } + """) + except: + page.mouse.wheel(0, 5000) + else: + try: + last_card.scroll_into_view_if_needed() + except: + page.mouse.wheel(0, 5000) + + time.sleep(SCROLL_DELAY) + + print() + + +# ============================================================ +# ZBIERANIE DANYCH +# ============================================================ + +def extract_reviews(page): + results = {} + + cards = page.locator('div[data-review-id]') + + unique_count = count_unique_reviews(cards) + print(f"\nZbieram dane z {unique_count} unikalnych opinii...") + + for i in range(cards.count()): + card = cards.nth(i) + + try: + review_id = card.get_attribute("data-review-id") + + if not review_id: + continue + + # Deduplikacja + if review_id in results: + continue + + rating = extract_rating(card) + + if rating is None: + continue + + date_raw = extract_date_text(card) + date_approx = parse_google_date(date_raw) + + results[review_id] = { + "review_id": review_id, + "rating": rating, + "date_raw": date_raw, + "date_approx": date_approx, + } + + except Exception as e: + print(f"Błąd przy opinii #{i}: {e}") + + return pd.DataFrame(results.values()) + + +# ============================================================ +# PODSUMOWANIE +# ============================================================ + +def print_summary(df): + print("\n==============================") + print("ŁĄCZNIE") + print("==============================") + print(len(df)) + + print("\n==============================") + print("ROZKŁAD GWIAZDEK") + print("==============================") + + distribution = ( + df["rating"] + .value_counts() + .reindex([5, 4, 3, 2, 1], fill_value=0) + ) + + total = len(df) + + for stars, count in distribution.items(): + percent = (count / total * 100) if total else 0 + + print( + f"{stars}★: " + f"{count:>4} " + f"({percent:>5.1f}%)" + ) + + print("\n==============================") + print("ŚREDNIA") + print("==============================") + + if len(df): + print(round(df["rating"].mean(), 3)) + else: + print("Brak danych") + + +# ============================================================ +# FILTROWANIE PO OKRESIE +# ============================================================ + +def print_period_summary(df, date_from=None, date_to=None): + temp = df.copy() + + temp["date_approx"] = pd.to_datetime( + temp["date_approx"], + errors="coerce" + ) + + if date_from: + temp = temp[ + temp["date_approx"] >= pd.to_datetime(date_from) + ] + + if date_to: + temp = temp[ + temp["date_approx"] <= pd.to_datetime(date_to) + ] + + print("\n==============================") + print(f"OKRES: {date_from} -> {date_to}") + print("==============================") + + if temp.empty: + print("Brak opinii w tym okresie.") + return + + distribution = ( + temp["rating"] + .value_counts() + .reindex([5, 4, 3, 2, 1], fill_value=0) + ) + + print(f"Liczba opinii: {len(temp)}") + + for stars, count in distribution.items(): + print(f"{stars}★: {count}") + + print("Średnia:", round(temp["rating"].mean(), 3)) + + +# ============================================================ +# MAIN +# ============================================================ + +def main(): + with sync_playwright() as p: + browser = p.chromium.launch( + headless=HEADLESS + ) + + context = browser.new_context( + locale="pl-PL", + viewport={ + "width": 1400, + "height": 900, + }, + ) + + page = context.new_page() + + print("Otwieram Google Maps...") + + page.goto( + URL, + wait_until="domcontentloaded", + timeout=60_000 + ) + + time.sleep(3) + + accept_cookies(page) + + opened = open_reviews_panel(page) + + if not opened: + print( + "\nNie udało się automatycznie otworzyć panelu opinii." + ) + print( + "Kliknij ręcznie liczbę opinii / „Więcej opinii”." + ) + + input( + "Gdy panel będzie otwarty, naciśnij ENTER..." + ) + + time.sleep(2) + + cards = page.locator('div[data-review-id]') + + print( + "Opinie widoczne po otwarciu panelu:", + count_unique_reviews(cards) + ) + + if count_unique_reviews(cards) < 5: + print( + "\nUWAGA: panel opinii prawdopodobnie " + "nie został poprawnie otwarty." + ) + + input( + "Otwórz ręcznie wszystkie opinie " + "i naciśnij ENTER..." + ) + + load_all_reviews(page) + + df = extract_reviews(page) + + browser.close() + + if df.empty: + print("\nNie znaleziono żadnych opinii.") + return + + df.to_csv( + "google_reviews_ratings.csv", + index=False, + encoding="utf-8-sig" + ) + + print_summary(df) + + print("\nZapisano:") + print("google_reviews_ratings.csv") + + # ======================================================== + # PRZYKŁADOWE FILTROWANIE + # Odkomentuj jeśli chcesz + # ======================================================== + + # print_period_summary( + # df, + # "2025-01-01", + # "2025-12-31" + # ) + + +if __name__ == "__main__": + main()