manifest-api 0.1.0__tar.gz
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.
- manifest_api-0.1.0/.claude/settings.local.json +12 -0
- manifest_api-0.1.0/.gitignore +5 -0
- manifest_api-0.1.0/PKG-INFO +112 -0
- manifest_api-0.1.0/README.md +82 -0
- manifest_api-0.1.0/claude.md +38 -0
- manifest_api-0.1.0/db.py +74 -0
- manifest_api-0.1.0/example/fill_contact_form.py +112 -0
- manifest_api-0.1.0/example/post_to_linkedin.py +33 -0
- manifest_api-0.1.0/manifest.py +108 -0
- manifest_api-0.1.0/manifest_api/__init__.py +28 -0
- manifest_api-0.1.0/manifest_api/client.py +237 -0
- manifest_api-0.1.0/pyproject.toml +39 -0
- manifest_api-0.1.0/requirements.txt +6 -0
- manifest_api-0.1.0/server.py +44 -0
- manifest_api-0.1.0/session.py +51 -0
- manifest_api-0.1.0/tests/__init__.py +0 -0
- manifest_api-0.1.0/tests/test_client.py +144 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(git add *)",
|
|
5
|
+
"Bash(git commit -m ' *)",
|
|
6
|
+
"Bash(git push *)",
|
|
7
|
+
"Bash(git -C /Users/maxnordstrom/Desktop/semantic-agent-layer status)",
|
|
8
|
+
"Bash(git -C /Users/maxnordstrom/Desktop/semantic-agent-layer diff)",
|
|
9
|
+
"Bash(git *)"
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: manifest-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Manifest API — structured action manifests for AI agents
|
|
5
|
+
Project-URL: Homepage, https://omfang.io/docs
|
|
6
|
+
Project-URL: Repository, https://github.com/omfang/manifest-api
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/omfang/manifest-api/issues
|
|
8
|
+
Author-email: Omfang AB <hello@omfang.io>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,ai,automation,browser,manifest,web
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# manifest-api
|
|
32
|
+
|
|
33
|
+
Python SDK for the [Manifest API](https://omfang.io/docs) — extracts structured action manifests from web pages so AI agents know *what they can do*, not just what's on screen.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install manifest-api
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
### Sync
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from manifest_api import ManifestClient
|
|
47
|
+
|
|
48
|
+
client = ManifestClient(api_key="your-key") # or set MANIFEST_API_KEY env var
|
|
49
|
+
manifest = client.get("https://example.com")
|
|
50
|
+
|
|
51
|
+
print(manifest.current_page_state)
|
|
52
|
+
print(manifest.actions)
|
|
53
|
+
|
|
54
|
+
# Convenience helpers
|
|
55
|
+
action = manifest.action("submit-form")
|
|
56
|
+
inputs = manifest.actions_of_type("input")
|
|
57
|
+
required = manifest.required_actions
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Async
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from manifest_api import AsyncManifestClient
|
|
65
|
+
|
|
66
|
+
async def main():
|
|
67
|
+
async with AsyncManifestClient(api_key="your-key") as client:
|
|
68
|
+
manifest = await client.get("https://example.com")
|
|
69
|
+
print(manifest.current_page_state)
|
|
70
|
+
|
|
71
|
+
asyncio.run(main())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## All methods
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# Both ManifestClient and AsyncManifestClient expose:
|
|
78
|
+
manifest = client.get("https://example.com") # POST /manifest → Manifest
|
|
79
|
+
health = client.health() # GET /health → dict
|
|
80
|
+
valid = client.session_valid() # GET /session-status → bool
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Manifest helpers
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
manifest.action("id") # → Action | None
|
|
87
|
+
manifest.actions_of_type("input") # → list[Action]
|
|
88
|
+
manifest.required_actions # → list[Action]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Action types
|
|
92
|
+
|
|
93
|
+
`button` · `input` · `textarea` · `select` · `checkbox` · `radio` · `other`
|
|
94
|
+
|
|
95
|
+
## Error handling
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from manifest_api import AuthenticationError, RateLimitError, APIError
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
manifest = client.get("https://example.com")
|
|
102
|
+
except AuthenticationError:
|
|
103
|
+
print("Check your API key")
|
|
104
|
+
except RateLimitError:
|
|
105
|
+
print("Slow down — rate limit hit")
|
|
106
|
+
except APIError as e:
|
|
107
|
+
print(f"Server error {e.status_code}")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Docs
|
|
111
|
+
|
|
112
|
+
[https://omfang.io/docs](https://omfang.io/docs)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# manifest-api
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Manifest API](https://omfang.io/docs) — extracts structured action manifests from web pages so AI agents know *what they can do*, not just what's on screen.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install manifest-api
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
### Sync
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from manifest_api import ManifestClient
|
|
17
|
+
|
|
18
|
+
client = ManifestClient(api_key="your-key") # or set MANIFEST_API_KEY env var
|
|
19
|
+
manifest = client.get("https://example.com")
|
|
20
|
+
|
|
21
|
+
print(manifest.current_page_state)
|
|
22
|
+
print(manifest.actions)
|
|
23
|
+
|
|
24
|
+
# Convenience helpers
|
|
25
|
+
action = manifest.action("submit-form")
|
|
26
|
+
inputs = manifest.actions_of_type("input")
|
|
27
|
+
required = manifest.required_actions
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Async
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from manifest_api import AsyncManifestClient
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
async with AsyncManifestClient(api_key="your-key") as client:
|
|
38
|
+
manifest = await client.get("https://example.com")
|
|
39
|
+
print(manifest.current_page_state)
|
|
40
|
+
|
|
41
|
+
asyncio.run(main())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## All methods
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
# Both ManifestClient and AsyncManifestClient expose:
|
|
48
|
+
manifest = client.get("https://example.com") # POST /manifest → Manifest
|
|
49
|
+
health = client.health() # GET /health → dict
|
|
50
|
+
valid = client.session_valid() # GET /session-status → bool
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Manifest helpers
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
manifest.action("id") # → Action | None
|
|
57
|
+
manifest.actions_of_type("input") # → list[Action]
|
|
58
|
+
manifest.required_actions # → list[Action]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Action types
|
|
62
|
+
|
|
63
|
+
`button` · `input` · `textarea` · `select` · `checkbox` · `radio` · `other`
|
|
64
|
+
|
|
65
|
+
## Error handling
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from manifest_api import AuthenticationError, RateLimitError, APIError
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
manifest = client.get("https://example.com")
|
|
72
|
+
except AuthenticationError:
|
|
73
|
+
print("Check your API key")
|
|
74
|
+
except RateLimitError:
|
|
75
|
+
print("Slow down — rate limit hit")
|
|
76
|
+
except APIError as e:
|
|
77
|
+
print(f"Server error {e.status_code}")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Docs
|
|
81
|
+
|
|
82
|
+
[https://omfang.io/docs](https://omfang.io/docs)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# semantic-agent-layer
|
|
2
|
+
|
|
3
|
+
## What this is
|
|
4
|
+
A proof of concept for a semantic UI layer for AI agents. Instead of scraping raw HTML or using vision models to interact with web interfaces, this project extracts structured action manifests from web pages — telling agents *what they can do* on a page, not just what's on it.
|
|
5
|
+
|
|
6
|
+
The long-term vision is infrastructure that makes any webpage agent-readable natively, similar to what HTML did for browsers.
|
|
7
|
+
|
|
8
|
+
## Status
|
|
9
|
+
Core pipeline is working end to end. Successfully published a post to Omfang AB's LinkedIn page autonomously.
|
|
10
|
+
|
|
11
|
+
## Completed
|
|
12
|
+
- Define stable JSON schema for action manifests across different sites
|
|
13
|
+
- Accessibility tree extraction via `page.aria_snapshot()`
|
|
14
|
+
- LLM-based translation of raw snapshot to clean JSON action manifest
|
|
15
|
+
- Execution layer: navigate to composer, fill post body, publish as company page
|
|
16
|
+
- Session cookie persistence via `session.py` (login once, skip forever after)
|
|
17
|
+
- Parameterized post text via `post_to_linkedin(text: str)`
|
|
18
|
+
- Single `post_to_linkedin.py` callable by any agent
|
|
19
|
+
- `manifest.py` — `get_action_manifest(url)` works on any URL, returns structured JSON
|
|
20
|
+
|
|
21
|
+
## Pipeline
|
|
22
|
+
1. `linkedin_snapshot.py` — captures accessibility tree of LinkedIn feed
|
|
23
|
+
2. `translate-snapshot.py` — translates snapshot to clean JSON action manifest
|
|
24
|
+
3. `execute_post.py` — navigates to company composer, types post, publishes
|
|
25
|
+
|
|
26
|
+
## Stack
|
|
27
|
+
- Python 3.13
|
|
28
|
+
- Playwright (browser automation)
|
|
29
|
+
- Anthropic SDK (snapshot translation)
|
|
30
|
+
- python-dotenv (env vars)
|
|
31
|
+
- Virtual environment: `semantic/`
|
|
32
|
+
|
|
33
|
+
## Environment
|
|
34
|
+
- API key loaded from `.env` via `python-dotenv`
|
|
35
|
+
- Always activate venv before running: `source semantic/bin/activate`
|
|
36
|
+
- Run scripts from the root `semantic-agent-layer/` folder
|
|
37
|
+
|
|
38
|
+
## Next steps
|
manifest_api-0.1.0/db.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
|
|
5
|
+
import psycopg2
|
|
6
|
+
from psycopg2.extras import Json
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _conn():
|
|
13
|
+
return psycopg2.connect(
|
|
14
|
+
host=os.getenv("DB_HOST"),
|
|
15
|
+
port=os.getenv("DB_PORT", 5432),
|
|
16
|
+
dbname=os.getenv("DB_NAME"),
|
|
17
|
+
user=os.getenv("DB_USER"),
|
|
18
|
+
password=os.getenv("DB_PASSWORD"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _hash(key: str) -> str:
|
|
23
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify_api_key(raw_key: str) -> bool:
|
|
27
|
+
with _conn() as conn, conn.cursor() as cur:
|
|
28
|
+
cur.execute(
|
|
29
|
+
"SELECT 1 FROM api_keys WHERE key_hash = %s AND revoked = false",
|
|
30
|
+
(_hash(raw_key),),
|
|
31
|
+
)
|
|
32
|
+
return cur.fetchone() is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_api_key(email: str) -> str:
|
|
36
|
+
raw = secrets.token_urlsafe(32)
|
|
37
|
+
with _conn() as conn, conn.cursor() as cur:
|
|
38
|
+
cur.execute(
|
|
39
|
+
"INSERT INTO api_keys (key_hash, email) VALUES (%s, %s)",
|
|
40
|
+
(_hash(raw), email),
|
|
41
|
+
)
|
|
42
|
+
return raw
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_cached_manifest(url: str) -> dict | None:
|
|
46
|
+
ttl = int(os.getenv("CACHE_TTL_HOURS", "6"))
|
|
47
|
+
with _conn() as conn, conn.cursor() as cur:
|
|
48
|
+
cur.execute(
|
|
49
|
+
"SELECT manifest FROM manifest_cache WHERE url = %s AND cached_at > now() - (%s * interval '1 hour')",
|
|
50
|
+
(url, ttl),
|
|
51
|
+
)
|
|
52
|
+
row = cur.fetchone()
|
|
53
|
+
return row[0] if row else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_rate_limit(key_hash: str) -> bool:
|
|
57
|
+
limit = int(os.getenv("RATE_LIMIT_PER_MINUTE", "10"))
|
|
58
|
+
with _conn() as conn, conn.cursor() as cur:
|
|
59
|
+
cur.execute("INSERT INTO request_log (key_hash) VALUES (%s)", (key_hash,))
|
|
60
|
+
cur.execute(
|
|
61
|
+
"SELECT COUNT(*) FROM request_log WHERE key_hash = %s AND requested_at > now() - interval '60 seconds'",
|
|
62
|
+
(key_hash,),
|
|
63
|
+
)
|
|
64
|
+
count = cur.fetchone()[0]
|
|
65
|
+
return count < limit
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_manifest(url: str, manifest: dict) -> None:
|
|
69
|
+
with _conn() as conn, conn.cursor() as cur:
|
|
70
|
+
cur.execute(
|
|
71
|
+
"""INSERT INTO manifest_cache (url, manifest, cached_at) VALUES (%s, %s, now())
|
|
72
|
+
ON CONFLICT (url) DO UPDATE SET manifest = EXCLUDED.manifest, cached_at = now()""",
|
|
73
|
+
(url, Json(manifest)),
|
|
74
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
import anthropic
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from playwright.sync_api import sync_playwright
|
|
7
|
+
from session import browser_context
|
|
8
|
+
from manifest import get_action_manifest
|
|
9
|
+
|
|
10
|
+
load_dotenv()
|
|
11
|
+
|
|
12
|
+
_client = anthropic.Anthropic()
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://omfang.io"
|
|
15
|
+
|
|
16
|
+
_TEST_DATA = {
|
|
17
|
+
"name": "Agent Test",
|
|
18
|
+
"email": "test@omfang.io",
|
|
19
|
+
"subject": "Automated test from semantic agent layer",
|
|
20
|
+
"message": "This is an automated test from the semantic agent layer",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_FILL_PROMPT = """You have a page manifest and data to fill into a contact form.
|
|
24
|
+
|
|
25
|
+
Manifest:
|
|
26
|
+
{manifest}
|
|
27
|
+
|
|
28
|
+
Data to fill:
|
|
29
|
+
{data}
|
|
30
|
+
|
|
31
|
+
Return a JSON array of fill/click instructions that will complete and submit the form.
|
|
32
|
+
Each instruction is one of:
|
|
33
|
+
{{"action": "fill", "label": "<exact field label>", "value": "<value>"}}
|
|
34
|
+
{{"action": "click", "name": "<exact button text>"}}
|
|
35
|
+
|
|
36
|
+
Use the exact label text from the manifest actions. End with a click on the submit button.
|
|
37
|
+
Return only the JSON array, nothing else."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_url(href: str | None) -> str | None:
|
|
41
|
+
if not href:
|
|
42
|
+
return None
|
|
43
|
+
return urljoin(BASE_URL, href)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _plan_fill(manifest: dict) -> list[dict]:
|
|
47
|
+
prompt = _FILL_PROMPT.format(
|
|
48
|
+
manifest=json.dumps(manifest, indent=2),
|
|
49
|
+
data=json.dumps(_TEST_DATA, indent=2),
|
|
50
|
+
)
|
|
51
|
+
resp = _client.messages.create(
|
|
52
|
+
model="claude-sonnet-4-6",
|
|
53
|
+
max_tokens=500,
|
|
54
|
+
messages=[{"role": "user", "content": prompt}],
|
|
55
|
+
)
|
|
56
|
+
text = resp.content[0].text.strip()
|
|
57
|
+
if text.startswith("```"):
|
|
58
|
+
text = text.split("\n", 1)[1].rsplit("```", 1)[0]
|
|
59
|
+
return json.loads(text)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fill_contact_form() -> None:
|
|
63
|
+
# Step 1: discover contact page URL
|
|
64
|
+
print("Step 1: fetching homepage manifest...")
|
|
65
|
+
home = get_action_manifest(BASE_URL)
|
|
66
|
+
contact_nav = next(
|
|
67
|
+
n for n in home["navigation"]
|
|
68
|
+
if any(k in (n["label"] + " " + (n["url"] or "")).lower() for k in ("contact", "kontakt"))
|
|
69
|
+
)
|
|
70
|
+
contact_url = _resolve_url(contact_nav["url"])
|
|
71
|
+
print(f" → contact URL: {contact_url}")
|
|
72
|
+
|
|
73
|
+
# Step 2: discover form fields
|
|
74
|
+
print("Step 2: fetching contact page manifest...")
|
|
75
|
+
contact = get_action_manifest(contact_url)
|
|
76
|
+
print(f" → {len(contact['actions'])} actions, current state: {contact['current_page_state']}")
|
|
77
|
+
|
|
78
|
+
# Step 3: plan the fill
|
|
79
|
+
print("Step 3: planning form fill...")
|
|
80
|
+
steps = _plan_fill(contact)
|
|
81
|
+
for s in steps:
|
|
82
|
+
print(f" → {s}")
|
|
83
|
+
|
|
84
|
+
# Step 4: execute
|
|
85
|
+
print("Step 4: executing...")
|
|
86
|
+
_CHROMIUM_PATH = '/usr/bin/chromium-browser'
|
|
87
|
+
_executable_path = _CHROMIUM_PATH if os.path.exists(_CHROMIUM_PATH) else None
|
|
88
|
+
|
|
89
|
+
with sync_playwright() as p:
|
|
90
|
+
browser = p.chromium.launch(
|
|
91
|
+
headless=_executable_path is not None,
|
|
92
|
+
executable_path=_executable_path,
|
|
93
|
+
)
|
|
94
|
+
_, page = browser_context(browser, start_url=contact_url)
|
|
95
|
+
page.wait_for_load_state("domcontentloaded")
|
|
96
|
+
page.wait_for_timeout(2000)
|
|
97
|
+
|
|
98
|
+
for step in steps:
|
|
99
|
+
if step["action"] == "fill":
|
|
100
|
+
page.get_by_label(step["label"], exact=False).fill(step["value"])
|
|
101
|
+
page.wait_for_timeout(300)
|
|
102
|
+
elif step["action"] == "click":
|
|
103
|
+
page.get_by_role("button", name=step["name"], exact=False).click()
|
|
104
|
+
|
|
105
|
+
page.wait_for_timeout(3000)
|
|
106
|
+
browser.close()
|
|
107
|
+
|
|
108
|
+
print("Done.")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
fill_contact_form()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from playwright.sync_api import sync_playwright
|
|
3
|
+
from session import browser_context
|
|
4
|
+
|
|
5
|
+
COMPOSER_URL = "https://www.linkedin.com/company/113130156/admin/feed/posts?share=true"
|
|
6
|
+
|
|
7
|
+
def post_to_linkedin(text: str) -> None:
|
|
8
|
+
_CHROMIUM_PATH = '/usr/bin/chromium-browser'
|
|
9
|
+
_executable_path = _CHROMIUM_PATH if os.path.exists(_CHROMIUM_PATH) else None
|
|
10
|
+
|
|
11
|
+
with sync_playwright() as p:
|
|
12
|
+
browser = p.chromium.launch(
|
|
13
|
+
headless=_executable_path is not None,
|
|
14
|
+
executable_path=_executable_path,
|
|
15
|
+
)
|
|
16
|
+
context, page = browser_context(browser)
|
|
17
|
+
|
|
18
|
+
page.goto(COMPOSER_URL)
|
|
19
|
+
page.wait_for_selector('[contenteditable="true"]', timeout=15000)
|
|
20
|
+
page.wait_for_timeout(2000)
|
|
21
|
+
|
|
22
|
+
page.locator('[contenteditable="true"]').first.click()
|
|
23
|
+
page.keyboard.type(text)
|
|
24
|
+
page.wait_for_timeout(1000)
|
|
25
|
+
|
|
26
|
+
page.get_by_role("button", name="Lägg upp", exact=True).click()
|
|
27
|
+
page.wait_for_timeout(3000)
|
|
28
|
+
|
|
29
|
+
browser.close()
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
import sys
|
|
33
|
+
post_to_linkedin(sys.argv[1] if len(sys.argv) > 1 else "Test post from semantic agent layer.")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import anthropic
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
from playwright.sync_api import sync_playwright
|
|
6
|
+
from session import browser_context
|
|
7
|
+
from db import get_cached_manifest, save_manifest
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
_client = anthropic.Anthropic()
|
|
12
|
+
|
|
13
|
+
_PROMPT = """You are a semantic UI translator for AI agents.
|
|
14
|
+
|
|
15
|
+
Given the accessibility snapshot of a web page and supplemental DOM context,
|
|
16
|
+
return a JSON action manifest with this structure:
|
|
17
|
+
{{
|
|
18
|
+
"url": "<page url>",
|
|
19
|
+
"authenticated_user": "<name or null>",
|
|
20
|
+
"current_page_state": "<brief description>",
|
|
21
|
+
"actions": [
|
|
22
|
+
{{
|
|
23
|
+
"id": "<slug>",
|
|
24
|
+
"label": "<human label>",
|
|
25
|
+
"type": "<input|textarea|select|button|checkbox|radio|other>",
|
|
26
|
+
"description": "<what it does>",
|
|
27
|
+
"required": true
|
|
28
|
+
}}
|
|
29
|
+
],
|
|
30
|
+
"navigation": [
|
|
31
|
+
{{"label": "<link text>", "url": "<href or null>"}}
|
|
32
|
+
]
|
|
33
|
+
}}
|
|
34
|
+
|
|
35
|
+
Rules:
|
|
36
|
+
- authenticated_user: extract from profile links, greeting text, or avatar labels; null if not detectable
|
|
37
|
+
- actions: only interactive things (buttons, form fields, composers) — skip ads, feed content, footers
|
|
38
|
+
- required: set true if the field appears in dom_context.required_fields (matched by placeholder, name, or label)
|
|
39
|
+
- type: use dom_context.input_types to refine the type (e.g. "email", "tel") when available
|
|
40
|
+
- disabled: omit actions for elements in dom_context.disabled_elements
|
|
41
|
+
- navigation: primary nav links only
|
|
42
|
+
- Return valid JSON only, nothing else
|
|
43
|
+
|
|
44
|
+
Snapshot:
|
|
45
|
+
{snapshot}
|
|
46
|
+
|
|
47
|
+
DOM context:
|
|
48
|
+
{dom_context}"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_DOM_EXTRACTORS = {
|
|
52
|
+
"required_fields": """() => Array.from(document.querySelectorAll("input[required], textarea[required], select[required]"))
|
|
53
|
+
.map(el => el.placeholder || el.name || el.id)
|
|
54
|
+
.filter(Boolean)""",
|
|
55
|
+
"placeholders": """() => Array.from(document.querySelectorAll("input[placeholder], textarea[placeholder]"))
|
|
56
|
+
.map(el => el.placeholder)
|
|
57
|
+
.filter(Boolean)""",
|
|
58
|
+
"disabled_elements": """() => Array.from(document.querySelectorAll("input[disabled], button[disabled], select[disabled], textarea[disabled]"))
|
|
59
|
+
.map(el => el.placeholder || el.name || el.id || (el.textContent || '').trim())
|
|
60
|
+
.filter(Boolean)""",
|
|
61
|
+
"input_types": """() => Array.from(document.querySelectorAll("input"))
|
|
62
|
+
.map(el => ({ type: el.type, name: el.name || el.id || el.placeholder }))
|
|
63
|
+
.filter(el => el.name)""",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_action_manifest(url: str) -> dict:
|
|
68
|
+
cached = get_cached_manifest(url)
|
|
69
|
+
if cached is not None:
|
|
70
|
+
return cached
|
|
71
|
+
|
|
72
|
+
_CHROMIUM_PATH = '/usr/bin/chromium-browser'
|
|
73
|
+
_executable_path = _CHROMIUM_PATH if os.path.exists(_CHROMIUM_PATH) else None
|
|
74
|
+
|
|
75
|
+
with sync_playwright() as p:
|
|
76
|
+
browser = p.chromium.launch(
|
|
77
|
+
headless=_executable_path is not None,
|
|
78
|
+
executable_path=_executable_path,
|
|
79
|
+
)
|
|
80
|
+
context, page = browser_context(browser, start_url=url)
|
|
81
|
+
page.wait_for_load_state("domcontentloaded")
|
|
82
|
+
page.wait_for_timeout(2000)
|
|
83
|
+
snapshot = page.aria_snapshot()
|
|
84
|
+
dom_context = {key: page.evaluate(js) for key, js in _DOM_EXTRACTORS.items()}
|
|
85
|
+
browser.close()
|
|
86
|
+
|
|
87
|
+
response = _client.messages.create(
|
|
88
|
+
model="claude-sonnet-4-6",
|
|
89
|
+
max_tokens=2000,
|
|
90
|
+
messages=[{"role": "user", "content": _PROMPT.format(
|
|
91
|
+
snapshot=snapshot,
|
|
92
|
+
dom_context=json.dumps(dom_context, indent=2),
|
|
93
|
+
)}],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
text = response.content[0].text.strip()
|
|
97
|
+
# strip markdown code fences if model wraps output
|
|
98
|
+
if text.startswith("```"):
|
|
99
|
+
text = text.split("\n", 1)[1].rsplit("```", 1)[0]
|
|
100
|
+
manifest = json.loads(text)
|
|
101
|
+
manifest["url"] = url
|
|
102
|
+
save_manifest(url, manifest)
|
|
103
|
+
return manifest
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
import pprint
|
|
108
|
+
pprint.pprint(get_action_manifest("https://www.linkedin.com/feed/"))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""manifest-api — Python SDK for the Manifest API."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
Action,
|
|
5
|
+
APIError,
|
|
6
|
+
AsyncManifestClient,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
Manifest,
|
|
9
|
+
ManifestClient,
|
|
10
|
+
ManifestError,
|
|
11
|
+
NavLink,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"ManifestClient",
|
|
20
|
+
"AsyncManifestClient",
|
|
21
|
+
"Manifest",
|
|
22
|
+
"Action",
|
|
23
|
+
"NavLink",
|
|
24
|
+
"ManifestError",
|
|
25
|
+
"AuthenticationError",
|
|
26
|
+
"RateLimitError",
|
|
27
|
+
"APIError",
|
|
28
|
+
]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Manifest API client — sync and async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
_BASE_URL = "https://manifest.omfang.io"
|
|
12
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Exceptions
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
class ManifestError(Exception):
|
|
20
|
+
"""Base exception for all Manifest API errors."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthenticationError(ManifestError):
|
|
24
|
+
"""Missing or invalid API key."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RateLimitError(ManifestError):
|
|
28
|
+
"""Rate limit exceeded (429)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIError(ManifestError):
|
|
32
|
+
"""Unexpected API error (5xx or other)."""
|
|
33
|
+
def __init__(self, message: str, status_code: int) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Data models
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Action:
|
|
44
|
+
id: str
|
|
45
|
+
label: str
|
|
46
|
+
type: str
|
|
47
|
+
description: str
|
|
48
|
+
required: bool
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def _from_dict(cls, d: dict) -> "Action":
|
|
52
|
+
return cls(
|
|
53
|
+
id=d["id"],
|
|
54
|
+
label=d["label"],
|
|
55
|
+
type=d["type"],
|
|
56
|
+
description=d.get("description", ""),
|
|
57
|
+
required=d.get("required", False),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class NavLink:
|
|
63
|
+
label: str
|
|
64
|
+
url: str
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def _from_dict(cls, d: dict) -> "NavLink":
|
|
68
|
+
return cls(label=d["label"], url=d["url"])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Manifest:
|
|
73
|
+
url: str
|
|
74
|
+
authenticated_user: Optional[str]
|
|
75
|
+
current_page_state: str
|
|
76
|
+
actions: List[Action] = field(default_factory=list)
|
|
77
|
+
navigation: List[NavLink] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def _from_dict(cls, d: dict) -> "Manifest":
|
|
81
|
+
return cls(
|
|
82
|
+
url=d["url"],
|
|
83
|
+
authenticated_user=d.get("authenticated_user"),
|
|
84
|
+
current_page_state=d.get("current_page_state", ""),
|
|
85
|
+
actions=[Action._from_dict(a) for a in d.get("actions", [])],
|
|
86
|
+
navigation=[NavLink._from_dict(n) for n in d.get("navigation", [])],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def action(self, id: str) -> Optional[Action]:
|
|
90
|
+
"""Return the action with the given id, or None."""
|
|
91
|
+
return next((a for a in self.actions if a.id == id), None)
|
|
92
|
+
|
|
93
|
+
def actions_of_type(self, type: str) -> List[Action]:
|
|
94
|
+
"""Return all actions matching the given type."""
|
|
95
|
+
return [a for a in self.actions if a.type == type]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def required_actions(self) -> List[Action]:
|
|
99
|
+
"""Return all actions where required=True."""
|
|
100
|
+
return [a for a in self.actions if a.required]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Shared helpers
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _resolve_api_key(api_key: Optional[str]) -> str:
|
|
108
|
+
key = api_key or os.environ.get("MANIFEST_API_KEY", "")
|
|
109
|
+
if not key:
|
|
110
|
+
raise AuthenticationError(
|
|
111
|
+
"No API key provided. Pass api_key= or set MANIFEST_API_KEY."
|
|
112
|
+
)
|
|
113
|
+
return key
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _headers(api_key: str) -> dict:
|
|
117
|
+
return {"X-API-Key": api_key, "Content-Type": "application/json"}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
121
|
+
if response.status_code == 401:
|
|
122
|
+
raise AuthenticationError("Invalid or missing API key (401).")
|
|
123
|
+
if response.status_code == 429:
|
|
124
|
+
raise RateLimitError("Rate limit exceeded (429). Slow down requests.")
|
|
125
|
+
if response.status_code >= 500:
|
|
126
|
+
raise APIError(
|
|
127
|
+
f"Server error: {response.text}", status_code=response.status_code
|
|
128
|
+
)
|
|
129
|
+
if response.status_code >= 400:
|
|
130
|
+
raise APIError(
|
|
131
|
+
f"Client error {response.status_code}: {response.text}",
|
|
132
|
+
status_code=response.status_code,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Sync client
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
class ManifestClient:
|
|
141
|
+
"""Synchronous Manifest API client."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
api_key: Optional[str] = None,
|
|
146
|
+
base_url: str = _BASE_URL,
|
|
147
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
148
|
+
) -> None:
|
|
149
|
+
self._api_key = _resolve_api_key(api_key)
|
|
150
|
+
self._base_url = base_url.rstrip("/")
|
|
151
|
+
self._client = httpx.Client(timeout=timeout)
|
|
152
|
+
|
|
153
|
+
def get(self, url: str) -> Manifest:
|
|
154
|
+
"""Fetch an action manifest for the given URL."""
|
|
155
|
+
response = self._client.post(
|
|
156
|
+
f"{self._base_url}/manifest",
|
|
157
|
+
json={"url": url},
|
|
158
|
+
headers=_headers(self._api_key),
|
|
159
|
+
)
|
|
160
|
+
_raise_for_status(response)
|
|
161
|
+
return Manifest._from_dict(response.json())
|
|
162
|
+
|
|
163
|
+
def health(self) -> dict:
|
|
164
|
+
"""Check API health. Returns the raw JSON response."""
|
|
165
|
+
response = self._client.get(f"{self._base_url}/health")
|
|
166
|
+
_raise_for_status(response)
|
|
167
|
+
return response.json()
|
|
168
|
+
|
|
169
|
+
def session_valid(self) -> bool:
|
|
170
|
+
"""Return True if the current session cookie is still valid."""
|
|
171
|
+
response = self._client.get(
|
|
172
|
+
f"{self._base_url}/session-status",
|
|
173
|
+
headers=_headers(self._api_key),
|
|
174
|
+
)
|
|
175
|
+
_raise_for_status(response)
|
|
176
|
+
return response.json().get("valid", False)
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
self._client.close()
|
|
180
|
+
|
|
181
|
+
def __enter__(self) -> "ManifestClient":
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
def __exit__(self, *_: object) -> None:
|
|
185
|
+
self.close()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Async client
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
class AsyncManifestClient:
|
|
193
|
+
"""Asynchronous Manifest API client."""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
api_key: Optional[str] = None,
|
|
198
|
+
base_url: str = _BASE_URL,
|
|
199
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
200
|
+
) -> None:
|
|
201
|
+
self._api_key = _resolve_api_key(api_key)
|
|
202
|
+
self._base_url = base_url.rstrip("/")
|
|
203
|
+
self._client = httpx.AsyncClient(timeout=timeout)
|
|
204
|
+
|
|
205
|
+
async def get(self, url: str) -> Manifest:
|
|
206
|
+
"""Fetch an action manifest for the given URL."""
|
|
207
|
+
response = await self._client.post(
|
|
208
|
+
f"{self._base_url}/manifest",
|
|
209
|
+
json={"url": url},
|
|
210
|
+
headers=_headers(self._api_key),
|
|
211
|
+
)
|
|
212
|
+
_raise_for_status(response)
|
|
213
|
+
return Manifest._from_dict(response.json())
|
|
214
|
+
|
|
215
|
+
async def health(self) -> dict:
|
|
216
|
+
"""Check API health. Returns the raw JSON response."""
|
|
217
|
+
response = await self._client.get(f"{self._base_url}/health")
|
|
218
|
+
_raise_for_status(response)
|
|
219
|
+
return response.json()
|
|
220
|
+
|
|
221
|
+
async def session_valid(self) -> bool:
|
|
222
|
+
"""Return True if the current session cookie is still valid."""
|
|
223
|
+
response = await self._client.get(
|
|
224
|
+
f"{self._base_url}/session-status",
|
|
225
|
+
headers=_headers(self._api_key),
|
|
226
|
+
)
|
|
227
|
+
_raise_for_status(response)
|
|
228
|
+
return response.json().get("valid", False)
|
|
229
|
+
|
|
230
|
+
async def close(self) -> None:
|
|
231
|
+
await self._client.aclose()
|
|
232
|
+
|
|
233
|
+
async def __aenter__(self) -> "AsyncManifestClient":
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
async def __aexit__(self, *_: object) -> None:
|
|
237
|
+
await self.close()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "manifest-api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the Manifest API — structured action manifests for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Omfang AB", email = "hello@omfang.io" }]
|
|
13
|
+
keywords = ["ai", "agents", "browser", "automation", "manifest", "web"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["httpx>=0.27"]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=8.0", "respx>=0.21", "pytest-asyncio>=0.23"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://omfang.io/docs"
|
|
35
|
+
Repository = "https://github.com/omfang/manifest-api"
|
|
36
|
+
"Bug Tracker" = "https://github.com/omfang/manifest-api/issues"
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import uvicorn
|
|
3
|
+
from fastapi import FastAPI, Header, HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from db import verify_api_key, check_rate_limit, _hash
|
|
6
|
+
from manifest import get_action_manifest
|
|
7
|
+
from session import SessionExpiredError, check_session
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
app = FastAPI()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ManifestRequest(BaseModel):
|
|
14
|
+
url: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.get("/health")
|
|
18
|
+
def health():
|
|
19
|
+
return {"status": "ok"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.get("/session-status")
|
|
23
|
+
def session_status():
|
|
24
|
+
if check_session():
|
|
25
|
+
return {"valid": True}
|
|
26
|
+
return {"valid": False, "message": "Session expired"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.post("/manifest")
|
|
30
|
+
def manifest(req: ManifestRequest, x_api_key: str = Header(default=None)):
|
|
31
|
+
raw_key = x_api_key or ""
|
|
32
|
+
if not verify_api_key(raw_key):
|
|
33
|
+
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
|
34
|
+
if not check_rate_limit(_hash(raw_key)):
|
|
35
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded. Max 10 requests per minute.")
|
|
36
|
+
try:
|
|
37
|
+
return get_action_manifest(req.url)
|
|
38
|
+
except SessionExpiredError as e:
|
|
39
|
+
logger.error(str(e))
|
|
40
|
+
raise HTTPException(status_code=503, detail=str(e))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from playwright.sync_api import sync_playwright
|
|
3
|
+
|
|
4
|
+
SESSION_FILE = "session.json"
|
|
5
|
+
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
6
|
+
_CHROMIUM_PATH = "/usr/bin/chromium-browser"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SessionExpiredError(RuntimeError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def browser_context(browser, start_url="https://www.linkedin.com/feed/"):
|
|
14
|
+
"""Return (context, page) navigated to start_url, prompting for login if needed."""
|
|
15
|
+
has_session = os.path.exists(SESSION_FILE)
|
|
16
|
+
kwargs = {"user_agent": _UA}
|
|
17
|
+
if has_session:
|
|
18
|
+
kwargs["storage_state"] = SESSION_FILE
|
|
19
|
+
|
|
20
|
+
context = browser.new_context(**kwargs)
|
|
21
|
+
page = context.new_page()
|
|
22
|
+
page.goto(start_url)
|
|
23
|
+
|
|
24
|
+
if not has_session:
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
f"No session file found at '{SESSION_FILE}'. "
|
|
27
|
+
"Run locally first to log in and generate the session file."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if "login" in page.url or "authwall" in page.url:
|
|
31
|
+
raise SessionExpiredError(
|
|
32
|
+
"Session expired — refresh session.json by running session.py locally and re-uploading to the server"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return context, page
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_session() -> bool:
|
|
39
|
+
"""Return True if session.json exists and is still valid on LinkedIn."""
|
|
40
|
+
if not os.path.exists(SESSION_FILE):
|
|
41
|
+
return False
|
|
42
|
+
executable_path = _CHROMIUM_PATH if os.path.exists(_CHROMIUM_PATH) else None
|
|
43
|
+
with sync_playwright() as p:
|
|
44
|
+
browser = p.chromium.launch(headless=executable_path is not None, executable_path=executable_path)
|
|
45
|
+
try:
|
|
46
|
+
browser_context(browser)
|
|
47
|
+
return True
|
|
48
|
+
except SessionExpiredError:
|
|
49
|
+
return False
|
|
50
|
+
finally:
|
|
51
|
+
browser.close()
|
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Tests for manifest_api client."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pytest
|
|
5
|
+
import respx
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from manifest_api import (
|
|
9
|
+
ManifestClient,
|
|
10
|
+
AsyncManifestClient,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
APIError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
BASE = "https://manifest.omfang.io"
|
|
17
|
+
|
|
18
|
+
MANIFEST_PAYLOAD = {
|
|
19
|
+
"url": "https://example.com",
|
|
20
|
+
"authenticated_user": None,
|
|
21
|
+
"current_page_state": "Landing page",
|
|
22
|
+
"actions": [
|
|
23
|
+
{
|
|
24
|
+
"id": "submit-form",
|
|
25
|
+
"label": "Submit",
|
|
26
|
+
"type": "button",
|
|
27
|
+
"description": "Submits the contact form",
|
|
28
|
+
"required": False,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "email-input",
|
|
32
|
+
"label": "Email",
|
|
33
|
+
"type": "input",
|
|
34
|
+
"description": "Email address field",
|
|
35
|
+
"required": True,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
"navigation": [{"label": "Home", "url": "/"}],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Sync client
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@respx.mock
|
|
47
|
+
def test_successful_manifest_fetch():
|
|
48
|
+
respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
|
|
49
|
+
|
|
50
|
+
client = ManifestClient(api_key="test-key")
|
|
51
|
+
manifest = client.get("https://example.com")
|
|
52
|
+
|
|
53
|
+
assert manifest.url == "https://example.com"
|
|
54
|
+
assert manifest.current_page_state == "Landing page"
|
|
55
|
+
assert len(manifest.actions) == 2
|
|
56
|
+
assert len(manifest.navigation) == 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_missing_api_key_raises_authentication_error(monkeypatch):
|
|
60
|
+
monkeypatch.delenv("MANIFEST_API_KEY", raising=False)
|
|
61
|
+
with pytest.raises(AuthenticationError):
|
|
62
|
+
ManifestClient()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@respx.mock
|
|
66
|
+
def test_401_raises_authentication_error():
|
|
67
|
+
respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(401))
|
|
68
|
+
|
|
69
|
+
client = ManifestClient(api_key="bad-key")
|
|
70
|
+
with pytest.raises(AuthenticationError):
|
|
71
|
+
client.get("https://example.com")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@respx.mock
|
|
75
|
+
def test_429_raises_rate_limit_error():
|
|
76
|
+
respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(429))
|
|
77
|
+
|
|
78
|
+
client = ManifestClient(api_key="test-key")
|
|
79
|
+
with pytest.raises(RateLimitError):
|
|
80
|
+
client.get("https://example.com")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@respx.mock
|
|
84
|
+
def test_500_raises_api_error():
|
|
85
|
+
respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(500, text="oops"))
|
|
86
|
+
|
|
87
|
+
client = ManifestClient(api_key="test-key")
|
|
88
|
+
with pytest.raises(APIError) as exc_info:
|
|
89
|
+
client.get("https://example.com")
|
|
90
|
+
assert exc_info.value.status_code == 500
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@respx.mock
|
|
94
|
+
def test_action_lookup():
|
|
95
|
+
respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
|
|
96
|
+
|
|
97
|
+
client = ManifestClient(api_key="test-key")
|
|
98
|
+
manifest = client.get("https://example.com")
|
|
99
|
+
|
|
100
|
+
action = manifest.action("submit-form")
|
|
101
|
+
assert action is not None
|
|
102
|
+
assert action.label == "Submit"
|
|
103
|
+
assert manifest.action("nonexistent") is None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@respx.mock
|
|
107
|
+
def test_required_actions_filter():
|
|
108
|
+
respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
|
|
109
|
+
|
|
110
|
+
client = ManifestClient(api_key="test-key")
|
|
111
|
+
manifest = client.get("https://example.com")
|
|
112
|
+
|
|
113
|
+
required = manifest.required_actions
|
|
114
|
+
assert len(required) == 1
|
|
115
|
+
assert required[0].id == "email-input"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@respx.mock
|
|
119
|
+
def test_actions_of_type():
|
|
120
|
+
respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
|
|
121
|
+
|
|
122
|
+
client = ManifestClient(api_key="test-key")
|
|
123
|
+
manifest = client.get("https://example.com")
|
|
124
|
+
|
|
125
|
+
buttons = manifest.actions_of_type("button")
|
|
126
|
+
assert len(buttons) == 1
|
|
127
|
+
assert buttons[0].id == "submit-form"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Async client
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
@respx.mock
|
|
135
|
+
async def test_async_client_happy_path():
|
|
136
|
+
respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
|
|
137
|
+
|
|
138
|
+
async with AsyncManifestClient(api_key="test-key") as client:
|
|
139
|
+
manifest = await client.get("https://example.com")
|
|
140
|
+
|
|
141
|
+
assert manifest.url == "https://example.com"
|
|
142
|
+
assert len(manifest.actions) == 2
|
|
143
|
+
assert manifest.action("submit-form") is not None
|
|
144
|
+
assert manifest.required_actions[0].id == "email-input"
|