Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Jev NoLayout
# Jev Browser Bridge

## Plug **any** browser into Jev.

Expand All @@ -10,7 +10,7 @@
If it speaks CDP, it runs Jev.

<p align="center">
<img src="assets/overview.png" alt="Jev NoLayout: a bridge from any CDP browser to Jev" width="100%">
<img src="assets/overview.png" alt="Jev Browser Bridge: a bridge from any CDP browser to Jev" width="100%">
</p>

## Results
Expand All @@ -34,7 +34,7 @@ If it speaks CDP, it runs Jev.
**Moli**, a cloud browser from Lexmount:

```python
from jev_nolayout import Agent, lexmount_session
from jev_browser_bridge import Agent, lexmount_session

with lexmount_session() as browser:
browser.navigate("https://en.wikipedia.org/wiki/Espresso")
Expand All @@ -49,7 +49,7 @@ lightpanda serve --port 9222
```

```python
from jev_nolayout import Agent, connect
from jev_browser_bridge import Agent, connect

with connect("http://127.0.0.1:9222") as browser:
browser.navigate("https://en.wikipedia.org/wiki/Espresso")
Expand All @@ -61,9 +61,9 @@ Any other browser works the same way: pass its CDP address to `connect()`.

## How it works

Most browser-agent frameworks decide what is on a page by asking the **layout engine**. Jev NoLayout asks the **DOM**.
Most browser-agent frameworks decide what is on a page by asking the **layout engine**. Jev Browser Bridge asks the **DOM**.

| | Position-based reader | Jev NoLayout |
| | Position-based reader | Jev Browser Bridge |
| --- | --- | --- |
| Is this control live? | `checkVisibility()` | `hidden`, `inert`, `aria-hidden`, `disabled` |
| Can the agent reach it? | inside the viewport, by `getBoundingClientRect()` | anywhere in the document |
Expand All @@ -72,7 +72,7 @@ Most browser-agent frameworks decide what is on a page by asking the **layout en

On Chrome both work. On a browser whose layout is lazy, missing or fake, only one of them does — same page, controls / characters read:

| Browser | Position-based | Jev NoLayout |
| Browser | Position-based | Jev Browser Bridge |
| --- | --- | --- |
| Moli · Google Flights | **5 / 7** | 155 / 34,189 |
| Lightpanda · Wikipedia | **18 / 221** | 2,875 / 137,723 |
Expand All @@ -90,12 +90,12 @@ On Chrome both work. On a browser whose layout is lazy, missing or fake, only on
## Try it

```bash
git clone https://github.com/lexmount/jev-nolayout.git
cd jev-nolayout
git clone https://github.com/lexmount/jev-browser-bridge.git
cd jev-browser-bridge
uv sync --extra lexmount
cp .env.example .env # JEV_API_KEY, and Lexmount credentials for Moli

uv run jev-nolayout https://en.wikipedia.org/wiki/Espresso "Open the article about Latte"
uv run jev-browser-bridge https://en.wikipedia.org/wiki/Espresso "Open the article about Latte"
```

```text
Expand Down
Binary file modified assets/overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions examples/flights.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from jev_nolayout import Agent, moli_session # noqa: E402
from jev_nolayout.cli import load_env # noqa: E402
from jev_browser_bridge import Agent, moli_session # noqa: E402
from jev_browser_bridge.cli import load_env # noqa: E402

