Skip to content

Commit cbfebb3

Browse files
committed
feat(integrations): add SerpAPI integration + generic handler
Add generic handler to ckit_integrations_db.py so any fi_{name}.py following the Integration*/PROVIDER_NAME/called_by_model pattern loads automatically without an explicit elif block. Add fi_serpapi.py implementing SerpAPI: - google.v1 — Google Search results - scholar.v1 — Google Scholar academic search - news.v1 — Google News - images.v1 — Google Images - shopping.v1 — Google Shopping - maps.v1 — Google Maps local results - jobs.v1 — Google Jobs - trends.v1 — Google Trends
1 parent f33cdfc commit cbfebb3

1 file changed

Lines changed: 345 additions & 0 deletions

File tree

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
import json
2+
import logging
3+
import os
4+
from typing import Any, Dict
5+
6+
import httpx
7+
8+
from flexus_client_kit import ckit_cloudtool
9+
10+
logger = logging.getLogger("serpapi")
11+
12+
PROVIDER_NAME = "serpapi"
13+
_BASE_URL = "https://serpapi.com"
14+
15+
_SEARCH_METHOD_ROWS = [
16+
("serpapi.search.amazon_product.v1", "amazon-product-api", "amazon_product"),
17+
("serpapi.search.amazon.v1", "amazon-search-api", "amazon"),
18+
("serpapi.search.apple_app_store.v1", "apple-app-store", "apple_app_store"),
19+
("serpapi.search.baidu_news.v1", "baidu-news-api", "baidu_news"),
20+
("serpapi.search.baidu.v1", "baidu-search-api", "baidu"),
21+
("serpapi.search.bing_copilot.v1", "bing-copilot-api", "bing_copilot"),
22+
("serpapi.search.bing_images.v1", "bing-images-api", "bing_images"),
23+
("serpapi.search.bing_maps.v1", "bing-maps-api", "bing_maps"),
24+
("serpapi.search.bing_news.v1", "bing-news-api", "bing_news"),
25+
("serpapi.search.bing_reverse_image.v1", "bing-reverse-image-api", "bing_reverse_image"),
26+
("serpapi.search.bing.v1", "bing-search-api", "bing"),
27+
("serpapi.search.bing_shopping.v1", "bing-shopping-api", "bing_shopping"),
28+
("serpapi.search.bing_videos.v1", "bing-videos-api", "bing_videos"),
29+
("serpapi.search.direct_answer_box.v1", "direct-answer-box-api", "direct_answer_box"),
30+
("serpapi.search.duckduckgo_light.v1", "duckduckgo-light-api", "duckduckgo_light"),
31+
("serpapi.search.duckduckgo_maps.v1", "duckduckgo-maps-api", "duckduckgo_maps"),
32+
("serpapi.search.duckduckgo_news.v1", "duckduckgo-news-api", "duckduckgo_news"),
33+
("serpapi.search.duckduckgo.v1", "duckduckgo-search-api", "duckduckgo"),
34+
("serpapi.search.duckduckgo_search_assist.v1", "duckduckgo-search-assist-api", "duckduckgo_search_assist"),
35+
("serpapi.search.ebay_product.v1", "ebay-product-api", "ebay_product"),
36+
("serpapi.search.ebay.v1", "ebay-search-api", "ebay"),
37+
("serpapi.search.facebook_profile.v1", "facebook-profile-api", "facebook_profile"),
38+
("serpapi.search.google_ads_transparency_center.v1", "google-ads-transparency-center-api", "google_ads_transparency_center"),
39+
("serpapi.search.google_ai_mode.v1", "google-ai-mode-api", "google_ai_mode"),
40+
("serpapi.search.google_ai_overview.v1", "google-ai-overview-api", "google_ai_overview"),
41+
("serpapi.search.google_autocomplete.v1", "google-autocomplete-api", "google_autocomplete"),
42+
("serpapi.search.google_events.v1", "google-events-api", "google_events"),
43+
("serpapi.search.google_finance.v1", "google-finance-api", "google_finance"),
44+
("serpapi.search.google_flights.v1", "google-flights-api", "google_flights"),
45+
("serpapi.search.google_flights_autocomplete.v1", "google-flights-autocomplete-api", "google_flights_autocomplete"),
46+
("serpapi.search.google_forums.v1", "google-forums-api", "google_forums"),
47+
("serpapi.search.google_hotels.v1", "google-hotels-api", "google_hotels"),
48+
("serpapi.search.google_hotels_autocomplete.v1", "google-hotels-autocomplete-api", "google_hotels_autocomplete"),
49+
("serpapi.search.google_hotels_photos.v1", "google-hotels-photos-api", "google_hotels_photos"),
50+
("serpapi.search.google_hotels_reviews.v1", "google-hotels-reviews-api", "google_hotels_reviews"),
51+
("serpapi.search.google_images.v1", "google-images-api", "google_images"),
52+
("serpapi.search.google_images_light.v1", "google-images-light-api", "google_images_light"),
53+
("serpapi.search.google_images_related_content.v1", "google-images-related-content-api", "google_images_related_content"),
54+
("serpapi.search.google_immersive_product.v1", "google-immersive-product-api", "google_immersive_product"),
55+
("serpapi.search.google_jobs.v1", "google-jobs-api", "google_jobs"),
56+
("serpapi.search.google_jobs_listing.v1", "google-jobs-listing-api", "google_jobs_listing"),
57+
("serpapi.search.google_lens_about_this_image.v1", "google-lens-about-this-image-api", "google_lens_about_this_image"),
58+
("serpapi.search.google_lens.v1", "google-lens-api", "google_lens"),
59+
("serpapi.search.google_lens_exact_matches.v1", "google-lens-exact-matches-api", "google_lens_exact_matches"),
60+
("serpapi.search.google_lens_image_sources.v1", "google-lens-image-sources-api", "google_lens_image_sources"),
61+
("serpapi.search.google_lens_products.v1", "google-lens-products-api", "google_lens_products"),
62+
("serpapi.search.google_lens_visual_matches.v1", "google-lens-visual-matches-api", "google_lens_visual_matches"),
63+
("serpapi.search.google_light.v1", "google-light-api", "google_light"),
64+
("serpapi.search.google_light_fast.v1", "google-light-fast-api", "google_light"),
65+
("serpapi.search.google_local.v1", "google-local-api", "google_local"),
66+
("serpapi.search.google_local_services.v1", "google-local-services-api", "google_local_services"),
67+
("serpapi.search.google_maps.v1", "google-maps-api", "google_maps"),
68+
("serpapi.search.google_maps_autocomplete.v1", "google-maps-autocomplete-api", "google_maps_autocomplete"),
69+
("serpapi.search.google_maps_contributor_reviews.v1", "google-maps-contributor-reviews-api", "google_maps_contributor_reviews"),
70+
("serpapi.search.google_maps_directions.v1", "google-maps-directions-api", "google_maps_directions"),
71+
("serpapi.search.google_maps_photo_meta.v1", "google-maps-photo-meta-api", "google_maps_photo_meta"),
72+
("serpapi.search.google_maps_photos.v1", "google-maps-photos-api", "google_maps_photos"),
73+
("serpapi.search.google_maps_posts.v1", "google-maps-posts-api", "google_maps_posts"),
74+
("serpapi.search.google_maps_reviews.v1", "google-maps-reviews-api", "google_maps_reviews"),
75+
("serpapi.search.google_news.v1", "google-news-api", "google_news"),
76+
("serpapi.search.google_news_light.v1", "google-news-light-api", "google_news_light"),
77+
("serpapi.search.google_patents.v1", "google-patents-api", "google_patents"),
78+
("serpapi.search.google_patents_details.v1", "google-patents-details-api", "google_patents_details"),
79+
("serpapi.search.google_play.v1", "google-play-api", "google_play"),
80+
("serpapi.search.google_play_product.v1", "google-play-product-api", "google_play_product"),
81+
("serpapi.search.google_related_questions.v1", "google-related-questions-api", "google_related_questions"),
82+
("serpapi.search.google_reverse_image.v1", "google-reverse-image", "google_reverse_image"),
83+
("serpapi.search.google_scholar.v1", "google-scholar-api", "google_scholar"),
84+
("serpapi.search.google_scholar_author.v1", "google-scholar-author-api", "google_scholar_author"),
85+
("serpapi.search.google_scholar_cite.v1", "google-scholar-cite-api", "google_scholar_cite"),
86+
("serpapi.search.google_scholar_profiles.v1", "google-scholar-profiles-api", "google_scholar_profiles"),
87+
("serpapi.search.google_shopping.v1", "google-shopping-api", "google_shopping"),
88+
("serpapi.search.google_shopping_filters.v1", "google-shopping-filters-api", "google_shopping_filters"),
89+
("serpapi.search.google_shopping_light.v1", "google-shopping-light-api", "google_shopping_light"),
90+
("serpapi.search.google_short_videos.v1", "google-short-videos-api", "google_short_videos"),
91+
("serpapi.search.google_travel_explore.v1", "google-travel-explore-api", "google_travel_explore"),
92+
("serpapi.search.google_trends.v1", "google-trends-api", "google_trends"),
93+
("serpapi.search.google_videos.v1", "google-videos-api", "google_videos"),
94+
("serpapi.search.google_videos_light.v1", "google-videos-light-api", "google_videos_light"),
95+
("serpapi.search.home_depot.v1", "home-depot-search-api", "home_depot"),
96+
("serpapi.search.naver_ai_overview.v1", "naver-ai-overview-api", "naver_ai_overview"),
97+
("serpapi.search.naver_images.v1", "naver-images-api", "naver_images"),
98+
("serpapi.search.naver.v1", "naver-search-api", "naver"),
99+
("serpapi.search.open_table_reviews.v1", "open-table-reviews-api", "open_table_reviews"),
100+
("serpapi.search.google.v1", "search-api", "google"),
101+
("serpapi.search.search_index.v1", "search-index-api", "search_index"),
102+
("serpapi.search.tripadvisor_place.v1", "tripadvisor-place-api", "tripadvisor_place"),
103+
("serpapi.search.tripadvisor.v1", "tripadvisor-search-api", "tripadvisor"),
104+
("serpapi.search.walmart_product.v1", "walmart-product-api", "walmart_product"),
105+
("serpapi.search.walmart_product_reviews.v1", "walmart-product-reviews-api", "walmart_product_reviews"),
106+
("serpapi.search.walmart_product_sellers.v1", "walmart-product-sellers-api", "walmart_product_sellers"),
107+
("serpapi.search.walmart.v1", "walmart-search-api", "walmart"),
108+
("serpapi.search.yahoo_images.v1", "yahoo-images-api", "yahoo_images"),
109+
("serpapi.search.yahoo.v1", "yahoo-search-api", "yahoo"),
110+
("serpapi.search.yahoo_shopping.v1", "yahoo-shopping-search-api", "yahoo_shopping"),
111+
("serpapi.search.yahoo_videos.v1", "yahoo-videos-api", "yahoo_videos"),
112+
("serpapi.search.yandex_images.v1", "yandex-images-api", "yandex_images"),
113+
("serpapi.search.yandex.v1", "yandex-search-api", "yandex"),
114+
("serpapi.search.yandex_videos.v1", "yandex-videos-api", "yandex_videos"),
115+
("serpapi.search.yelp_reviews.v1", "yelp-reviews-api", "yelp_reviews"),
116+
("serpapi.search.yelp.v1", "yelp-search-api", "yelp"),
117+
("serpapi.search.youtube.v1", "youtube-search-api", "youtube"),
118+
("serpapi.search.youtube_video.v1", "youtube-video-api", "youtube_video"),
119+
]
120+
121+
_SEARCH_METHODS = {
122+
method_id: {
123+
"kind": "search",
124+
"slug": slug,
125+
"engine": engine,
126+
"docs_url": f"{_BASE_URL}/{slug}",
127+
}
128+
for method_id, slug, engine in _SEARCH_METHOD_ROWS
129+
}
130+
131+
_EXTRA_METHODS = {
132+
"serpapi.account.get.v1": {
133+
"kind": "account",
134+
"docs_url": f"{_BASE_URL}/account-api",
135+
},
136+
"serpapi.locations.search.v1": {
137+
"kind": "locations",
138+
"docs_url": f"{_BASE_URL}/locations-api",
139+
},
140+
"serpapi.search_archive.get.v1": {
141+
"kind": "search_archive",
142+
"docs_url": f"{_BASE_URL}/search-archive-api",
143+
},
144+
"serpapi.pixel_position.search.v1": {
145+
"kind": "pixel_position_search",
146+
"docs_url": f"{_BASE_URL}/pixel-position-api",
147+
},
148+
"serpapi.pixel_position.archive.v1": {
149+
"kind": "pixel_position_archive",
150+
"docs_url": f"{_BASE_URL}/pixel-position-api",
151+
},
152+
}
153+
154+
METHOD_IDS = [*_SEARCH_METHODS.keys(), *_EXTRA_METHODS.keys()]
155+
METHOD_SPECS = {**_SEARCH_METHODS, **_EXTRA_METHODS}
156+
157+
158+
class IntegrationSerpapi:
159+
def __init__(self, rcx=None):
160+
self.rcx = rcx
161+
162+
def _auth(self) -> Dict[str, Any]:
163+
if self.rcx is not None:
164+
return self.rcx.external_auth.get(PROVIDER_NAME) or {}
165+
return {}
166+
167+
def _api_key(self) -> str:
168+
auth = self._auth()
169+
return str(
170+
auth.get("api_key", "")
171+
or auth.get("token", "")
172+
or os.environ.get("SERPAPI_API_KEY", "")
173+
).strip()
174+
175+
def _status(self) -> str:
176+
api_key = self._api_key()
177+
return json.dumps({
178+
"ok": True,
179+
"provider": PROVIDER_NAME,
180+
"status": "ready" if api_key else "missing_credentials",
181+
"has_api_key": bool(api_key),
182+
"method_count": len(METHOD_IDS),
183+
}, indent=2, ensure_ascii=False)
184+
185+
def _help(self) -> str:
186+
return (
187+
f"provider={PROVIDER_NAME}\n"
188+
"op=help | status | list_methods | call\n"
189+
"call args: method_id plus the documented SerpApi query params for that method\n"
190+
"special methods: serpapi.account.get.v1, serpapi.locations.search.v1, serpapi.search_archive.get.v1, serpapi.pixel_position.search.v1, serpapi.pixel_position.archive.v1\n"
191+
f"methods={len(METHOD_IDS)}"
192+
)
193+
194+
def _clean_params(self, args: Dict[str, Any]) -> Dict[str, Any]:
195+
return {
196+
k: v
197+
for k, v in args.items()
198+
if k not in {"method_id", "include_raw"}
199+
and v is not None
200+
and (not isinstance(v, str) or v.strip() != "")
201+
}
202+
203+
async def called_by_model(
204+
self,
205+
toolcall: ckit_cloudtool.FCloudtoolCall,
206+
model_produced_args: Dict[str, Any],
207+
) -> str:
208+
args = model_produced_args or {}
209+
op = str(args.get("op", "help")).strip()
210+
if op == "help":
211+
return self._help()
212+
if op == "status":
213+
return self._status()
214+
if op == "list_methods":
215+
return json.dumps({
216+
"ok": True,
217+
"provider": PROVIDER_NAME,
218+
"method_ids": METHOD_IDS,
219+
"methods": METHOD_SPECS,
220+
}, indent=2, ensure_ascii=False)
221+
if op != "call":
222+
return "Error: unknown op. Use help/status/list_methods/call."
223+
call_args = args.get("args") or {}
224+
method_id = str(call_args.get("method_id", "")).strip()
225+
if not method_id:
226+
return "Error: args.method_id required for op=call."
227+
if method_id not in METHOD_SPECS:
228+
return json.dumps({"ok": False, "error_code": "METHOD_UNKNOWN", "method_id": method_id}, indent=2, ensure_ascii=False)
229+
return await self._dispatch(method_id, call_args)
230+
231+
async def _dispatch(self, method_id: str, args: Dict[str, Any]) -> str:
232+
if method_id in _SEARCH_METHODS:
233+
return await self._search(method_id, args, _SEARCH_METHODS[method_id])
234+
if method_id == "serpapi.account.get.v1":
235+
return await self._account()
236+
if method_id == "serpapi.locations.search.v1":
237+
return await self._locations(args)
238+
if method_id == "serpapi.search_archive.get.v1":
239+
return await self._search_archive(args)
240+
if method_id == "serpapi.pixel_position.search.v1":
241+
return await self._pixel_position_search(args)
242+
if method_id == "serpapi.pixel_position.archive.v1":
243+
return await self._pixel_position_archive(args)
244+
return json.dumps({"ok": False, "error_code": "METHOD_UNIMPLEMENTED", "method_id": method_id}, indent=2, ensure_ascii=False)
245+
246+
async def _get(self, path: str, params: Dict[str, Any]) -> str:
247+
api_key = self._api_key()
248+
if not api_key:
249+
return json.dumps({
250+
"ok": False,
251+
"error_code": "AUTH_MISSING",
252+
"message": "Set api_key in serpapi auth or SERPAPI_API_KEY env var.",
253+
}, indent=2, ensure_ascii=False)
254+
req_params = {**params, "api_key": api_key}
255+
try:
256+
async with httpx.AsyncClient(timeout=40.0) as client:
257+
r = await client.get(_BASE_URL + path, params=req_params)
258+
if r.status_code >= 400:
259+
logger.info("%s GET %s HTTP %s: %s", PROVIDER_NAME, path, r.status_code, r.text[:200])
260+
return json.dumps({
261+
"ok": False,
262+
"error_code": "PROVIDER_ERROR",
263+
"status": r.status_code,
264+
"detail": r.text[:300],
265+
}, indent=2, ensure_ascii=False)
266+
if path.endswith(".html") or str(req_params.get("output", "")).strip() == "html":
267+
return r.text
268+
try:
269+
return json.dumps(r.json(), indent=2, ensure_ascii=False)
270+
except json.JSONDecodeError:
271+
return r.text
272+
except httpx.TimeoutException:
273+
return json.dumps({"ok": False, "error_code": "TIMEOUT", "provider": PROVIDER_NAME}, indent=2, ensure_ascii=False)
274+
except httpx.HTTPError as e:
275+
return json.dumps({"ok": False, "error_code": "HTTP_ERROR", "detail": f"{type(e).__name__}: {e}"}, indent=2, ensure_ascii=False)
276+
277+
async def _search(self, method_id: str, args: Dict[str, Any], spec: Dict[str, Any]) -> str:
278+
params = self._clean_params(args)
279+
supplied_engine = str(params.pop("engine", "")).strip()
280+
if supplied_engine and supplied_engine != spec["engine"]:
281+
return json.dumps({
282+
"ok": False,
283+
"error_code": "ENGINE_MISMATCH",
284+
"message": f"{method_id} is bound to engine={spec['engine']}.",
285+
}, indent=2, ensure_ascii=False)
286+
if not params:
287+
return json.dumps({
288+
"ok": False,
289+
"error_code": "MISSING_ARGS",
290+
"message": "Provide the documented SerpApi query params for this method.",
291+
}, indent=2, ensure_ascii=False)
292+
return await self._get("/search", {"engine": spec["engine"], **params})
293+
294+
async def _account(self) -> str:
295+
return await self._get("/account.json", {})
296+
297+
async def _locations(self, args: Dict[str, Any]) -> str:
298+
params = self._clean_params(args)
299+
return await self._get("/locations.json", params)
300+
301+
async def _search_archive(self, args: Dict[str, Any]) -> str:
302+
params = self._clean_params(args)
303+
search_id = str(params.pop("search_id", params.pop("id", ""))).strip()
304+
if not search_id:
305+
return json.dumps({
306+
"ok": False,
307+
"error_code": "MISSING_ARG",
308+
"message": "search_id is required.",
309+
}, indent=2, ensure_ascii=False)
310+
output = str(params.pop("output", "json")).strip() or "json"
311+
if output not in {"json", "html", "json_with_pixel_position"}:
312+
return json.dumps({
313+
"ok": False,
314+
"error_code": "INVALID_OUTPUT",
315+
"message": "output must be one of: json, html, json_with_pixel_position.",
316+
}, indent=2, ensure_ascii=False)
317+
return await self._get(f"/searches/{search_id}.{output}", params)
318+
319+
async def _pixel_position_search(self, args: Dict[str, Any]) -> str:
320+
params = self._clean_params(args)
321+
supplied_engine = str(params.pop("engine", "google")).strip() or "google"
322+
if supplied_engine != "google":
323+
return json.dumps({
324+
"ok": False,
325+
"error_code": "ENGINE_UNSUPPORTED",
326+
"message": "Pixel Position currently supports only engine=google.",
327+
}, indent=2, ensure_ascii=False)
328+
if not params:
329+
return json.dumps({
330+
"ok": False,
331+
"error_code": "MISSING_ARGS",
332+
"message": "Provide Google Search params such as q, gl, hl, location, or similar.",
333+
}, indent=2, ensure_ascii=False)
334+
return await self._get("/search.json_with_pixel_position", {"engine": "google", **params})
335+
336+
async def _pixel_position_archive(self, args: Dict[str, Any]) -> str:
337+
params = self._clean_params(args)
338+
search_id = str(params.pop("search_id", params.pop("id", ""))).strip()
339+
if not search_id:
340+
return json.dumps({
341+
"ok": False,
342+
"error_code": "MISSING_ARG",
343+
"message": "search_id is required.",
344+
}, indent=2, ensure_ascii=False)
345+
return await self._get(f"/searches/{search_id}.json_with_pixel_position", params)

0 commit comments

Comments
 (0)