From 14ac36581ab8aca3a8f7702987042785c41c1ed7 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Thu, 10 Sep 2026 13:26:43 +0200 Subject: [PATCH 1/2] Add initial Markdown Output support --- README.md | 37 +++++++++++++++ README.md.erb | 37 +++++++++++++++ src/lib.rs | 3 ++ src/serpapi.rs | 105 ++++++++++++++++++++++++++++++++++++++++-- tests/serpapi-test.rs | 56 ++++++++++++++++++++++ 5 files changed, 233 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d51db54..0e2ee10 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,33 @@ async fn main() -> Result<(), Box> { [Google search documentation](https://serpapi.com/search-api). More hands on examples are available below. +### Response formats + +Use `search` for structured results decoded into a `serde_json::Value`: + +```rust +let results = client.search(parameter).await?; +``` + +Use `md` for a token-efficient Markdown `String` optimized for LLMs and AI agents. +It returns clean headings, links and tables behind a YAML frontmatter while using +roughly half the tokens of the JSON output: + +```rust +let markdown = client.md(parameter).await?; +``` + +Use `html` when you need the raw response from the search engine: + +```rust +let raw_html = client.html(parameter).await?; +``` + +`md` and `html` always force the output format, so any `output` search parameter is ignored. +Archived results are also available as Markdown with `client.search_archive_md(&id).await?`. + +Learn more about [SerpApi Markdown output](https://serpapi.com/markdown-output). + #### Documentations * [Full documentation on SerpApi.com](https://serpapi.com) @@ -139,6 +166,12 @@ println!("{}", archived_results); assert_eq!(archive_id, search_id); ``` +The same archived search is available as Markdown: + +```rust +let markdown = client.search_archive_md(&id).await.expect("request"); +``` + ### Account API ```rust let client = Client::new(HashMap::::new()); @@ -150,11 +183,15 @@ let account = client.account(parameter).await.expect("request"); It returns your account information. ### Technical features +- Search results as JSON with `search`, Markdown with `md`, or raw search engine HTML with `html` - Dynamic JSON decoding using Serde JSON - Asyncronous HTTP request handle method using tokio and reqwest - Async tests using Tokio ### Changes log +- Unreleased: + - Add Markdown output for LLMs and AI agents with `client.md(parameter)` and `client.search_archive_md(&id)`. + - `client.html(parameter)` now calls the search endpoint with `output=html` which returns the raw search engine HTML. - 1.1.0: Always reuse the same client object instead of creating a new one for each search. - This is a breaking change for the API because the client must be unwrapped in the main function. ```rust diff --git a/README.md.erb b/README.md.erb index 1728d0a..2f258d1 100644 --- a/README.md.erb +++ b/README.md.erb @@ -102,6 +102,33 @@ async fn main() -> Result<(), Box> { [Google search documentation](https://serpapi.com/search-api). More hands on examples are available below. +### Response formats + +Use `search` for structured results decoded into a `serde_json::Value`: + +```rust +let results = client.search(parameter).await?; +``` + +Use `md` for a token-efficient Markdown `String` optimized for LLMs and AI agents. +It returns clean headings, links and tables behind a YAML frontmatter while using +roughly half the tokens of the JSON output: + +```rust +let markdown = client.md(parameter).await?; +``` + +Use `html` when you need the raw response from the search engine: + +```rust +let raw_html = client.html(parameter).await?; +``` + +`md` and `html` always force the output format, so any `output` search parameter is ignored. +Archived results are also available as Markdown with `client.search_archive_md(&id).await?`. + +Learn more about [SerpApi Markdown output](https://serpapi.com/markdown-output). + #### Documentations * [Full documentation on SerpApi.com](https://serpapi.com) @@ -154,6 +181,12 @@ println!("{}", archived_results); assert_eq!(archive_id, search_id); ``` +The same archived search is available as Markdown: + +```rust +let markdown = client.search_archive_md(&id).await.expect("request"); +``` + ### Account API ```rust let client = Client::new(HashMap::::new()); @@ -165,11 +198,15 @@ let account = client.account(parameter).await.expect("request"); It returns your account information. ### Technical features +- Search results as JSON with `search`, Markdown with `md`, or raw search engine HTML with `html` - Dynamic JSON decoding using Serde JSON - Asyncronous HTTP request handle method using tokio and reqwest - Async tests using Tokio ### Changes log +- Unreleased: + - Add Markdown output for LLMs and AI agents with `client.md(parameter)` and `client.search_archive_md(&id)`. + - `client.html(parameter)` now calls the search endpoint with `output=html` which returns the raw search engine HTML. - 1.1.0: Always reuse the same client object instead of creating a new one for each search. - This is a breaking change for the API because the client must be unwrapped in the main function. ```rust diff --git a/src/lib.rs b/src/lib.rs index 34e812d..58fa421 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,4 +5,7 @@ //! SerpApi.com enables to do localized search, leverage advanced search engine features and a lot more... //! A completed documentation is available at [SerpApi](https://serpapi.com). //! +//! Search results are available as JSON with `search`, as [Markdown](https://serpapi.com/markdown-output) +//! optimized for LLMs and AI agents with `md`, or as raw search engine HTML with `html`. +//! pub mod serpapi; diff --git a/src/serpapi.rs b/src/serpapi.rs index ae9a5ba..759b168 100644 --- a/src/serpapi.rs +++ b/src/serpapi.rs @@ -67,11 +67,47 @@ impl Client { Ok(results) } - // execute a search and return the result as raw HTML formatted as String + /// execute a search on serpapi.com + /// and return the results as Markdown formatted as String. + /// The Markdown output is optimized for LLMs and AI agents. + /// It holds a YAML frontmatter followed by headings, links and tables + /// using about half the tokens of the JSON output. + /// see: https://serpapi.com/markdown-output /// # Arguments - /// * `parameter` html search parameter + /// * `parameter` search parameter, the output is always set to md. + /// /// # Examples: + /// ```no_run + /// use std::collections::HashMap; + /// use serpapi::serpapi::Client; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut default = HashMap::::new(); + /// default.insert("engine".to_string(), "google".to_string()); + /// default.insert("api_key".to_string(), "secret_api_key".to_string()); + /// // initialize the serpapi client + /// let client = Client::new(default).unwrap(); + /// let mut parameter = HashMap::::new(); + /// parameter.insert("q".to_string(), "coffee".to_string()); + /// // md returns the search results as a Markdown String. + /// let markdown = client.md(parameter).await.expect("request"); + /// assert!(markdown.starts_with("---")); + /// } /// ``` + pub async fn md( + &self, + parameter: HashMap, + ) -> Result> { + let body = self.text("/search", force_output(parameter, "md")).await?; + Ok(body) + } + + // execute a search and return the result as raw HTML formatted as String + /// # Arguments + /// * `parameter` html search parameter, the output is always set to html. + /// # Examples: + /// ```no_run /// use std::collections::HashMap; /// use serpapi::serpapi::Client; /// @@ -93,7 +129,9 @@ impl Client { &self, parameter: HashMap, ) -> Result> { - let body = self.get("/html", parameter).await?; + let body = self + .text("/search", force_output(parameter, "html")) + .await?; Ok(body) } @@ -136,6 +174,21 @@ impl Client { Ok(results) } + /// Retrieve a search result from the Search Archive API as Markdown. + /// see: https://serpapi.com/markdown-output + /// # Arguments + /// * `search_id` from the original search: `results["search_metadata"]["id"]` + pub async fn search_archive_md( + &self, + search_id: &str, + ) -> Result> { + let mut endpoint = "/searches/".to_string(); + endpoint.push_str(search_id); + endpoint.push_str(".md"); + let body = self.text(&endpoint, HashMap::new()).await?; + Ok(body) + } + // Get account information using Account API pub async fn account( &self, @@ -152,15 +205,44 @@ impl Client { ) -> Result> { let body = self.get(endpoint, parameter).await?; //debug: println!("Body:\n{}", body); - let value: serde_json::Value = serde_json::from_str(&body).unwrap(); + // a non JSON body means the output is html or md, see: Client::html and Client::md + let value: serde_json::Value = serde_json::from_str(&body)?; Ok(value) } + /// execute a request and return the body as String. + /// SerpApi reports errors as JSON even when html or md output is requested, + /// so a JSON response to a text request is reported as an error. + /// # Arguments + /// * `endpoint` HTTP service URI + /// * `parameter` search parameter + pub async fn text( + &self, + endpoint: &str, + parameter: HashMap, + ) -> Result> { + let (content_type, body) = self.raw(endpoint, parameter).await?; + if content_type.starts_with("application/json") { + return Err(format!("search failed on {} with: {}", endpoint, body).into()); + } + Ok(body) + } + pub async fn get( &self, endpoint: &str, parameter: HashMap, ) -> Result> { + let (_content_type, body) = self.raw(endpoint, parameter).await?; + Ok(body) + } + + /// execute a request and return the content type along with the body. + async fn raw( + &self, + endpoint: &str, + parameter: HashMap, + ) -> Result<(String, String), Box> { let mut query = HashMap::::new(); query.insert("source".to_string(), "rust".to_string()); for (key, value) in self.parameter.iter() { @@ -175,7 +257,20 @@ impl Client { let mut url = HOST.to_string(); url.push_str(endpoint); let res = self.http.get(url).query(&query).send().await?; + let content_type = res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_lowercase(); let body = res.text().await?; - Ok(body) + Ok((content_type, body)) } } + +/// force the output format whatever the caller provides. +/// the format drives the return type: json -> serde_json::Value, html / md -> String. +fn force_output(mut parameter: HashMap, format: &str) -> HashMap { + parameter.insert("output".to_string(), format.to_string()); + parameter +} diff --git a/tests/serpapi-test.rs b/tests/serpapi-test.rs index 8642e63..f67aede 100644 --- a/tests/serpapi-test.rs +++ b/tests/serpapi-test.rs @@ -56,6 +56,42 @@ async fn html() { assert!(html.len() > 100); } +#[tokio::test] +async fn markdown() { + let mut default = HashMap::::new(); + default.insert("engine".to_string(), "google".to_string()); + default.insert("api_key".to_string(), api_key()); + + // initialize the search engine + let client = Client::new(default).unwrap(); + + let mut parameter = HashMap::::new(); + parameter.insert("q".to_string(), "coffee".to_string()); + parameter.insert( + "location".to_string(), + "Austin, TX, Texas, United States".to_string(), + ); + // md returns the search results as a Markdown String. + let markdown = client.md(parameter).await.expect("request"); + // the Markdown output starts with a YAML frontmatter + assert!(markdown.starts_with("---")); + assert!(markdown.contains("coffee")); +} + +#[tokio::test] +async fn markdown_ignores_the_output_parameter() { + let mut default = HashMap::::new(); + default.insert("engine".to_string(), "google".to_string()); + default.insert("api_key".to_string(), api_key()); + let client = Client::new(default).unwrap(); + + let mut parameter = HashMap::::new(); + parameter.insert("q".to_string(), "coffee".to_string()); + parameter.insert("output".to_string(), "json".to_string()); + let markdown = client.md(parameter).await.expect("request"); + assert!(markdown.starts_with("---")); +} + #[tokio::test] async fn location() { let default = HashMap::::new(); @@ -107,3 +143,23 @@ async fn search_archive() { println!("{}", archived_results); assert_eq!(archive_id, search_id); } + +#[tokio::test] +async fn search_archive_md() { + let mut default = HashMap::::new(); + default.insert("engine".to_string(), "google".to_string()); + default.insert("api_key".to_string(), api_key()); + let client = Client::new(default).unwrap(); + + let mut parameter = HashMap::::new(); + parameter.insert("q".to_string(), "coffee".to_string()); + let initial_results = client.search(parameter).await.expect("request"); + let id = initial_results["search_metadata"]["id"] + .as_str() + .expect("search id"); + + // search in archive as Markdown + let markdown = client.search_archive_md(id).await.expect("request"); + assert!(markdown.starts_with("---")); + assert!(markdown.contains(id)); +} From df1cf9b27a823bfef352e55e652e2476b6cca5fa Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Wed, 7 Oct 2026 13:53:40 +0200 Subject: [PATCH 2/2] Add support for image upload API --- .gitignore | 1 + Cargo.lock | 17 ++++ Cargo.toml | 4 +- README.md | 97 +++++++++++++++++++ README.md.erb | 39 ++++++++ examples/google_lens_upload_image.rs | 74 +++++++++++++++ src/lib.rs | 2 + src/serpapi.rs | 133 ++++++++++++++++++++++++--- tests/serpapi-test.rs | 68 ++++++++++++++ 9 files changed, 421 insertions(+), 14 deletions(-) create mode 100644 examples/google_lens_upload_image.rs diff --git a/.gitignore b/.gitignore index a255f58..731905f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /Cargo.lock *.log +.env diff --git a/Cargo.lock b/Cargo.lock index 294092d..63ea7b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,6 +605,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "0.8.4" @@ -892,6 +902,7 @@ dependencies = [ "lazy_static", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -1202,6 +1213,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.8" diff --git a/Cargo.toml b/Cargo.toml index 3833828..390bc7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,8 @@ include = [ serde_json = "1.0" # Asynchronous runtime tokio = { version = "1", features = ["full"] } -# HTTP client -reqwest = "0.11.7" +# HTTP client, multipart is required by the Image API upload +reqwest = { version = "0.11.7", features = ["multipart"] } [dev-dependencies] criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } diff --git a/README.md b/README.md index 0e2ee10..26b371e 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,38 @@ let locations = data.as_array().unwrap(); It returns the first 3 locations matching Austin (Texas, Texas, Rochester) +### Image API + +Upload an image with the Image API, then use its `image_id` with a Search API engine +supporting uploaded images such as [Google Lens](https://serpapi.com/google-lens-upload-an-image). +Supported formats are jpg, jpeg, png and webp up to 500 KB. Uploaded image IDs expire after 10 minutes. + +```rust +let mut default = HashMap::::new(); +default.insert("api_key".to_string(), "your_secret_key".to_string()); +let client = Client::new(default).unwrap(); + +// upload an image from a file path +let upload = client.upload_image("./image.jpg", HashMap::new()).await?; +let image_id = upload["image_id"].as_str().expect("image id"); + +// search with google lens using the uploaded image +let mut parameter = HashMap::::new(); +parameter.insert("engine".to_string(), "google_lens".to_string()); +parameter.insert("image_id".to_string(), image_id.to_string()); +let results = client.search(parameter).await?; +let visual_matches = results["visual_matches"].as_array().unwrap(); +``` + +An in-memory image can be uploaded with `upload_image_bytes`: + +```rust +let upload = client.upload_image_bytes(image_bytes, "image.png", HashMap::new()).await?; +``` + +`upload_image` returns an error when the image is rejected by the API, for instance because of an unsupported format. + +[See Image API documentation](https://serpapi.com/image-api) · [See Google Lens image upload documentation](https://serpapi.com/google-lens-upload-an-image) ### Search Archive API @@ -184,12 +216,15 @@ It returns your account information. ### Technical features - Search results as JSON with `search`, Markdown with `md`, or raw search engine HTML with `html` +- Image upload for Google Lens and other engines with `upload_image` - Dynamic JSON decoding using Serde JSON - Asyncronous HTTP request handle method using tokio and reqwest - Async tests using Tokio ### Changes log - Unreleased: + - Add Image API support with `client.upload_image(path, parameter)` and `client.upload_image_bytes(bytes, file_name, parameter)`. + - Requests are sent over https directly instead of following the http redirect. - Add Markdown output for LLMs and AI agents with `client.md(parameter)` and `client.search_archive_md(&id)`. - `client.html(parameter)` now calls the search endpoint with `output=html` which returns the raw search engine HTML. - 1.1.0: Always reuse the same client object instead of creating a new one for each search. @@ -1296,6 +1331,68 @@ Ok(()) * source code: [examples/google_images_search.rs](https://github.com/serpapi/serpapi-rust/blob/master/examples/google_images_search.rs) see: [https://serpapi.com/images-results](https://serpapi.com/images-results) +### Search google lens with an uploaded image +```rust +let mut default = HashMap::new(); +default.insert("api_key".to_string(), "your_secret_api_key".to_string()); +default.insert("engine".to_string(), "google_lens".to_string()); +// initialize the search engine +let client = Client::new(default).unwrap(); + +// upload the image given on the command line, +// or download a sample image when no path is provided: +// cargo run --example google_lens_upload_image -- ./image.jpg +println!("uploading..."); +let upload = match env::args().nth(1) { + Some(path) => client.upload_image(&path, HashMap::new()).await?, + None => { + let image = reqwest::get("https://i.imgur.com/5bGzZi7.jpg") + .await? + .bytes() + .await? + .to_vec(); + client + .upload_image_bytes(image, "image.jpg", HashMap::new()) + .await? + } +}; +let image_id = upload["image_id"].as_str().unwrap(); +println!(" - image uploaded with id: {}", image_id); + +// let's search with google lens using the uploaded image +let mut parameter = HashMap::new(); +parameter.insert("image_id".to_string(), image_id.to_string()); + +// search returns a JSON as serde_json::Value which can be accessed like a HashMap. +println!("waiting..."); +let results = client.search(parameter).await?; +println!("results received"); +println!("--- JSON ---"); +let status = &results["search_metadata"]["status"]; +if status != "Success" { + println!("search failed with status: {}", status); +} else { + println!("search is successfull"); + let visual_matches = results["visual_matches"].as_array().unwrap(); + println!(" - number of visual_matches: {}", visual_matches.len()); + println!( + " - visual_matches first result description: {}", + results["visual_matches"][0] + ); + println!( + " - async search completed with {}\n", + results["search_parameters"]["engine"] + ); +} + +print!("ok"); +Ok(()) + +``` + + * source code: [examples/google_lens_upload_image.rs](https://github.com/serpapi/serpapi-rust/blob/master/examples/google_lens_upload_image.rs) +see: [https://serpapi.com/google-lens-upload-an-image](https://serpapi.com/google-lens-upload-an-image) + ## License MIT License diff --git a/README.md.erb b/README.md.erb index 2f258d1..c635476 100644 --- a/README.md.erb +++ b/README.md.erb @@ -149,6 +149,38 @@ let locations = data.as_array().unwrap(); It returns the first 3 locations matching Austin (Texas, Texas, Rochester) +### Image API + +Upload an image with the Image API, then use its `image_id` with a Search API engine +supporting uploaded images such as [Google Lens](https://serpapi.com/google-lens-upload-an-image). +Supported formats are jpg, jpeg, png and webp up to 500 KB. Uploaded image IDs expire after 10 minutes. + +```rust +let mut default = HashMap::::new(); +default.insert("api_key".to_string(), "your_secret_key".to_string()); +let client = Client::new(default).unwrap(); + +// upload an image from a file path +let upload = client.upload_image("./image.jpg", HashMap::new()).await?; +let image_id = upload["image_id"].as_str().expect("image id"); + +// search with google lens using the uploaded image +let mut parameter = HashMap::::new(); +parameter.insert("engine".to_string(), "google_lens".to_string()); +parameter.insert("image_id".to_string(), image_id.to_string()); +let results = client.search(parameter).await?; +let visual_matches = results["visual_matches"].as_array().unwrap(); +``` + +An in-memory image can be uploaded with `upload_image_bytes`: + +```rust +let upload = client.upload_image_bytes(image_bytes, "image.png", HashMap::new()).await?; +``` + +`upload_image` returns an error when the image is rejected by the API, for instance because of an unsupported format. + +[See Image API documentation](https://serpapi.com/image-api) · [See Google Lens image upload documentation](https://serpapi.com/google-lens-upload-an-image) ### Search Archive API @@ -199,12 +231,15 @@ It returns your account information. ### Technical features - Search results as JSON with `search`, Markdown with `md`, or raw search engine HTML with `html` +- Image upload for Google Lens and other engines with `upload_image` - Dynamic JSON decoding using Serde JSON - Asyncronous HTTP request handle method using tokio and reqwest - Async tests using Tokio ### Changes log - Unreleased: + - Add Image API support with `client.upload_image(path, parameter)` and `client.upload_image_bytes(bytes, file_name, parameter)`. + - Requests are sent over https directly instead of following the http redirect. - Add Markdown output for LLMs and AI agents with `client.md(parameter)` and `client.search_archive_md(&id)`. - `client.html(parameter)` now calls the search endpoint with `output=html` which returns the raw search engine HTML. - 1.1.0: Always reuse the same client object instead of creating a new one for each search. @@ -329,6 +364,10 @@ see: [https://serpapi.com/google-play-api](https://serpapi.com/google-play-api) <%= snippet('rust', 'examples/google_images_search.rs') %> see: [https://serpapi.com/images-results](https://serpapi.com/images-results) +### Search google lens with an uploaded image +<%= snippet('rust', 'examples/google_lens_upload_image.rs') %> +see: [https://serpapi.com/google-lens-upload-an-image](https://serpapi.com/google-lens-upload-an-image) + ## License MIT License diff --git a/examples/google_lens_upload_image.rs b/examples/google_lens_upload_image.rs new file mode 100644 index 0000000..d540778 --- /dev/null +++ b/examples/google_lens_upload_image.rs @@ -0,0 +1,74 @@ +// search example for google_lens with an uploaded image +// +use serpapi::serpapi::Client; +use std::collections::HashMap; +use std::env; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Read your private API Key from an environment variable. + // Copy/paste from [https://serpapi.com/dashboard] to your shell: + // ```bash + // export API_key="paste_your_private_api_key" + // ``` + let api_key = match env::var_os("SERPAPI_KEY") { + Some(v) => v.into_string().unwrap(), + None => panic!("$SERPAPI_KEY environment variable is not set!"), + }; + + println!("let's initiliaze the client to search on google_lens"); + let mut default = HashMap::new(); + default.insert("api_key".to_string(), api_key); + default.insert("engine".to_string(), "google_lens".to_string()); + // initialize the search engine + let client = Client::new(default).unwrap(); + + // upload the image given on the command line, + // or download a sample image when no path is provided: + // cargo run --example google_lens_upload_image -- ./image.jpg + println!("uploading..."); + let upload = match env::args().nth(1) { + Some(path) => client.upload_image(&path, HashMap::new()).await?, + None => { + let image = reqwest::get("https://i.imgur.com/5bGzZi7.jpg") + .await? + .bytes() + .await? + .to_vec(); + client + .upload_image_bytes(image, "image.jpg", HashMap::new()) + .await? + } + }; + let image_id = upload["image_id"].as_str().unwrap(); + println!(" - image uploaded with id: {}", image_id); + + // let's search with google lens using the uploaded image + let mut parameter = HashMap::new(); + parameter.insert("image_id".to_string(), image_id.to_string()); + + // search returns a JSON as serde_json::Value which can be accessed like a HashMap. + println!("waiting..."); + let results = client.search(parameter).await?; + println!("results received"); + println!("--- JSON ---"); + let status = &results["search_metadata"]["status"]; + if status != "Success" { + println!("search failed with status: {}", status); + } else { + println!("search is successfull"); + let visual_matches = results["visual_matches"].as_array().unwrap(); + println!(" - number of visual_matches: {}", visual_matches.len()); + println!( + " - visual_matches first result description: {}", + results["visual_matches"][0] + ); + println!( + " - async search completed with {}\n", + results["search_parameters"]["engine"] + ); + } + + print!("ok"); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 58fa421..0939a7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,5 +7,7 @@ //! //! Search results are available as JSON with `search`, as [Markdown](https://serpapi.com/markdown-output) //! optimized for LLMs and AI agents with `md`, or as raw search engine HTML with `html`. +//! Images can be uploaded with `upload_image` using the [Image API](https://serpapi.com/image-api) +//! and searched with engines such as Google Lens. //! pub mod serpapi; diff --git a/src/serpapi.rs b/src/serpapi.rs index 759b168..ef21eb5 100644 --- a/src/serpapi.rs +++ b/src/serpapi.rs @@ -4,6 +4,7 @@ //! Client wraps a custom HTTP client designed for SerpApi.com //! use std::collections::HashMap; +use std::path::Path; // model serpapi client // because of Rust designed we propose to create a new search everytime @@ -17,7 +18,9 @@ pub struct Client { pub http: reqwest::Client, } -const HOST: &str = "http://serpapi.com"; +// https is required: a plain http request is redirected with a 301 +// which turns the Image API multipart POST into a GET. +const HOST: &str = "https://serpapi.com"; impl Client { /// initialize a serp api client with default parameters. @@ -161,6 +164,72 @@ impl Client { Ok(results) } + /// Upload an image using the Image API. + /// The returned `image_id` can be supplied to Search API engines + /// supporting uploaded images, such as Google Lens. + /// Supported formats: jpg, jpeg, png and webp up to 500 KB. + /// The `image_id` expires after 10 minutes. + /// see: https://serpapi.com/image-api + /// # Arguments + /// * `path` image file path + /// * `parameter` request parameter, such as an `api_key` overriding the client default + /// # Examples + /// ```no_run + /// use std::collections::HashMap; + /// use serpapi::serpapi::Client; + /// + /// #[tokio::main] + /// async fn main() { + /// let mut default = HashMap::::new(); + /// default.insert("api_key".to_string(), "secret_api_key".to_string()); + /// let client = Client::new(default).unwrap(); + /// let upload = client.upload_image("./image.jpg", HashMap::new()).await.expect("upload"); + /// let image_id = upload["image_id"].as_str().expect("image id"); + /// // search with google lens using the uploaded image + /// let mut parameter = HashMap::::new(); + /// parameter.insert("engine".to_string(), "google_lens".to_string()); + /// parameter.insert("image_id".to_string(), image_id.to_string()); + /// let results = client.search(parameter).await.expect("request"); + /// // let visual_matches = results["visual_matches"].as_array().unwrap(); + /// } + /// ``` + pub async fn upload_image>( + &self, + path: P, + parameter: HashMap, + ) -> Result> { + let path = path.as_ref(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("image"); + let image = tokio::fs::read(path).await?; + self.upload_image_bytes(image, file_name, parameter).await + } + + /// Upload an in-memory image using the Image API. + /// see: Client::upload_image + /// # Arguments + /// * `image` binary content of the image + /// * `file_name` file name including the extension, such as "image.png" + /// * `parameter` request parameter, such as an `api_key` overriding the client default + pub async fn upload_image_bytes( + &self, + image: Vec, + file_name: &str, + parameter: HashMap, + ) -> Result> { + let part = reqwest::multipart::Part::bytes(image) + .file_name(file_name.to_string()) + .mime_str(image_mime_type(file_name))?; + let form = reqwest::multipart::Form::new().part("image", part); + let results = self.post_multipart("/image", parameter, form).await?; + if let Some(error) = results["error"].as_str() { + return Err(format!("image upload failed with: {}", error).into()); + } + Ok(results) + } + // Retrieve search result from the Search Archive API pub async fn search_archive( &self, @@ -243,17 +312,7 @@ impl Client { endpoint: &str, parameter: HashMap, ) -> Result<(String, String), Box> { - let mut query = HashMap::::new(); - query.insert("source".to_string(), "rust".to_string()); - for (key, value) in self.parameter.iter() { - if !parameter.contains_key(key) { - query.insert(key.to_string(), value.to_string()); - } - } - for (key, value) in parameter.iter() { - query.insert(key.to_string(), value.to_string()); - } - + let query = self.query(parameter); let mut url = HOST.to_string(); url.push_str(endpoint); let res = self.http.get(url).query(&query).send().await?; @@ -266,6 +325,41 @@ impl Client { let body = res.text().await?; Ok((content_type, body)) } + + /// execute a multipart/form-data POST request and decode the JSON response. + /// the request parameters are sent as form fields alongside the given form parts. + async fn post_multipart( + &self, + endpoint: &str, + parameter: HashMap, + mut form: reqwest::multipart::Form, + ) -> Result> { + for (key, value) in self.query(parameter) { + form = form.text(key, value); + } + let mut url = HOST.to_string(); + url.push_str(endpoint); + let res = self.http.post(url).multipart(form).send().await?; + let body = res.text().await?; + let value: serde_json::Value = serde_json::from_str(&body)?; + Ok(value) + } + + /// merge the client default parameter with the request parameter. + /// the request parameter takes precedence over the client default. + fn query(&self, parameter: HashMap) -> HashMap { + let mut query = HashMap::::new(); + query.insert("source".to_string(), "rust".to_string()); + for (key, value) in self.parameter.iter() { + if !parameter.contains_key(key) { + query.insert(key.to_string(), value.to_string()); + } + } + for (key, value) in parameter.iter() { + query.insert(key.to_string(), value.to_string()); + } + query + } } /// force the output format whatever the caller provides. @@ -274,3 +368,18 @@ fn force_output(mut parameter: HashMap, format: &str) -> HashMap parameter.insert("output".to_string(), format.to_string()); parameter } + +/// guess the image MIME type from the file extension. +/// the Image API supports jpg, jpeg, png and webp. +fn image_mime_type(file_name: &str) -> &'static str { + let extension = Path::new(file_name) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase()); + match extension.as_deref() { + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("png") => "image/png", + Some("webp") => "image/webp", + _ => "application/octet-stream", + } +} diff --git a/tests/serpapi-test.rs b/tests/serpapi-test.rs index f67aede..8efa305 100644 --- a/tests/serpapi-test.rs +++ b/tests/serpapi-test.rs @@ -104,6 +104,74 @@ async fn location() { assert!(locations[0]["name"].as_str().unwrap().contains("Austin")); } +// smallest valid 1x1 transparent PNG +const PNG_1X1: [u8; 67] = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, +]; + +#[tokio::test] +async fn upload_image_from_file() { + let mut default = HashMap::::new(); + default.insert("api_key".to_string(), api_key()); + let client = Client::new(default).unwrap(); + + let path = std::env::temp_dir().join("serpapi-rust-upload.png"); + std::fs::write(&path, PNG_1X1).expect("write image"); + + let upload = client + .upload_image(&path, HashMap::new()) + .await + .expect("upload"); + std::fs::remove_file(&path).ok(); + + assert_eq!(upload["message"], "Image uploaded successfully."); + assert!(!upload["image_id"].as_str().expect("image id").is_empty()); +} + +#[tokio::test] +async fn upload_image_from_bytes() { + let client = Client::new(HashMap::::new()).unwrap(); + // the api_key is provided as a request parameter instead of a client default + let mut parameter = HashMap::::new(); + parameter.insert("api_key".to_string(), api_key()); + + let upload = client + .upload_image_bytes(PNG_1X1.to_vec(), "image.png", parameter) + .await + .expect("upload"); + assert!(!upload["image_id"].as_str().expect("image id").is_empty()); +} + +#[tokio::test] +async fn upload_image_rejects_invalid_image() { + let mut default = HashMap::::new(); + default.insert("api_key".to_string(), api_key()); + let client = Client::new(default).unwrap(); + + let error = client + .upload_image_bytes( + b"invalid image data".to_vec(), + "invalid.txt", + HashMap::new(), + ) + .await + .expect_err("invalid image"); + assert!(error.to_string().contains("Invalid image format")); +} + +#[tokio::test] +async fn upload_image_missing_file() { + let client = Client::new(HashMap::::new()).unwrap(); + let result = client + .upload_image("/does/not/exist.png", HashMap::new()) + .await; + assert!(result.is_err()); +} + #[tokio::test] async fn account() { let client = Client::new(HashMap::::new()).unwrap();