lfx-keenable 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.
- lfx_keenable-0.1.0/.gitignore +6 -0
- lfx_keenable-0.1.0/LICENSE +21 -0
- lfx_keenable-0.1.0/PKG-INFO +76 -0
- lfx_keenable-0.1.0/README.md +60 -0
- lfx_keenable-0.1.0/pyproject.toml +55 -0
- lfx_keenable-0.1.0/src/lfx_keenable/__init__.py +16 -0
- lfx_keenable-0.1.0/src/lfx_keenable/components/keenable/__init__.py +4 -0
- lfx_keenable-0.1.0/src/lfx_keenable/components/keenable/_client.py +183 -0
- lfx_keenable-0.1.0/src/lfx_keenable/components/keenable/keenable_fetch.py +92 -0
- lfx_keenable-0.1.0/src/lfx_keenable/components/keenable/keenable_search.py +145 -0
- lfx_keenable-0.1.0/src/lfx_keenable/extension.json +16 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keenable
|
|
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,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lfx-keenable
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keenable web-search and page-fetch components as a standalone Langflow Extension Bundle.
|
|
5
|
+
Project-URL: Homepage, https://keenable.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.keenable.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/keenableai/lfx-keenable
|
|
8
|
+
Author-email: Keenable <hello@keenable.ai>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,bundle,extension,keenable,langflow,lfx,search,web-search
|
|
12
|
+
Requires-Python: <3.15,>=3.10
|
|
13
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
14
|
+
Requires-Dist: lfx<2.0.0,>=1.10.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# lfx-keenable
|
|
18
|
+
|
|
19
|
+
[Keenable](https://keenable.ai) web-search and page-fetch components for
|
|
20
|
+
[Langflow](https://langflow.org), packaged as a standalone **Langflow Extension
|
|
21
|
+
Bundle**. Two components ship in the `keenable` bundle group:
|
|
22
|
+
|
|
23
|
+
- **Keenable Search** — web search built for AI agents.
|
|
24
|
+
- **Keenable Fetch** — fetch a page and return its main content as markdown.
|
|
25
|
+
|
|
26
|
+
Both are **keyless by default**: with no API key they call Keenable's public
|
|
27
|
+
endpoints; provide a key to use the authenticated endpoints (required for
|
|
28
|
+
`mode="realtime"` and for higher rate limits).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install lfx-keenable
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The bundle registers automatically via the `langflow.extensions` entry-point.
|
|
37
|
+
Restart your Langflow server; the components appear in the palette under the
|
|
38
|
+
**keenable** group, and work as agent tools (the query / URL inputs are
|
|
39
|
+
tool-enabled).
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
- **API key (optional).** Set it on the component, or via the `KEENABLE_API_KEY`
|
|
44
|
+
environment variable. Blank → the keyless public endpoint is used.
|
|
45
|
+
- **Endpoint (optional).** `KEENABLE_API_URL` overrides the base URL (HTTPS
|
|
46
|
+
enforced; plain `http` only for loopback). The base URL is never a
|
|
47
|
+
component/LLM-settable input — that would be an SSRF foothold.
|
|
48
|
+
|
|
49
|
+
## Components
|
|
50
|
+
|
|
51
|
+
### Keenable Search
|
|
52
|
+
|
|
53
|
+
`query` plus optional per-query filters — `site`, `mode` (`pro` | `realtime`),
|
|
54
|
+
and publication / index date bounds (`published_after`/`before`,
|
|
55
|
+
`acquired_after`/`before`). Returns a table of results
|
|
56
|
+
(`title`, `url`, `description`, `published_at`, `acquired_at`). There is no
|
|
57
|
+
`max_results` input — the API returns a fixed-size result set as-is.
|
|
58
|
+
|
|
59
|
+
### Keenable Fetch
|
|
60
|
+
|
|
61
|
+
`url` → the page's main content as markdown plus metadata (`title`,
|
|
62
|
+
`description`, `author`, `published_at` when available). Rejects non-`http(s)`
|
|
63
|
+
schemes and private/internal hosts client-side before sending.
|
|
64
|
+
|
|
65
|
+
## Develop
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
cd lfx-keenable
|
|
69
|
+
pip install -e .
|
|
70
|
+
lfx extension validate .
|
|
71
|
+
pytest # unit tests (offline; transport mocked)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT © Keenable
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# lfx-keenable
|
|
2
|
+
|
|
3
|
+
[Keenable](https://keenable.ai) web-search and page-fetch components for
|
|
4
|
+
[Langflow](https://langflow.org), packaged as a standalone **Langflow Extension
|
|
5
|
+
Bundle**. Two components ship in the `keenable` bundle group:
|
|
6
|
+
|
|
7
|
+
- **Keenable Search** — web search built for AI agents.
|
|
8
|
+
- **Keenable Fetch** — fetch a page and return its main content as markdown.
|
|
9
|
+
|
|
10
|
+
Both are **keyless by default**: with no API key they call Keenable's public
|
|
11
|
+
endpoints; provide a key to use the authenticated endpoints (required for
|
|
12
|
+
`mode="realtime"` and for higher rate limits).
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install lfx-keenable
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The bundle registers automatically via the `langflow.extensions` entry-point.
|
|
21
|
+
Restart your Langflow server; the components appear in the palette under the
|
|
22
|
+
**keenable** group, and work as agent tools (the query / URL inputs are
|
|
23
|
+
tool-enabled).
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
- **API key (optional).** Set it on the component, or via the `KEENABLE_API_KEY`
|
|
28
|
+
environment variable. Blank → the keyless public endpoint is used.
|
|
29
|
+
- **Endpoint (optional).** `KEENABLE_API_URL` overrides the base URL (HTTPS
|
|
30
|
+
enforced; plain `http` only for loopback). The base URL is never a
|
|
31
|
+
component/LLM-settable input — that would be an SSRF foothold.
|
|
32
|
+
|
|
33
|
+
## Components
|
|
34
|
+
|
|
35
|
+
### Keenable Search
|
|
36
|
+
|
|
37
|
+
`query` plus optional per-query filters — `site`, `mode` (`pro` | `realtime`),
|
|
38
|
+
and publication / index date bounds (`published_after`/`before`,
|
|
39
|
+
`acquired_after`/`before`). Returns a table of results
|
|
40
|
+
(`title`, `url`, `description`, `published_at`, `acquired_at`). There is no
|
|
41
|
+
`max_results` input — the API returns a fixed-size result set as-is.
|
|
42
|
+
|
|
43
|
+
### Keenable Fetch
|
|
44
|
+
|
|
45
|
+
`url` → the page's main content as markdown plus metadata (`title`,
|
|
46
|
+
`description`, `author`, `published_at` when available). Rejects non-`http(s)`
|
|
47
|
+
schemes and private/internal hosts client-side before sending.
|
|
48
|
+
|
|
49
|
+
## Develop
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd lfx-keenable
|
|
53
|
+
pip install -e .
|
|
54
|
+
lfx extension validate .
|
|
55
|
+
pytest # unit tests (offline; transport mocked)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT © Keenable
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lfx-keenable"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Keenable web-search and page-fetch components as a standalone Langflow Extension Bundle."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10,<3.15"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Keenable", email = "hello@keenable.ai" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["langflow", "lfx", "extension", "bundle", "keenable", "search", "web-search", "agents"]
|
|
12
|
+
|
|
13
|
+
# Runtime deps: lfx (the BUNDLE_API surface) plus httpx for the HTTP transport.
|
|
14
|
+
# httpx is already an lfx dependency, but we list it as a direct runtime dep so
|
|
15
|
+
# the bundle keeps building even if lfx changes its transitive deps later.
|
|
16
|
+
dependencies = [
|
|
17
|
+
"lfx>=1.10.0,<2.0.0",
|
|
18
|
+
"httpx>=0.27,<1.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://keenable.ai"
|
|
23
|
+
Documentation = "https://docs.keenable.ai"
|
|
24
|
+
Repository = "https://github.com/keenableai/lfx-keenable"
|
|
25
|
+
|
|
26
|
+
# Manifest-shipping distributions are discovered via the ``langflow.extensions``
|
|
27
|
+
# entry-point. The value is the dotted path to the package containing
|
|
28
|
+
# ``extension.json``; the loader walks ``importlib.metadata.files()`` from there
|
|
29
|
+
# to find the manifest.
|
|
30
|
+
[project.entry-points."langflow.extensions"]
|
|
31
|
+
lfx-keenable = "lfx_keenable"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
test = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
# extension.json + components live inside the lfx_keenable package so
|
|
44
|
+
# ``importlib.metadata.files(dist)`` finds them and the loader resolves
|
|
45
|
+
# bundles[].path relative to the manifest's directory.
|
|
46
|
+
packages = ["src/lfx_keenable"]
|
|
47
|
+
include = ["src/lfx_keenable/extension.json", "src/lfx_keenable/components/**/*.py"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = [
|
|
51
|
+
"src/lfx_keenable",
|
|
52
|
+
"README.md",
|
|
53
|
+
"LICENSE",
|
|
54
|
+
"pyproject.toml",
|
|
55
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""lfx-keenable: Keenable Search + Fetch bundle for Langflow.
|
|
2
|
+
|
|
3
|
+
This package is the distribution unit ``lfx-keenable``. At runtime Langflow's
|
|
4
|
+
loader discovers ``extension.json`` shipped alongside this ``__init__.py`` and
|
|
5
|
+
registers the components under the namespaced IDs
|
|
6
|
+
``ext:keenable:KeenableSearchComponent@official`` and
|
|
7
|
+
``ext:keenable:KeenableFetchComponent@official``.
|
|
8
|
+
|
|
9
|
+
Both components are keyless by default — they call Keenable's public endpoints
|
|
10
|
+
with no API key, and use the authenticated endpoints when a key is configured.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from lfx_keenable.components.keenable.keenable_fetch import KeenableFetchComponent
|
|
14
|
+
from lfx_keenable.components.keenable.keenable_search import KeenableSearchComponent
|
|
15
|
+
|
|
16
|
+
__all__ = ["KeenableFetchComponent", "KeenableSearchComponent"]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Shared transport for the Keenable Langflow components.
|
|
2
|
+
|
|
3
|
+
One place for the parts of the Keenable contract that both the search and the
|
|
4
|
+
fetch component need: keyed-vs-keyless endpoint selection, the attribution
|
|
5
|
+
headers, HTTPS-only base-URL resolution, the client-side SSRF guard, and turning
|
|
6
|
+
a non-2xx response into a single, readable error string. The components import
|
|
7
|
+
these helpers; this module itself defines no ``Component`` subclass, so the
|
|
8
|
+
bundle loader ignores it for discovery.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ipaddress
|
|
14
|
+
import os
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
_VERSION = metadata.version("lfx-keenable")
|
|
23
|
+
except metadata.PackageNotFoundError: # pragma: no cover - editable/source checkout
|
|
24
|
+
_VERSION = "unknown"
|
|
25
|
+
|
|
26
|
+
# Tagged User-Agent so Keenable can attribute traffic from this integration.
|
|
27
|
+
_USER_AGENT = f"keenable-langflow/{_VERSION}"
|
|
28
|
+
|
|
29
|
+
# The load-bearing attribution signal: the Keenable backend segments traffic by
|
|
30
|
+
# this header (adoption dashboards). The User-Agent above is a secondary tag.
|
|
31
|
+
_ATTRIBUTION_TITLE = "Langflow"
|
|
32
|
+
|
|
33
|
+
# Endpoint comes from the environment, never from a component/LLM-settable input
|
|
34
|
+
# (an arbitrary base URL would be an SSRF foothold).
|
|
35
|
+
_DEFAULT_BASE_URL = "https://api.keenable.ai"
|
|
36
|
+
_BASE_URL_ENV = "KEENABLE_API_URL"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KeenableError(Exception):
|
|
40
|
+
"""A Keenable transport/API error, carrying a message safe to show a user."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_base_url() -> str:
|
|
44
|
+
"""Resolve the API base URL from ``KEENABLE_API_URL`` and enforce HTTPS."""
|
|
45
|
+
base = (os.environ.get(_BASE_URL_ENV) or _DEFAULT_BASE_URL).rstrip("/")
|
|
46
|
+
parsed = urlsplit(base)
|
|
47
|
+
# A usable absolute URL needs a host; bail out clearly on e.g. "https://"
|
|
48
|
+
# rather than letting a malformed base produce a broken request URL later.
|
|
49
|
+
if parsed.hostname:
|
|
50
|
+
if parsed.scheme == "https":
|
|
51
|
+
return base
|
|
52
|
+
# Permit plain http only for local development against a loopback host.
|
|
53
|
+
if parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}:
|
|
54
|
+
return base
|
|
55
|
+
msg = f"{_BASE_URL_ENV} must be an https:// URL with a host, got {base!r}"
|
|
56
|
+
raise KeenableError(msg)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def reject_private_fetch_target(url: str) -> None:
|
|
60
|
+
"""Refuse obviously private/internal fetch targets before sending (SSRF).
|
|
61
|
+
|
|
62
|
+
The backend enforces this server-side too, but a client-side guard avoids
|
|
63
|
+
leaking an internal hostname in a request and is required by our integration
|
|
64
|
+
contract. Hostnames that are not IP literals are passed through — the
|
|
65
|
+
backend's SSRF guard is the backstop for those.
|
|
66
|
+
"""
|
|
67
|
+
host = (urlsplit(url).hostname or "").strip().lower()
|
|
68
|
+
if not host:
|
|
69
|
+
msg = f"Refusing to fetch a URL with no host: {url!r}"
|
|
70
|
+
raise KeenableError(msg)
|
|
71
|
+
if host in {"localhost", "metadata.google.internal"}:
|
|
72
|
+
msg = f"Refusing to fetch a private/internal host: {host!r}"
|
|
73
|
+
raise KeenableError(msg)
|
|
74
|
+
try:
|
|
75
|
+
ip = ipaddress.ip_address(host)
|
|
76
|
+
except ValueError:
|
|
77
|
+
return
|
|
78
|
+
if (
|
|
79
|
+
ip.is_loopback
|
|
80
|
+
or ip.is_private
|
|
81
|
+
or ip.is_link_local
|
|
82
|
+
or ip.is_reserved
|
|
83
|
+
or ip.is_multicast
|
|
84
|
+
or ip.is_unspecified
|
|
85
|
+
):
|
|
86
|
+
msg = f"Refusing to fetch a private/internal address: {host!r}"
|
|
87
|
+
raise KeenableError(msg)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _headers(api_key: str | None) -> dict[str, str]:
|
|
91
|
+
headers = {"User-Agent": _USER_AGENT, "X-Keenable-Title": _ATTRIBUTION_TITLE}
|
|
92
|
+
if api_key:
|
|
93
|
+
headers["X-API-Key"] = api_key
|
|
94
|
+
return headers
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _select_path(api_key: str | None, public_path: str, keyed_path: str) -> str:
|
|
98
|
+
return keyed_path if api_key else public_path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
102
|
+
"""Map a non-2xx Keenable response to a readable :class:`KeenableError`."""
|
|
103
|
+
if response.is_success:
|
|
104
|
+
return
|
|
105
|
+
detail = ""
|
|
106
|
+
try:
|
|
107
|
+
body = response.json()
|
|
108
|
+
if isinstance(body, dict):
|
|
109
|
+
detail = str(body.get("message") or body.get("error") or body.get("detail") or "")
|
|
110
|
+
except ValueError:
|
|
111
|
+
detail = (response.text or "").strip()
|
|
112
|
+
label = {
|
|
113
|
+
401: "Keenable authentication failed (401)",
|
|
114
|
+
402: "Keenable: insufficient credits (402)",
|
|
115
|
+
429: "Keenable rate limit exceeded (429)",
|
|
116
|
+
}.get(response.status_code, f"Keenable API error ({response.status_code})")
|
|
117
|
+
raise KeenableError(f"{label}: {detail}" if detail else label)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _decode(response: httpx.Response) -> dict[str, Any]:
|
|
121
|
+
_raise_for_status(response)
|
|
122
|
+
try:
|
|
123
|
+
data = response.json()
|
|
124
|
+
except ValueError as e:
|
|
125
|
+
snippet = (response.text or "")[:200]
|
|
126
|
+
msg = f"Keenable API returned a non-JSON response: {snippet!r}"
|
|
127
|
+
raise KeenableError(msg) from e
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
msg = f"Unexpected response from the Keenable API: {data!r}"
|
|
130
|
+
raise KeenableError(msg)
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def keenable_post(
|
|
135
|
+
public_path: str,
|
|
136
|
+
keyed_path: str,
|
|
137
|
+
payload: dict[str, Any],
|
|
138
|
+
api_key: str | None,
|
|
139
|
+
timeout: float,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""POST ``payload`` to the keyed or keyless endpoint and return the body."""
|
|
142
|
+
path = _select_path(api_key, public_path, keyed_path)
|
|
143
|
+
url = f"{resolve_base_url()}{path}"
|
|
144
|
+
headers = {**_headers(api_key), "Content-Type": "application/json"}
|
|
145
|
+
try:
|
|
146
|
+
with httpx.Client(timeout=timeout) as client:
|
|
147
|
+
response = client.post(url, json=payload, headers=headers)
|
|
148
|
+
except httpx.RequestError as e:
|
|
149
|
+
msg = f"Could not reach the Keenable API: {e!r}"
|
|
150
|
+
raise KeenableError(msg) from e
|
|
151
|
+
return _decode(response)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def keenable_get(
|
|
155
|
+
public_path: str,
|
|
156
|
+
keyed_path: str,
|
|
157
|
+
params: dict[str, Any],
|
|
158
|
+
api_key: str | None,
|
|
159
|
+
timeout: float,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""GET the keyed or keyless endpoint with query ``params``; return the body."""
|
|
162
|
+
path = _select_path(api_key, public_path, keyed_path)
|
|
163
|
+
url = f"{resolve_base_url()}{path}"
|
|
164
|
+
try:
|
|
165
|
+
with httpx.Client(timeout=timeout) as client:
|
|
166
|
+
response = client.get(url, params=params, headers=_headers(api_key))
|
|
167
|
+
except httpx.RequestError as e:
|
|
168
|
+
msg = f"Could not reach the Keenable API: {e!r}"
|
|
169
|
+
raise KeenableError(msg) from e
|
|
170
|
+
return _decode(response)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def resolve_api_key(raw: Any) -> str | None:
|
|
174
|
+
"""The non-blank component key, else ``KEENABLE_API_KEY``, else ``None``.
|
|
175
|
+
|
|
176
|
+
Langflow resolves a ``SecretStrInput`` to a plain string at runtime; an
|
|
177
|
+
empty/whitespace value means "no key", in which case we fall back to the
|
|
178
|
+
environment and finally to the keyless public endpoints.
|
|
179
|
+
"""
|
|
180
|
+
key = raw.strip() if isinstance(raw, str) else ""
|
|
181
|
+
if not key:
|
|
182
|
+
key = (os.environ.get("KEENABLE_API_KEY") or "").strip()
|
|
183
|
+
return key or None
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Keenable page-fetch component for Langflow."""
|
|
2
|
+
|
|
3
|
+
from lfx.custom.custom_component.component import Component
|
|
4
|
+
from lfx.io import FloatInput, MessageTextInput, Output, SecretStrInput
|
|
5
|
+
from lfx.log.logger import logger
|
|
6
|
+
from lfx.schema.data import Data
|
|
7
|
+
from lfx.schema.dataframe import DataFrame
|
|
8
|
+
|
|
9
|
+
from lfx_keenable.components.keenable._client import (
|
|
10
|
+
KeenableError,
|
|
11
|
+
keenable_get,
|
|
12
|
+
reject_private_fetch_target,
|
|
13
|
+
resolve_api_key,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class KeenableFetchComponent(Component):
|
|
18
|
+
"""Fetch a web page via Keenable and return its main content as markdown.
|
|
19
|
+
|
|
20
|
+
The companion to :class:`KeenableSearchComponent`: give it a URL (e.g. one
|
|
21
|
+
found via search) and it returns ``{url, title, content, ...}``. Keyless by
|
|
22
|
+
default; rejects non-http(s) and private/internal URLs before sending.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
display_name = "Keenable Fetch"
|
|
26
|
+
description = "Fetch a web page via Keenable and return its content as markdown. Keyless by default."
|
|
27
|
+
documentation = "https://github.com/keenableai/lfx-keenable"
|
|
28
|
+
icon = "Keenable"
|
|
29
|
+
|
|
30
|
+
inputs = [
|
|
31
|
+
SecretStrInput(
|
|
32
|
+
name="api_key",
|
|
33
|
+
display_name="Keenable API Key",
|
|
34
|
+
required=False,
|
|
35
|
+
info=(
|
|
36
|
+
"Optional. With no key the keyless public fetch endpoint is used. "
|
|
37
|
+
"Falls back to the KEENABLE_API_KEY environment variable."
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
MessageTextInput(
|
|
41
|
+
name="url",
|
|
42
|
+
display_name="URL",
|
|
43
|
+
info="The URL of the page to fetch and extract as markdown.",
|
|
44
|
+
required=True,
|
|
45
|
+
tool_mode=True,
|
|
46
|
+
),
|
|
47
|
+
FloatInput(
|
|
48
|
+
name="timeout",
|
|
49
|
+
display_name="Timeout (s)",
|
|
50
|
+
info="Request timeout in seconds.",
|
|
51
|
+
value=30.0,
|
|
52
|
+
advanced=True,
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
outputs = [
|
|
57
|
+
Output(display_name="Page", name="dataframe", method="fetch"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def fetch_page(self) -> list[Data]:
|
|
61
|
+
"""Fetch one page and return its extracted content as a single Data."""
|
|
62
|
+
try:
|
|
63
|
+
url = (self.url or "").strip()
|
|
64
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
65
|
+
msg = f"Refusing to fetch a non-http(s) URL: {url!r}"
|
|
66
|
+
raise KeenableError(msg)
|
|
67
|
+
reject_private_fetch_target(url)
|
|
68
|
+
|
|
69
|
+
api_key = resolve_api_key(self.api_key)
|
|
70
|
+
data = keenable_get(
|
|
71
|
+
"/v1/fetch/public",
|
|
72
|
+
"/v1/fetch",
|
|
73
|
+
{"url": url},
|
|
74
|
+
api_key,
|
|
75
|
+
float(self.timeout or 30.0),
|
|
76
|
+
)
|
|
77
|
+
result = Data(text=data.get("content") or "", data=data)
|
|
78
|
+
self.status = result
|
|
79
|
+
except KeenableError as e:
|
|
80
|
+
logger.error(str(e))
|
|
81
|
+
error = Data(data={"error": str(e)})
|
|
82
|
+
self.status = error
|
|
83
|
+
return [error]
|
|
84
|
+
else:
|
|
85
|
+
return [result]
|
|
86
|
+
|
|
87
|
+
def fetch(self) -> DataFrame:
|
|
88
|
+
"""Fetched page as a single-row DataFrame (the component's output)."""
|
|
89
|
+
return DataFrame(self.fetch_page())
|
|
90
|
+
|
|
91
|
+
def run_model(self) -> DataFrame:
|
|
92
|
+
return self.fetch()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Keenable web-search component for Langflow."""
|
|
2
|
+
|
|
3
|
+
from lfx.custom.custom_component.component import Component
|
|
4
|
+
from lfx.io import DropdownInput, FloatInput, MessageTextInput, Output, SecretStrInput
|
|
5
|
+
from lfx.log.logger import logger
|
|
6
|
+
from lfx.schema.data import Data
|
|
7
|
+
from lfx.schema.dataframe import DataFrame
|
|
8
|
+
|
|
9
|
+
from lfx_keenable.components.keenable._client import KeenableError, keenable_post, resolve_api_key
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KeenableSearchComponent(Component):
|
|
13
|
+
"""Query the Keenable web-search API built for AI agents.
|
|
14
|
+
|
|
15
|
+
Keyless by default: with no API key the keyless public endpoint
|
|
16
|
+
(``/v1/search/public``) is used. Provide an API key (or set
|
|
17
|
+
``KEENABLE_API_KEY``) to use the authenticated endpoint — required for
|
|
18
|
+
``mode="realtime"`` and for higher rate limits.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
display_name = "Keenable Search"
|
|
22
|
+
description = "Web search built for AI agents, powered by Keenable. Keyless by default."
|
|
23
|
+
documentation = "https://github.com/keenableai/lfx-keenable"
|
|
24
|
+
icon = "Keenable"
|
|
25
|
+
|
|
26
|
+
inputs = [
|
|
27
|
+
SecretStrInput(
|
|
28
|
+
name="api_key",
|
|
29
|
+
display_name="Keenable API Key",
|
|
30
|
+
required=False,
|
|
31
|
+
info=(
|
|
32
|
+
"Optional. With no key the keyless public search endpoint is used. "
|
|
33
|
+
"Falls back to the KEENABLE_API_KEY environment variable. A key is "
|
|
34
|
+
"required for mode='realtime' and lifts rate limits."
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
MessageTextInput(
|
|
38
|
+
name="query",
|
|
39
|
+
display_name="Search Query",
|
|
40
|
+
info="The search query to run.",
|
|
41
|
+
tool_mode=True,
|
|
42
|
+
),
|
|
43
|
+
MessageTextInput(
|
|
44
|
+
name="site",
|
|
45
|
+
display_name="Site",
|
|
46
|
+
info="Restrict results to a single domain, e.g. 'github.com'.",
|
|
47
|
+
advanced=True,
|
|
48
|
+
tool_mode=True,
|
|
49
|
+
),
|
|
50
|
+
DropdownInput(
|
|
51
|
+
name="mode",
|
|
52
|
+
display_name="Search Mode",
|
|
53
|
+
info=(
|
|
54
|
+
"'pro' (default, deeper retrieval) or 'realtime' (low latency). "
|
|
55
|
+
"'realtime' requires an API key — it is not available keyless."
|
|
56
|
+
),
|
|
57
|
+
options=["pro", "realtime"],
|
|
58
|
+
value="pro",
|
|
59
|
+
advanced=True,
|
|
60
|
+
),
|
|
61
|
+
MessageTextInput(
|
|
62
|
+
name="published_after",
|
|
63
|
+
display_name="Published After",
|
|
64
|
+
info="Only pages published on or after this date (YYYY-MM-DD).",
|
|
65
|
+
advanced=True,
|
|
66
|
+
tool_mode=True,
|
|
67
|
+
),
|
|
68
|
+
MessageTextInput(
|
|
69
|
+
name="published_before",
|
|
70
|
+
display_name="Published Before",
|
|
71
|
+
info="Only pages published on or before this date (YYYY-MM-DD).",
|
|
72
|
+
advanced=True,
|
|
73
|
+
tool_mode=True,
|
|
74
|
+
),
|
|
75
|
+
MessageTextInput(
|
|
76
|
+
name="acquired_after",
|
|
77
|
+
display_name="Indexed After",
|
|
78
|
+
info="Only pages indexed by Keenable on or after this date (YYYY-MM-DD).",
|
|
79
|
+
advanced=True,
|
|
80
|
+
tool_mode=True,
|
|
81
|
+
),
|
|
82
|
+
MessageTextInput(
|
|
83
|
+
name="acquired_before",
|
|
84
|
+
display_name="Indexed Before",
|
|
85
|
+
info="Only pages indexed by Keenable on or before this date (YYYY-MM-DD).",
|
|
86
|
+
advanced=True,
|
|
87
|
+
tool_mode=True,
|
|
88
|
+
),
|
|
89
|
+
FloatInput(
|
|
90
|
+
name="timeout",
|
|
91
|
+
display_name="Timeout (s)",
|
|
92
|
+
info="Request timeout in seconds.",
|
|
93
|
+
value=30.0,
|
|
94
|
+
advanced=True,
|
|
95
|
+
),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
outputs = [
|
|
99
|
+
Output(display_name="Results", name="dataframe", method="search"),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
def fetch_results(self) -> list[Data]:
|
|
103
|
+
"""Call the Keenable search API and return one Data per result."""
|
|
104
|
+
try:
|
|
105
|
+
api_key = resolve_api_key(self.api_key)
|
|
106
|
+
payload: dict = {"query": self.query, "mode": self.mode or "pro"}
|
|
107
|
+
for field in (
|
|
108
|
+
"site",
|
|
109
|
+
"published_after",
|
|
110
|
+
"published_before",
|
|
111
|
+
"acquired_after",
|
|
112
|
+
"acquired_before",
|
|
113
|
+
):
|
|
114
|
+
value = getattr(self, field, None)
|
|
115
|
+
if value:
|
|
116
|
+
payload[field] = value
|
|
117
|
+
|
|
118
|
+
data = keenable_post(
|
|
119
|
+
"/v1/search/public",
|
|
120
|
+
"/v1/search",
|
|
121
|
+
payload,
|
|
122
|
+
api_key,
|
|
123
|
+
float(self.timeout or 30.0),
|
|
124
|
+
)
|
|
125
|
+
results = data.get("results")
|
|
126
|
+
if not isinstance(results, list):
|
|
127
|
+
msg = f"Unexpected response from the Keenable search API: {data!r}"
|
|
128
|
+
raise KeenableError(msg)
|
|
129
|
+
# The API returns a fixed-size result set as-is (no max_results param).
|
|
130
|
+
out = [Data(text=(item.get("title") or item.get("url") or ""), data=item) for item in results]
|
|
131
|
+
self.status = out
|
|
132
|
+
except KeenableError as e:
|
|
133
|
+
logger.error(str(e))
|
|
134
|
+
error = Data(data={"error": str(e)})
|
|
135
|
+
self.status = error
|
|
136
|
+
return [error]
|
|
137
|
+
else:
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
def search(self) -> DataFrame:
|
|
141
|
+
"""Search results as a DataFrame (the component's table output)."""
|
|
142
|
+
return DataFrame(self.fetch_results())
|
|
143
|
+
|
|
144
|
+
def run_model(self) -> DataFrame:
|
|
145
|
+
return self.search()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schemas.langflow.org/extension/v1.json",
|
|
3
|
+
"id": "lfx-keenable",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"name": "Keenable Search",
|
|
6
|
+
"description": "Keenable web-search and page-fetch components as a standalone Langflow Extension Bundle. Keyless by default.",
|
|
7
|
+
"lfx": {
|
|
8
|
+
"compat": ["1"]
|
|
9
|
+
},
|
|
10
|
+
"bundles": [
|
|
11
|
+
{
|
|
12
|
+
"name": "keenable",
|
|
13
|
+
"path": "components/keenable"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|