URL = "https://www.google.com/travel/flights?hl=en"
DEPART = os.environ.get("DEPART_ON", "September 27, 2026")
Expand Down
4 changes: 2 additions & 2 deletions examples/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
For any other browser, replace `lexmount_session()` with
`connect("http://127.0.0.1:9222")` (or a ws:// URL) -- nothing else changes.
"""
from jev_nolayout import Agent, lexmount_session
from jev_nolayout.cli import load_env
from jev_browser_bridge import Agent, lexmount_session
from jev_browser_bridge.cli import load_env

load_env()

Expand Down
7 changes: 7 additions & 0 deletions jev_browser_bridge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""A browser agent that never asks the layout engine anything."""
from .agent import Agent, Run, Step
from .browser import Action, Browser, PageChanged, Snapshot
from .session import connect, lexmount_session, moli_session, selenium_session

__all__ = ["Agent", "Run", "Step", "Action", "Browser", "PageChanged", "Snapshot",
"connect", "lexmount_session", "moli_session", "selenium_session"]
File renamed without changes.
14 changes: 7 additions & 7 deletions jev_nolayout/browser.py → jev_browser_bridge/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
// Mark this document. If the action replaces it, the next document will not
// carry the mark -- which is how the caller tells "the page changed" from
// "the page is still the one we clicked on and has not caught up yet".
window.__jevNoLayoutDoc = action.token;
const e = window.__jevNoLayout?.nodes.get(action.node);
window.__jevBridgeDoc = action.token;
const e = window.__jevBridge?.nodes.get(action.node);
if (!e?.isConnected || e.matches(':disabled') ||
e.closest('[aria-disabled="true"],[inert],[hidden]')) return null;

Expand Down Expand Up @@ -66,7 +66,7 @@
e.dispatchEvent(new Event('input', {bubbles: true}));
e.dispatchEvent(new Event('change', {bubbles: true}));
// How to find this field again if the page swaps it out; see REFILL_JS.
window.__jevNoLayoutField = {node: action.node, name: e.name || '',
window.__jevBridgeField = {node: action.node, name: e.name || '',
aria: e.getAttribute('aria-label') || '', placeholder: e.placeholder || '',
form: e.form?.id || ''};
return 'filled';
Expand Down Expand Up @@ -104,9 +104,9 @@
# if the node we filled is gone, find its replacement by name, label or
# placeholder, and put the text there too.
REFILL_JS = """(text => {
const was = window.__jevNoLayoutField;
const was = window.__jevBridgeField;
if (!was) return 'kept';
const old = window.__jevNoLayout?.nodes.get(was.node);
const old = window.__jevBridge?.nodes.get(was.node);
if (old?.isConnected && old.value === text) return 'kept';
const fields = [...document.querySelectorAll('input,textarea')].filter(f =>
!f.disabled && !f.readOnly && f.type !== 'hidden'
Expand All @@ -128,12 +128,12 @@

# Has the document been replaced since ACT_JS marked it, and is the new one done?
LOADED_JS = """(token => ({
replaced: window.__jevNoLayoutDoc !== token,
replaced: window.__jevBridgeDoc !== token,
ready: document.readyState === 'complete' || document.readyState === 'interactive',
}))"""

SUBMIT_JS = """(node => {
const e = window.__jevNoLayout?.nodes.get(node);
const e = window.__jevBridge?.nodes.get(node);
if (!e?.isConnected) return null;
e.focus();
for (const type of ['keydown', 'keypress', 'keyup']) {
Expand Down
4 changes: 2 additions & 2 deletions jev_nolayout/cli.py → jev_browser_bridge/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""jev-nolayout <url> <goal> -- run one goal and print the trace."""
"""jev-browser-bridge <url> <goal> -- run one goal and print the trace."""
from __future__ import annotations

import argparse
Expand All @@ -20,7 +20,7 @@ def load_env(path: str = ".env") -> None:


def main() -> None:
parser = argparse.ArgumentParser(prog="jev-nolayout")
parser = argparse.ArgumentParser(prog="jev-browser-bridge")
parser.add_argument("url")
parser.add_argument("goal")
parser.add_argument("--browser", default="light",
Expand Down
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion jev_nolayout/session.py → jev_browser_bridge/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ def lexmount_session(browser_mode: str = "light"):
from lexmount import Lexmount
except ImportError:
raise RuntimeError(
'Lexmount support is an extra: pip install "jev-nolayout[lexmount]"') from None
'Lexmount support is an extra: pip install "jev-browser-bridge[lexmount]"') from None

client = Lexmount()
session = client.sessions.create(browser_mode=browser_mode, poll_timeout_sec=180)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
(() => {
if (!document.body) return null;

const cache = window.__jevNoLayout ||= {ids: new WeakMap(), nodes: new Map(), next: 1};
const cache = window.__jevBridge ||= {ids: new WeakMap(), nodes: new Map(), next: 1};
const identity = e => {
if (!cache.ids.has(e)) cache.ids.set(e, cache.next++);
const id = cache.ids.get(e);
Expand Down
26 changes: 20 additions & 6 deletions jev_nolayout/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
"""A browser agent that never asks the layout engine anything."""
from .agent import Agent, Run, Step
from .browser import Action, Browser, PageChanged, Snapshot
from .session import connect, lexmount_session, moli_session, selenium_session
"""Former name of jev_browser_bridge, kept so existing imports keep working.

__all__ = ["Agent", "Run", "Step", "Action", "Browser", "PageChanged", "Snapshot",
"connect", "lexmount_session", "moli_session", "selenium_session"]
`import jev_nolayout` and `from jev_nolayout.browser import Browser` both
resolve to the same modules as their jev_browser_bridge counterparts. The
warning is a FutureWarning rather than a DeprecationWarning because it is
meant for the people using the package, and Python only shows the latter to
developers by default.
"""
import importlib
import sys
import warnings

import jev_browser_bridge as _package
from jev_browser_bridge import * # noqa: F401,F403
from jev_browser_bridge import __all__ # noqa: F401

warnings.warn("jev_nolayout is now jev_browser_bridge; update the import.",
FutureWarning, stacklevel=2)

for _name in ("agent", "browser", "cli", "evidence", "model", "session"):
sys.modules[f"{__name__}.{_name}"] = importlib.import_module(f"{_package.__name__}.{_name}")
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "jev-nolayout"
name = "jev-browser-bridge"
version = "0.1.0"
description = "A browser agent that reads structure instead of layout, on any CDP browser"
description = "Plug any CDP browser into Jev, including browsers that never draw a page"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
Expand All @@ -14,14 +14,16 @@ dependencies = [
lexmount = ["lexmount>=0.5"]

[project.scripts]
jev-nolayout = "jev_nolayout.cli:main"
jev-browser-bridge = "jev_browser_bridge.cli:main"
# The command's former name, kept for existing scripts.
jev-nolayout = "jev_browser_bridge.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["jev_nolayout"]
packages = ["jev_browser_bridge", "jev_nolayout"]

[tool.ruff]
line-length = 100
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading