soropy 1.0.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.
- soropy/__init__.py +86 -0
- soropy/auth.py +194 -0
- soropy/auto_reply.py +158 -0
- soropy/browser.py +129 -0
- soropy/channel.py +219 -0
- soropy/chat.py +860 -0
- soropy/client.py +516 -0
- soropy/constants.py +135 -0
- soropy/contacts.py +421 -0
- soropy/exceptions.py +64 -0
- soropy/message_tracker.py +111 -0
- soropy/multi.py +219 -0
- soropy/session.py +59 -0
- soropy/types.py +118 -0
- soropy/utils.py +186 -0
- soropy-1.0.0.dist-info/METADATA +66 -0
- soropy-1.0.0.dist-info/RECORD +20 -0
- soropy-1.0.0.dist-info/WHEEL +5 -0
- soropy-1.0.0.dist-info/licenses/LICENSE +21 -0
- soropy-1.0.0.dist-info/top_level.txt +1 -0
soropy/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SoroPy - Professional Soroush Plus Web Client Library
|
|
3
|
+
=====================================================
|
|
4
|
+
|
|
5
|
+
A comprehensive, production-grade Python library for automating
|
|
6
|
+
Soroush Plus (splus.ir) web messenger.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Multi-account simultaneous sessions
|
|
10
|
+
- Headless browser support
|
|
11
|
+
- Auto-reply with rule engine and duplicate prevention
|
|
12
|
+
- Chat extraction, messaging, channel posting
|
|
13
|
+
- Contact management
|
|
14
|
+
- Persistent session management
|
|
15
|
+
- Thread-safe design
|
|
16
|
+
|
|
17
|
+
Basic Usage:
|
|
18
|
+
>>> from soropy import SoroushClient
|
|
19
|
+
>>> client = SoroushClient("+989123456789")
|
|
20
|
+
>>> client.login()
|
|
21
|
+
>>> chats = client.get_chats()
|
|
22
|
+
>>> client.send_message("علی", "سلام!")
|
|
23
|
+
>>> client.close()
|
|
24
|
+
|
|
25
|
+
Multi-Account:
|
|
26
|
+
>>> from soropy import MultiAccountManager
|
|
27
|
+
>>> manager = MultiAccountManager()
|
|
28
|
+
>>> manager.add_account("+989123456789")
|
|
29
|
+
>>> manager.add_account("+989187654321")
|
|
30
|
+
>>> manager.login_all()
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__version__ = "1.0.0"
|
|
34
|
+
__author__ = "SoroPy Team"
|
|
35
|
+
__license__ = "MIT"
|
|
36
|
+
|
|
37
|
+
from soropy.client import SoroushClient
|
|
38
|
+
from soropy.auto_reply import AutoReplyEngine, ReplyRule
|
|
39
|
+
from soropy.types import (
|
|
40
|
+
ChatInfo,
|
|
41
|
+
ContactInfo,
|
|
42
|
+
MessageInfo,
|
|
43
|
+
UnreadChat,
|
|
44
|
+
SendResult,
|
|
45
|
+
LoginStatus,
|
|
46
|
+
)
|
|
47
|
+
from soropy.exceptions import (
|
|
48
|
+
SoroPyError,
|
|
49
|
+
LoginError,
|
|
50
|
+
SessionError,
|
|
51
|
+
ChatError,
|
|
52
|
+
MessageError,
|
|
53
|
+
ContactError,
|
|
54
|
+
ChannelError,
|
|
55
|
+
BrowserError,
|
|
56
|
+
TimeoutError as SoroPyTimeoutError,
|
|
57
|
+
)
|
|
58
|
+
from soropy.session import SessionManager
|
|
59
|
+
from soropy.message_tracker import MessageTracker
|
|
60
|
+
|
|
61
|
+
# Multi-account manager
|
|
62
|
+
from soropy.multi import MultiAccountManager
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"SoroushClient",
|
|
66
|
+
"MultiAccountManager",
|
|
67
|
+
"AutoReplyEngine",
|
|
68
|
+
"ReplyRule",
|
|
69
|
+
"ChatInfo",
|
|
70
|
+
"ContactInfo",
|
|
71
|
+
"MessageInfo",
|
|
72
|
+
"UnreadChat",
|
|
73
|
+
"SendResult",
|
|
74
|
+
"LoginStatus",
|
|
75
|
+
"SessionManager",
|
|
76
|
+
"MessageTracker",
|
|
77
|
+
"SoroPyError",
|
|
78
|
+
"LoginError",
|
|
79
|
+
"SessionError",
|
|
80
|
+
"ChatError",
|
|
81
|
+
"MessageError",
|
|
82
|
+
"ContactError",
|
|
83
|
+
"ChannelError",
|
|
84
|
+
"BrowserError",
|
|
85
|
+
"SoroPyTimeoutError",
|
|
86
|
+
]
|
soropy/auth.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication flow: phone entry → verification code → chat page wait.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional, Callable
|
|
7
|
+
|
|
8
|
+
from selenium.webdriver.remote.webdriver import WebDriver
|
|
9
|
+
from selenium.webdriver.common.by import By
|
|
10
|
+
from selenium.webdriver.common.keys import Keys
|
|
11
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
12
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
13
|
+
|
|
14
|
+
from soropy import constants as C
|
|
15
|
+
from soropy.utils import (
|
|
16
|
+
get_logger,
|
|
17
|
+
wait_and_find,
|
|
18
|
+
safe_click,
|
|
19
|
+
safe_type,
|
|
20
|
+
wait_page_load,
|
|
21
|
+
)
|
|
22
|
+
from soropy.types import LoginStatus
|
|
23
|
+
from soropy.exceptions import LoginError
|
|
24
|
+
|
|
25
|
+
logger = get_logger("soropy.auth")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Authenticator:
|
|
29
|
+
"""Encapsulates the multi-step Soroush Plus login process."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, driver: WebDriver):
|
|
32
|
+
self._driver = driver
|
|
33
|
+
|
|
34
|
+
# ── public ─────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def is_logged_in(self) -> bool:
|
|
37
|
+
"""Check whether the current page shows the chat list."""
|
|
38
|
+
time.sleep(3)
|
|
39
|
+
wait_page_load(self._driver, C.PAGE_LOAD_TIMEOUT)
|
|
40
|
+
for sel in C.SEL_LOGGED_IN_INDICATORS:
|
|
41
|
+
try:
|
|
42
|
+
if self._driver.find_elements(By.CSS_SELECTOR, sel):
|
|
43
|
+
logger.info("Already logged in (matched %s)", sel)
|
|
44
|
+
return True
|
|
45
|
+
except Exception:
|
|
46
|
+
continue
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def login(
|
|
50
|
+
self,
|
|
51
|
+
phone: str,
|
|
52
|
+
code_callback: Optional[Callable[[], str]] = None,
|
|
53
|
+
) -> LoginStatus:
|
|
54
|
+
"""
|
|
55
|
+
Run the full login flow.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
phone : str
|
|
60
|
+
Normalised phone number (e.g. "+989123456789").
|
|
61
|
+
code_callback : callable, optional
|
|
62
|
+
A function that returns the verification code string.
|
|
63
|
+
Defaults to ``input()`` prompt.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
LoginStatus
|
|
68
|
+
"""
|
|
69
|
+
self._driver.get(C.SPLUS_WEB_URL)
|
|
70
|
+
logger.info("Navigated to %s", C.SPLUS_WEB_URL)
|
|
71
|
+
wait_page_load(self._driver, 15)
|
|
72
|
+
|
|
73
|
+
if self.is_logged_in():
|
|
74
|
+
return LoginStatus.ALREADY_LOGGED_IN
|
|
75
|
+
|
|
76
|
+
time.sleep(2)
|
|
77
|
+
self._dismiss_popup()
|
|
78
|
+
time.sleep(1)
|
|
79
|
+
|
|
80
|
+
if not self._enter_phone(phone):
|
|
81
|
+
raise LoginError("Could not enter phone number")
|
|
82
|
+
|
|
83
|
+
if not self._click_next():
|
|
84
|
+
raise LoginError("Could not click 'next' button")
|
|
85
|
+
|
|
86
|
+
time.sleep(3)
|
|
87
|
+
|
|
88
|
+
if code_callback is None:
|
|
89
|
+
code_callback = lambda: input("🔑 Enter verification code: ")
|
|
90
|
+
|
|
91
|
+
code = code_callback()
|
|
92
|
+
if not self._enter_code(code):
|
|
93
|
+
raise LoginError("Could not enter verification code")
|
|
94
|
+
|
|
95
|
+
if not self._wait_for_chat_page():
|
|
96
|
+
raise LoginError("Chat page did not load after login")
|
|
97
|
+
|
|
98
|
+
return LoginStatus.SUCCESS
|
|
99
|
+
|
|
100
|
+
# ── steps ──────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _dismiss_popup(self) -> bool:
|
|
103
|
+
logger.debug("Checking for popup...")
|
|
104
|
+
for xpath in C.XPATH_DISMISS_POPUP_VARIANTS:
|
|
105
|
+
try:
|
|
106
|
+
el = WebDriverWait(self._driver, 5).until(
|
|
107
|
+
EC.element_to_be_clickable((By.XPATH, xpath))
|
|
108
|
+
)
|
|
109
|
+
safe_click(self._driver, el)
|
|
110
|
+
logger.info("Popup dismissed")
|
|
111
|
+
time.sleep(1)
|
|
112
|
+
return True
|
|
113
|
+
except Exception:
|
|
114
|
+
continue
|
|
115
|
+
logger.debug("No popup found")
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def _enter_phone(self, phone: str) -> bool:
|
|
119
|
+
logger.info("Entering phone: %s", phone)
|
|
120
|
+
wait_page_load(self._driver)
|
|
121
|
+
|
|
122
|
+
inp = wait_and_find(
|
|
123
|
+
self._driver, By.CSS_SELECTOR, C.SEL_PHONE_INPUT,
|
|
124
|
+
timeout=10, clickable=True,
|
|
125
|
+
)
|
|
126
|
+
if not inp:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
inp.click()
|
|
130
|
+
time.sleep(0.2)
|
|
131
|
+
inp.send_keys(Keys.CONTROL + "a")
|
|
132
|
+
time.sleep(0.1)
|
|
133
|
+
inp.send_keys(Keys.DELETE)
|
|
134
|
+
time.sleep(0.2)
|
|
135
|
+
|
|
136
|
+
for ch in phone:
|
|
137
|
+
inp.send_keys(ch)
|
|
138
|
+
time.sleep(0.05)
|
|
139
|
+
|
|
140
|
+
time.sleep(0.5)
|
|
141
|
+
actual = inp.get_attribute("value")
|
|
142
|
+
logger.debug("Phone field value: %s", actual)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
def _click_next(self) -> bool:
|
|
146
|
+
logger.info("Clicking 'next'...")
|
|
147
|
+
time.sleep(1)
|
|
148
|
+
btn = wait_and_find(
|
|
149
|
+
self._driver, By.XPATH, C.XPATH_NEXT_BUTTON,
|
|
150
|
+
timeout=10, clickable=True,
|
|
151
|
+
)
|
|
152
|
+
if btn and safe_click(self._driver, btn):
|
|
153
|
+
logger.info("'Next' clicked")
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
def _enter_code(self, code: str) -> bool:
|
|
158
|
+
logger.info("Entering verification code")
|
|
159
|
+
wait_page_load(self._driver)
|
|
160
|
+
|
|
161
|
+
code_input = None
|
|
162
|
+
for by, val in [
|
|
163
|
+
(By.ID, C.SEL_CODE_INPUT_ID),
|
|
164
|
+
(By.CSS_SELECTOR, C.SEL_CODE_INPUT_ARIA),
|
|
165
|
+
(By.CSS_SELECTOR, C.SEL_CODE_INPUT_NUMERIC),
|
|
166
|
+
]:
|
|
167
|
+
code_input = wait_and_find(self._driver, by, val, timeout=10, clickable=True)
|
|
168
|
+
if code_input:
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if not code_input:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
safe_type(self._driver, code_input, code)
|
|
175
|
+
logger.info("Code entered – waiting for automatic redirect")
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
def _wait_for_chat_page(self) -> bool:
|
|
179
|
+
logger.info("Waiting for chat page...")
|
|
180
|
+
start = time.time()
|
|
181
|
+
while time.time() - start < C.LOGIN_WAIT:
|
|
182
|
+
try:
|
|
183
|
+
found = self._driver.find_elements(
|
|
184
|
+
By.CSS_SELECTOR,
|
|
185
|
+
"[class*='chatlist'], [class*='dialog'], [class*='folders-tabs']",
|
|
186
|
+
)
|
|
187
|
+
if found:
|
|
188
|
+
elapsed = int(time.time() - start)
|
|
189
|
+
logger.info("Chat page loaded (%ds)", elapsed)
|
|
190
|
+
return True
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
time.sleep(1)
|
|
194
|
+
return True # optimistic – some indicators may not match
|
soropy/auto_reply.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-reply engine with rule matching and duplicate prevention.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
from typing import List, Optional, Dict, Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from soropy.utils import get_logger
|
|
11
|
+
from soropy.message_tracker import MessageTracker
|
|
12
|
+
|
|
13
|
+
logger = get_logger("soropy.auto_reply")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ReplyRule:
|
|
18
|
+
"""A single keyword → response mapping."""
|
|
19
|
+
keyword: str
|
|
20
|
+
response: str
|
|
21
|
+
case_sensitive: bool = False
|
|
22
|
+
exact_match: bool = False
|
|
23
|
+
priority: int = 0 # higher = matched first
|
|
24
|
+
|
|
25
|
+
def matches(self, text: str) -> bool:
|
|
26
|
+
if self.case_sensitive:
|
|
27
|
+
src, kw = text.strip(), self.keyword
|
|
28
|
+
else:
|
|
29
|
+
src, kw = text.strip().lower(), self.keyword.lower()
|
|
30
|
+
|
|
31
|
+
if self.exact_match:
|
|
32
|
+
return src == kw
|
|
33
|
+
return kw in src
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AutoReplyEngine:
|
|
37
|
+
"""
|
|
38
|
+
Rule-based auto-reply engine with duplicate message tracking.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
engine = AutoReplyEngine()
|
|
42
|
+
engine.add_rule("سلام", "علیک سلام")
|
|
43
|
+
engine.default_reply = "پیامت دریافت شد"
|
|
44
|
+
|
|
45
|
+
reply = engine.get_reply("سلام دوست من", "علی")
|
|
46
|
+
# → "علیک سلام"
|
|
47
|
+
|
|
48
|
+
# Won't return a reply for the same message twice:
|
|
49
|
+
reply2 = engine.get_reply("سلام دوست من", "علی")
|
|
50
|
+
# → None (already replied)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
tracker: Optional[MessageTracker] = None,
|
|
56
|
+
default_reply: str = "پیامت دریافت شد",
|
|
57
|
+
default_prefix: str = "جواب",
|
|
58
|
+
):
|
|
59
|
+
self._rules: List[ReplyRule] = []
|
|
60
|
+
self._tracker = tracker or MessageTracker()
|
|
61
|
+
self.default_reply = default_reply
|
|
62
|
+
self.default_prefix = default_prefix
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
|
|
65
|
+
# ── Rule management ────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def add_rule(
|
|
68
|
+
self,
|
|
69
|
+
keyword: str,
|
|
70
|
+
response: str,
|
|
71
|
+
case_sensitive: bool = False,
|
|
72
|
+
exact_match: bool = False,
|
|
73
|
+
priority: int = 0,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Add a reply rule."""
|
|
76
|
+
rule = ReplyRule(
|
|
77
|
+
keyword=keyword,
|
|
78
|
+
response=response,
|
|
79
|
+
case_sensitive=case_sensitive,
|
|
80
|
+
exact_match=exact_match,
|
|
81
|
+
priority=priority,
|
|
82
|
+
)
|
|
83
|
+
with self._lock:
|
|
84
|
+
self._rules.append(rule)
|
|
85
|
+
# Keep sorted by priority descending
|
|
86
|
+
self._rules.sort(key=lambda r: r.priority, reverse=True)
|
|
87
|
+
logger.debug("Rule added: '%s' → '%s'", keyword, response)
|
|
88
|
+
|
|
89
|
+
def remove_rule(self, keyword: str) -> bool:
|
|
90
|
+
"""Remove the first rule matching *keyword*."""
|
|
91
|
+
with self._lock:
|
|
92
|
+
for i, rule in enumerate(self._rules):
|
|
93
|
+
if rule.keyword == keyword:
|
|
94
|
+
self._rules.pop(i)
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
def clear_rules(self) -> None:
|
|
99
|
+
"""Remove all rules."""
|
|
100
|
+
with self._lock:
|
|
101
|
+
self._rules.clear()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def rules(self) -> List[ReplyRule]:
|
|
105
|
+
with self._lock:
|
|
106
|
+
return list(self._rules)
|
|
107
|
+
|
|
108
|
+
def load_rules_from_dict(self, mapping: Dict[str, str]) -> None:
|
|
109
|
+
"""Bulk-load keyword→response pairs from a dict."""
|
|
110
|
+
for kw, resp in mapping.items():
|
|
111
|
+
self.add_rule(kw, resp)
|
|
112
|
+
|
|
113
|
+
# ── Reply generation ───────────────────────────────
|
|
114
|
+
|
|
115
|
+
def get_reply(
|
|
116
|
+
self,
|
|
117
|
+
message_text: str,
|
|
118
|
+
chat_name: str,
|
|
119
|
+
msg_index: int = 1,
|
|
120
|
+
skip_duplicate_check: bool = False,
|
|
121
|
+
) -> Optional[str]:
|
|
122
|
+
"""
|
|
123
|
+
Determine the reply for *message_text* from *chat_name*.
|
|
124
|
+
|
|
125
|
+
Returns None if the message has already been replied to
|
|
126
|
+
(unless skip_duplicate_check is True).
|
|
127
|
+
"""
|
|
128
|
+
text = message_text.strip()
|
|
129
|
+
if not text:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
# Duplicate check
|
|
133
|
+
if not skip_duplicate_check and self._tracker.is_replied(chat_name, text):
|
|
134
|
+
logger.debug("Duplicate detected, skipping: %s / %s", chat_name, text[:30])
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
# Match rules
|
|
138
|
+
with self._lock:
|
|
139
|
+
for rule in self._rules:
|
|
140
|
+
if rule.matches(text):
|
|
141
|
+
return rule.response
|
|
142
|
+
|
|
143
|
+
# Default
|
|
144
|
+
return f"{self.default_prefix} {msg_index}"
|
|
145
|
+
|
|
146
|
+
def mark_replied(self, chat_name: str, message_text: str) -> None:
|
|
147
|
+
"""Record that we replied to this message (prevents duplicates)."""
|
|
148
|
+
self._tracker.mark_replied(chat_name, message_text)
|
|
149
|
+
|
|
150
|
+
# ── Tracker access ─────────────────────────────────
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def tracker(self) -> MessageTracker:
|
|
154
|
+
return self._tracker
|
|
155
|
+
|
|
156
|
+
def prune_old_entries(self) -> int:
|
|
157
|
+
"""Remove expired tracker entries."""
|
|
158
|
+
return self._tracker.prune()
|
soropy/browser.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser (Chrome/Chromium) lifecycle management.
|
|
3
|
+
|
|
4
|
+
Handles driver creation with anti-detection, headless mode,
|
|
5
|
+
custom profiles, and teardown.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from selenium import webdriver
|
|
12
|
+
from selenium.webdriver.chrome.options import Options
|
|
13
|
+
from selenium.webdriver.chrome.service import Service
|
|
14
|
+
|
|
15
|
+
from soropy.session import SessionManager
|
|
16
|
+
from soropy.utils import get_logger
|
|
17
|
+
from soropy.exceptions import BrowserError
|
|
18
|
+
|
|
19
|
+
logger = get_logger("soropy.browser")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BrowserManager:
|
|
23
|
+
"""Creates and manages a Selenium Chrome WebDriver."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
phone: Optional[str] = None,
|
|
28
|
+
headless: bool = False,
|
|
29
|
+
session_manager: Optional[SessionManager] = None,
|
|
30
|
+
window_size: str = "1300,900",
|
|
31
|
+
extra_args: Optional[list] = None,
|
|
32
|
+
chrome_binary: Optional[str] = None,
|
|
33
|
+
chromedriver_path: Optional[str] = None,
|
|
34
|
+
):
|
|
35
|
+
self._phone = phone
|
|
36
|
+
self._headless = headless
|
|
37
|
+
self._session_mgr = session_manager or SessionManager()
|
|
38
|
+
self._window_size = window_size
|
|
39
|
+
self._extra_args = extra_args or []
|
|
40
|
+
self._chrome_binary = chrome_binary
|
|
41
|
+
self._chromedriver_path = chromedriver_path
|
|
42
|
+
self._driver: Optional[webdriver.Chrome] = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def driver(self) -> webdriver.Chrome:
|
|
46
|
+
if self._driver is None:
|
|
47
|
+
raise BrowserError("Browser not started. Call start() first.")
|
|
48
|
+
return self._driver
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_running(self) -> bool:
|
|
52
|
+
if self._driver is None:
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
# If the browser window is closed this will throw
|
|
56
|
+
_ = self._driver.title
|
|
57
|
+
return True
|
|
58
|
+
except Exception:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def start(self) -> webdriver.Chrome:
|
|
62
|
+
"""Launch Chrome and return the WebDriver."""
|
|
63
|
+
options = Options()
|
|
64
|
+
|
|
65
|
+
# ── Anti-detection ────────────────────────────
|
|
66
|
+
options.add_argument("--no-sandbox")
|
|
67
|
+
options.add_argument("--disable-dev-shm-usage")
|
|
68
|
+
options.add_argument("--disable-blink-features=AutomationControlled")
|
|
69
|
+
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
|
70
|
+
options.add_experimental_option("useAutomationExtension", False)
|
|
71
|
+
options.add_argument(f"--window-size={self._window_size}")
|
|
72
|
+
|
|
73
|
+
# ── Headless ──────────────────────────────────
|
|
74
|
+
if self._headless:
|
|
75
|
+
options.add_argument("--headless=new")
|
|
76
|
+
options.add_argument("--disable-gpu")
|
|
77
|
+
options.add_argument("--no-first-run")
|
|
78
|
+
options.add_argument("--no-default-browser-check")
|
|
79
|
+
logger.info("Headless mode enabled")
|
|
80
|
+
|
|
81
|
+
# ── Session profile ───────────────────────────
|
|
82
|
+
if self._phone:
|
|
83
|
+
profile_path = self._session_mgr.get_path(self._phone)
|
|
84
|
+
options.add_argument(f"--user-data-dir={profile_path}")
|
|
85
|
+
logger.info("Session profile: %s", profile_path)
|
|
86
|
+
|
|
87
|
+
# ── Custom binary ─────────────────────────────
|
|
88
|
+
if self._chrome_binary:
|
|
89
|
+
options.binary_location = self._chrome_binary
|
|
90
|
+
|
|
91
|
+
# ── Extra args ────────────────────────────────
|
|
92
|
+
for arg in self._extra_args:
|
|
93
|
+
options.add_argument(arg)
|
|
94
|
+
|
|
95
|
+
# ── Service ───────────────────────────────────
|
|
96
|
+
service_kwargs = {}
|
|
97
|
+
if self._chromedriver_path:
|
|
98
|
+
service_kwargs["executable_path"] = self._chromedriver_path
|
|
99
|
+
service = Service(**service_kwargs)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
self._driver = webdriver.Chrome(service=service, options=options)
|
|
103
|
+
if not self._headless:
|
|
104
|
+
self._driver.maximize_window()
|
|
105
|
+
|
|
106
|
+
# Remove webdriver flag
|
|
107
|
+
self._driver.execute_script(
|
|
108
|
+
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
|
109
|
+
)
|
|
110
|
+
logger.info("Browser started successfully")
|
|
111
|
+
return self._driver
|
|
112
|
+
except Exception as e:
|
|
113
|
+
raise BrowserError(f"Failed to start Chrome: {e}")
|
|
114
|
+
|
|
115
|
+
def stop(self) -> None:
|
|
116
|
+
"""Quit the browser."""
|
|
117
|
+
if self._driver:
|
|
118
|
+
try:
|
|
119
|
+
self._driver.quit()
|
|
120
|
+
logger.info("Browser stopped")
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
finally:
|
|
124
|
+
self._driver = None
|
|
125
|
+
|
|
126
|
+
def restart(self) -> webdriver.Chrome:
|
|
127
|
+
"""Stop and re-start."""
|
|
128
|
+
self.stop()
|
|
129
|
+
return self.start()
|