Files
actionbook__actionbook/packages/dify-plugin/providers/hyperbrowser.py
sen 2db938d519 feat(dify-plugin): v0.1.1 HTTP API migration and serverless hardening (#162)
* chore: align dify plugin manifest and privacy policy

* [packages/dify-plugin]refactor: replace hyperbrowser SDK with direct HTTP API

Remove hyperbrowser Python SDK dependency and use requests to call
Hyperbrowser REST API directly. This eliminates an extra dependency
that may not install cleanly in Dify Cloud's serverless runtime.

- providers/hyperbrowser.py: HTTP POST/PUT instead of SDK client
- pyproject.toml: drop hyperbrowser>=0.1.0 dependency
- requirements.txt: add explicit requirements file
- tests/test_providers.py: mock requests instead of SDK classes

* [packages/dify-plugin]feat: add HTML misroute guard and simplify tool error handling

Add utils/http_guard.py to detect when API responses return HTML
instead of expected text/JSON — a common symptom of ACTIONBOOK_API_URL
pointing to a non-Actionbook endpoint (e.g. Dify app API).

- Integrate HTML guard into search_actions, get_action_by_area_id, provider
- Simplify verbose error branches into concise messages
- Remove debug-level logging from hot paths
- Update all related tests with Content-Type headers and new assertions

* [packages/dify-plugin]refactor: lazy-load browser deps for serverless compatibility

Defer importing playwright and connection_pool until first browser
tool invocation. This allows search_actions and get_action_by_area_id
to work even when playwright binaries are unavailable (e.g. Dify
Cloud serverless runtime).

- tools/__init__.py: lazy __getattr__ for browser tool classes
- browser_create_session.py: thread-safe lazy pool import
- browser_operator.py: lazy import with stub exception fallbacks

* [packages/dify-plugin]fix: review fixes — dead code, lazy import, session recovery

- Remove unreachable except BaseException blocks in search_actions
  and get_action_by_area_id (dead code: only re-raised what it caught)
- Apply lazy connection_pool import in browser_stop_session, matching
  browser_create_session and browser_operator pattern for serverless
- Preserve provider_name/api_key on session recovery reconnect to
  prevent remote session leaks when browser_stop_session runs later
- Update tests accordingly

* [root]chore: use changeset for dify-plugin version bump

Revert manual 0.1.1 bump back to 0.1.0 and add a changeset file
so CI handles versioning through the standard two-phase flow.
Also extend sync-versions.js to run `uv lock` after updating
pyproject.toml to keep uv.lock in sync automatically.
2026-02-25 22:04:23 +08:00

155 lines
4.6 KiB
Python

"""Hyperbrowser cloud browser provider.
Persistence model: Profile-based.
Each Dify tool call creates a NEW session but loads the same Profile,
restoring cookies / localStorage from the previous call.
Only active session time is billed — idle waiting between calls is free.
CDP connection: ws_endpoint is returned directly by the API.
No additional SDK calls needed after create_session().
Uses direct HTTP API calls instead of the hyperbrowser SDK to avoid
extra dependencies in Dify Cloud's serverless runtime.
Docs: https://docs.hyperbrowser.ai/sessions/profiles
API: https://docs.hyperbrowser.ai/api-reference
"""
import logging
import uuid
from dataclasses import dataclass
from typing import Any
import requests
logger = logging.getLogger(__name__)
_API_BASE = "https://api.hyperbrowser.ai"
_TIMEOUT = 30
@dataclass
class HyperbrowserSession:
"""Active Hyperbrowser session."""
_ws_endpoint: str
_session_id: str
_api_key: str
@property
def ws_endpoint(self) -> str:
return self._ws_endpoint
@property
def session_id(self) -> str:
return self._session_id
def stop(self) -> None:
"""Stop session and persist Profile state."""
try:
resp = requests.put(
f"{_API_BASE}/api/session/{self._session_id}/stop",
headers={"x-api-key": self._api_key},
timeout=_TIMEOUT,
)
resp.raise_for_status()
except Exception:
logger.exception("Failed to stop Hyperbrowser session %s", self._session_id)
raise
class HyperbrowserProvider:
"""
Cloud browser provider backed by Hyperbrowser REST API.
Session persistence strategy (Dify workflow context):
- Pass a stable profile_id (e.g., f"dify-{workflow_id}-{user_id}")
- Set persist_changes=True so cookies/localStorage are saved on stop()
- Next Dify tool call creates a fresh session but loads the same profile
- This avoids billing for idle time between Dify HTTP calls
See: https://docs.hyperbrowser.ai/sessions/profiles
"""
def __init__(self, api_key: str) -> None:
self._api_key = api_key
def create_session(
self,
profile_id: str | None = None,
use_proxy: bool = False,
persist_changes: bool = True,
**kwargs: Any,
) -> HyperbrowserSession:
"""
Create a Hyperbrowser session via REST API.
Args:
profile_id: Profile ID for persistent state.
use_proxy: Route through a residential proxy.
persist_changes: Save browser state to Profile on stop.
"""
body: dict[str, Any] = {"useProxy": use_proxy}
if profile_id:
normalized_profile_id = _normalize_profile_id(profile_id)
body["profile"] = {
"id": normalized_profile_id,
"persistChanges": persist_changes,
}
url = f"{_API_BASE}/api/session"
resp = requests.post(
url,
headers={
"x-api-key": self._api_key,
"Content-Type": "application/json",
},
json=body,
timeout=_TIMEOUT,
)
if not resp.ok:
body_preview = resp.text[:300] if resp.text else "(empty)"
raise RuntimeError(
f"Hyperbrowser API error: HTTP {resp.status_code}\n"
f"Response: {body_preview}"
)
data = resp.json()
session_id = data.get("id") or data.get("sessionId", "")
ws_endpoint = data.get("wsEndpoint") or data.get("sessionWebsocketUrl", "")
if not session_id or not ws_endpoint:
raise RuntimeError(
f"Hyperbrowser API returned incomplete session data: {data}"
)
return HyperbrowserSession(
_ws_endpoint=ws_endpoint,
_session_id=session_id,
_api_key=self._api_key,
)
def stop_session(self, session_id: str) -> None:
"""Stop session by ID. Profile state is persisted on stop."""
resp = requests.put(
f"{_API_BASE}/api/session/{session_id}/stop",
headers={"x-api-key": self._api_key},
timeout=_TIMEOUT,
)
resp.raise_for_status()
def _normalize_profile_id(profile_id: str) -> str:
"""Normalize arbitrary profile_id input to UUID string accepted by Hyperbrowser."""
raw = profile_id.strip()
if not raw:
raise ValueError("profile_id cannot be empty when provided")
try:
return str(uuid.UUID(raw))
except ValueError:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"actionbook:{raw}"))