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.
- beo_mcdp-0.2.0.dist-info/METADATA +161 -0
- beo_mcdp-0.2.0.dist-info/RECORD +31 -0
- beo_mcdp-0.2.0.dist-info/WHEEL +5 -0
- beo_mcdp-0.2.0.dist-info/top_level.txt +1 -0
- cdp_scraper/__init__.py +24 -0
- cdp_scraper/_version.py +1 -0
- cdp_scraper/browser/__init__.py +0 -0
- cdp_scraper/browser/actions.py +74 -0
- cdp_scraper/browser/capture.py +191 -0
- cdp_scraper/browser/diagnostics.py +57 -0
- cdp_scraper/browser/helpers.py +2 -0
- cdp_scraper/browser/session.py +142 -0
- cdp_scraper/browser/state.py +2 -0
- cdp_scraper/config.py +79 -0
- cdp_scraper/errors.py +10 -0
- cdp_scraper/logs.py +56 -0
- cdp_scraper/messaging/__init__.py +5 -0
- cdp_scraper/messaging/redpanda.py +105 -0
- cdp_scraper/parsers.py +2 -0
- cdp_scraper/parsing/__init__.py +6 -0
- cdp_scraper/parsing/mapper.py +202 -0
- cdp_scraper/parsing/paths.py +80 -0
- cdp_scraper/parsing/transforms.py +123 -0
- cdp_scraper/platforms/__init__.py +7 -0
- cdp_scraper/platforms/registry.py +28 -0
- cdp_scraper/platforms/runner.py +124 -0
- cdp_scraper/platforms/spec.py +129 -0
- cdp_scraper/utils/__init__.py +9 -0
- cdp_scraper/utils/database.py +12 -0
- cdp_scraper/utils/redpanda_client.py +8 -0
- cdp_scraper/utils/utils.py +136 -0
|
@@ -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 @@
|
|
|
1
|
+
cdp_scraper
|
cdp_scraper/__init__.py
ADDED
|
@@ -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
|
+
]
|
cdp_scraper/_version.py
ADDED
|
@@ -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
|