|
| 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