beo-mcdp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: beo-mcdp
3
+ Version: 0.2.0
4
+ Summary: Core CDP Browser Automation and Utils Provider
5
+ Author: Dwi Hardiyanto
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: playwright>=1.57.0
9
+ Provides-Extra: patchright
10
+ Requires-Dist: patchright; extra == "patchright"
11
+ Provides-Extra: kafka
12
+ Requires-Dist: confluent-kafka; extra == "kafka"
13
+ Dynamic: author
14
+ Dynamic: description
15
+ Dynamic: description-content-type
16
+ Dynamic: provides-extra
17
+ Dynamic: requires-dist
18
+ Dynamic: requires-python
19
+ Dynamic: summary
20
+
21
+ # CDP Scraper Provider
22
+
23
+ Library *core* untuk ekstraksi data lewat browser Chrome (CDP). Satu mesin yang sama dipakai untuk
24
+ banyak sosial media: platform baru cukup **dideskripsikan** (URL, pola API, mapping field), tanpa
25
+ menulis ulang sesi browser, scroll, capture, maupun parser.
26
+
27
+ ## Instalasi
28
+
29
+ ```bash
30
+ pip install git+https://github.com/dwihard/social_scrapers.git@v0.2.0
31
+ # opsional: pip install "cdp_scraper[kafka]" / "cdp_scraper[patchright]"
32
+ ```
33
+
34
+ ## Cara kerja
35
+
36
+ 1. Browser membuka halaman (`Endpoint.url`).
37
+ 2. Library mendengarkan response API yang dipanggil halaman (`Endpoint.api_match`).
38
+ 3. JSON-nya diubah jadi list dict oleh `parser` (biasanya `FieldMapper`), di-dedupe lewat `key`, lalu halaman di-scroll sampai `limit`/data habis.
39
+
40
+ ## Menambah platform baru
41
+
42
+ ```python
43
+ from cdp_scraper import (Endpoint, F, FieldMapper, Platform, Scraper,
44
+ const, make_scroller, register_platform, end_when_falsy)
45
+
46
+ parser = FieldMapper(
47
+ items="data.posts", # path ke list item
48
+ fields={
49
+ "id": ("id", "str", ""), # (path, transform, default)
50
+ "url": F(template="https://site.com/{author.handle}/{id}"),
51
+ "likes": ("stats.likes", "int", 0),
52
+ "tags": "tags[].name", # semua elemen
53
+ "posted": F("created", transform="iso_utc"),
54
+ "platform": const("site"),
55
+ },
56
+ )
57
+
58
+ site = Platform(
59
+ name="site", # juga jadi label log: [site] ...
60
+ cdp={"cdp_url": "http://localhost:9223"}, # opsional: Chrome/slot terpisah per platform
61
+ overlay_selectors=('div.popup-close',), # popup yang ditutup otomatis
62
+ login_url_hints=("/login",), # deteksi browser belum login
63
+ endpoints=[Endpoint(
64
+ "search", url="https://site.com/search?q={keyword}", api_match="api/search",
65
+ parser=parser, params=("keyword",), label="keyword", limit=20, max_limit=50,
66
+ end=end_when_falsy("has_more"),
67
+ scroll=make_scroller(wheel=2500, click_pattern="load more"),
68
+ )],
69
+ )
70
+ register_platform(site)
71
+
72
+ result = await Scraper("site").run("search", {"keyword": "kopi"}, limit=30) # satu query
73
+ results = await Scraper("site").run_many("search", [{"keyword": "a"}, {"keyword": "b"}])
74
+ ```
75
+
76
+ `run()` melempar exception (`PageCrashed`, `BrowserBusy`, `ValueError` untuk parameter salah).
77
+ `run_many()` memakai satu tab untuk banyak query dan menjadikan error per-item `status="error"`.
78
+ Contoh lengkap TikTok dan X/Twitter ada di `examples/`.
79
+
80
+ ## Mapping parsing (`FieldMapper`)
81
+
82
+ | Sintaks path | Arti |
83
+ |---|---|
84
+ | `a.b.c` | nilai bersarang |
85
+ | `a[0].b`, `a[-1]` | indeks |
86
+ | `a[].b` | semua elemen (hasil list) |
87
+ | `a[type=1].b` | elemen yang cocok filter (hasil list) |
88
+
89
+ Bentuk spec field: `"path"` · `("path", "transform", default)` · `F(path | any_of=[...] | template=... | fn=...,
90
+ default=, transform=, required=)` · `const(x)` · `callable(record)`.
91
+
92
+ Opsi `FieldMapper`: `item_path` (sub-path record di tiap item), `where` / `record_where`
93
+ (`{path: nilai | EXISTS | callable}` atau callable), `unwrap` (membuka pembungkus), `items` boleh
94
+ list path atau callable.
95
+
96
+ Transform bawaan: `int float str bool url lower upper strip clean_text username hashtag iso_utc
97
+ date_id join first count unique drop_empty` dan `each:<transform>` untuk list. Argumen format tanggal:
98
+ `iso_utc:%a %b %d %H:%M:%S %z %Y`. Tambah sendiri: `register_transform("nama", fn)`.
99
+
100
+ Jika logika terlalu rumit untuk mapping, gunakan `F(fn=...)` atau parser Python biasa
101
+ (`parser=` menerima callable apa pun yang mengembalikan list dict).
102
+
103
+ ## Hook & aksi halaman
104
+
105
+ `prepare` (setelah halaman terbuka) dan `scroll` (tiap putaran) adalah fungsi `async def (page)`.
106
+ Bawaan: `make_scroller(...)`, `make_wait_for(selector)`, `close_overlays(page, selectors)`.
107
+ `Endpoint.capture_options` meneruskan kwargs tambahan ke `capture` (`poll_interval`, `settle`, dst).
108
+
109
+ ## Konfigurasi browser
110
+
111
+ ```python
112
+ from cdp_scraper import CDPConfig, setup_cdp
113
+ setup_cdp(cdp_url="http://localhost:9222", max_sessions=2, lock_timeout=60) # default global
114
+ cfg = CDPConfig.from_env("TWITTER_") # TWITTER_CDP_URL, TWITTER_MAX_CONCURRENT_SESSIONS, ...
115
+ ```
116
+
117
+ Setiap request memakai **tab baru sendiri** (ditutup saat selesai) dan dibatasi `max_sessions`
118
+ bersamaan per konfigurasi. Konfigurasi yang sama berbagi slot; `cdp_url` berbeda = slot berbeda.
119
+ Screenshot debug disimpan di `CDPConfig.debug_dir` (default folder kerja); matikan dengan
120
+ `debug_screenshots=False`.
121
+
122
+ ## Logging
123
+
124
+ Format seragam `[job] Status message: detail`, job = nama platform:
125
+
126
+ ```
127
+ [2026-11-10 08:19:38] [INFO] [site] Success message: Capture api/search selesai: 20 data
128
+ ```
129
+
130
+ Library hanya memakai logger `cdp_scraper` (tanpa handler). Aktifkan dengan `setup_logging()`
131
+ atau injeksi logger sendiri: `setup_cdp(custom_logger=...)`. `get_logger("kafka").error("...")`
132
+ untuk log aplikasi dengan format yang sama.
133
+
134
+ ## Kafka / Redpanda
135
+
136
+ ```python
137
+ from cdp_scraper import RedpandaProducer, publish_chunks
138
+ producer = RedpandaProducer() # KAFKA_BOOTSTRAP_SERVERS; koneksi dibuat saat dipakai
139
+ if producer.available:
140
+ stats = publish_chunks(producer, "topic-x", keywords, chunk_size=2,
141
+ build_payload=lambda chunk: {"keywords": chunk})
142
+ # {"berhasil": n, "gagal": n}
143
+ ```
144
+ Kontrak sumber data (keyword/akun) ada di `cdp_scraper.utils.database` (`KeywordSource`,
145
+ `AccountSource`); implementasinya milik aplikasi.
146
+
147
+ ## Pengujian
148
+
149
+ ```bash
150
+ python -m unittest discover -s tests -t .
151
+ ```
152
+ Tes memakai browser palsu, jadi tidak butuh Chrome/Kafka.
153
+
154
+ ## Perubahan dari 0.1.x (breaking)
155
+
156
+ - Konfigurasi sekarang `CDPConfig` (immutable, per platform); `setup_cdp(...)` tetap ada. Variabel modul `config.CDP_URL` dst dihapus.
157
+ - `get_session_slots` dan `PW_ENGINE` dihapus; pakai `CDPSessionManager` / `manager.engine`.
158
+ - `BrowserBusy` (subclass `TimeoutError`) menggantikan `TimeoutError` polos.
159
+ - `helpers.close_login_modal(page, selectors)` kini wajib diberi selector (tidak ada lagi selector TikTok bawaan).
160
+ - `utils`: tidak lagi mengimpor Flask; `extract_challenges` (khusus TikTok) dihapus; `calculate_engagement_rate` menerima `interaction_keys` / `view_key` (default sama dengan perilaku lama).
161
+ - `utils.database` kini berupa Protocol; `utils.redpanda_client` tidak lagi membuat producer saat import (gunakan `cdp_scraper.messaging`).
@@ -0,0 +1,31 @@
1
+ cdp_scraper/__init__.py,sha256=N9umzREVvK7eTlSS6PSqLkjCV7Ha8amM-znhrkt7lB8,1549
2
+ cdp_scraper/_version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
3
+ cdp_scraper/config.py,sha256=CBFFngFkZ5yh0IqkZB_S9jBstoCtvNZGNmakGH0Oemw,2525
4
+ cdp_scraper/errors.py,sha256=RH7oZ77Mi_tv_WPNSpk7hsIZAz8Eq6WA2cw4nNo3Hk0,291
5
+ cdp_scraper/logs.py,sha256=e7Xu9LuMrSQYGykXV6ROURWRBQLdKLqVYTE3bob8Hb8,1367
6
+ cdp_scraper/parsers.py,sha256=ZdvFia4r1tsvN-sIg45AfQJ9j4xTXwW-170GLr8DaDw,114
7
+ cdp_scraper/browser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ cdp_scraper/browser/actions.py,sha256=5Qrmvn1K-wnCuBeRlXUeEWS99A8z_9VbTvvNVKNef0k,2900
9
+ cdp_scraper/browser/capture.py,sha256=U5YkQZ4P34ZiN5Xb37COEbma7Xd5YGot3C3vywc87UI,6819
10
+ cdp_scraper/browser/diagnostics.py,sha256=2vTyQ1-2U5lADTMHpOHkbS4yrE7PNExKaKfYVzy8wPc,1791
11
+ cdp_scraper/browser/helpers.py,sha256=v8bQMYFXH2Q6jLBQUltpNS1nuLNLKgW53PEU6eD4Wr0,133
12
+ cdp_scraper/browser/session.py,sha256=wHVi3iqB1NnM7LNQhqEA96gesk8e4-EqzThMnJgnptc,5127
13
+ cdp_scraper/browser/state.py,sha256=ewQR29KRYjtnwa7c_RLfXcuwspsuJ9DABIUte9fN4Dk,101
14
+ cdp_scraper/messaging/__init__.py,sha256=AMBdGZuzhgO9d3dXzCHy6nOWiqNyNInlpdUhUvBgrlQ,253
15
+ cdp_scraper/messaging/redpanda.py,sha256=6M1ifGVRHzv7xb3jdX5dB0b2a4w8Cd1iUPPpayGTyXc,3450
16
+ cdp_scraper/parsing/__init__.py,sha256=q_QBPjNYD9ypeQOHQ4Nud3jdxdMCqrzmh4MQDZINNi8,313
17
+ cdp_scraper/parsing/mapper.py,sha256=Mj1mMQuZ0AcijXw_kyWPGaOSKiZhtvrLIiaiyRoXT5M,6849
18
+ cdp_scraper/parsing/paths.py,sha256=WAMZqdLjUL3dVhNuj2r1XXnCmzvtxyYwIZEhxMxXLgA,2468
19
+ cdp_scraper/parsing/transforms.py,sha256=Ez4XVQMxCULD62OP1kodKtA8WJPcHx6MbA570Kx_HiE,4044
20
+ cdp_scraper/platforms/__init__.py,sha256=Ce9MUVPNMaCsvrTybHVEon3Q54VrhVwDm6yO7LdmYL8,401
21
+ cdp_scraper/platforms/registry.py,sha256=XU4nrMD4tGQMeWmaszRkAP-HIkJmsPMptH1XyhhAx4A,673
22
+ cdp_scraper/platforms/runner.py,sha256=traqQBjTMTgYZCkwwsyCJcFkoeahRsrjuy5wWzWzxyA,5293
23
+ cdp_scraper/platforms/spec.py,sha256=we1BMN03sMZF4xgGR2FTf1GWFxF3fsUh40RF322-gEw,4838
24
+ cdp_scraper/utils/__init__.py,sha256=NJAlMwoaEEHRRS5TH-4JUePYzR7n8VPtne6fiqNWWgc,671
25
+ cdp_scraper/utils/database.py,sha256=9oiw9ppN51R4RJRdd7y-BxNvFY0XHKSttbuPceLNFgA,333
26
+ cdp_scraper/utils/redpanda_client.py,sha256=HoEoNSUb38ljTsJMvpub51c5tzePWduuPmPWOEZq-jM,283
27
+ cdp_scraper/utils/utils.py,sha256=48ONTL8OKVHlCMVQ32dgV7_WcsnhnrIsQRVtNH_jiyc,3694
28
+ beo_mcdp-0.2.0.dist-info/METADATA,sha256=sAY9_sc30ShpRmsfJqLWvQsJmCNrk5llk2VbeTxQ2F4,6865
29
+ beo_mcdp-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
30
+ beo_mcdp-0.2.0.dist-info/top_level.txt,sha256=NliR-0bvpE9-A1PTF-2gSom_tWGAa_gaOePosP27G1Q,12
31
+ beo_mcdp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ cdp_scraper
@@ -0,0 +1,24 @@
1
+ from ._version import __version__
2
+ from .browser.actions import close_overlays, make_scroller, make_wait_for, scroll_page_down
3
+ from .browser.capture import CaptureResult, capture, capture_api
4
+ from .browser.session import (CDPSessionManager, default_manager, get_manager,
5
+ is_browser_busy, playwright_session)
6
+ from .config import CDPConfig, get_default_config, set_default_config, setup_cdp
7
+ from .errors import BrowserBusy, CDPError, PageCrashed
8
+ from .logs import JobLogger, get_logger, setup_logging
9
+ from .messaging import RedpandaProducer, chunk_list, publish_chunks
10
+ from .parsing import EXISTS, F, FieldMapper, const, get_path, register_transform
11
+ from .platforms import (Endpoint, Platform, ScrapeResult, Scraper, end_when_empty,
12
+ end_when_falsy, get_platform, list_platforms, register_platform)
13
+
14
+ __all__ = [
15
+ "__version__", "CDPConfig", "setup_cdp", "get_default_config", "set_default_config",
16
+ "CDPError", "PageCrashed", "BrowserBusy", "JobLogger", "get_logger", "setup_logging",
17
+ "CDPSessionManager", "get_manager", "default_manager", "playwright_session", "is_browser_busy",
18
+ "capture", "capture_api", "CaptureResult",
19
+ "close_overlays", "make_scroller", "make_wait_for", "scroll_page_down",
20
+ "FieldMapper", "F", "const", "EXISTS", "get_path", "register_transform",
21
+ "Platform", "Endpoint", "Scraper", "ScrapeResult", "register_platform", "get_platform",
22
+ "list_platforms", "end_when_falsy", "end_when_empty",
23
+ "RedpandaProducer", "publish_chunks", "chunk_list",
24
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
File without changes
@@ -0,0 +1,74 @@
1
+ """Aksi halaman yang bisa dirakit per platform: tutup overlay, scroll, tunggu elemen."""
2
+ from ..logs import get_logger
3
+
4
+ _SCROLL_JS = """(o) => {
5
+ if (o.window === 'bottom') window.scrollTo(0, document.documentElement.scrollHeight);
6
+ else if (o.window === 'viewport') window.scrollBy(0, window.innerHeight * o.factor);
7
+ if (o.containers) {
8
+ for (const el of document.querySelectorAll('div')) {
9
+ const st = getComputedStyle(el);
10
+ if ((st.overflowY === 'auto' || st.overflowY === 'scroll')
11
+ && el.scrollHeight > el.clientHeight + 50) {
12
+ el.scrollTop = el.scrollHeight;
13
+ }
14
+ }
15
+ }
16
+ if (o.pattern) {
17
+ const re = new RegExp(o.pattern, 'i');
18
+ const btn = [...document.querySelectorAll('button, [role="button"]')]
19
+ .find(b => re.test((b.innerText || '').trim()));
20
+ if (btn) btn.click();
21
+ }
22
+ }"""
23
+
24
+
25
+ async def close_overlays(page, selectors):
26
+ """Klik tombol penutup popup/modal (selector CSS diberikan platform)."""
27
+ if not selectors:
28
+ return
29
+ try:
30
+ await page.evaluate(
31
+ "(sels) => document.querySelectorAll(sels.join(',')).forEach(b => b.click())",
32
+ list(selectors))
33
+ except Exception:
34
+ pass
35
+
36
+
37
+ async def scroll_page_down(page):
38
+ await page.keyboard.press("PageDown")
39
+
40
+
41
+ def make_scroller(*, wheel=2500, window_scroll="bottom", viewport_factor=2.0,
42
+ containers=False, click_pattern=None, mouse=(0.5, 0.5), key=None):
43
+ """Bangun fungsi scroll generik.
44
+
45
+ wheel: delta mouse wheel (0 = nonaktif); window_scroll: 'bottom' | 'viewport' | None;
46
+ containers: scroll semua div overflow; click_pattern: regex teks tombol 'load more'/'retry';
47
+ mouse: posisi kursor (fraksi viewport); key: tombol keyboard tambahan (mis. 'End').
48
+ """
49
+ options = {"window": window_scroll, "factor": viewport_factor,
50
+ "containers": containers, "pattern": click_pattern}
51
+ use_js = bool(window_scroll or containers or click_pattern)
52
+
53
+ async def _scroll(page):
54
+ if wheel:
55
+ vp = page.viewport_size or {"width": 1280, "height": 800}
56
+ await page.mouse.move(vp["width"] * mouse[0], vp["height"] * mouse[1])
57
+ await page.mouse.wheel(0, wheel)
58
+ if use_js:
59
+ await page.evaluate(_SCROLL_JS, options)
60
+ if key:
61
+ await page.keyboard.press(key)
62
+
63
+ return _scroll
64
+
65
+
66
+ def make_wait_for(selector, timeout_ms=15000, message=None):
67
+ """Fungsi prepare: tunggu elemen pertama muncul (hanya warning jika tidak muncul)."""
68
+ async def _wait(page, log=None):
69
+ log = log or get_logger()
70
+ try:
71
+ await page.wait_for_selector(selector, state="attached", timeout=timeout_ms)
72
+ except Exception:
73
+ log.warning(message or f"Elemen '{selector}' belum muncul setelah {timeout_ms // 1000} detik.")
74
+ return _wait
@@ -0,0 +1,191 @@
1
+ """Capture response API generik (smart scroll & wait)."""
2
+ import asyncio
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from ..errors import PageCrashed
7
+ from ..logs import get_logger
8
+ from .diagnostics import DebugOptions, page_diag, save_debug_shot
9
+
10
+
11
+ def compile_matcher(match):
12
+ """api_match bisa: substring (str), regex terkompilasi, atau callable(url) -> bool."""
13
+ if hasattr(match, "search"):
14
+ return (lambda url: bool(match.search(url))), match.pattern
15
+ if callable(match):
16
+ return match, getattr(match, "__name__", "callable")
17
+ text = str(match)
18
+ return (lambda url: text in url), text
19
+
20
+
21
+ def key_getter(key):
22
+ if callable(key):
23
+ return key
24
+ return lambda item: item.get(key) if isinstance(item, dict) else None
25
+
26
+
27
+ async def default_scroll(page):
28
+ await page.keyboard.press("PageDown")
29
+
30
+
31
+ @dataclass
32
+ class CaptureResult:
33
+ items: List[Any] = field(default_factory=list)
34
+ stopped_by: str = "max_scrolls"
35
+ scrolls: int = 0
36
+ diagnostics: Dict[str, Any] = field(default_factory=dict)
37
+
38
+
39
+ async def capture(page, url, api_match, extract_fn, key_fn, limit,
40
+ max_scrolls=8, max_wait=8.0, stall_limit=3,
41
+ initial_sleep=5, scroll_fn=None, prepare_fn=None, end_fn=None,
42
+ nav_timeout_ms=60000, wait_until="domcontentloaded",
43
+ poll_interval=0.5, settle=1.0, final_sleep=1.0,
44
+ log=None, diag=None, debug=None):
45
+ log = log or get_logger()
46
+ diag = {} if diag is None else diag
47
+ debug = debug or DebugOptions()
48
+ matches, match_label = compile_matcher(api_match)
49
+ get_key = key_getter(key_fn)
50
+
51
+ items = []
52
+ seen = set()
53
+ state = {"limit_reached": False, "exhausted": False, "crashed": False}
54
+
55
+ def check_crash():
56
+ if state["crashed"]:
57
+ raise PageCrashed("Tab Chrome crash (cek --shm-size / memori container)")
58
+
59
+ def on_crash(_):
60
+ state["crashed"] = True
61
+ log.error("Tab crash terdeteksi (Target crashed).")
62
+
63
+ async def handle_resp(res):
64
+ if state["limit_reached"] or res.request.method == "OPTIONS":
65
+ return
66
+ if not matches(res.url):
67
+ return
68
+ if res.status != 200:
69
+ log.warning(f"API: {match_label} status {res.status}: {res.url[:150]}")
70
+ return
71
+ try:
72
+ data_json = await res.json()
73
+ except Exception as e:
74
+ log.warning(f"API: Gagal parse JSON ({match_label}): {e}")
75
+ return
76
+ try:
77
+ if isinstance(data_json, dict):
78
+ log.info(f"API: {match_label} keys={list(data_json.keys())[:8]}")
79
+ for item in extract_fn(data_json):
80
+ k = get_key(item)
81
+ if not k or k in seen:
82
+ continue
83
+ seen.add(k)
84
+ items.append(item)
85
+ if len(items) >= limit:
86
+ state["limit_reached"] = True
87
+ break
88
+ if end_fn and end_fn(data_json):
89
+ state["exhausted"] = True
90
+ log.info("API: Server menandai data sudah habis.")
91
+ log.info(f"API: Total tertangkap: {len(items)}")
92
+ except Exception as e:
93
+ log.warning(f"API: Gagal ekstrak data: {e}")
94
+
95
+ stopped_by = "max_scrolls"
96
+ scrolls = 0
97
+ stalled = 0
98
+ page.on("response", handle_resp)
99
+ page.on("crash", on_crash)
100
+ try:
101
+ log.info(f"Membuka link: {url}")
102
+ try:
103
+ nav_resp = await page.goto(url, wait_until=wait_until, timeout=nav_timeout_ms)
104
+ except Exception as e:
105
+ if "crash" in str(e).lower():
106
+ state["crashed"] = True
107
+ raise
108
+ check_crash()
109
+ await asyncio.sleep(initial_sleep)
110
+ check_crash()
111
+ await page_diag(page, nav_resp, "after_goto", diag=diag, log=log)
112
+ check_crash()
113
+
114
+ if prepare_fn:
115
+ await prepare_fn(page)
116
+ check_crash()
117
+
118
+ log.info(f"Data awal tertangkap: {len(items)}")
119
+
120
+ scroller = scroll_fn or default_scroll
121
+ for i in range(max_scrolls):
122
+ check_crash()
123
+ if state["limit_reached"]:
124
+ log.info("Limit tercapai, berhenti scroll.")
125
+ break
126
+ if state["exhausted"]:
127
+ log.info("Tidak ada data lagi, berhenti scroll.")
128
+ break
129
+
130
+ before = len(items)
131
+ scrolls = i + 1
132
+ log.info(f"Scroll {scrolls}/{max_scrolls}...")
133
+ try:
134
+ await scroller(page)
135
+ except Exception as e:
136
+ if "crash" in str(e).lower():
137
+ state["crashed"] = True
138
+ check_crash()
139
+ log.warning(f"Scroll gagal: {e}")
140
+
141
+ waited = 0.0
142
+ while (len(items) == before and waited < max_wait
143
+ and not state["limit_reached"] and not state["exhausted"]
144
+ and not state["crashed"]):
145
+ await asyncio.sleep(poll_interval)
146
+ waited += poll_interval
147
+ check_crash()
148
+
149
+ if len(items) == before:
150
+ if state["exhausted"]:
151
+ break
152
+ log.warning("Data baru tidak terdeteksi (mungkin mentok/lambat).")
153
+ stalled += 1
154
+ else:
155
+ stalled = 0
156
+ if state["exhausted"]:
157
+ break
158
+ await asyncio.sleep(settle)
159
+
160
+ if stalled >= stall_limit:
161
+ log.info(f"{stall_limit}x scroll tanpa data baru. Berhenti.")
162
+ break
163
+
164
+ await asyncio.sleep(final_sleep)
165
+ if state["limit_reached"]:
166
+ stopped_by = "limit"
167
+ elif state["exhausted"]:
168
+ stopped_by = "exhausted"
169
+ elif stalled >= stall_limit:
170
+ stopped_by = "stalled"
171
+
172
+ if not items:
173
+ await page_diag(page, None, "no_items", diag=diag, log=log)
174
+ await save_debug_shot(page, "debug_last.png", debug=debug, log=log)
175
+ elif stalled >= stall_limit:
176
+ await page_diag(page, None, "stalled", diag=diag, log=log)
177
+ await save_debug_shot(page, "debug_stalled.png", debug=debug, log=log)
178
+ finally:
179
+ try:
180
+ page.remove_listener("response", handle_resp)
181
+ page.remove_listener("crash", on_crash)
182
+ except Exception:
183
+ pass
184
+
185
+ log.success(f"Capture {match_label} selesai: {len(items)} data")
186
+ return CaptureResult(items=items, stopped_by=stopped_by, scrolls=scrolls, diagnostics=diag)
187
+
188
+
189
+ async def capture_api(page, url, api_match, extract_fn, key_fn, limit, **kwargs):
190
+ """Versi ringkas: hanya mengembalikan list item (kompatibel 0.1.x)."""
191
+ return (await capture(page, url, api_match, extract_fn, key_fn, limit, **kwargs)).items
@@ -0,0 +1,57 @@
1
+ """Diagnostik halaman: fingerprint, isi body, screenshot debug."""
2
+ import os
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ from ..logs import get_logger
7
+ from .state import LAST_DIAG
8
+
9
+
10
+ @dataclass
11
+ class DebugOptions:
12
+ enabled: bool = True
13
+ directory: Optional[str] = None
14
+
15
+
16
+ async def page_diag(page, nav_resp, tag, diag=None, log=None):
17
+ log = log or get_logger()
18
+ diag = {} if diag is None else diag
19
+ info = {"tag": tag, "engine": diag.get("engine") or LAST_DIAG.get("engine"), "url": page.url,
20
+ "nav_status": nav_resp.status if nav_resp else None}
21
+ try:
22
+ info["title"] = await page.title()
23
+ except Exception:
24
+ pass
25
+ try:
26
+ info.update(await page.evaluate("""() => ({
27
+ webdriver: navigator.webdriver,
28
+ ua: navigator.userAgent,
29
+ languages: navigator.languages,
30
+ plugins: navigator.plugins.length,
31
+ has_window_chrome: !!window.chrome
32
+ })"""))
33
+ except Exception as e:
34
+ info["eval_error"] = str(e)
35
+ try:
36
+ info["body_snippet"] = (await page.inner_text("body", timeout=3000))[:200].replace("\n", " ")
37
+ except Exception:
38
+ pass
39
+ diag.update(info)
40
+ LAST_DIAG.update(info)
41
+ log.info(f"Diagnostik: {info}")
42
+ return info
43
+
44
+
45
+ async def save_debug_shot(page, name="debug_last.png", debug=None, log=None):
46
+ log = log or get_logger()
47
+ debug = debug or DebugOptions()
48
+ if not debug.enabled:
49
+ return None
50
+ path = os.path.join(debug.directory or os.getcwd(), name)
51
+ try:
52
+ await page.screenshot(path=path, timeout=5000)
53
+ log.success(f"Diagnostik: Screenshot disimpan: {path}")
54
+ return path
55
+ except Exception as e:
56
+ log.warning(f"Diagnostik: Gagal screenshot: {e}")
57
+ return None
@@ -0,0 +1,2 @@
1
+ """Alias kompatibilitas; gunakan `actions.close_overlays`."""
2
+ from .actions import close_overlays as close_login_modal # noqa: F401