hermex 0.1.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.
- hermex/__init__.py +14 -0
- hermex/assets/bg_48.png +0 -0
- hermex/assets/bg_96.png +0 -0
- hermex/chatgpt.py +170 -0
- hermex/config.py +9 -0
- hermex/exceptions.py +2 -0
- hermex/gemini.py +212 -0
- hermex/gemini_watermark_remover.py +70 -0
- hermex/models.py +30 -0
- hermex/scraper_base.py +450 -0
- hermex/utils.py +43 -0
- hermex-0.1.0.dist-info/METADATA +184 -0
- hermex-0.1.0.dist-info/RECORD +16 -0
- hermex-0.1.0.dist-info/WHEEL +5 -0
- hermex-0.1.0.dist-info/licenses/LICENSE +28 -0
- hermex-0.1.0.dist-info/top_level.txt +1 -0
hermex/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from hermex.chatgpt import ChatGPT
|
|
2
|
+
from hermex.exceptions import LoginRequiredError
|
|
3
|
+
from hermex.gemini import Gemini
|
|
4
|
+
from hermex.models import AssistantMessage, State
|
|
5
|
+
from hermex.utils import clear_data
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AssistantMessage",
|
|
9
|
+
"State",
|
|
10
|
+
"LoginRequiredError",
|
|
11
|
+
"Gemini",
|
|
12
|
+
"ChatGPT",
|
|
13
|
+
"clear_data",
|
|
14
|
+
]
|
hermex/assets/bg_48.png
ADDED
|
Binary file
|
hermex/assets/bg_96.png
ADDED
|
Binary file
|
hermex/chatgpt.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pyperclip
|
|
4
|
+
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
|
5
|
+
from selenium.webdriver.common.action_chains import ActionChains
|
|
6
|
+
from selenium.webdriver.common.by import By
|
|
7
|
+
from selenium.webdriver.common.keys import Keys
|
|
8
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
9
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
10
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
11
|
+
|
|
12
|
+
from hermex.config import SUPPORTED_IMAGE_EXTENSIONS
|
|
13
|
+
from hermex.models import AssistantMessage, State
|
|
14
|
+
from hermex.scraper_base import Scraper
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChatGPT(Scraper):
|
|
18
|
+
"""
|
|
19
|
+
Scraper for ChatGPT (chatgpt.com).
|
|
20
|
+
|
|
21
|
+
Supports text queries, image uploads, and downloading generated images.
|
|
22
|
+
Works without login for all current features including image upload.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def open_url(self, url="https://chatgpt.com", timeout=30):
|
|
26
|
+
if "chatgpt.com" not in url:
|
|
27
|
+
raise ValueError(f"Expected a chatgpt.com URL, got: {url}")
|
|
28
|
+
super().open_url(url, timeout)
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def wait_for_page_load(self, timeout: float = 30) -> None:
|
|
32
|
+
WebDriverWait(self.driver, timeout).until(
|
|
33
|
+
EC.presence_of_element_located(
|
|
34
|
+
(By.CSS_SELECTOR, 'div[contenteditable="true"]')
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _detect_login(self):
|
|
39
|
+
try:
|
|
40
|
+
self.driver.find_element(
|
|
41
|
+
By.CSS_SELECTOR, 'button[data-testid="login-button"]'
|
|
42
|
+
)
|
|
43
|
+
self.is_logged_in = False
|
|
44
|
+
except Exception:
|
|
45
|
+
self.is_logged_in = True
|
|
46
|
+
|
|
47
|
+
def send_message(
|
|
48
|
+
self,
|
|
49
|
+
message,
|
|
50
|
+
submit=True,
|
|
51
|
+
images: list[str | Path] = None,
|
|
52
|
+
paste=False,
|
|
53
|
+
fake_typing=True,
|
|
54
|
+
typing_delay: float = None,
|
|
55
|
+
):
|
|
56
|
+
if images:
|
|
57
|
+
self._upload_imgs(images)
|
|
58
|
+
|
|
59
|
+
wait = WebDriverWait(self.driver, 20)
|
|
60
|
+
input_box = wait.until(
|
|
61
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, 'div[contenteditable="true"]'))
|
|
62
|
+
)
|
|
63
|
+
input_box.click()
|
|
64
|
+
self.sleep(0.5)
|
|
65
|
+
|
|
66
|
+
if paste:
|
|
67
|
+
self._paste_into(
|
|
68
|
+
message, input_box, fake_typing=fake_typing, typing_delay=typing_delay
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
self._type_into(message, input_box, typing_delay=typing_delay)
|
|
72
|
+
|
|
73
|
+
if images:
|
|
74
|
+
self._wait_until_state(State.TYPING)
|
|
75
|
+
|
|
76
|
+
if submit:
|
|
77
|
+
input_box.send_keys("\n")
|
|
78
|
+
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def _upload_imgs(self, image_paths: list[str | Path]):
|
|
82
|
+
resolved = []
|
|
83
|
+
for image_path in image_paths:
|
|
84
|
+
image_path = Path(image_path).resolve()
|
|
85
|
+
if image_path.suffix.lower() not in SUPPORTED_IMAGE_EXTENSIONS:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Unsupported file type '{image_path.suffix}'. Must be one of: {SUPPORTED_IMAGE_EXTENSIONS}"
|
|
88
|
+
)
|
|
89
|
+
resolved.append(image_path)
|
|
90
|
+
|
|
91
|
+
file_input = self.driver.find_element(By.CSS_SELECTOR, "#upload-photos")
|
|
92
|
+
self.driver.execute_script("arguments[0].style.display = 'block';", file_input)
|
|
93
|
+
file_input.send_keys("\n".join(str(p) for p in resolved))
|
|
94
|
+
|
|
95
|
+
def get_last_response(
|
|
96
|
+
self, get_markdown=False, remove_watermark=False
|
|
97
|
+
) -> AssistantMessage:
|
|
98
|
+
# ChatGPT does not watermark generated images, so remove_watermark is a no-op.
|
|
99
|
+
|
|
100
|
+
wait = WebDriverWait(self.driver, 20)
|
|
101
|
+
|
|
102
|
+
def _get_img(element: WebElement):
|
|
103
|
+
image_elems = element.find_elements(By.CSS_SELECTOR, "img")
|
|
104
|
+
if not image_elems:
|
|
105
|
+
raise NoSuchElementException("No image element in this response.")
|
|
106
|
+
self.driver.execute_script("arguments[0].click();", image_elems[0])
|
|
107
|
+
self.sleep(2)
|
|
108
|
+
down_btn = wait.until(
|
|
109
|
+
EC.presence_of_element_located(
|
|
110
|
+
(By.CSS_SELECTOR, 'header button[aria-label="Save"]')
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
self.driver.execute_script("arguments[0].click();", down_btn)
|
|
114
|
+
img = self._get_downloaded_file()
|
|
115
|
+
self.sleep(1)
|
|
116
|
+
ActionChains(self.driver).send_keys(Keys.ESCAPE).perform()
|
|
117
|
+
self.sleep(0.5)
|
|
118
|
+
return img
|
|
119
|
+
|
|
120
|
+
def _get_text(element: WebElement, get_markdown: bool):
|
|
121
|
+
elem = element.find_element(By.CSS_SELECTOR, ".markdown")
|
|
122
|
+
inner_text = elem.text.strip()
|
|
123
|
+
if inner_text == "":
|
|
124
|
+
return None
|
|
125
|
+
if not get_markdown:
|
|
126
|
+
return inner_text
|
|
127
|
+
element.find_element(
|
|
128
|
+
By.CSS_SELECTOR, 'button[aria-label="Copy response"]'
|
|
129
|
+
).click()
|
|
130
|
+
self.sleep(0.5)
|
|
131
|
+
return pyperclip.paste()
|
|
132
|
+
|
|
133
|
+
responses = wait.until(
|
|
134
|
+
EC.presence_of_all_elements_located((By.CSS_SELECTOR, ".agent-turn"))
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if not responses:
|
|
138
|
+
raise TimeoutException("No responses found in the chat.")
|
|
139
|
+
|
|
140
|
+
last_response = responses[-1]
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
text_content = _get_text(last_response, get_markdown)
|
|
144
|
+
except NoSuchElementException:
|
|
145
|
+
text_content = None
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
img = _get_img(last_response)
|
|
149
|
+
except NoSuchElementException:
|
|
150
|
+
img = None
|
|
151
|
+
|
|
152
|
+
if text_content is None and img is None:
|
|
153
|
+
raise RuntimeError("Response contained neither text nor image.")
|
|
154
|
+
|
|
155
|
+
return AssistantMessage(text=text_content, image=img)
|
|
156
|
+
|
|
157
|
+
def get_state(self) -> State:
|
|
158
|
+
if self.driver.find_elements(By.CSS_SELECTOR, '[data-testid="stop-button"]'):
|
|
159
|
+
return State.GENERATING
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
send_btn = self.driver.find_element(
|
|
163
|
+
By.CSS_SELECTOR, '[data-testid="send-button"]'
|
|
164
|
+
)
|
|
165
|
+
except NoSuchElementException:
|
|
166
|
+
return State.IDLE
|
|
167
|
+
|
|
168
|
+
if send_btn.get_attribute("disabled"):
|
|
169
|
+
return State.UPLOADING
|
|
170
|
+
return State.TYPING
|
hermex/config.py
ADDED
hermex/exceptions.py
ADDED
hermex/gemini.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pyperclip
|
|
4
|
+
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
|
5
|
+
from selenium.webdriver.common.by import By
|
|
6
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
7
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
8
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
9
|
+
|
|
10
|
+
from hermex.config import SUPPORTED_IMAGE_EXTENSIONS
|
|
11
|
+
from hermex.exceptions import LoginRequiredError
|
|
12
|
+
from hermex.gemini_watermark_remover import gemini_remove_watermark
|
|
13
|
+
from hermex.models import AssistantMessage, State
|
|
14
|
+
from hermex.scraper_base import Scraper
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Gemini(Scraper):
|
|
18
|
+
"""
|
|
19
|
+
Scraper for Google Gemini (gemini.google.com).
|
|
20
|
+
|
|
21
|
+
Supports text queries, image uploads, and downloading generated images.
|
|
22
|
+
Works in guest mode for basic text queries; image upload requires a
|
|
23
|
+
logged-in session established via `Gemini.setup()`.
|
|
24
|
+
|
|
25
|
+
Generated images are optionally post-processed to remove the Gemini
|
|
26
|
+
watermark via `remove_watermark=True` on `query()` or `get_last_response()`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def open_url(self, url="https://gemini.google.com", timeout=30):
|
|
30
|
+
if "gemini.google.com" not in url:
|
|
31
|
+
raise ValueError(f"Expected a gemini.google.com URL, got: {url}")
|
|
32
|
+
super().open_url(url, timeout)
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def wait_for_page_load(self, timeout: float = 30) -> None:
|
|
36
|
+
WebDriverWait(self.driver, timeout).until(
|
|
37
|
+
EC.presence_of_element_located((By.TAG_NAME, "rich-textarea"))
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _detect_login(self):
|
|
41
|
+
try:
|
|
42
|
+
WebDriverWait(self.driver, 3).until(
|
|
43
|
+
EC.presence_of_element_located(
|
|
44
|
+
(By.CSS_SELECTOR, 'a[aria-label="Sign in"]')
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
self.is_logged_in = False
|
|
48
|
+
except TimeoutException:
|
|
49
|
+
self.is_logged_in = True
|
|
50
|
+
|
|
51
|
+
def send_message(
|
|
52
|
+
self,
|
|
53
|
+
message: str,
|
|
54
|
+
submit=True,
|
|
55
|
+
images: list[str | Path] = None,
|
|
56
|
+
paste=False,
|
|
57
|
+
fake_typing=True,
|
|
58
|
+
typing_delay: float = None,
|
|
59
|
+
):
|
|
60
|
+
wait = WebDriverWait(self.driver, 20)
|
|
61
|
+
input_box = wait.until(
|
|
62
|
+
EC.element_to_be_clickable((By.TAG_NAME, "rich-textarea"))
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if images:
|
|
66
|
+
if not self.is_logged_in:
|
|
67
|
+
raise LoginRequiredError(
|
|
68
|
+
"Image upload requires login. Run Gemini.setup() to log in."
|
|
69
|
+
)
|
|
70
|
+
self._upload_imgs(images)
|
|
71
|
+
|
|
72
|
+
input_box.click()
|
|
73
|
+
self.sleep(0.5)
|
|
74
|
+
input_p = input_box.find_element(By.TAG_NAME, "p")
|
|
75
|
+
|
|
76
|
+
if paste:
|
|
77
|
+
self._paste_into(
|
|
78
|
+
message, input_p, fake_typing=fake_typing, typing_delay=typing_delay
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
self._type_into(message, input_p, typing_delay=typing_delay)
|
|
82
|
+
|
|
83
|
+
if images:
|
|
84
|
+
self._wait_until_state(State.TYPING)
|
|
85
|
+
|
|
86
|
+
if submit:
|
|
87
|
+
input_p.send_keys("\n")
|
|
88
|
+
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def _upload_imgs(self, image_paths: list[str | Path]):
|
|
92
|
+
resolved = []
|
|
93
|
+
for image_path in image_paths:
|
|
94
|
+
image_path = Path(image_path).resolve()
|
|
95
|
+
if image_path.suffix.lower() not in SUPPORTED_IMAGE_EXTENSIONS:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Unsupported file type '{image_path.suffix}'. Must be one of: {SUPPORTED_IMAGE_EXTENSIONS}"
|
|
98
|
+
)
|
|
99
|
+
resolved.append(image_path)
|
|
100
|
+
|
|
101
|
+
wait = WebDriverWait(self.driver, 10)
|
|
102
|
+
|
|
103
|
+
# Gemini's JS calls input.click() internally when the upload menu item is clicked,
|
|
104
|
+
# which would open an OS file dialog we can't control. Patch the prototype before
|
|
105
|
+
# any clicks so that call is silently suppressed. The patch is restored after upload.
|
|
106
|
+
self.driver.execute_script("""
|
|
107
|
+
const orig = HTMLInputElement.prototype.click;
|
|
108
|
+
HTMLInputElement.prototype.click = function() {
|
|
109
|
+
if (this.type === 'file') return;
|
|
110
|
+
return orig.apply(this, arguments);
|
|
111
|
+
};
|
|
112
|
+
window.__restoreFileClick = () => { HTMLInputElement.prototype.click = orig; };
|
|
113
|
+
""")
|
|
114
|
+
|
|
115
|
+
self.driver.find_element(
|
|
116
|
+
By.CSS_SELECTOR,
|
|
117
|
+
'[data-node-type="input-area"] button[aria-label="Open upload file menu"]',
|
|
118
|
+
).click()
|
|
119
|
+
|
|
120
|
+
# Clicking "Upload files" creates the hidden input[name="Filedata"] and triggers .click() on it.
|
|
121
|
+
wait.until(
|
|
122
|
+
EC.element_to_be_clickable(
|
|
123
|
+
(By.CSS_SELECTOR, '[data-test-id="local-images-files-uploader-button"]')
|
|
124
|
+
)
|
|
125
|
+
).click()
|
|
126
|
+
|
|
127
|
+
# The input is display:none — unhide it so Selenium accepts send_keys.
|
|
128
|
+
# send_keys on a file input bypasses the OS dialog entirely (ChromeDriver handles it internally).
|
|
129
|
+
file_input = wait.until(
|
|
130
|
+
EC.presence_of_element_located((By.CSS_SELECTOR, 'input[name="Filedata"]'))
|
|
131
|
+
)
|
|
132
|
+
self.driver.execute_script("arguments[0].style.display = 'block';", file_input)
|
|
133
|
+
file_input.send_keys("\n".join(str(p) for p in resolved))
|
|
134
|
+
self.driver.execute_script(
|
|
135
|
+
"window.__restoreFileClick && window.__restoreFileClick();"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def get_last_response(
|
|
139
|
+
self, get_markdown=False, remove_watermark=False
|
|
140
|
+
) -> AssistantMessage:
|
|
141
|
+
def _get_img(element: WebElement):
|
|
142
|
+
element.find_element(
|
|
143
|
+
By.TAG_NAME, "download-generated-image-button"
|
|
144
|
+
).find_element(By.TAG_NAME, "button").click()
|
|
145
|
+
img = self._get_downloaded_file()
|
|
146
|
+
return img
|
|
147
|
+
|
|
148
|
+
def _get_text(element: WebElement, get_markdown):
|
|
149
|
+
elem = element.find_element(By.CSS_SELECTOR, ".markdown")
|
|
150
|
+
inner_text = elem.text.strip()
|
|
151
|
+
if inner_text == "":
|
|
152
|
+
return None
|
|
153
|
+
if not get_markdown:
|
|
154
|
+
return inner_text
|
|
155
|
+
element.find_element(By.TAG_NAME, "copy-button").click()
|
|
156
|
+
self.sleep(0.5)
|
|
157
|
+
return pyperclip.paste()
|
|
158
|
+
|
|
159
|
+
wait = WebDriverWait(self.driver, 20)
|
|
160
|
+
responses = wait.until(
|
|
161
|
+
EC.presence_of_all_elements_located((By.TAG_NAME, "model-response"))
|
|
162
|
+
)
|
|
163
|
+
last_response = responses[-1]
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
text_content = _get_text(last_response, get_markdown)
|
|
167
|
+
except NoSuchElementException:
|
|
168
|
+
text_content = None
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
img = _get_img(last_response)
|
|
172
|
+
except NoSuchElementException:
|
|
173
|
+
img = None
|
|
174
|
+
|
|
175
|
+
if text_content is None and img is None:
|
|
176
|
+
raise RuntimeError("Response contained neither text nor image.")
|
|
177
|
+
|
|
178
|
+
if remove_watermark and img is not None:
|
|
179
|
+
gemini_remove_watermark(str(img), str(img))
|
|
180
|
+
|
|
181
|
+
return AssistantMessage(text=text_content, image=img)
|
|
182
|
+
|
|
183
|
+
def get_state(self) -> State:
|
|
184
|
+
input_area = self.driver.find_element(
|
|
185
|
+
By.CSS_SELECTOR, '[data-node-type="input-area"]'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
send_stop = input_area.find_element(
|
|
189
|
+
By.CSS_SELECTOR, '[aria-label="Send message"], [aria-label="Stop response"]'
|
|
190
|
+
)
|
|
191
|
+
# While generating, Gemini swaps the send button's aria-label to "Stop response".
|
|
192
|
+
if send_stop.get_attribute("aria-label") == "Stop response":
|
|
193
|
+
return State.GENERATING
|
|
194
|
+
|
|
195
|
+
# During upload, the send button is disabled (aria-disabled="true") but still
|
|
196
|
+
# focusable (tabindex="0"). In IDLE the button is also disabled but tabindex="-1",
|
|
197
|
+
# so tabindex is the only signal that distinguishes the two states.
|
|
198
|
+
if (
|
|
199
|
+
send_stop.get_attribute("aria-disabled") == "true"
|
|
200
|
+
and send_stop.get_attribute("tabindex") == "0"
|
|
201
|
+
):
|
|
202
|
+
return State.UPLOADING
|
|
203
|
+
|
|
204
|
+
# When the input has content, Gemini hides the mic button to reveal the send button.
|
|
205
|
+
mic = input_area.find_element(By.CSS_SELECTOR, '[aria-label="Microphone"]')
|
|
206
|
+
container = mic.find_element(
|
|
207
|
+
By.XPATH, 'ancestor::*[contains(@class, "mic-button-container")]'
|
|
208
|
+
)
|
|
209
|
+
if "hidden" in container.get_attribute("class"):
|
|
210
|
+
return State.TYPING
|
|
211
|
+
|
|
212
|
+
return State.IDLE
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from importlib.resources import files
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
_ASSETS_DIR = files("hermex") / "assets"
|
|
7
|
+
|
|
8
|
+
_alpha_map_small = None
|
|
9
|
+
_alpha_map_large = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _calc_alpha(img, size):
|
|
13
|
+
if img.shape[:2] != size:
|
|
14
|
+
img = cv2.resize(img, size, interpolation=cv2.INTER_AREA)
|
|
15
|
+
return np.max(img, axis=2).astype(np.float32) / 255.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_assets():
|
|
19
|
+
global _alpha_map_small, _alpha_map_large
|
|
20
|
+
if _alpha_map_small is not None:
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
bg_small = cv2.imread(str(_ASSETS_DIR / "bg_48.png"))
|
|
24
|
+
bg_large = cv2.imread(str(_ASSETS_DIR / "bg_96.png"))
|
|
25
|
+
if bg_small is None or bg_large is None:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Could not load watermark reference assets from {_ASSETS_DIR}."
|
|
28
|
+
)
|
|
29
|
+
_alpha_map_small = _calc_alpha(bg_small, (48, 48))
|
|
30
|
+
_alpha_map_large = _calc_alpha(bg_large, (96, 96))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_config(width, height):
|
|
34
|
+
if width > 1024 and height > 1024:
|
|
35
|
+
return {"margin": 64, "size": 96, "map": _alpha_map_large}
|
|
36
|
+
else:
|
|
37
|
+
return {"margin": 32, "size": 48, "map": _alpha_map_small}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def gemini_remove_watermark(input_path: str, output_path: str):
|
|
41
|
+
_load_assets()
|
|
42
|
+
|
|
43
|
+
img = cv2.imread(input_path)
|
|
44
|
+
if img is None:
|
|
45
|
+
raise ValueError(f"Could not read input image: {input_path}")
|
|
46
|
+
|
|
47
|
+
h, w = img.shape[:2]
|
|
48
|
+
config = _get_config(w, h)
|
|
49
|
+
alpha_map = config["map"]
|
|
50
|
+
size = config["size"]
|
|
51
|
+
margin = config["margin"]
|
|
52
|
+
|
|
53
|
+
x = w - margin - size
|
|
54
|
+
y = h - margin - size
|
|
55
|
+
|
|
56
|
+
if x < 0 or y < 0:
|
|
57
|
+
raise ValueError(f"Image too small to process: {input_path}")
|
|
58
|
+
|
|
59
|
+
roi = img[y : y + size, x : x + size].astype(np.float32)
|
|
60
|
+
|
|
61
|
+
alpha = alpha_map[:, :, np.newaxis]
|
|
62
|
+
alpha_clamped = np.minimum(alpha, 0.99)
|
|
63
|
+
restored_roi = (roi - (alpha * 255.0)) / (1.0 - alpha_clamped)
|
|
64
|
+
restored_roi = np.clip(restored_roi, 0, 255)
|
|
65
|
+
|
|
66
|
+
mask_3ch = np.repeat(alpha > 0.002, 3, axis=2)
|
|
67
|
+
final_roi = np.where(mask_3ch, restored_roi, roi)
|
|
68
|
+
|
|
69
|
+
img[y : y + size, x : x + size] = final_roi.astype(np.uint8)
|
|
70
|
+
cv2.imwrite(output_path, img)
|
hermex/models.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class State(str, Enum):
|
|
7
|
+
"""Current UI state of the chatbot interface."""
|
|
8
|
+
|
|
9
|
+
IDLE = "idle"
|
|
10
|
+
"""Ready and waiting for input."""
|
|
11
|
+
|
|
12
|
+
GENERATING = "generating"
|
|
13
|
+
"""Model is actively producing a response."""
|
|
14
|
+
|
|
15
|
+
TYPING = "typing"
|
|
16
|
+
"""Input box has content that has not been submitted yet."""
|
|
17
|
+
|
|
18
|
+
UPLOADING = "uploading"
|
|
19
|
+
"""A file upload is in progress."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AssistantMessage:
|
|
24
|
+
"""Result returned by `query()` and `get_last_response()`."""
|
|
25
|
+
|
|
26
|
+
text: str | None = None
|
|
27
|
+
"""Plain text content of the response, or markdown if `get_markdown=True`. None if the response is image-only."""
|
|
28
|
+
|
|
29
|
+
image: Path | None = None
|
|
30
|
+
"""Path to the downloaded image file. None if the response contains no image."""
|
hermex/scraper_base.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
import warnings
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from shutil import move as move_file
|
|
9
|
+
from tempfile import TemporaryDirectory
|
|
10
|
+
|
|
11
|
+
import undetected_chromedriver as uc
|
|
12
|
+
from selenium.common.exceptions import TimeoutException
|
|
13
|
+
from selenium.webdriver.common.keys import Keys
|
|
14
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
15
|
+
|
|
16
|
+
from hermex.config import LONG_WAIT, MIN_CHROME_VERSION, SHORT_WAIT
|
|
17
|
+
from hermex.config import data_dir as _default_data_dir
|
|
18
|
+
from hermex.models import AssistantMessage, State
|
|
19
|
+
from hermex.utils import get_user_agent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _detect_chrome_version() -> int:
|
|
23
|
+
chrome = uc.find_chrome_executable()
|
|
24
|
+
out = subprocess.check_output([chrome, "--version"], text=True)
|
|
25
|
+
version = int(re.search(r"(\d+)\.", out).group(1))
|
|
26
|
+
if version < MIN_CHROME_VERSION:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
f"Chrome {version} is not supported. Hermex requires Chrome {MIN_CHROME_VERSION} or higher."
|
|
29
|
+
)
|
|
30
|
+
return version
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Scraper(ABC):
|
|
34
|
+
"""
|
|
35
|
+
Abstract base class for LLM chatbot scrapers.
|
|
36
|
+
|
|
37
|
+
Manages the Chrome browser lifecycle, simulated typing, file downloads,
|
|
38
|
+
and state polling. Subclasses implement the platform-specific DOM interactions
|
|
39
|
+
for a particular chatbot (e.g. Gemini, ChatGPT).
|
|
40
|
+
|
|
41
|
+
Use `Scraper.setup()` once to establish a persistent login session, then
|
|
42
|
+
instantiate the subclass directly for subsequent runs.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
_state_error_tolerance = (
|
|
46
|
+
20 # seconds of consecutive state-detection failures before giving up
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
chrome_version=None,
|
|
52
|
+
download_dir=Path("."),
|
|
53
|
+
headless=False,
|
|
54
|
+
typing_delay=0.025,
|
|
55
|
+
disable_web_security=True,
|
|
56
|
+
data_dir=None,
|
|
57
|
+
):
|
|
58
|
+
"""
|
|
59
|
+
:param chrome_version: Chrome major version number. Defaults to auto-detecting
|
|
60
|
+
the installed Chrome version.
|
|
61
|
+
:param download_dir: Directory where downloaded files (e.g. generated images)
|
|
62
|
+
are saved.
|
|
63
|
+
:param headless: Run the browser without a visible window.
|
|
64
|
+
:param typing_delay: Seconds between each keystroke when typing
|
|
65
|
+
character-by-character.
|
|
66
|
+
:param disable_web_security: Pass --disable-web-security to Chrome. Needed for
|
|
67
|
+
some scrapers (e.g. ChatGPT, Gemini) but triggers bot detection on stricter
|
|
68
|
+
sites — set False for those.
|
|
69
|
+
:param data_dir: Root directory where Hermex stores its data. Defaults to the
|
|
70
|
+
platform-appropriate data directory (~/.local/share/hermex on Linux,
|
|
71
|
+
~/Library/Application Support/hermex on macOS). Browser profiles are stored
|
|
72
|
+
as subdirectories within this path (e.g. data_dir/chrome_profile/).
|
|
73
|
+
"""
|
|
74
|
+
if data_dir is None:
|
|
75
|
+
data_dir = _default_data_dir
|
|
76
|
+
self._data_dir = Path(data_dir)
|
|
77
|
+
self.browser_profile_dir = self._data_dir / "chrome_profile"
|
|
78
|
+
self.chrome_version = chrome_version or _detect_chrome_version()
|
|
79
|
+
self.disable_web_security = disable_web_security
|
|
80
|
+
self._temp_dir = TemporaryDirectory()
|
|
81
|
+
self._selenium_download_dir = Path(self._temp_dir.name)
|
|
82
|
+
self.download_dir = Path(download_dir)
|
|
83
|
+
self.headless = headless
|
|
84
|
+
self.driver = None
|
|
85
|
+
self.typing_delay = typing_delay
|
|
86
|
+
self.is_logged_in = False
|
|
87
|
+
|
|
88
|
+
self.download_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
|
|
90
|
+
if not (self._data_dir / f".setup_{self.__class__.__name__.lower()}").exists():
|
|
91
|
+
warnings.warn(
|
|
92
|
+
f"{self.__class__.__name__}.setup() has not been run. "
|
|
93
|
+
"It is strongly recommended to run it once before use — it builds a "
|
|
94
|
+
"browser profile that significantly reduces bot detection risk.",
|
|
95
|
+
UserWarning,
|
|
96
|
+
stacklevel=2,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _initialize_driver(self):
|
|
100
|
+
"""Initialize and configure the Chrome driver"""
|
|
101
|
+
options = uc.ChromeOptions()
|
|
102
|
+
options.add_argument("--start-maximized")
|
|
103
|
+
options.add_argument("--disable-notifications")
|
|
104
|
+
options.add_argument(f"--user-data-dir={self.browser_profile_dir}")
|
|
105
|
+
options.add_argument("--enable-javascript")
|
|
106
|
+
options.add_argument("--enable-cookies")
|
|
107
|
+
options.add_argument("--no-sandbox")
|
|
108
|
+
if self.disable_web_security:
|
|
109
|
+
options.add_argument("--disable-web-security")
|
|
110
|
+
options.add_argument("--allow-running-insecure-content")
|
|
111
|
+
options.add_argument("--disable-blink-features=AutomationControlled")
|
|
112
|
+
options.add_argument("--disable-features=IsolateOrigins,site-per-process")
|
|
113
|
+
|
|
114
|
+
options.add_argument(f"--user-agent={get_user_agent(self.chrome_version)}")
|
|
115
|
+
|
|
116
|
+
options.add_experimental_option(
|
|
117
|
+
"prefs",
|
|
118
|
+
{
|
|
119
|
+
"download.default_directory": str(self._selenium_download_dir),
|
|
120
|
+
"download.prompt_for_download": False,
|
|
121
|
+
"download.directory_upgrade": True,
|
|
122
|
+
"safebrowsing.enabled": False,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if self.headless:
|
|
127
|
+
options.add_argument("--headless")
|
|
128
|
+
options.add_argument("--disable-gpu")
|
|
129
|
+
options.add_argument("--window-size=1920,1080")
|
|
130
|
+
|
|
131
|
+
self.driver = uc.Chrome(
|
|
132
|
+
options=options, use_subprocess=True, version_main=self.chrome_version
|
|
133
|
+
)
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def open_url(self, url=None, timeout=30):
|
|
137
|
+
"""
|
|
138
|
+
Open a URL in the browser and wait for the page to be ready.
|
|
139
|
+
|
|
140
|
+
:param url: URL to navigate to.
|
|
141
|
+
:param timeout: Maximum seconds to wait for the page to be ready before raising
|
|
142
|
+
TimeoutException.
|
|
143
|
+
"""
|
|
144
|
+
if not self.driver:
|
|
145
|
+
self._initialize_driver()
|
|
146
|
+
|
|
147
|
+
self.driver.get(url)
|
|
148
|
+
self.wait_for_page_load(timeout)
|
|
149
|
+
self._detect_login()
|
|
150
|
+
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
@abstractmethod
|
|
154
|
+
def wait_for_page_load(self, timeout: float = 30) -> None:
|
|
155
|
+
"""Wait until the page is ready to interact with."""
|
|
156
|
+
|
|
157
|
+
@abstractmethod
|
|
158
|
+
def _detect_login(self) -> None:
|
|
159
|
+
"""Detect whether the user is logged in and set self.is_logged_in accordingly."""
|
|
160
|
+
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def send_message(
|
|
163
|
+
self,
|
|
164
|
+
message: str,
|
|
165
|
+
submit: bool = True,
|
|
166
|
+
images: list[str | Path] = None,
|
|
167
|
+
paste: bool = False,
|
|
168
|
+
fake_typing: bool = True,
|
|
169
|
+
typing_delay: float = None,
|
|
170
|
+
) -> "Scraper":
|
|
171
|
+
"""
|
|
172
|
+
Input a message into the chat, optionally attaching images.
|
|
173
|
+
|
|
174
|
+
:param message: Text to send.
|
|
175
|
+
:param submit: Whether to press Enter after composing the message.
|
|
176
|
+
:param images: List of image file paths to attach before the message.
|
|
177
|
+
:param paste: If True, paste the message instead of typing it character by
|
|
178
|
+
character. Useful for long messages where typing is too slow.
|
|
179
|
+
:param fake_typing: When paste=True, type dummy text first to avoid bot
|
|
180
|
+
detection, then replace it with the real message.
|
|
181
|
+
:param typing_delay: Seconds between each keystroke. Overrides the
|
|
182
|
+
instance-level default set in the constructor for this call
|
|
183
|
+
only.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
@abstractmethod
|
|
187
|
+
def get_last_response(
|
|
188
|
+
self, get_markdown: bool = False, remove_watermark: bool = False
|
|
189
|
+
) -> "AssistantMessage":
|
|
190
|
+
"""
|
|
191
|
+
Retrieve the last response from the chat interface.
|
|
192
|
+
|
|
193
|
+
:param get_markdown: If True, return the raw markdown source instead of plain text.
|
|
194
|
+
:param remove_watermark: If True, remove the watermark from any downloaded image.
|
|
195
|
+
:return: AssistantMessage with text and image fields (either may be None, but not both).
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
@abstractmethod
|
|
199
|
+
def get_state(self) -> State:
|
|
200
|
+
"""
|
|
201
|
+
Return the current state of the chatbot UI.
|
|
202
|
+
|
|
203
|
+
Possible states:
|
|
204
|
+
- State.IDLE: the interface is ready and waiting for input.
|
|
205
|
+
- State.TYPING: the input box has content that has not been submitted yet.
|
|
206
|
+
- State.UPLOADING: a file upload is in progress.
|
|
207
|
+
- State.GENERATING: the model is actively generating a response.
|
|
208
|
+
|
|
209
|
+
:return: A State value representing the current UI state.
|
|
210
|
+
:raises Exception: if the state cannot be determined (e.g. expected DOM elements
|
|
211
|
+
are missing). Callers that need to tolerate transient failures should use
|
|
212
|
+
wait_until_idle() instead, which has built-in error tolerance.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def _wait_until_state(self, target: State, timeout: float = None) -> None:
|
|
216
|
+
if timeout is None:
|
|
217
|
+
timeout = LONG_WAIT
|
|
218
|
+
start = time.time()
|
|
219
|
+
error_since = None
|
|
220
|
+
while time.time() - start < timeout:
|
|
221
|
+
try:
|
|
222
|
+
if self.get_state() == target:
|
|
223
|
+
return
|
|
224
|
+
error_since = None
|
|
225
|
+
except Exception:
|
|
226
|
+
if error_since is None:
|
|
227
|
+
error_since = time.time()
|
|
228
|
+
elif time.time() - error_since > self._state_error_tolerance:
|
|
229
|
+
raise
|
|
230
|
+
time.sleep(1)
|
|
231
|
+
raise TimeoutException(
|
|
232
|
+
f"Chatbot did not reach state '{target}' within {timeout}s."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def wait_until_idle(self, timeout: float = None) -> None:
|
|
236
|
+
"""
|
|
237
|
+
Block until the chatbot has finished generating its response.
|
|
238
|
+
|
|
239
|
+
:param timeout: Maximum seconds to wait before raising TimeoutException. Defaults to 5 minutes.
|
|
240
|
+
"""
|
|
241
|
+
self._wait_until_state(State.IDLE, timeout)
|
|
242
|
+
|
|
243
|
+
def query(
|
|
244
|
+
self,
|
|
245
|
+
message: str,
|
|
246
|
+
timeout: float = None,
|
|
247
|
+
images: list[str | Path] = None,
|
|
248
|
+
paste: bool = False,
|
|
249
|
+
fake_typing: bool = True,
|
|
250
|
+
typing_delay: float = None,
|
|
251
|
+
get_markdown: bool = False,
|
|
252
|
+
remove_watermark: bool = False,
|
|
253
|
+
) -> "AssistantMessage":
|
|
254
|
+
"""
|
|
255
|
+
Send a message, wait for the response to complete, and return it.
|
|
256
|
+
|
|
257
|
+
:param message: Text to send.
|
|
258
|
+
:param timeout: Maximum seconds to wait for the response before raising TimeoutException. Defaults to 5 minutes.
|
|
259
|
+
:param images: List of image file paths to attach (platform-dependent).
|
|
260
|
+
:param paste: If True, paste the message instead of typing it character by character.
|
|
261
|
+
Useful for long messages where typing is too slow.
|
|
262
|
+
:param fake_typing: When paste=True, type dummy text first to avoid bot detection,
|
|
263
|
+
then replace it with the real message.
|
|
264
|
+
:param typing_delay: Seconds between each keystroke. Overrides the instance-level default.
|
|
265
|
+
:param get_markdown: If True, return the raw markdown source instead of plain text.
|
|
266
|
+
:param remove_watermark: If True, remove the watermark from any downloaded image.
|
|
267
|
+
:return: AssistantMessage with text and image fields (either may be None, but not both).
|
|
268
|
+
"""
|
|
269
|
+
self.send_message(
|
|
270
|
+
message,
|
|
271
|
+
images=images,
|
|
272
|
+
paste=paste,
|
|
273
|
+
fake_typing=fake_typing,
|
|
274
|
+
typing_delay=typing_delay,
|
|
275
|
+
)
|
|
276
|
+
self.wait_until_idle(timeout)
|
|
277
|
+
return self.get_last_response(
|
|
278
|
+
get_markdown=get_markdown, remove_watermark=remove_watermark
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _type_into(
|
|
282
|
+
self,
|
|
283
|
+
message: str,
|
|
284
|
+
input_box: WebElement,
|
|
285
|
+
typing_delay: float = None,
|
|
286
|
+
):
|
|
287
|
+
delay = typing_delay if typing_delay is not None else self.typing_delay
|
|
288
|
+
for char in message:
|
|
289
|
+
if char == "\n": # Handle Newline: Shift+Enter
|
|
290
|
+
input_box.send_keys(Keys.SHIFT, Keys.ENTER)
|
|
291
|
+
elif ord(char) > 0xFFFF: # Handle Emojies
|
|
292
|
+
self.driver.execute_script(
|
|
293
|
+
"document.execCommand('insertText', false, arguments[0]);", char
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
input_box.send_keys(char)
|
|
297
|
+
self.sleep(delay)
|
|
298
|
+
|
|
299
|
+
self.sleep(2)
|
|
300
|
+
return self
|
|
301
|
+
|
|
302
|
+
def _paste_into(
|
|
303
|
+
self,
|
|
304
|
+
message: str,
|
|
305
|
+
input_box: WebElement,
|
|
306
|
+
fake_typing=True,
|
|
307
|
+
typing_delay: float = None,
|
|
308
|
+
):
|
|
309
|
+
if fake_typing:
|
|
310
|
+
self._type_into(
|
|
311
|
+
"Some fake text... " * 20, input_box, typing_delay=typing_delay
|
|
312
|
+
)
|
|
313
|
+
self.driver.execute_script(
|
|
314
|
+
"document.execCommand('selectAll', false, null);"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
self.driver.execute_script(
|
|
318
|
+
"document.execCommand('insertText', false, arguments[0]);", message
|
|
319
|
+
)
|
|
320
|
+
self.sleep(2)
|
|
321
|
+
return self
|
|
322
|
+
|
|
323
|
+
def sleep(self, t):
|
|
324
|
+
"""
|
|
325
|
+
Sleep for approximately t seconds, with a small random jitter to appear more human-like.
|
|
326
|
+
|
|
327
|
+
:param t: Target sleep duration in seconds.
|
|
328
|
+
"""
|
|
329
|
+
minmax_factor = 0.2
|
|
330
|
+
min_time = t - t * minmax_factor
|
|
331
|
+
max_time = t + t * minmax_factor
|
|
332
|
+
time.sleep(random.uniform(min_time, max_time))
|
|
333
|
+
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def long_wait(self):
|
|
337
|
+
"""Wait for the default long duration (5 minutes). Use after sending a prompt
|
|
338
|
+
that triggers image generation or a slow response."""
|
|
339
|
+
return self.sleep(LONG_WAIT)
|
|
340
|
+
|
|
341
|
+
def short_wait(self):
|
|
342
|
+
"""Wait for the default short duration (7 seconds). Use after UI interactions
|
|
343
|
+
that need a moment to settle."""
|
|
344
|
+
return self.sleep(SHORT_WAIT)
|
|
345
|
+
|
|
346
|
+
def refresh_page(self):
|
|
347
|
+
"""Reload the current page."""
|
|
348
|
+
self.driver.refresh()
|
|
349
|
+
return self
|
|
350
|
+
|
|
351
|
+
def get_current_url(self, only_base=False):
|
|
352
|
+
"""
|
|
353
|
+
Return the current browser URL.
|
|
354
|
+
|
|
355
|
+
:param only_base: If True, strip query parameters and return only the base URL.
|
|
356
|
+
"""
|
|
357
|
+
url = self.driver.current_url
|
|
358
|
+
if only_base:
|
|
359
|
+
return url.split("?")[0]
|
|
360
|
+
return url
|
|
361
|
+
|
|
362
|
+
def _get_downloaded_file(self, wait_time=60):
|
|
363
|
+
"""Wait for a file to be downloaded and return its path"""
|
|
364
|
+
elapsed = 0
|
|
365
|
+
poll_interval = 1
|
|
366
|
+
|
|
367
|
+
while elapsed < wait_time:
|
|
368
|
+
files = list(self._selenium_download_dir.iterdir())
|
|
369
|
+
if files:
|
|
370
|
+
file = files[0]
|
|
371
|
+
dest = self.download_dir / file.name
|
|
372
|
+
move_file(file, dest)
|
|
373
|
+
return dest
|
|
374
|
+
|
|
375
|
+
time.sleep(poll_interval)
|
|
376
|
+
elapsed += poll_interval
|
|
377
|
+
|
|
378
|
+
raise TimeoutException("File download timed out.")
|
|
379
|
+
|
|
380
|
+
def close(self):
|
|
381
|
+
"""Close the browser and clean up"""
|
|
382
|
+
if self.driver:
|
|
383
|
+
self.driver.quit()
|
|
384
|
+
self.driver = None
|
|
385
|
+
|
|
386
|
+
@classmethod
|
|
387
|
+
def setup(cls, data_dir=None):
|
|
388
|
+
"""
|
|
389
|
+
First-time setup required before using Hermex.
|
|
390
|
+
|
|
391
|
+
Opens a browser window so you can browse around briefly. This builds a
|
|
392
|
+
browser profile that looks like a real user, which significantly reduces
|
|
393
|
+
bot detection risk in subsequent automated runs. Everyone must run this
|
|
394
|
+
at least once after installation.
|
|
395
|
+
|
|
396
|
+
If you need login-gated features (e.g. image upload), log in
|
|
397
|
+
during this session. Hermex will reuse the saved session in all future
|
|
398
|
+
runs — repeat setup only if your session expires.
|
|
399
|
+
|
|
400
|
+
Close the browser window when done.
|
|
401
|
+
|
|
402
|
+
:param data_dir: Must match the data_dir you pass to the constructor. Defaults
|
|
403
|
+
to the platform-appropriate data directory.
|
|
404
|
+
|
|
405
|
+
Usage:
|
|
406
|
+
Gemini.setup()
|
|
407
|
+
"""
|
|
408
|
+
if data_dir is None:
|
|
409
|
+
data_dir = _default_data_dir
|
|
410
|
+
print(
|
|
411
|
+
"==> Opening browser. Browse around briefly, then close the window when done."
|
|
412
|
+
)
|
|
413
|
+
with warnings.catch_warnings():
|
|
414
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
415
|
+
scraper = cls(data_dir=data_dir)
|
|
416
|
+
scraper.open_url()
|
|
417
|
+
while True:
|
|
418
|
+
try:
|
|
419
|
+
scraper.driver.window_handles
|
|
420
|
+
time.sleep(2)
|
|
421
|
+
except Exception:
|
|
422
|
+
break
|
|
423
|
+
scraper.close()
|
|
424
|
+
|
|
425
|
+
marker = Path(data_dir) / f".setup_{cls.__name__.lower()}"
|
|
426
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
marker.touch()
|
|
428
|
+
|
|
429
|
+
@classmethod
|
|
430
|
+
def simple_query(cls, prompt, images=None, timeout=None):
|
|
431
|
+
"""
|
|
432
|
+
Open the browser, send a prompt, and return the response.
|
|
433
|
+
|
|
434
|
+
Convenience method for one-shot scripts that don't need a persistent
|
|
435
|
+
session. Opens the browser, sends the prompt, closes the browser, and
|
|
436
|
+
returns the full AssistantMessage.
|
|
437
|
+
|
|
438
|
+
:param prompt: The prompt text to send.
|
|
439
|
+
:param images: Optional list of image file paths to attach.
|
|
440
|
+
:param timeout: Maximum seconds to wait for the response. Defaults to 5 minutes.
|
|
441
|
+
:return: AssistantMessage with text and image fields.
|
|
442
|
+
"""
|
|
443
|
+
scraper = cls()
|
|
444
|
+
response = (
|
|
445
|
+
scraper.open_url()
|
|
446
|
+
.short_wait()
|
|
447
|
+
.query(prompt, images=images, timeout=timeout)
|
|
448
|
+
)
|
|
449
|
+
scraper.close()
|
|
450
|
+
return response
|
hermex/utils.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from hermex.config import data_dir as _default_data_dir
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_user_agent(chrome_version: int) -> str:
|
|
9
|
+
version_str = f"{chrome_version}.0.0.0"
|
|
10
|
+
if sys.platform == "darwin":
|
|
11
|
+
return (
|
|
12
|
+
f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
13
|
+
f"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
14
|
+
f"Chrome/{version_str} Safari/537.36"
|
|
15
|
+
)
|
|
16
|
+
elif sys.platform == "win32":
|
|
17
|
+
return (
|
|
18
|
+
f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
19
|
+
f"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
20
|
+
f"Chrome/{version_str} Safari/537.36"
|
|
21
|
+
)
|
|
22
|
+
elif sys.platform.startswith("linux"):
|
|
23
|
+
return (
|
|
24
|
+
f"Mozilla/5.0 (X11; Linux x86_64) "
|
|
25
|
+
f"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
26
|
+
f"Chrome/{version_str} Safari/537.36"
|
|
27
|
+
)
|
|
28
|
+
else:
|
|
29
|
+
raise NotImplementedError(
|
|
30
|
+
f"User agent not defined for platform: {sys.platform}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clear_data(data_dir=_default_data_dir):
|
|
35
|
+
"""
|
|
36
|
+
Delete all data stored by Hermex (browser profiles, etc.).
|
|
37
|
+
|
|
38
|
+
:param data_dir: Root data directory to remove. Defaults to the platform-appropriate
|
|
39
|
+
Hermex data directory. Pass a custom path if you initialised scrapers with one.
|
|
40
|
+
"""
|
|
41
|
+
data_dir = Path(data_dir).expanduser()
|
|
42
|
+
if data_dir.exists():
|
|
43
|
+
shutil.rmtree(data_dir)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drive ChatGPT and Gemini from Python — no API keys, no billing, just the free web UI.
|
|
5
|
+
Author-email: Usama <pseudo.usama@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://hermex.usama.ai
|
|
8
|
+
Project-URL: Documentation, https://hermex.usama.ai
|
|
9
|
+
Project-URL: Repository, https://github.com/pseudo-usama/hermex
|
|
10
|
+
Keywords: chatgpt,gemini,llm,scraper,automation,selenium,browser
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: selenium>=4.0
|
|
23
|
+
Requires-Dist: undetected-chromedriver>=3.5
|
|
24
|
+
Requires-Dist: pyperclip>=1.8
|
|
25
|
+
Requires-Dist: opencv-python>=4.0
|
|
26
|
+
Requires-Dist: platformdirs>=4.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: ruff; extra == "dev"
|
|
29
|
+
Requires-Dist: mkdocs; extra == "dev"
|
|
30
|
+
Requires-Dist: mkdocs-material; extra == "dev"
|
|
31
|
+
Requires-Dist: mkdocstrings[python]; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="https://raw.githubusercontent.com/pseudo-usama/hermex/main/docs/assets/logo.svg" alt="Hermex" width="450" style="margin: 24px 0;"/>
|
|
36
|
+
<br>
|
|
37
|
+
<em>Drive ChatGPT and Gemini from Python — no API keys, no billing, just the free web UI.</em>
|
|
38
|
+
<br><br>
|
|
39
|
+
<a href="https://pypi.org/project/hermex"><img src="https://img.shields.io/pypi/v/hermex?color=3cb371" alt="PyPI"/></a>
|
|
40
|
+
<img src="https://img.shields.io/pypi/pyversions/hermex?color=3cb371" alt="Python 3.11+"/>
|
|
41
|
+
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"/>
|
|
42
|
+
<a href="https://github.com/pseudo-usama/hermex"><img src="https://img.shields.io/badge/GitHub-Hermex-181717?logo=github" alt="GitHub Repo"/></a>
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
ChatGPT and Gemini are incredibly capable — but their official APIs are expensive, and for many tasks you simply don't need them. If you want to run OCR on an image, generate artwork, extract text from a screenshot, or just ask a quick question in a script, paying per-token for API access is overkill when the free web UI can do the same thing.
|
|
48
|
+
|
|
49
|
+
Hermex lets you drive ChatGPT and Gemini from Python just like a human would: it opens a real Chrome browser, types your message, uploads your images, waits for the response, and hands it back to you as a Python object. No API keys, no billing, no rate-limit tiers.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from hermex import ChatGPT
|
|
53
|
+
|
|
54
|
+
response = ChatGPT.simple_query("What does this receipt say?", images=["receipt.jpg"])
|
|
55
|
+
print(response.text)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
It uses undetected-chromedriver under the hood to avoid bot detection, and reuses a persistent browser profile so your login session survives across runs.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install hermex
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Requires Python 3.11+ and Google Chrome 130+.
|
|
67
|
+
|
|
68
|
+
## First-time setup
|
|
69
|
+
|
|
70
|
+
Hermex reuses a persistent Chrome profile so you only need to log in once:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from hermex import Gemini
|
|
74
|
+
|
|
75
|
+
Gemini.setup() # opens a browser — log in, browse briefly, then close the window
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
After setup, all future runs reuse the saved session automatically. Repeat this if your session expires.
|
|
79
|
+
|
|
80
|
+
Guest mode (no login) works for basic text queries on Gemini but image upload requires a logged-in session. ChatGPT works without login for all features including image upload.
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
### Single query
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from hermex import Gemini, ChatGPT
|
|
88
|
+
|
|
89
|
+
# Gemini
|
|
90
|
+
gemini = Gemini()
|
|
91
|
+
gemini.open_url()
|
|
92
|
+
response = gemini.query("Summarize the history of the internet.")
|
|
93
|
+
print(response.text)
|
|
94
|
+
gemini.close()
|
|
95
|
+
|
|
96
|
+
# ChatGPT
|
|
97
|
+
chatgpt = ChatGPT()
|
|
98
|
+
chatgpt.open_url()
|
|
99
|
+
response = chatgpt.query("Summarize the history of the internet.")
|
|
100
|
+
print(response.text)
|
|
101
|
+
chatgpt.close()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Sending images
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
response = gemini.query(
|
|
108
|
+
"Describe what's in this image.",
|
|
109
|
+
images=["photo.jpg"],
|
|
110
|
+
)
|
|
111
|
+
print(response.text)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### One-shot query
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from hermex import Gemini, ChatGPT
|
|
118
|
+
|
|
119
|
+
response = Gemini.simple_query("What is the capital of France?")
|
|
120
|
+
print(response.text)
|
|
121
|
+
|
|
122
|
+
response = ChatGPT.simple_query("What is the capital of France?")
|
|
123
|
+
print(response.text)
|
|
124
|
+
|
|
125
|
+
# With an image
|
|
126
|
+
response = Gemini.simple_query("Describe this image.", images=["photo.jpg"])
|
|
127
|
+
print(response.text)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## AssistantMessage object
|
|
131
|
+
|
|
132
|
+
`query()` and `get_last_response()` return an `AssistantMessage` dataclass:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
@dataclass
|
|
136
|
+
class AssistantMessage:
|
|
137
|
+
text: str | None # plain text (or markdown if get_markdown=True)
|
|
138
|
+
image: Path | None # path to downloaded image, or None
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## API reference
|
|
142
|
+
|
|
143
|
+
Both `Gemini` and `ChatGPT` share the same interface — all methods below apply to both unless noted.
|
|
144
|
+
|
|
145
|
+
| Method | Description |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `open_url(url, timeout)` | Open the chat interface in the browser |
|
|
148
|
+
| `send_message(message, submit, images, paste, fake_typing, typing_delay)` | Type and optionally submit a message |
|
|
149
|
+
| `query(message, timeout, images, paste, get_markdown, remove_watermark)` | Send a message, wait for the response, and return it |
|
|
150
|
+
| `get_last_response(get_markdown, remove_watermark)` | Retrieve the most recent response |
|
|
151
|
+
| `wait_until_idle(timeout)` | Block until the chatbot finishes generating |
|
|
152
|
+
| `get_state()` | Return the current UI state (`State.IDLE`, `GENERATING`, `TYPING`, `UPLOADING`) |
|
|
153
|
+
| `simple_query(prompt, images, timeout)` | Class method — open, query, close in one call |
|
|
154
|
+
| `short_wait()` | Sleep ~7 seconds |
|
|
155
|
+
| `long_wait()` | Sleep ~5 minutes |
|
|
156
|
+
| `refresh_page()` | Reload the current page |
|
|
157
|
+
| `close()` | Close the browser |
|
|
158
|
+
| `setup()` | One-time login setup (class method) |
|
|
159
|
+
|
|
160
|
+
### Constructor options
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
Gemini(
|
|
164
|
+
chrome_version=None, # auto-detected from installed Chrome
|
|
165
|
+
download_dir=Path("."), # where generated images are saved
|
|
166
|
+
headless=False,
|
|
167
|
+
typing_delay=0.025, # seconds between keystrokes
|
|
168
|
+
disable_web_security=True,
|
|
169
|
+
)
|
|
170
|
+
# ChatGPT accepts the same parameters
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Watermark removal
|
|
174
|
+
|
|
175
|
+
Gemini watermarks its generated images. Pass `remove_watermark=True` to strip it:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
response = gemini.query("Generate an image of a sunset.", remove_watermark=True)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Notes
|
|
182
|
+
|
|
183
|
+
- Bot detection is mitigated through per-character typing delays, fake typing before paste, a persistent browser profile, and a spoofed user agent. Avoid running headless for sensitive sessions.
|
|
184
|
+
- Browser profile and session data are stored in the platform data directory (`~/Library/Application Support/hermex` on macOS).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
hermex/__init__.py,sha256=EVvI4p48rvJ4JFd-WiuLy12x-CnbBXrm2pR7r1cWQdc,328
|
|
2
|
+
hermex/chatgpt.py,sha256=VBkn4BnGdTDBAQ2nahgUQd6J37aWZnr9M2QtXMaG8aY,5884
|
|
3
|
+
hermex/config.py,sha256=sIg9zLyp2jL3sU19i_nvTuo78ORCB6bZQ13RzcjbTfk,238
|
|
4
|
+
hermex/exceptions.py,sha256=BzVSEhe6koXcqAFlR1WFumYS5nzAK40SeURhScybYkk,46
|
|
5
|
+
hermex/gemini.py,sha256=AuTYYoLATPIhrsSWZLSsb9mGgAx9DIbLBoK7RKqGhvo,8092
|
|
6
|
+
hermex/gemini_watermark_remover.py,sha256=0Fgzl8Y79AZ2lfNWehdN_8qKjN69rhWcUffODQSbckA,2034
|
|
7
|
+
hermex/models.py,sha256=Gd2JGpxf-jKke_UMsSIV86mLT8kCXlJEmgs1K6oRvP4,822
|
|
8
|
+
hermex/scraper_base.py,sha256=m0J_E515CjxIM1mm5jYwmFzERm55qVG2zdXe9v1bOY4,16936
|
|
9
|
+
hermex/utils.py,sha256=Ijf902yuk6h3zc5_f710Ka615HdRMjb__6igX7vyZLQ,1403
|
|
10
|
+
hermex/assets/bg_48.png,sha256=0vxfyPpe6kmuKURDb2d4nZR0btv7BtYMbfkYuZ_8utQ,1985
|
|
11
|
+
hermex/assets/bg_96.png,sha256=Xy9kzsxRSvysH1USgof3BdNLQnaerEwFEzbZl-B90vk,9044
|
|
12
|
+
hermex-0.1.0.dist-info/licenses/LICENSE,sha256=D38kxrHy69-dEG1UgREItynBc4BRoqQAJcUWzXj0FdQ,1356
|
|
13
|
+
hermex-0.1.0.dist-info/METADATA,sha256=b8ZN8AQXlcw0cTFQoFQD1R9gdoGKGbJxIrCKiISK9kQ,6668
|
|
14
|
+
hermex-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
hermex-0.1.0.dist-info/top_level.txt,sha256=i-5LsdJ954ahrPbto1XJ5iC0R9jC6-txPyINoSkoqPI,7
|
|
16
|
+
hermex-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Usama (PSEUDO)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The watermark removal logic in hermex/gemini_watermark_remover.py is adapted
|
|
26
|
+
from GeminiWatermarkTool (https://github.com/allenk/GeminiWatermarkTool) by
|
|
27
|
+
allenk, originally written in C++ and rewritten in Python for this project.
|
|
28
|
+
That work is also released under the MIT License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hermex
|