gemx 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.
- gemx/__init__.py +29 -0
- gemx/cli.py +114 -0
- gemx/client.py +235 -0
- gemx/errors.py +19 -0
- gemx/formats.py +103 -0
- gemx/py.typed +0 -0
- gemx-0.1.0.dist-info/METADATA +111 -0
- gemx-0.1.0.dist-info/RECORD +11 -0
- gemx-0.1.0.dist-info/WHEEL +4 -0
- gemx-0.1.0.dist-info/entry_points.txt +2 -0
- gemx-0.1.0.dist-info/licenses/LICENSE +21 -0
gemx/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Gemx — drive the Gemini web UI from Python.
|
|
2
|
+
|
|
3
|
+
A play on "Gemini". Treats ``gemini.google.com`` as if it were an API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .client import Gemx, GemxConfig
|
|
9
|
+
from .errors import (
|
|
10
|
+
GemxError,
|
|
11
|
+
InputError,
|
|
12
|
+
ResponseParseError,
|
|
13
|
+
ResponseTimeoutError,
|
|
14
|
+
)
|
|
15
|
+
from .formats import OutputFormat, format_instruction, parse_output
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Gemx",
|
|
19
|
+
"GemxConfig",
|
|
20
|
+
"GemxError",
|
|
21
|
+
"InputError",
|
|
22
|
+
"OutputFormat",
|
|
23
|
+
"ResponseParseError",
|
|
24
|
+
"ResponseTimeoutError",
|
|
25
|
+
"format_instruction",
|
|
26
|
+
"parse_output",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
gemx/cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Command-line interface: ``gemx``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from xml.etree import ElementTree as ET
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .client import Gemx, GemxConfig
|
|
15
|
+
from .errors import GemxError
|
|
16
|
+
from .formats import OutputFormat
|
|
17
|
+
|
|
18
|
+
DEFAULT_PROFILE_DIR = Path("~/.gemx/profile")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
"""Construct the ``gemx`` argument parser."""
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="gemx",
|
|
25
|
+
description="Drive the Gemini web UI from the command line.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument("--version", action="version", version=f"gemx {__version__}")
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"prompt",
|
|
30
|
+
nargs="?",
|
|
31
|
+
help="Prompt to send. If omitted, read from stdin.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"-f",
|
|
35
|
+
"--format",
|
|
36
|
+
type=OutputFormat.from_str,
|
|
37
|
+
default=OutputFormat.JSON,
|
|
38
|
+
metavar="{json,xml,txt}",
|
|
39
|
+
help="Output format requested from Gemini (default: json).",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"-p",
|
|
43
|
+
"--profile-dir",
|
|
44
|
+
type=Path,
|
|
45
|
+
default=DEFAULT_PROFILE_DIR,
|
|
46
|
+
help=f"Chrome profile dir for the session (default: {DEFAULT_PROFILE_DIR}).",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--headful",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Show the browser window instead of running headless.",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--response-timeout",
|
|
55
|
+
type=int,
|
|
56
|
+
default=180,
|
|
57
|
+
metavar="SECONDS",
|
|
58
|
+
help="Max seconds to wait for a response to start (default: 180).",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"-v",
|
|
62
|
+
"--verbose",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Log progress to stderr.",
|
|
65
|
+
)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _render(value: object, fmt: OutputFormat) -> str:
|
|
70
|
+
"""Serialize a parsed value back to a string for stdout."""
|
|
71
|
+
if fmt is OutputFormat.JSON:
|
|
72
|
+
return json.dumps(value, indent=2, ensure_ascii=False)
|
|
73
|
+
if fmt is OutputFormat.XML:
|
|
74
|
+
assert isinstance(value, ET.Element)
|
|
75
|
+
return ET.tostring(value, encoding="unicode")
|
|
76
|
+
return str(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _run(args: argparse.Namespace) -> int:
|
|
80
|
+
prompt = args.prompt if args.prompt is not None else sys.stdin.read()
|
|
81
|
+
prompt = prompt.strip()
|
|
82
|
+
if not prompt:
|
|
83
|
+
print("error: empty prompt", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
|
|
86
|
+
config = GemxConfig(
|
|
87
|
+
profile_dir=args.profile_dir,
|
|
88
|
+
headless=not args.headful,
|
|
89
|
+
response_timeout_s=args.response_timeout,
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
async with Gemx(config) as gemx:
|
|
93
|
+
result = await gemx.ask(prompt, args.format)
|
|
94
|
+
except GemxError as exc:
|
|
95
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
print(_render(result, args.format))
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv: list[str] | None = None) -> int:
|
|
103
|
+
"""Entry point for the ``gemx`` console script."""
|
|
104
|
+
args = build_parser().parse_args(argv)
|
|
105
|
+
logging.basicConfig(
|
|
106
|
+
level=logging.INFO if args.verbose else logging.WARNING,
|
|
107
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
108
|
+
stream=sys.stderr,
|
|
109
|
+
)
|
|
110
|
+
return asyncio.run(_run(args))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
raise SystemExit(main())
|
gemx/client.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Drive the Gemini web UI via Playwright.
|
|
2
|
+
|
|
3
|
+
This is a formalized version of a hack that drives ``gemini.google.com/app`` as
|
|
4
|
+
if it were an API. The non-obvious parts (and why this exists):
|
|
5
|
+
|
|
6
|
+
* **Input** must go through ``document.execCommand('insertText')``. Gemini's
|
|
7
|
+
editor is Quill, which keeps its own document model and ignores DOM surgery,
|
|
8
|
+
Playwright ``fill()``, and synthetic ``InputEvent``s — those leave the model
|
|
9
|
+
empty and the turn errors with "I encountered an error". ``execCommand`` fires
|
|
10
|
+
the trusted ``beforeinput``/``input`` pair Quill honors, exactly like a paste.
|
|
11
|
+
* **The response** lives in ``message-content .markdown`` (not a bare
|
|
12
|
+
``.markdown``), and Gemini re-mounts / empties that node after streaming, so we
|
|
13
|
+
retain the *peak* text seen rather than reading the DOM once at the end.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from playwright.async_api import Page, async_playwright
|
|
25
|
+
|
|
26
|
+
from .errors import InputError, ResponseTimeoutError
|
|
27
|
+
from .formats import OutputFormat, format_instruction, parse_output
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("gemx")
|
|
30
|
+
|
|
31
|
+
GEMINI_URL = "https://gemini.google.com/app"
|
|
32
|
+
INPUT_SELECTOR = '.ql-editor[contenteditable="true"]'
|
|
33
|
+
SEND_SELECTOR = 'button[aria-label="Send message"]'
|
|
34
|
+
RESPONSE_SELECTOR = "message-content .markdown"
|
|
35
|
+
|
|
36
|
+
_INSERT_TEXT_JS = """(text) => {
|
|
37
|
+
const editor = document.querySelector('.ql-editor[contenteditable="true"]');
|
|
38
|
+
if (!editor) return false;
|
|
39
|
+
editor.focus();
|
|
40
|
+
const sel = window.getSelection();
|
|
41
|
+
sel.removeAllRanges();
|
|
42
|
+
const range = document.createRange();
|
|
43
|
+
range.selectNodeContents(editor);
|
|
44
|
+
range.collapse(false);
|
|
45
|
+
sel.addRange(range);
|
|
46
|
+
return document.execCommand('insertText', false, text);
|
|
47
|
+
}"""
|
|
48
|
+
|
|
49
|
+
_LONGEST_RESPONSE_JS = """(args) => {
|
|
50
|
+
const [selector, initialCount] = args;
|
|
51
|
+
const els = document.querySelectorAll(selector);
|
|
52
|
+
if (els.length <= initialCount) return '';
|
|
53
|
+
let best = '';
|
|
54
|
+
els.forEach(el => {
|
|
55
|
+
const t = el.innerText || el.textContent || '';
|
|
56
|
+
if (t.length > best.length) best = t;
|
|
57
|
+
});
|
|
58
|
+
return best;
|
|
59
|
+
}"""
|
|
60
|
+
|
|
61
|
+
_DISMISS_WELCOME_JS = """() => {
|
|
62
|
+
const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
|
|
63
|
+
for (const btn of buttons) {
|
|
64
|
+
const text = btn.innerText || btn.textContent || '';
|
|
65
|
+
if (text.match(/continue|get started|skip|next|accept|try gemini/i)) {
|
|
66
|
+
btn.click();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class GemxConfig: # pylint: disable=too-many-instance-attributes
|
|
76
|
+
"""Tunables for a :class:`Gemx` session."""
|
|
77
|
+
|
|
78
|
+
profile_dir: Path
|
|
79
|
+
headless: bool = True
|
|
80
|
+
nav_timeout_ms: int = 60_000
|
|
81
|
+
input_timeout_ms: int = 30_000
|
|
82
|
+
response_timeout_s: int = 180
|
|
83
|
+
stabilization_timeout_s: int = 120
|
|
84
|
+
poll_interval_s: int = 2
|
|
85
|
+
viewport_width: int = 1280
|
|
86
|
+
viewport_height: int = 720
|
|
87
|
+
user_agent: str = (
|
|
88
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
89
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
90
|
+
)
|
|
91
|
+
launch_args: tuple[str, ...] = field(
|
|
92
|
+
default=(
|
|
93
|
+
"--disable-blink-features=AutomationControlled",
|
|
94
|
+
"--disable-dev-shm-usage",
|
|
95
|
+
"--no-sandbox",
|
|
96
|
+
"--disable-setuid-sandbox",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Gemx:
|
|
102
|
+
"""A Gemini web-UI session.
|
|
103
|
+
|
|
104
|
+
Use as an async context manager so the browser is cleaned up::
|
|
105
|
+
|
|
106
|
+
async with Gemx(GemxConfig(profile_dir=Path("~/.gemx/profile"))) as gemx:
|
|
107
|
+
data = await gemx.ask("List 3 fruits", OutputFormat.JSON)
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, config: GemxConfig) -> None:
|
|
111
|
+
self._config = config
|
|
112
|
+
|
|
113
|
+
async def ask(
|
|
114
|
+
self, prompt: str, fmt: OutputFormat = OutputFormat.JSON
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""Send ``prompt`` to Gemini and return its reply parsed as ``fmt``.
|
|
117
|
+
|
|
118
|
+
The format instruction is appended to the prompt so Gemini emits the
|
|
119
|
+
requested shape.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
InputError: If the prompt could not be entered.
|
|
123
|
+
ResponseTimeoutError: If no response arrived in time.
|
|
124
|
+
ResponseParseError: If the reply could not be parsed as ``fmt``.
|
|
125
|
+
"""
|
|
126
|
+
full_prompt = f"{prompt}\n\n{format_instruction(fmt)}"
|
|
127
|
+
raw = await self.ask_raw(full_prompt)
|
|
128
|
+
return parse_output(raw, fmt)
|
|
129
|
+
|
|
130
|
+
async def ask_raw(self, prompt: str) -> str:
|
|
131
|
+
"""Send ``prompt`` verbatim and return Gemini's raw reply text."""
|
|
132
|
+
cfg = self._config
|
|
133
|
+
profile = cfg.profile_dir.expanduser()
|
|
134
|
+
async with async_playwright() as p:
|
|
135
|
+
context = await p.chromium.launch_persistent_context(
|
|
136
|
+
str(profile),
|
|
137
|
+
headless=cfg.headless,
|
|
138
|
+
args=list(cfg.launch_args),
|
|
139
|
+
user_agent=cfg.user_agent,
|
|
140
|
+
viewport={
|
|
141
|
+
"width": cfg.viewport_width,
|
|
142
|
+
"height": cfg.viewport_height,
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
page = (
|
|
147
|
+
context.pages[0] if context.pages else await context.new_page()
|
|
148
|
+
)
|
|
149
|
+
await self._navigate(page)
|
|
150
|
+
await self._enter_prompt(page, prompt)
|
|
151
|
+
await self._submit(page)
|
|
152
|
+
return await self._await_response(page)
|
|
153
|
+
finally:
|
|
154
|
+
await context.close()
|
|
155
|
+
|
|
156
|
+
async def _navigate(self, page: Page) -> None:
|
|
157
|
+
cfg = self._config
|
|
158
|
+
await page.goto(GEMINI_URL, wait_until="load", timeout=cfg.nav_timeout_ms)
|
|
159
|
+
await page.wait_for_load_state("domcontentloaded")
|
|
160
|
+
await page.wait_for_load_state("networkidle", timeout=30_000)
|
|
161
|
+
await page.wait_for_timeout(5_000)
|
|
162
|
+
|
|
163
|
+
body_text = await page.evaluate("() => document.body.innerText || ''")
|
|
164
|
+
if "welcome to gemini" in body_text.lower():
|
|
165
|
+
logger.info("welcome screen detected; dismissing")
|
|
166
|
+
dismissed = await page.evaluate(_DISMISS_WELCOME_JS)
|
|
167
|
+
await page.wait_for_timeout(3_000 if dismissed else 1_000)
|
|
168
|
+
|
|
169
|
+
await page.wait_for_selector(INPUT_SELECTOR, timeout=cfg.input_timeout_ms)
|
|
170
|
+
|
|
171
|
+
async def _enter_prompt(self, page: Page, prompt: str) -> None:
|
|
172
|
+
await page.click(INPUT_SELECTOR)
|
|
173
|
+
ok: bool = await page.evaluate(_INSERT_TEXT_JS, prompt)
|
|
174
|
+
entered = (await page.locator(INPUT_SELECTOR).first.inner_text()).strip()
|
|
175
|
+
logger.info("entered prompt: ok=%s chars=%d", ok, len(entered))
|
|
176
|
+
if not ok or not entered:
|
|
177
|
+
raise InputError("Quill did not accept the prompt text")
|
|
178
|
+
|
|
179
|
+
async def _submit(self, page: Page) -> None:
|
|
180
|
+
await page.wait_for_selector(
|
|
181
|
+
SEND_SELECTOR, timeout=self._config.input_timeout_ms
|
|
182
|
+
)
|
|
183
|
+
await page.click(SEND_SELECTOR)
|
|
184
|
+
|
|
185
|
+
async def _await_response(self, page: Page) -> str:
|
|
186
|
+
cfg = self._config
|
|
187
|
+
initial = await page.evaluate(
|
|
188
|
+
"(s) => document.querySelectorAll(s).length", RESPONSE_SELECTOR
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Wait for a new response node to appear.
|
|
192
|
+
elapsed = 0
|
|
193
|
+
while elapsed < cfg.response_timeout_s:
|
|
194
|
+
count = await page.evaluate(
|
|
195
|
+
"(s) => document.querySelectorAll(s).length", RESPONSE_SELECTOR
|
|
196
|
+
)
|
|
197
|
+
if count > initial:
|
|
198
|
+
break
|
|
199
|
+
await asyncio.sleep(cfg.poll_interval_s)
|
|
200
|
+
elapsed += cfg.poll_interval_s
|
|
201
|
+
else:
|
|
202
|
+
raise ResponseTimeoutError(
|
|
203
|
+
f"No response node after {cfg.response_timeout_s}s"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Stream until the longest node's text stops growing, retaining the peak.
|
|
207
|
+
best = ""
|
|
208
|
+
last_len = 0
|
|
209
|
+
stable = 0
|
|
210
|
+
waited = 0
|
|
211
|
+
while waited < cfg.stabilization_timeout_s:
|
|
212
|
+
await asyncio.sleep(cfg.poll_interval_s)
|
|
213
|
+
waited += cfg.poll_interval_s
|
|
214
|
+
current: str = await page.evaluate(
|
|
215
|
+
_LONGEST_RESPONSE_JS, [RESPONSE_SELECTOR, initial]
|
|
216
|
+
)
|
|
217
|
+
if len(current) > len(best):
|
|
218
|
+
best = current
|
|
219
|
+
if len(current) > last_len:
|
|
220
|
+
last_len = len(current)
|
|
221
|
+
stable = 0
|
|
222
|
+
elif best:
|
|
223
|
+
stable += 1
|
|
224
|
+
if stable >= 2 and len(best) > 0:
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
if not best:
|
|
228
|
+
raise ResponseTimeoutError("Response node never produced text")
|
|
229
|
+
return best
|
|
230
|
+
|
|
231
|
+
async def __aenter__(self) -> Gemx:
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
235
|
+
return None
|
gemx/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exceptions raised by Gemx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GemxError(Exception):
|
|
7
|
+
"""Base class for all Gemx errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InputError(GemxError):
|
|
11
|
+
"""The prompt could not be entered into Gemini's editor."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ResponseTimeoutError(GemxError):
|
|
15
|
+
"""Gemini did not produce a response within the configured window."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ResponseParseError(GemxError):
|
|
19
|
+
"""Gemini responded, but the payload could not be parsed for the format."""
|
gemx/formats.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Output formats Gemx can request from Gemini.
|
|
2
|
+
|
|
3
|
+
The chosen format is injected into the prompt as an instruction and also drives
|
|
4
|
+
how Gemx extracts the structured payload from Gemini's reply.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Any
|
|
13
|
+
from xml.etree import ElementTree as ET
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OutputFormat(StrEnum):
|
|
17
|
+
"""Structured output formats Gemx understands."""
|
|
18
|
+
|
|
19
|
+
JSON = "json"
|
|
20
|
+
XML = "xml"
|
|
21
|
+
TXT = "txt"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_str(cls, value: str) -> OutputFormat:
|
|
25
|
+
"""Parse a format name case-insensitively.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ValueError: If ``value`` is not a supported format.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
return cls(value.strip().lower())
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
supported = ", ".join(f.value for f in cls)
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Unsupported output format {value!r}; expected one of: {supported}"
|
|
36
|
+
) from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def format_instruction(fmt: OutputFormat) -> str:
|
|
40
|
+
"""Return the prompt instruction that asks Gemini for ``fmt`` output."""
|
|
41
|
+
if fmt is OutputFormat.JSON:
|
|
42
|
+
return (
|
|
43
|
+
"Respond with a single valid JSON object only. Do not include prose, "
|
|
44
|
+
"markdown fences, or commentary outside the JSON."
|
|
45
|
+
)
|
|
46
|
+
if fmt is OutputFormat.XML:
|
|
47
|
+
return (
|
|
48
|
+
"Respond with a single well-formed XML document only. Do not include "
|
|
49
|
+
"prose, markdown fences, or commentary outside the XML."
|
|
50
|
+
)
|
|
51
|
+
return (
|
|
52
|
+
"Respond with plain text only. Do not include markdown fences, JSON, or "
|
|
53
|
+
"XML."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _strip_fences(text: str) -> str:
|
|
58
|
+
"""Strip a leading format label and surrounding markdown code fences."""
|
|
59
|
+
text = text.strip()
|
|
60
|
+
text = re.sub(r"^(JSON|XML)\s*\n?", "", text, flags=re.IGNORECASE)
|
|
61
|
+
fence = re.search(r"```[a-zA-Z]*\s*(.*?)```", text, flags=re.DOTALL)
|
|
62
|
+
if fence:
|
|
63
|
+
return fence.group(1).strip()
|
|
64
|
+
return text
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clean_json_text(text: str) -> str:
|
|
68
|
+
"""Best-effort repair of common JSON quirks in LLM output."""
|
|
69
|
+
text = text.strip()
|
|
70
|
+
text = re.sub(r",\s*([}\]])", r"\1", text)
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_output(text: str, fmt: OutputFormat) -> Any:
|
|
75
|
+
"""Extract a structured value from Gemini's raw reply for ``fmt``.
|
|
76
|
+
|
|
77
|
+
JSON yields a ``dict``/``list``, XML yields an
|
|
78
|
+
:class:`xml.etree.ElementTree.Element`, and TXT yields the cleaned ``str``.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If the payload cannot be parsed as ``fmt``.
|
|
82
|
+
"""
|
|
83
|
+
cleaned = _strip_fences(text)
|
|
84
|
+
|
|
85
|
+
if fmt is OutputFormat.TXT:
|
|
86
|
+
return cleaned
|
|
87
|
+
|
|
88
|
+
if fmt is OutputFormat.JSON:
|
|
89
|
+
start = cleaned.find("{")
|
|
90
|
+
end = cleaned.rfind("}") + 1
|
|
91
|
+
candidate = cleaned[start:end] if start != -1 and end != 0 else cleaned
|
|
92
|
+
try:
|
|
93
|
+
return json.loads(_clean_json_text(candidate))
|
|
94
|
+
except json.JSONDecodeError as exc:
|
|
95
|
+
raise ValueError(f"Could not parse JSON from response: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
start = cleaned.find("<")
|
|
98
|
+
end = cleaned.rfind(">") + 1
|
|
99
|
+
candidate = cleaned[start:end] if start != -1 and end != 0 else cleaned
|
|
100
|
+
try:
|
|
101
|
+
return ET.fromstring(candidate)
|
|
102
|
+
except ET.ParseError as exc:
|
|
103
|
+
raise ValueError(f"Could not parse XML from response: {exc}") from exc
|
gemx/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gemx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drive the Gemini web UI from Python via Playwright
|
|
5
|
+
Project-URL: Homepage, https://github.com/ra0x3/gemx
|
|
6
|
+
Project-URL: Repository, https://github.com/ra0x3/gemx
|
|
7
|
+
Project-URL: Issues, https://github.com/ra0x3/gemx/issues
|
|
8
|
+
Author: ra0x3
|
|
9
|
+
Maintainer: ra0x3
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: automation,browser,gemini,llm,playwright,scraping
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: playwright>=1.49
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# Gemx
|
|
24
|
+
|
|
25
|
+
**Drive the Gemini web UI from Python.** A play on "Gemini" — Gemx treats
|
|
26
|
+
`gemini.google.com` as if it were an API, using Playwright to enter a prompt,
|
|
27
|
+
submit it, and capture the structured reply.
|
|
28
|
+
|
|
29
|
+
It exists because the obvious approaches don't work: Gemini's editor is
|
|
30
|
+
[Quill](https://quilljs.com/), which keeps its own document model and ignores
|
|
31
|
+
DOM surgery, Playwright `fill()`, and synthetic input events — those leave the
|
|
32
|
+
model empty and the turn errors with *"I encountered an error."* Gemx injects
|
|
33
|
+
text via `execCommand('insertText')` (the same trusted input pipeline a manual
|
|
34
|
+
paste uses), reads the reply from `message-content .markdown`, and retains the
|
|
35
|
+
*peak* streamed text because Gemini re-mounts the response node mid-stream.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv add gemx
|
|
41
|
+
# Playwright needs a browser the first time:
|
|
42
|
+
uv run playwright install chromium
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## CLI
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# JSON (default)
|
|
49
|
+
gemx "List 3 NBA teams as a JSON array"
|
|
50
|
+
|
|
51
|
+
# XML
|
|
52
|
+
gemx --format xml "Describe the solar system as XML"
|
|
53
|
+
|
|
54
|
+
# Plain text, reading the prompt from stdin
|
|
55
|
+
echo "Summarize the plot of Dune in one sentence" | gemx --format txt
|
|
56
|
+
|
|
57
|
+
# Watch the browser while it works
|
|
58
|
+
gemx --headful --verbose "Hello there"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The chosen `--format` (`json`, `xml`, or `txt`) is appended to the prompt as an
|
|
62
|
+
instruction *and* drives how Gemx parses the reply.
|
|
63
|
+
|
|
64
|
+
| Option | Description |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `-f, --format {json,xml,txt}` | Output format (default: `json`). |
|
|
67
|
+
| `-p, --profile-dir PATH` | Chrome profile dir (default: `~/.gemx/profile`). |
|
|
68
|
+
| `--headful` | Show the browser window. |
|
|
69
|
+
| `--response-timeout SECONDS` | Wait for a response to start (default: 180). |
|
|
70
|
+
| `-v, --verbose` | Log progress to stderr. |
|
|
71
|
+
|
|
72
|
+
## Library
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import asyncio
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
from gemx import Gemx, GemxConfig, OutputFormat
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def main() -> None:
|
|
81
|
+
config = GemxConfig(profile_dir=Path("~/.gemx/profile"))
|
|
82
|
+
async with Gemx(config) as gemx:
|
|
83
|
+
data = await gemx.ask("List 3 fruits as JSON", OutputFormat.JSON)
|
|
84
|
+
print(data)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
asyncio.run(main())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Authentication
|
|
91
|
+
|
|
92
|
+
Gemx drives a real, signed-in Gemini session. Point `--profile-dir` at a Chrome
|
|
93
|
+
profile that is already logged into your Google account (run once with
|
|
94
|
+
`--headful` to sign in); subsequent runs reuse that profile.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv sync
|
|
100
|
+
uv run ruff check .
|
|
101
|
+
uv run mypy
|
|
102
|
+
uv run pylint src/gemx
|
|
103
|
+
uv run pytest
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The browser-console debugging scripts used to discover and verify the current
|
|
107
|
+
Gemini selectors live in [`tests/scripts/`](tests/scripts/).
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT © ra0x3 — [Stonehedge Labs](https://github.com/stonehedgelabs)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
gemx/__init__.py,sha256=5_lo6aTFGHef-6oOs7l0V-WL3NjXy9fy2-VHYjUpYi8,595
|
|
2
|
+
gemx/cli.py,sha256=AEZOVzovGtd3fTcZhaNFv1pcGHWlg2rx1SJ_d6tiJbQ,3232
|
|
3
|
+
gemx/client.py,sha256=b3sprX_Esv5nhI0I-97pCw-Ki-0nzJhEvu8jcsMGd4I,8572
|
|
4
|
+
gemx/errors.py,sha256=e0h-gLm9NR_FZpYErdzwOOfSLhuo28c1ryJUSrJXQlo,470
|
|
5
|
+
gemx/formats.py,sha256=d9jTaKV9oaGa6iruiM95sHMxWf5nGgZ99NsaGhtcR4k,3308
|
|
6
|
+
gemx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
gemx-0.1.0.dist-info/METADATA,sha256=3IxArBlr1CGkfHWIFUGsf2ZtyTxCoKCW6GNm9umWMx8,3393
|
|
8
|
+
gemx-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
gemx-0.1.0.dist-info/entry_points.txt,sha256=NMTvUjDGNXOZOarNBSA93tpiLgyb-sARANVi0lHHDV4,39
|
|
10
|
+
gemx-0.1.0.dist-info/licenses/LICENSE,sha256=jd9AQQBkmyuf_q0BrYApY8xmkzWxEKlYDCSmlh6a31c,1080
|
|
11
|
+
gemx-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ra0x3 / Stonehedge Labs
|
|
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.
|