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.
@@ -0,0 +1,6 @@
1
+ dist/
2
+ .venv/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ *.pyc
@@ -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,4 @@
1
+ from .keenable_fetch import KeenableFetchComponent
2
+ from .keenable_search import KeenableSearchComponent
3
+
4
+ __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
+ }