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 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
+ ]
Binary file
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
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+
3
+ from platformdirs import user_data_dir
4
+
5
+ SUPPORTED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg"}
6
+ data_dir = Path(user_data_dir("hermex", appauthor=False))
7
+ LONG_WAIT = 5 * 60
8
+ SHORT_WAIT = 7
9
+ MIN_CHROME_VERSION = 130
hermex/exceptions.py ADDED
@@ -0,0 +1,2 @@
1
+ class LoginRequiredError(Exception):
2
+ pass
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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