pointclick 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.
- pointclick/__init__.py +1 -0
- pointclick/server.py +421 -0
- pointclick/snapshot.js +112 -0
- pointclick-0.1.0.dist-info/METADATA +246 -0
- pointclick-0.1.0.dist-info/RECORD +9 -0
- pointclick-0.1.0.dist-info/WHEEL +4 -0
- pointclick-0.1.0.dist-info/entry_points.txt +2 -0
- pointclick-0.1.0.dist-info/licenses/LICENSE +21 -0
- pointclick-0.1.0.dist-info/licenses/LICENSE-jev +21 -0
pointclick/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pointclick: a browser MCP server that lists what you can click, by number."""
|
pointclick/server.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""pointclick: jev's compact indexed snapshot for the fast path, Playwright for everything else."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from mcp.server.mcpserver import Image, MCPServer
|
|
11
|
+
from playwright.async_api import Error as PWError
|
|
12
|
+
from playwright.async_api import TimeoutError as PWTimeout
|
|
13
|
+
from playwright.async_api import async_playwright
|
|
14
|
+
|
|
15
|
+
SNAPSHOT = (Path(__file__).parent / "snapshot.js").read_text()
|
|
16
|
+
OPS = {"click": "CLICK", "fill": "TYPE", "select": "SELECT"}
|
|
17
|
+
VALUE_ROLES = {"textbox", "searchbox", "combobox", "spinbutton"}
|
|
18
|
+
FINGERPRINT = (
|
|
19
|
+
"() => [location.href, document.title, document.body?.innerText.length,"
|
|
20
|
+
" document.querySelectorAll('a,button,input,select,textarea,[role]').length].join('|')"
|
|
21
|
+
)
|
|
22
|
+
# Two animation frames, or 150 ms, whichever first.
|
|
23
|
+
SETTLE = (
|
|
24
|
+
"() => new Promise(r => { let n = 0; const f = () => ++n >= 2 ? r() : requestAnimationFrame(f);"
|
|
25
|
+
" requestAnimationFrame(f); setTimeout(r, 150); })"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# After an action: done once the DOM has been still for QUIET s and no request younger than YOUNG s is
|
|
29
|
+
# pending, or after CAP s. Older requests (long-polls, streams) don't hold it up.
|
|
30
|
+
QUIET, YOUNG, CAP = 0.15, 0.5, 2.0
|
|
31
|
+
IGNORED_REQUESTS = {"image", "media", "font"}
|
|
32
|
+
|
|
33
|
+
mcp = MCPServer("pointclick", version=version("pointclick"))
|
|
34
|
+
S = {
|
|
35
|
+
"pw": None,
|
|
36
|
+
"browser": None,
|
|
37
|
+
"ctx": None,
|
|
38
|
+
"page": None,
|
|
39
|
+
"index": {},
|
|
40
|
+
"console": [],
|
|
41
|
+
"fp": None,
|
|
42
|
+
"notes": [],
|
|
43
|
+
"inflight": {},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Clients may send tool calls in parallel; one browser and one current page serve them in turn.
|
|
48
|
+
_lock = asyncio.Lock()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _one_at_a_time(tool):
|
|
52
|
+
@functools.wraps(tool)
|
|
53
|
+
async def run(*args, **kwargs):
|
|
54
|
+
async with _lock:
|
|
55
|
+
return await tool(*args, **kwargs)
|
|
56
|
+
|
|
57
|
+
return run
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _track(page):
|
|
61
|
+
# Popups (OAuth, new tabs) become the current page.
|
|
62
|
+
S["page"] = page
|
|
63
|
+
page.on("console", lambda m: _log(f"[{m.type}] {m.text}"))
|
|
64
|
+
page.on("pageerror", lambda e: _log(f"[pageerror] {e}"))
|
|
65
|
+
page.on("request", _request_started)
|
|
66
|
+
page.on("requestfinished", _request_done)
|
|
67
|
+
page.on("requestfailed", _request_done)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _request_started(request):
|
|
71
|
+
if request.resource_type not in IGNORED_REQUESTS:
|
|
72
|
+
S["inflight"][request] = asyncio.get_running_loop().time()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _request_done(request):
|
|
76
|
+
S["inflight"].pop(request, None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _log(line):
|
|
80
|
+
S["console"].append(line)
|
|
81
|
+
del S["console"][:-300]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _launch(pw):
|
|
85
|
+
# Installed Chrome first; Playwright's bundled Chromium when it's missing.
|
|
86
|
+
headless = os.environ.get("HEADED") != "1"
|
|
87
|
+
channel = os.environ.get("BROWSER_CHANNEL", "chrome")
|
|
88
|
+
if channel == "chromium":
|
|
89
|
+
return await pw.chromium.launch(headless=headless)
|
|
90
|
+
try:
|
|
91
|
+
return await pw.chromium.launch(channel=channel, headless=headless)
|
|
92
|
+
except PWError as e:
|
|
93
|
+
try:
|
|
94
|
+
return await pw.chromium.launch(headless=headless)
|
|
95
|
+
except PWError as e2:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"No browser. {channel!r}: {str(e).splitlines()[0]} Bundled Chromium: {str(e2).splitlines()[0]} "
|
|
98
|
+
"Install Chrome, or run: playwright install chromium"
|
|
99
|
+
) from None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _page():
|
|
103
|
+
if S["page"] and not S["page"].is_closed():
|
|
104
|
+
return S["page"]
|
|
105
|
+
if not S["pw"]:
|
|
106
|
+
S["pw"] = await async_playwright().start()
|
|
107
|
+
S["browser"] = await _launch(S["pw"])
|
|
108
|
+
S["ctx"] = await S["browser"].new_context(viewport={"width": 1280, "height": 800})
|
|
109
|
+
S["ctx"].on("page", _track)
|
|
110
|
+
open_pages = [p for p in S["ctx"].pages if not p.is_closed()]
|
|
111
|
+
if open_pages:
|
|
112
|
+
S["page"] = open_pages[-1]
|
|
113
|
+
return S["page"]
|
|
114
|
+
return await S["ctx"].new_page()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _settle(page):
|
|
118
|
+
try:
|
|
119
|
+
await page.wait_for_load_state("domcontentloaded", timeout=5000)
|
|
120
|
+
await page.evaluate(SETTLE)
|
|
121
|
+
except (PWError, PWTimeout):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _frame_snapshot(frame, full):
|
|
126
|
+
if frame != frame.page.main_frame:
|
|
127
|
+
box = await (await frame.frame_element()).bounding_box()
|
|
128
|
+
if not box or not box["width"] or not box["height"]:
|
|
129
|
+
return None
|
|
130
|
+
vp = frame.page.viewport_size
|
|
131
|
+
if not full and (box["y"] >= vp["height"] or box["y"] + box["height"] <= 0):
|
|
132
|
+
return None
|
|
133
|
+
return await frame.evaluate(SNAPSHOT, full)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _snapshot(full):
|
|
137
|
+
page = await _page()
|
|
138
|
+
for attempt in range(40):
|
|
139
|
+
try:
|
|
140
|
+
frames = []
|
|
141
|
+
for frame in page.frames:
|
|
142
|
+
if frame.is_detached():
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
snap = await _frame_snapshot(frame, full)
|
|
146
|
+
except PWError:
|
|
147
|
+
if frame == page.main_frame:
|
|
148
|
+
raise
|
|
149
|
+
continue
|
|
150
|
+
if snap:
|
|
151
|
+
frames.append((frame, snap))
|
|
152
|
+
if frames and frames[0][0] == page.main_frame:
|
|
153
|
+
return page, frames
|
|
154
|
+
except PWError:
|
|
155
|
+
pass
|
|
156
|
+
await asyncio.sleep(0.05)
|
|
157
|
+
raise RuntimeError("Page never settled")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _norm(label):
|
|
161
|
+
return " ".join(label.split())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _render(page, frames, full):
|
|
165
|
+
S["index"] = {}
|
|
166
|
+
main = frames[0][1]
|
|
167
|
+
lines = [f"url: {main['url']}", f"title: {main['title']}"]
|
|
168
|
+
n = 0
|
|
169
|
+
for frame, snap in frames:
|
|
170
|
+
if frame != page.main_frame:
|
|
171
|
+
lines.append(f"-- frame {snap['url'][:80]} --")
|
|
172
|
+
nodes = {}
|
|
173
|
+
for a in snap["actions"]:
|
|
174
|
+
if a["kind"] not in OPS:
|
|
175
|
+
continue
|
|
176
|
+
e = nodes.get(a["node"])
|
|
177
|
+
if e is None:
|
|
178
|
+
n += 1
|
|
179
|
+
label = _norm(a["label"].split(" → ")[0])
|
|
180
|
+
e = nodes[a["node"]] = {"i": str(n), "a": a, "label": label, "ops": [], "options": []}
|
|
181
|
+
S["index"][e["i"]] = {"frame": frame, "node": a["node"], "label": label, "options": e["options"]}
|
|
182
|
+
if OPS[a["kind"]] not in e["ops"]:
|
|
183
|
+
e["ops"].append(OPS[a["kind"]])
|
|
184
|
+
if a["kind"] == "select":
|
|
185
|
+
e["options"].append((a["value"], _norm(a["label"].split(" → ", 1)[-1])))
|
|
186
|
+
for e in nodes.values():
|
|
187
|
+
a = e["a"]
|
|
188
|
+
bits = [f"[{e['i']}]", a["role"], repr(e["label"][:120])]
|
|
189
|
+
value = a.get("current_value") if a["kind"] == "select" else a.get("value")
|
|
190
|
+
if value and a["role"] in VALUE_ROLES:
|
|
191
|
+
bits.append(f"value={value[:80]!r}")
|
|
192
|
+
bits += [k for k in ("checked", "selected", "expanded") if a.get(k) == "true"]
|
|
193
|
+
bits.append(f"<{','.join(e['ops'])}>")
|
|
194
|
+
lines.append(" ".join(bits))
|
|
195
|
+
for k, (_, label) in enumerate(e["options"], 1):
|
|
196
|
+
lines.append(f" [{e['i']}:{k}] {label}")
|
|
197
|
+
if snap.get("omitted_actions"):
|
|
198
|
+
lines.append(f"({snap['omitted_actions']} more elements omitted)")
|
|
199
|
+
scroll = main["scroll"]
|
|
200
|
+
if not full and scroll["y"] + main["h"] < scroll["height"] - 2:
|
|
201
|
+
lines.append(f"(more below: {scroll['height'] - scroll['y'] - main['h']}px; SCROLL_DOWN or observe(full=true))")
|
|
202
|
+
text = (main.get("text") or "").strip()
|
|
203
|
+
if text:
|
|
204
|
+
lines += ["", "text:", text[: 4000 if full else 1500]]
|
|
205
|
+
return "\n".join(lines)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def _fingerprint(page):
|
|
209
|
+
try:
|
|
210
|
+
return await page.evaluate(FINGERPRINT)
|
|
211
|
+
except PWError:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _observe(full=False):
|
|
216
|
+
page, frames = await _snapshot(full)
|
|
217
|
+
S["fp"] = await _fingerprint(page)
|
|
218
|
+
return _render(page, frames, full)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _quiet(page):
|
|
222
|
+
loop = asyncio.get_running_loop()
|
|
223
|
+
deadline = loop.time() + CAP
|
|
224
|
+
last, changed = await _fingerprint(page), loop.time()
|
|
225
|
+
while loop.time() < deadline:
|
|
226
|
+
await asyncio.sleep(0.05)
|
|
227
|
+
fp, now = await _fingerprint(page), loop.time()
|
|
228
|
+
if fp != last:
|
|
229
|
+
last, changed = fp, now
|
|
230
|
+
elif now - changed >= QUIET and not any(now - t < YOUNG for t in S["inflight"].values()):
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def _wait_for_change(page, limit=3.0):
|
|
235
|
+
# Against the last observe, so a change that already landed returns at once.
|
|
236
|
+
deadline = asyncio.get_running_loop().time() + limit
|
|
237
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
238
|
+
if await _fingerprint(page) != S["fp"]:
|
|
239
|
+
return
|
|
240
|
+
await asyncio.sleep(0.1)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Stale(Exception):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
async def _resolve(target):
|
|
248
|
+
"""Index from the last observe ("3", or "3:2" for a select option), or any Playwright selector."""
|
|
249
|
+
page = await _page()
|
|
250
|
+
idx, _, opt = target.partition(":")
|
|
251
|
+
if idx.isdigit() and (not opt or opt.isdigit()):
|
|
252
|
+
entry = S["index"].get(idx)
|
|
253
|
+
if not entry:
|
|
254
|
+
raise Stale(f"No element [{idx}] in the last observe.")
|
|
255
|
+
el = await _element(entry)
|
|
256
|
+
if el is None:
|
|
257
|
+
# Re-rendered (framework swapped the node). Only follow it when the label is unambiguous.
|
|
258
|
+
await _observe()
|
|
259
|
+
same = [k for k, v in S["index"].items() if v["label"] == entry["label"] and v["frame"] == entry["frame"]]
|
|
260
|
+
if len(same) != 1:
|
|
261
|
+
raise Stale(f"[{idx}] is gone from the page.")
|
|
262
|
+
idx, entry = same[0], S["index"][same[0]]
|
|
263
|
+
el = await _element(entry)
|
|
264
|
+
if el is None:
|
|
265
|
+
raise Stale(f"[{idx}] is gone from the page.")
|
|
266
|
+
S["notes"].append(f"The element was re-rendered; used [{idx}] {entry['label']!r}.")
|
|
267
|
+
option = entry["options"][int(opt) - 1][0] if opt else None
|
|
268
|
+
return el, option
|
|
269
|
+
return page.locator(target).first, None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def _element(entry):
|
|
273
|
+
handle = await entry["frame"].evaluate_handle(
|
|
274
|
+
"id => { const e = window.__jevFast?.nodes.get(id); return e?.isConnected ? e : null; }", entry["node"]
|
|
275
|
+
)
|
|
276
|
+
return handle.as_element()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def _run(coro):
|
|
280
|
+
page = await _page()
|
|
281
|
+
S["notes"] = []
|
|
282
|
+
before = await _fingerprint(page)
|
|
283
|
+
try:
|
|
284
|
+
await coro
|
|
285
|
+
except Stale as e:
|
|
286
|
+
return f"Stale: {e} Fresh table:\n\n" + await _observe()
|
|
287
|
+
except ValueError as e:
|
|
288
|
+
return f"Error: {e}\n\n" + await _observe()
|
|
289
|
+
except (PWError, PWTimeout) as e:
|
|
290
|
+
if before is None or await _fingerprint(page) == before:
|
|
291
|
+
return f"Failed: {str(e).splitlines()[0][:300]}\n\n" + await _observe()
|
|
292
|
+
S["notes"].append(
|
|
293
|
+
"The action timed out, but the page changed since. Check whether it took effect before retrying."
|
|
294
|
+
)
|
|
295
|
+
await _settle(page)
|
|
296
|
+
await _quiet(page)
|
|
297
|
+
table = await _observe()
|
|
298
|
+
return "".join(f"Note: {n}\n" for n in S["notes"]) + ("\n" if S["notes"] else "") + table
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@mcp.tool()
|
|
302
|
+
@_one_at_a_time
|
|
303
|
+
async def navigate(url: str) -> str:
|
|
304
|
+
"""Open url and return the indexed element table."""
|
|
305
|
+
page = await _page()
|
|
306
|
+
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
|
307
|
+
await _settle(page)
|
|
308
|
+
await _quiet(page)
|
|
309
|
+
return await _observe()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@mcp.tool()
|
|
313
|
+
@_one_at_a_time
|
|
314
|
+
async def observe(full: bool = False) -> str:
|
|
315
|
+
"""Indexed table of interactive elements (all frames) plus visible text.
|
|
316
|
+
Default: viewport only (compact). full=true: whole page."""
|
|
317
|
+
return await _observe(full)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@mcp.tool()
|
|
321
|
+
@_one_at_a_time
|
|
322
|
+
async def act(operation: str, target: str = "", text: str = "") -> str:
|
|
323
|
+
"""Do one thing, then return the fresh table.
|
|
324
|
+
operation: CLICK | TYPE | SELECT | PRESS | HOVER | SCROLL_DOWN | SCROLL_UP | WAIT.
|
|
325
|
+
Returns once the page settles (max 2s). If it is still loading after that, WAIT (returns on change, max 3s).
|
|
326
|
+
target: index from the table ("3"; "3:2" picks a SELECT option), or a Playwright selector
|
|
327
|
+
("input[type=password]", "text=Send", "role=button[name='OK']") for anything not in the table.
|
|
328
|
+
text: TYPE value, SELECT option label (instead of "3:2"), or PRESS key ("Enter")."""
|
|
329
|
+
op = operation.upper().replace("TYPE_TEXT", "TYPE")
|
|
330
|
+
page = await _page()
|
|
331
|
+
|
|
332
|
+
async def do():
|
|
333
|
+
if op in ("SCROLL_DOWN", "SCROLL_UP"):
|
|
334
|
+
await page.mouse.wheel(0, 600 if op == "SCROLL_DOWN" else -600)
|
|
335
|
+
return
|
|
336
|
+
if op == "WAIT":
|
|
337
|
+
await _wait_for_change(page)
|
|
338
|
+
return
|
|
339
|
+
if op == "PRESS" and not target:
|
|
340
|
+
await page.keyboard.press(text)
|
|
341
|
+
return
|
|
342
|
+
if not target:
|
|
343
|
+
raise ValueError(f"{op} needs a target.")
|
|
344
|
+
el, option = await _resolve(target)
|
|
345
|
+
if op == "CLICK":
|
|
346
|
+
await el.click(timeout=5000, no_wait_after=True)
|
|
347
|
+
elif op == "TYPE":
|
|
348
|
+
await el.fill(text, timeout=5000)
|
|
349
|
+
elif op == "SELECT" and option:
|
|
350
|
+
await el.select_option(value=option, timeout=5000)
|
|
351
|
+
elif op == "SELECT":
|
|
352
|
+
await el.select_option(label=text, timeout=5000)
|
|
353
|
+
elif op == "PRESS":
|
|
354
|
+
await el.press(text, timeout=5000, no_wait_after=True)
|
|
355
|
+
elif op == "HOVER":
|
|
356
|
+
await el.hover(timeout=5000)
|
|
357
|
+
else:
|
|
358
|
+
raise ValueError(f"Unknown operation {operation!r}.")
|
|
359
|
+
|
|
360
|
+
return await _run(do())
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@mcp.tool()
|
|
364
|
+
@_one_at_a_time
|
|
365
|
+
async def upload(target: str, paths: list[str]) -> str:
|
|
366
|
+
"""Set files on a file input. target: selector (file inputs are not in the table), e.g. "input[type=file]"."""
|
|
367
|
+
|
|
368
|
+
async def do():
|
|
369
|
+
el, _ = await _resolve(target)
|
|
370
|
+
await el.set_input_files(paths, timeout=5000)
|
|
371
|
+
|
|
372
|
+
return await _run(do())
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@mcp.tool()
|
|
376
|
+
@_one_at_a_time
|
|
377
|
+
async def screenshot(full_page: bool = False) -> Image:
|
|
378
|
+
"""JPEG of the current page."""
|
|
379
|
+
page = await _page()
|
|
380
|
+
return Image(data=await page.screenshot(type="jpeg", quality=70, full_page=full_page), format="jpeg")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@mcp.tool()
|
|
384
|
+
@_one_at_a_time
|
|
385
|
+
async def evaluate(js: str, max_chars: int = 6000) -> str:
|
|
386
|
+
"""Run JS in the page: an expression, or a function like "() => document.title".
|
|
387
|
+
Returns JSON, cut at max_chars (0: no limit)."""
|
|
388
|
+
page = await _page()
|
|
389
|
+
out = json.dumps(await page.evaluate(js), ensure_ascii=False, default=str)
|
|
390
|
+
if max_chars and len(out) > max_chars:
|
|
391
|
+
return f"{out[:max_chars]}\n(cut at {max_chars} of {len(out)} chars; max_chars=0 returns all of it)"
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@mcp.tool()
|
|
396
|
+
@_one_at_a_time
|
|
397
|
+
async def console(clear: bool = True) -> str:
|
|
398
|
+
"""Console messages and page errors since the last call."""
|
|
399
|
+
lines = S["console"][-100:]
|
|
400
|
+
if clear:
|
|
401
|
+
S["console"].clear()
|
|
402
|
+
return "\n".join(lines) or "(empty)"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@mcp.tool()
|
|
406
|
+
@_one_at_a_time
|
|
407
|
+
async def close() -> str:
|
|
408
|
+
"""Close the browser. The next call starts a fresh, empty session."""
|
|
409
|
+
if S["browser"]:
|
|
410
|
+
await S["browser"].close()
|
|
411
|
+
await S["pw"].stop()
|
|
412
|
+
S.update(pw=None, browser=None, ctx=None, page=None, index={}, console=[], fp=None, notes=[], inflight={})
|
|
413
|
+
return "closed"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def main():
|
|
417
|
+
mcp.run()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
if __name__ == "__main__":
|
|
421
|
+
main()
|
pointclick/snapshot.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Adapted from browser-use/jev-ultrafast (MIT, see LICENSE-jev). Changes:
|
|
2
|
+
// `full` lifts the viewport filter; a control repeating its wrapper's label is dropped;
|
|
3
|
+
// a <button> is named by its text, not its value.
|
|
4
|
+
(full => {
|
|
5
|
+
if (!document.body) return null;
|
|
6
|
+
const cache = window.__jevFast ||= {ids:new WeakMap(), nodes:new Map(), next:1};
|
|
7
|
+
const identity = e => {
|
|
8
|
+
if (!cache.ids.has(e)) cache.ids.set(e,cache.next++);
|
|
9
|
+
const id=cache.ids.get(e); cache.nodes.set(id,e); return id;
|
|
10
|
+
};
|
|
11
|
+
for (const [id,e] of cache.nodes) if (!e.isConnected) cache.nodes.delete(id);
|
|
12
|
+
const safe = e => !['password','file','hidden'].includes(e.type);
|
|
13
|
+
const visible = e => !e.closest('[aria-hidden="true"],[inert]') &&
|
|
14
|
+
e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});
|
|
15
|
+
const name = (e,seen=new Set()) => {
|
|
16
|
+
if (!e || seen.has(e)) return '';
|
|
17
|
+
seen.add(e);
|
|
18
|
+
const referenced=(e.getAttribute('aria-labelledby')||'').split(/\s+/)
|
|
19
|
+
.map(id=>name(document.getElementById(id),seen)).filter(Boolean).join(' ');
|
|
20
|
+
return referenced || e.getAttribute('aria-label') ||
|
|
21
|
+
[...(e.labels||[])].map(l=>name(l,seen)).filter(Boolean).join(' ') ||
|
|
22
|
+
(e.tagName==='INPUT' && ['button','submit','reset'].includes(e.type) ? e.value : '') || e.getAttribute('alt') ||
|
|
23
|
+
(e.tagName==='INPUT' ? '' : [...e.childNodes].map(n=>n.nodeType===3 ? n.textContent :
|
|
24
|
+
n.nodeType===1 && n.getAttribute('aria-hidden')!=='true' ? name(n,seen) : '').join(' ').trim()) ||
|
|
25
|
+
e.getAttribute('title') || e.getAttribute('placeholder') || '';
|
|
26
|
+
};
|
|
27
|
+
const roles=['button','link','checkbox','radio','switch','tab','menuitem','menuitemradio',
|
|
28
|
+
'option','gridcell','combobox','textbox','searchbox','spinbutton'];
|
|
29
|
+
const selector='a[href],button,input,textarea,select,summary,[contenteditable="true"],'+
|
|
30
|
+
roles.map(role=>'[role="'+role+'"]').join(',');
|
|
31
|
+
const role = e => {
|
|
32
|
+
const explicit=e.getAttribute('role');
|
|
33
|
+
if (roles.includes(explicit)) return explicit;
|
|
34
|
+
if (e.tagName==='BUTTON' || e.tagName==='SUMMARY') return 'button';
|
|
35
|
+
if (e.tagName==='A') return 'link';
|
|
36
|
+
if (e.tagName==='SELECT') return 'combobox';
|
|
37
|
+
if (e.tagName==='TEXTAREA' || e.isContentEditable) return 'textbox';
|
|
38
|
+
if (e.tagName==='INPUT') {
|
|
39
|
+
if (['checkbox','radio'].includes(e.type)) return e.type;
|
|
40
|
+
if (['button','submit','reset','image'].includes(e.type)) return 'button';
|
|
41
|
+
if (e.type==='search') return 'searchbox';
|
|
42
|
+
if (e.type==='number') return 'spinbutton';
|
|
43
|
+
if (['text','email','url','tel'].includes(e.type)) return 'textbox';
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
cache.pageKey=()=>[performance.timeOrigin,location.href,scrollX,scrollY,innerWidth,innerHeight,
|
|
48
|
+
[...document.querySelectorAll('input,textarea,select')].filter(safe)
|
|
49
|
+
.map(e=>[identity(e),e.value,e.checked,e.selectedIndex,e.disabled,e.readOnly])];
|
|
50
|
+
cache.guard=e=>{
|
|
51
|
+
if (!e?.isConnected || !visible(e)) return null;
|
|
52
|
+
const scope=e.closest('form,dialog,[role="dialog"],article,li,tr,[role="row"]') || e.parentElement;
|
|
53
|
+
return [identity(e),role(e),name(e),e.value??null,e.checked??null,e.selectedIndex??null,
|
|
54
|
+
e.readOnly??null,e.matches(':disabled'),e.getAttribute('aria-disabled'),
|
|
55
|
+
e.getAttribute('aria-expanded'),e.getAttribute('aria-checked'),e.getAttribute('aria-selected'),
|
|
56
|
+
e.getAttribute('href'),scope?.innerText?.slice(0,6000)||''];
|
|
57
|
+
};
|
|
58
|
+
const actions=[];
|
|
59
|
+
for (const e of document.querySelectorAll(selector)) {
|
|
60
|
+
if (!safe(e) || !visible(e) || e.matches(':disabled') || e.closest('[aria-disabled="true"]')) continue;
|
|
61
|
+
const r=e.getBoundingClientRect(), x=r.x+r.width/2, y=r.y+r.height/2, rname=role(e);
|
|
62
|
+
if (!rname || r.width<=0 || r.height<=0 || (!full && (x<0 || y<0 || x>=innerWidth || y>=innerHeight))) continue;
|
|
63
|
+
if (rname==='gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
64
|
+
const outer=e.parentElement?.closest(selector);
|
|
65
|
+
if (outer && name(outer).replace(/\s+/g,' ').trim()===name(e).replace(/\s+/g,' ').trim()) continue;
|
|
66
|
+
const base={node:identity(e),role:rname,label:name(e)||rname,
|
|
67
|
+
rect:{x:r.x,y:r.y,w:r.width,h:r.height}};
|
|
68
|
+
for (const key of ['checked','selected','expanded']) {
|
|
69
|
+
const value=e.getAttribute('aria-'+key);
|
|
70
|
+
if (value!==null) base[key]=value;
|
|
71
|
+
}
|
|
72
|
+
if (['checkbox','radio'].includes(e.type)) base.checked=String(e.checked);
|
|
73
|
+
if (e.tagName==='SELECT') {
|
|
74
|
+
for (const o of e.options) if (!o.selected && !o.disabled && !o.closest('optgroup[disabled]'))
|
|
75
|
+
actions.push({...base,kind:'select',value:o.value,
|
|
76
|
+
current_value:[...e.selectedOptions].map(o=>o.label).join(', '),label:base.label+' → '+o.label});
|
|
77
|
+
} else {
|
|
78
|
+
const editable=!e.readOnly && e.getAttribute('aria-readonly')!=='true' &&
|
|
79
|
+
(['textbox','searchbox','spinbutton'].includes(rname) ||
|
|
80
|
+
(rname==='combobox' && ['INPUT','TEXTAREA'].includes(e.tagName)));
|
|
81
|
+
const value='value' in e ? String(e.value) :
|
|
82
|
+
e.isContentEditable || rname==='combobox' ? e.innerText.trim() : '';
|
|
83
|
+
actions.push({...base,kind:editable?'fill':'click',value});
|
|
84
|
+
if (editable) actions.push({...base,kind:'click',value,label:'Open '+base.label});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const words=[], walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);
|
|
88
|
+
const range=document.createRange(); let node,length=0;
|
|
89
|
+
while ((node=walker.nextNode()) && length<6000) {
|
|
90
|
+
const value=node.textContent.trim(), parent=node.parentElement;
|
|
91
|
+
if (!value || !parent || parent.closest('script,style,noscript,template') || !visible(parent)) continue;
|
|
92
|
+
range.selectNodeContents(node); const r=range.getBoundingClientRect();
|
|
93
|
+
if (r.width>0 && r.height>0 && (full || (r.bottom>0 && r.top<innerHeight && r.right>0 && r.left<innerWidth))) {
|
|
94
|
+
words.push(value); length+=value.length;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const text=words.join('\n').slice(0,6000), height=document.documentElement.scrollHeight;
|
|
98
|
+
const page_key=cache.pageKey(), guards={};
|
|
99
|
+
for (const a of actions) if (!(a.node in guards)) guards[a.node]=cache.guard(cache.nodes.get(a.node));
|
|
100
|
+
// Compare meaning and identity. Geometry is always resolved and hit-tested just before input.
|
|
101
|
+
const semantics=actions.map(({rect,...action})=>action);
|
|
102
|
+
const marker=[performance.timeOrigin,location.href,scrollX,scrollY,innerWidth,innerHeight,
|
|
103
|
+
document.title,text,semantics,page_key[6]];
|
|
104
|
+
const omitted_actions=Math.max(0,actions.length-250);
|
|
105
|
+
actions.splice(250);
|
|
106
|
+
actions.forEach((a,i)=>a.id='e'+(i+1));
|
|
107
|
+
if (scrollY+innerHeight<height-2) actions.push({id:'scroll_down',kind:'scroll',label:'Scroll down',delta:560});
|
|
108
|
+
if (scrollY>0) actions.push({id:'scroll_up',kind:'scroll',label:'Scroll up',delta:-560});
|
|
109
|
+
actions.push({id:'wait',kind:'wait',label:'Wait for the page to update'});
|
|
110
|
+
return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,text,
|
|
111
|
+
scroll:{y:scrollY,height},actions,marker,page_key,guards,omitted_actions};
|
|
112
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pointclick
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A browser MCP server with a small footprint: a numbered list of what you can click, and eight tools.
|
|
5
|
+
Project-URL: Homepage, https://github.com/dashgin/pointclick
|
|
6
|
+
Project-URL: Issues, https://github.com/dashgin/pointclick/issues
|
|
7
|
+
Author-email: Dashgin <dashgin@praxis.az>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
License-File: LICENSE-jev
|
|
11
|
+
Keywords: agent,automation,browser,mcp,playwright
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: mcp>=2
|
|
19
|
+
Requires-Dist: playwright>=1.50
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# pointclick
|
|
23
|
+
|
|
24
|
+
**A browser MCP server in under 1,000 tokens.** The page comes back as a numbered list of what
|
|
25
|
+
you can click, type into or select. Your agent points at a number; pointclick clicks it.
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/pointclick/)
|
|
28
|
+
[](https://github.com/dashgin/pointclick/actions/workflows/ci.yml)
|
|
29
|
+
[](LICENSE)
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
url: https://en.wikipedia.org/wiki/Main_Page
|
|
33
|
+
title: Wikipedia, the free encyclopedia
|
|
34
|
+
[1] link 'Wikipedia The Free Encyclopedia' <CLICK>
|
|
35
|
+
[2] searchbox 'Search Wikipedia' <TYPE,CLICK>
|
|
36
|
+
[3] button 'Search' <CLICK>
|
|
37
|
+
...
|
|
38
|
+
(more below: 3156px; SCROLL_DOWN or observe(full=true))
|
|
39
|
+
|
|
40
|
+
text:
|
|
41
|
+
Welcome to
|
|
42
|
+
Wikipedia
|
|
43
|
+
...
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
act("TYPE", "2", "Ada Lovelace") → the new list, once the page has settled
|
|
48
|
+
act("PRESS", "2", "Enter")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Why
|
|
52
|
+
|
|
53
|
+
Same task, same model (Claude Sonnet 5 in headless Claude Code), one browser MCP each —
|
|
54
|
+
sign in, search, sort, open a record, read a number off it. 18 of 18 runs got it right.
|
|
55
|
+
|
|
56
|
+
| | turns | tokens read | cost per run | time |
|
|
57
|
+
|---|---|---|---|---|
|
|
58
|
+
| **pointclick** | **7** | **104k** | **$0.056** | **15.8 s** |
|
|
59
|
+
| Playwright MCP | 13 | 266k | $0.118 | 27.5 s |
|
|
60
|
+
| agent-browser | 12 | 525k | $0.223 | 17.8 s |
|
|
61
|
+
|
|
62
|
+
Medians. Cost is the API price Claude Code reports; time is noisy at this sample size.
|
|
63
|
+
|
|
64
|
+
- **Eight tools, under 1k tokens of definitions.** Playwright MCP's are ~5k, Chrome DevTools MCP's
|
|
65
|
+
~7k, agent-browser's ~18k. Clients that load every tool up front pay that on every turn.
|
|
66
|
+
- **Only what you can act on.** No wrapper `div`s, no layout tree: controls, their state, and the
|
|
67
|
+
visible text.
|
|
68
|
+
- **Every action returns the settled page.** No follow-up snapshot call, and no waiting for the
|
|
69
|
+
network to go quiet: long-polls and streams don't stall it.
|
|
70
|
+
|
|
71
|
+
## Quick start
|
|
72
|
+
|
|
73
|
+
Needs Python 3.12+ and [uv](https://docs.astral.sh/uv/). Uses your installed Chrome, or
|
|
74
|
+
Playwright's Chromium if there is none (`uvx --from pointclick playwright install chromium`).
|
|
75
|
+
|
|
76
|
+
**Claude Code**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
claude mcp add pointclick -- uvx pointclick
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Cursor, Claude Desktop, Windsurf, and other clients** — add to the MCP config:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"mcpServers": {
|
|
87
|
+
"pointclick": { "command": "uvx", "args": ["pointclick"] }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**VS Code**
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
code --add-mcp '{"name":"pointclick","command":"uvx","args":["pointclick"]}'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Codex**
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
codex mcp add pointclick -- uvx pointclick
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Your first prompt
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
Open news.ycombinator.com, go to the second page, and tell me the top story there.
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Tools
|
|
111
|
+
|
|
112
|
+
| Tool | |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `navigate(url)` | Open a page, return the list |
|
|
115
|
+
| `observe(full=false)` | The list again. Viewport only unless `full` |
|
|
116
|
+
| `act(operation, target, text)` | `CLICK` `TYPE` `SELECT` `PRESS` `HOVER` `SCROLL_DOWN` `SCROLL_UP` `WAIT` |
|
|
117
|
+
| `upload(target, paths)` | Set files on a file input |
|
|
118
|
+
| `screenshot(full_page=false)` | JPEG |
|
|
119
|
+
| `evaluate(js, max_chars=6000)` | Run JS in the page, JSON back. A longer result is cut and says so; `0` for all of it |
|
|
120
|
+
| `console()` | Console messages and page errors since the last call |
|
|
121
|
+
| `close()` | End the session; the next call starts fresh |
|
|
122
|
+
|
|
123
|
+
`target` is a number from the list (`"3"`, or `"3:2"` for the second option of a select), or any
|
|
124
|
+
Playwright selector (`"input[type=password]"`, `"text=Send"`, `"role=button[name='OK']"`).
|
|
125
|
+
Password and file inputs are never in the list; reach them by selector.
|
|
126
|
+
|
|
127
|
+
| Env | |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `HEADED=1` | Show the window |
|
|
130
|
+
| `BROWSER_CHANNEL` | `chrome` (default), `msedge`, or `chromium` for Playwright's bundled build |
|
|
131
|
+
|
|
132
|
+
## How it works
|
|
133
|
+
|
|
134
|
+
- **The list** comes from one script that reads every visible control and the visible text in a
|
|
135
|
+
single pass — adapted from [jev-ultrafast](https://github.com/browser-use/jev-ultrafast)
|
|
136
|
+
(Browser Use × TypeSafe). It runs in every frame, cross-origin ones included, and a control that
|
|
137
|
+
only repeats its wrapper's label is listed once.
|
|
138
|
+
- **Settling.** After an action, pointclick returns once the page has been still for 150 ms and
|
|
139
|
+
no request younger than 0.5 s is pending — at most 2 s. Older requests (long-polls, streams)
|
|
140
|
+
don't hold it up. If the page is still busy after that, `WAIT` returns the moment it changes.
|
|
141
|
+
- **Numbers point at real elements**, not at a position. If a framework re-renders the element,
|
|
142
|
+
pointclick follows it when its label is unique on the page and says so; a duplicate label (two
|
|
143
|
+
"Start" buttons) is refused rather than guessed.
|
|
144
|
+
- **A timeout on a page that changed anyway** is reported that way, so the agent checks before
|
|
145
|
+
retrying instead of clicking twice.
|
|
146
|
+
- **Playwright underneath** handles selectors, uploads, popups (they become the current page),
|
|
147
|
+
screenshots and the console. Every session is a fresh, isolated browser context.
|
|
148
|
+
|
|
149
|
+
`server.py` is ~350 lines and `snapshot.js` ~110. Small enough to read before you trust it.
|
|
150
|
+
|
|
151
|
+
## Benchmarks
|
|
152
|
+
|
|
153
|
+
`bench/` has a small SPA shaped like a real dashboard — demo login, a list with search and sort, a
|
|
154
|
+
detail page, API calls over the network, and an optional long-poll like a realtime fallback.
|
|
155
|
+
|
|
156
|
+
**With a real agent** — headless Claude Code, built-in tools off, the task given in plain words:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
uv run python bench/agent_race.py --runs 3 --model sonnet
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| | correct | turns | tokens read | tokens written | cost | time, no long-poll | time, long-poll open |
|
|
163
|
+
|---|---|---|---|---|---|---|---|
|
|
164
|
+
| **pointclick** | 6/6 | **7** | **104k** | **620–660** | **$0.056** | **13.7 s** | 16.7 s |
|
|
165
|
+
| Playwright MCP 0.0.81 | 6/6 | 13 | 266–289k | 1.1k | $0.113–0.118 | 23.3 s | 28.5 s |
|
|
166
|
+
| agent-browser 0.38.1 | 6/6 | 12 | 525k | 760–780 | $0.223 | 19.1 s | **14.8 s** |
|
|
167
|
+
|
|
168
|
+
Medians of 3 runs per column. Turns and tokens barely move between runs; time moves by up to a
|
|
169
|
+
third with model latency, so read it as a range. Playwright MCP's action replies link to a
|
|
170
|
+
snapshot file instead of including the page, so the model asks for a snapshot after each step.
|
|
171
|
+
agent-browser's actions return nothing, so every step is an action plus a snapshot too.
|
|
172
|
+
|
|
173
|
+
**Tools alone** — the same flow driven by a script, so no model time is included:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
uv run python bench/race.py --runs 8 --poll 0
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
| | calls | text to the model | tool time | with a long-poll open |
|
|
180
|
+
|---|---|---|---|---|
|
|
181
|
+
| **pointclick** | **6** | **6.6k chars (~1.9k tokens)** | 2.25 s | **2.31 s** |
|
|
182
|
+
| Playwright MCP 0.0.81 | 6 | 17.0k chars (~4.9k tokens) | 3.32 s | 8.27 s |
|
|
183
|
+
| agent-browser 0.38.1 | 18 | 12.3k chars (~3.5k tokens) | **1.13 s** | 1.12 s |
|
|
184
|
+
|
|
185
|
+
agent-browser's tool time is lowest because its actions return nothing: it re-snapshots, and the
|
|
186
|
+
script polls for free. In a real agent every one of those calls is a model turn.
|
|
187
|
+
|
|
188
|
+
**Tool definitions**, sent with every request by clients that don't load tools lazily (all four
|
|
189
|
+
estimated the same way, JSON characters ÷ 3.5):
|
|
190
|
+
|
|
191
|
+
| | tools | definitions |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| **pointclick** | **8** | **~0.8k tokens** |
|
|
194
|
+
| Playwright MCP | 26 | ~5.2k tokens |
|
|
195
|
+
| Chrome DevTools MCP | 29 | ~6.9k tokens |
|
|
196
|
+
| agent-browser (`core`) | 29 | ~18.4k tokens |
|
|
197
|
+
|
|
198
|
+
Measured on an M-series Mac with Chrome 153. Raw results are in `bench/*.json`.
|
|
199
|
+
|
|
200
|
+
## When to use something else
|
|
201
|
+
|
|
202
|
+
- **Playwright MCP** — you need network interception, tracing, PDF export, or the full Playwright
|
|
203
|
+
surface.
|
|
204
|
+
- **Chrome DevTools MCP** — performance traces, Lighthouse-style audits, deep DevTools debugging.
|
|
205
|
+
- **agent-browser** — you'd rather drive the browser from a shell (CLI + skills), want saved auth
|
|
206
|
+
profiles, or need its long tail of commands.
|
|
207
|
+
- **Stagehand** — you want natural-language `act`/`extract` backed by its own model, or hosted
|
|
208
|
+
browsers on Browserbase.
|
|
209
|
+
|
|
210
|
+
pointclick does one job: get an agent through a web app with as little text and as few turns as
|
|
211
|
+
it can — locally, with no account and no second model.
|
|
212
|
+
|
|
213
|
+
## Security
|
|
214
|
+
|
|
215
|
+
- Everything on a page — text, labels, titles — is **untrusted input** to your model. A page can
|
|
216
|
+
try to instruct your agent. Keep the agent's permissions narrow and confirm consequential steps.
|
|
217
|
+
- Each session is an isolated browser context: no access to your Chrome profile, cookies or
|
|
218
|
+
passwords. Nothing persists after `close()`.
|
|
219
|
+
- `evaluate` runs arbitrary JavaScript in the page; `upload` reads files you name from disk.
|
|
220
|
+
|
|
221
|
+
## Limits
|
|
222
|
+
|
|
223
|
+
- The list is viewport-only by default; long pages take `SCROLL_DOWN` or `observe(full=true)`.
|
|
224
|
+
- Every action returns the whole list, not a diff.
|
|
225
|
+
- Chromium only, one page at a time per server. Calls sent in parallel run one after another.
|
|
226
|
+
- No network inspection, PDF export or dialog control; `evaluate` covers some of it.
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
uv sync --group dev
|
|
232
|
+
uv run pytest # 45 tests; fixture pages served from two local origins
|
|
233
|
+
uv run ruff check . && uv run ruff format --check .
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
To release, bump `version` in `pyproject.toml`, then push a matching tag (`v0.1.0`). `release.yml` publishes
|
|
237
|
+
to PyPI through trusted publishing; there is no token to keep.
|
|
238
|
+
|
|
239
|
+
## Credits
|
|
240
|
+
|
|
241
|
+
The page snapshot is adapted from [jev-ultrafast](https://github.com/browser-use/jev-ultrafast),
|
|
242
|
+
MIT © 2026 Browser Use — see `LICENSE-jev`.
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pointclick/__init__.py,sha256=hyKRHYhJ1P99GUL5bd4Efz7Pi6RsbIh4B0wJNO5eI9k,81
|
|
2
|
+
pointclick/server.py,sha256=u5OxOto8RaLUrEg7IWJiEb3NFvQkootabMuoO-vkEZ4,14722
|
|
3
|
+
pointclick/snapshot.js,sha256=UESumNjTyLgiZLZa_-zX_EuHFPOHKiLygoAxiHEqFFk,6909
|
|
4
|
+
pointclick-0.1.0.dist-info/METADATA,sha256=jgcJG4EOEF3s12dLu4vfsvNRfdeLLnj_rGRVbZ5jxng,10032
|
|
5
|
+
pointclick-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
pointclick-0.1.0.dist-info/entry_points.txt,sha256=XrS2c2Q40Pon8wHbfpaf0T72SEm5-QD8F0JrUtqsFQo,54
|
|
7
|
+
pointclick-0.1.0.dist-info/licenses/LICENSE,sha256=H2xzjbUsz8hg3tHkGMEuI78ezi6Bl0sjMV4XqR-ujJw,1064
|
|
8
|
+
pointclick-0.1.0.dist-info/licenses/LICENSE-jev,sha256=Wvo9l78eb5mNWH-hAeP-DQxBaEC3_ne81q6XaQozRjE,1068
|
|
9
|
+
pointclick-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dashgin
|
|
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.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Browser Use
|
|
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.
|