From 3069b16a39371d900dab8c701d197a76756da7cb Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Tue, 18 Aug 2026 11:36:02 +0200 Subject: [PATCH 1/2] Add initial Markdown support --- CHANGELOG.md | 2 + README.md | 103 +++++++++++++++- mod.ts | 2 + smoke_tests/commonjs/commonjs.js | 15 +++ smoke_tests/esm/esm.js | 15 +++ src/serpapi.ts | 123 ++++++++++++++++++++ tests/serpapi_test.ts | 194 +++++++++++++++++++++++++++++++ 7 files changed, 450 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1974c63..05f5a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to ### Added +- Add Markdown search and archive output support with `getMd` and + `getMdBySearchId`. - Expose `EngineParameters` type. - Expose `InvalidArgumentError` error. diff --git a/README.md b/README.md index a7ffff0..355827e 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,27 @@ console.log(response); [Deno](https://deno.land/x/serpapi). - Promises and async/await support. - Callbacks support. +- JSON, HTML, and token-efficient Markdown response formats. - [Examples in JavaScript/TypeScript on Node.js/Deno using ESM/CommonJS, and more](https://github.com/serpapi/serpapi-javascript/tree/master/examples). +## Markdown output for AI agents + +Use `getMd` to get token-efficient Markdown optimized for LLMs and AI agents: + +```js +import { getMd } from "serpapi"; + +const markdown = await getMd({ + engine: "google", + api_key: API_KEY, + q: "coffee", +}); +``` + +Archived results are also available as Markdown with `getMdBySearchId`. + +Learn more about [SerpApi Markdown output](https://serpapi.com/markdown-output). + ## Configuration You can declare a global `api_key` and `timeout` value by modifying the `config` @@ -176,18 +195,24 @@ for a manual approach: - [getHtml](#gethtml) - [Parameters](#parameters-1) - [Examples](#examples-1) -- [getJsonBySearchId](#getjsonbysearchid) +- [getMd](#getmd) - [Parameters](#parameters-2) - [Examples](#examples-2) -- [getHtmlBySearchId](#gethtmlbysearchid) +- [getJsonBySearchId](#getjsonbysearchid) - [Parameters](#parameters-3) - [Examples](#examples-3) -- [getAccount](#getaccount) +- [getHtmlBySearchId](#gethtmlbysearchid) - [Parameters](#parameters-4) - [Examples](#examples-4) -- [getLocations](#getlocations) +- [getMdBySearchId](#getmdbysearchid) - [Parameters](#parameters-5) - [Examples](#examples-5) +- [getAccount](#getaccount) + - [Parameters](#parameters-6) + - [Examples](#examples-6) +- [getLocations](#getlocations) + - [Parameters](#parameters-7) + - [Examples](#examples-7) ### getJson @@ -234,6 +259,31 @@ const html = await getHtml({ engine: "google", api_key: API_KEY, q: "coffee" }); getHtml({ engine: "google", api_key: API_KEY, q: "coffee" }, console.log); ``` +### getMd + +Get a Markdown response based on search parameters. + +#### Parameters + +- `parameters` + **[object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** + search query parameters for the engine +- `callback` **fn?** optional callback + +#### Examples + +```javascript +// async/await +const markdown = await getMd({ + engine: "google", + api_key: API_KEY, + q: "coffee", +}); + +// callback +getMd({ engine: "google", api_key: API_KEY, q: "coffee" }, console.log); +``` + ### getJsonBySearchId Get a JSON response given a search ID. @@ -325,6 +375,51 @@ const html = await getHtmlBySearchId(id, { api_key: API_KEY }); getHtmlBySearchId(id, { api_key: API_KEY }, console.log); ``` +### getMdBySearchId + +Get a Markdown response given a search ID. + +- This search ID can be obtained from the `search_metadata.id` key in the + response. +- Typically used together with the `async` parameter. +- Accepts an optional callback. + +#### Parameters + +- `searchId` + **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** + search ID +- `parameters` + **[object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** + (optional, default `{}`) + + - `parameters.api_key` + **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)?** + API key + - `parameters.timeout` + **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)?** + timeout in milliseconds +- `callback` **fn?** optional callback + +#### Examples + +```javascript +const response = await getJson({ + engine: "google", + api_key: API_KEY, + async: true, + q: "coffee", +}); +const { id } = response.search_metadata; +await delay(1000); // wait for the request to be processed. + +// async/await +const markdown = await getMdBySearchId(id, { api_key: API_KEY }); + +// callback +getMdBySearchId(id, { api_key: API_KEY }, console.log); +``` + ### getAccount Get account information of an API key. diff --git a/mod.ts b/mod.ts index 3eaa3f4..395febf 100644 --- a/mod.ts +++ b/mod.ts @@ -21,4 +21,6 @@ export { getJson, getJsonBySearchId, getLocations, + getMd, + getMdBySearchId, } from "./src/serpapi.ts"; diff --git a/smoke_tests/commonjs/commonjs.js b/smoke_tests/commonjs/commonjs.js index fde3f4e..1179e81 100644 --- a/smoke_tests/commonjs/commonjs.js +++ b/smoke_tests/commonjs/commonjs.js @@ -8,8 +8,10 @@ const { config, getJson, getHtml, + getMd, getJsonBySearchId, getHtmlBySearchId, + getMdBySearchId, getAccount, getLocations, } = require("serpapi"); @@ -89,6 +91,12 @@ const run = async () => { }); } + { + console.log("getMd"); + const markdown = await getMd(Object.assign({ engine: "google" }, params)); + if (!markdown.startsWith("---")) throw new Error("Incorrect Markdown"); + } + { console.log("getJsonBySearchId"); config.api_key = apiKey; @@ -111,6 +119,13 @@ const run = async () => { }); } + { + console.log("getMdBySearchId"); + config.api_key = apiKey; + const markdown = await getMdBySearchId(searchId); + if (!markdown.startsWith("---")) throw new Error("Incorrect Markdown"); + } + { console.log("getAccount"); config.api_key = apiKey; diff --git a/smoke_tests/esm/esm.js b/smoke_tests/esm/esm.js index 080eb85..7878076 100644 --- a/smoke_tests/esm/esm.js +++ b/smoke_tests/esm/esm.js @@ -16,6 +16,8 @@ import { getJson, getJsonBySearchId, getLocations, + getMd, + getMdBySearchId, } from "serpapi"; Dotenv.config(); @@ -92,6 +94,12 @@ let searchId; }); } +{ + console.log("getMd"); + const markdown = await getMd(Object.assign({ engine: "google" }, params)); + if (!markdown.startsWith("---")) throw new Error("Incorrect Markdown"); +} + { console.log("getJsonBySearchId"); config.api_key = apiKey; @@ -114,6 +122,13 @@ let searchId; }); } +{ + console.log("getMdBySearchId"); + config.api_key = apiKey; + const markdown = await getMdBySearchId(searchId); + if (!markdown.startsWith("---")) throw new Error("Incorrect Markdown"); +} + { console.log("getAccount"); config.api_key = apiKey; diff --git a/src/serpapi.ts b/src/serpapi.ts index 9422ea7..1eb6e16 100644 --- a/src/serpapi.ts +++ b/src/serpapi.ts @@ -175,6 +175,89 @@ async function _getHtml( return html; } +/** + * Get Markdown response based on search parameters. + * + * @param {object} parameters Search query parameters for the engine. Refer to https://serpapi.com/search-api for parameter explanations. + * @param {fn=} callback Optional callback. + * @example + * // async/await + * const markdown = await getMd({ engine: "google", api_key: API_KEY, q: "coffee" }); + * + * // callback + * getMd({ engine: "google", api_key: API_KEY, q: "coffee" }, console.log); + */ +export function getMd( + parameters: EngineParameters, + callback?: (markdown: string) => void, +): Promise; + +/** + * Get Markdown response based on search parameters. + * + * @param {string} engine Engine name. Refer to https://serpapi.com/search-api for valid engines. + * @param {object} parameters Search query parameters for the engine. Refer to https://serpapi.com/search-api for parameter explanations. + * @param {fn=} callback Optional callback. + * @example + * // async/await + * const markdown = await getMd("google", { api_key: API_KEY, q: "coffee" }); + * + * // callback + * getMd("google", { api_key: API_KEY, q: "coffee" }, console.log); + */ +export function getMd( + engine: string, + parameters: EngineParameters, + callback?: (markdown: string) => void, +): Promise; + +export function getMd( + ...args: + | [ + parameters: EngineParameters, + callback?: (markdown: string) => void, + ] + | [ + engine: string, + parameters: EngineParameters, + callback?: (markdown: string) => void, + ] +): Promise { + if (typeof args[0] === "string" && typeof args[1] === "object") { + const [engine, parameters, callback] = args; + const newParameters = { ...parameters, engine } as EngineParameters; + return _getMd(newParameters, callback); + } else if ( + typeof args[0] === "object" && + typeof args[1] !== "object" && + (typeof args[1] === "undefined" || typeof args[1] === "function") + ) { + const [parameters, callback] = args; + return _getMd(parameters, callback); + } else { + throw new InvalidArgumentError(); + } +} + +async function _getMd( + parameters: EngineParameters, + callback?: (markdown: string) => void, +): Promise { + const key = validateApiKey(parameters.api_key, true); + const timeout = validateTimeout(parameters.timeout); + const markdown = await _internals.execute( + SEARCH_PATH, + { + ...parameters, + api_key: key, + output: "md", + }, + timeout, + ); + callback?.(markdown); + return markdown; +} + /** * Get a JSON response given a search ID. * - This search ID can be obtained from the `search_metadata.id` key in the response. @@ -256,6 +339,46 @@ export async function getHtmlBySearchId( return html; } +/** + * Get a Markdown response given a search ID. + * - This search ID can be obtained from the `search_metadata.id` key in the response. + * - Typically used together with the `async` parameter. + * + * @param {string} searchId Search ID. + * @param {object} parameters + * @param {string=} [parameters.api_key] API key. + * @param {number=} [parameters.timeout] Timeout in milliseconds. + * @param {fn=} callback Optional callback. + * @example + * const response = await getJson({ engine: "google", api_key: API_KEY, async: true, q: "coffee" }); + * const { id } = response.search_metadata; + * await delay(1000); // wait for the request to be processed. + * + * // async/await + * const markdown = await getMdBySearchId(id, { api_key: API_KEY }); + * + * // callback + * getMdBySearchId(id, { api_key: API_KEY }, console.log); + */ +export async function getMdBySearchId( + searchId: string, + parameters: GetBySearchIdParameters = {}, + callback?: (markdown: string) => void, +) { + const key = validateApiKey(parameters.api_key); + const timeout = validateTimeout(parameters.timeout); + const markdown = await _internals.execute( + `${SEARCH_ARCHIVE_PATH}/${searchId}`, + { + api_key: key, + output: "md", + }, + timeout, + ); + callback?.(markdown); + return markdown; +} + /** * Get account information of an API key. * diff --git a/tests/serpapi_test.ts b/tests/serpapi_test.ts index 7daf994..47f1e2e 100644 --- a/tests/serpapi_test.ts +++ b/tests/serpapi_test.ts @@ -32,6 +32,8 @@ import { getJson, getJsonBySearchId, getLocations, + getMd, + getMdBySearchId, InvalidArgumentError, InvalidTimeoutError, MissingApiKeyError, @@ -592,6 +594,198 @@ describe( }, ); +describe( + "getMd", + { + sanitizeOps: false, + sanitizeResources: false, + }, + () => { + let urlStub: Stub; + + beforeAll(() => { + urlStub = stub(_internals, "getHostnameAndPort", () => BASE_OPTIONS); + }); + + afterEach(() => { + config.api_key = null; + }); + + afterAll(() => { + urlStub.restore(); + }); + + it("with no api_key", () => { + assertRejects( + async () => await getMd({ engine: "google", q: "Paris" }), + MissingApiKeyError, + ); + assertRejects( + async () => await getMd("google", { q: "Paris" }), + MissingApiKeyError, + ); + assertRejects( + // @ts-ignore testing invalid usage + async () => await getMd({}), + MissingApiKeyError, + ); + }); + + it("with invalid arguments", () => { + assertRejects( + // @ts-ignore testing invalid usage + async () => await getMd("google"), + InvalidArgumentError, + ); + assertRejects( + // @ts-ignore testing invalid usage + async () => await getMd(), + InvalidArgumentError, + ); + }); + + it("with invalid timeout", () => { + config.api_key = "test_api_key"; + assertRejects( + async () => await getMd({ engine: "google", q: "Paris", timeout: 0 }), + InvalidTimeoutError, + ); + assertRejects( + async () => await getMd({ engine: "google", q: "Paris", timeout: -10 }), + InvalidTimeoutError, + ); + assertRejects( + async () => await getMd("google", { q: "Paris", timeout: 0 }), + InvalidTimeoutError, + ); + assertRejects( + async () => await getMd("google", { q: "Paris", timeout: -10 }), + InvalidTimeoutError, + ); + }); + + it( + "async/await", + { + ignore: !HAS_API_KEY, + }, + async () => { + const markdown = await getMd({ + engine: "google", + q: "Paris", + api_key: SERPAPI_TEST_KEY, + timeout: 10000, + }); + assert(markdown.startsWith("---")); + }, + ); + + it("returns Markdown with async/await and callbacks", async () => { + const markdownResponse = "---\n## Organic Results\n"; + const executeStub = stub( + _internals, + "execute", + () => Promise.resolve(markdownResponse), + ); + config.api_key = "test_api_key"; + + try { + const markdown = await getMd({ + engine: "google", + q: "Paris", + output: "json", + }); + assertEquals(markdown, markdownResponse); + + const markdownFromOldApi = await getMd("google", { q: "Paris" }); + assertEquals(markdownFromOldApi, markdownResponse); + + const markdownFromCallback = await new Promise((done) => { + getMd({ engine: "google", q: "Paris" }, done); + }); + assertEquals(markdownFromCallback, markdownResponse); + + const markdownFromOldApiCallback = await new Promise((done) => { + getMd("google", { q: "Paris" }, done); + }); + assertEquals(markdownFromOldApiCallback, markdownResponse); + } finally { + executeStub.restore(); + } + + assertSpyCalls(executeStub, 4); + assertSpyCallArg(executeStub, 0, 0, "/search"); + assertSpyCallArg(executeStub, 0, 1, { + api_key: "test_api_key", + engine: "google", + output: "md", + q: "Paris", + }); + }); + }, +); + +describe( + "getMdBySearchId", + { + sanitizeOps: false, + sanitizeResources: false, + }, + () => { + afterEach(() => { + config.api_key = null; + }); + + it( + "async/await", + { + ignore: !HAS_API_KEY, + }, + async () => { + const response = await getJson({ + engine: "google", + api_key: SERPAPI_TEST_KEY, + q: "Paris", + }); + const markdown = await getMdBySearchId(response.search_metadata.id, { + api_key: SERPAPI_TEST_KEY, + timeout: 10000, + }); + assert(markdown.startsWith("---")); + }, + ); + + it("returns archived Markdown with async/await and callbacks", async () => { + const markdownResponse = "---\n## Organic Results\n"; + const executeStub = stub( + _internals, + "execute", + () => Promise.resolve(markdownResponse), + ); + config.api_key = "test_api_key"; + + try { + const markdown = await getMdBySearchId("search-id"); + assertEquals(markdown, markdownResponse); + + const markdownFromCallback = await new Promise((done) => { + getMdBySearchId("search-id", {}, done); + }); + assertEquals(markdownFromCallback, markdownResponse); + } finally { + executeStub.restore(); + } + + assertSpyCalls(executeStub, 2); + assertSpyCallArg(executeStub, 0, 0, "/searches/search-id"); + assertSpyCallArg(executeStub, 0, 1, { + api_key: "test_api_key", + output: "md", + }); + }); + }, +); + describe( "getJsonBySearchId", { From f9064e463bbeff9c941dfb741e99e3168d6a42f4 Mon Sep 17 00:00:00 2001 From: Josef Strzibny Date: Tue, 18 Aug 2026 12:01:24 +0200 Subject: [PATCH 2/2] Update deno.json --- deno.json | 7 +++++++ examples/deno/basic_example.ts | 2 +- examples/deno/pagination_example.ts | 2 +- scripts/build_npm.ts | 2 +- src/utils.ts | 2 +- tests/serpapi_test.ts | 14 ++++---------- tests/utils_test.ts | 13 ++++--------- tests/validators_test.ts | 11 ++--------- 8 files changed, 21 insertions(+), 32 deletions(-) diff --git a/deno.json b/deno.json index 4021446..fc2a984 100644 --- a/deno.json +++ b/deno.json @@ -6,6 +6,13 @@ "test:cov": "rm -rf cov_profile && deno task test --coverage=cov_profile && deno coverage cov_profile", "npm": "deno run -A scripts/build_npm.ts" }, + "imports": { + "@deno/dnt": "https://deno.land/x/dnt@0.40.0/mod.ts", + "@std/dotenv": "https://deno.land/std@0.173.0/dotenv/mod.ts", + "@std/testing/asserts": "https://deno.land/std@0.170.0/testing/asserts.ts", + "@std/testing/bdd": "https://deno.land/std@0.170.0/testing/bdd.ts", + "@std/testing/mock": "https://deno.land/std@0.170.0/testing/mock.ts" + }, "fmt": { "exclude": ["npm/", "examples/node", "smoke_tests/"] }, diff --git a/examples/deno/basic_example.ts b/examples/deno/basic_example.ts index 30e6aa3..9ef5f1e 100644 --- a/examples/deno/basic_example.ts +++ b/examples/deno/basic_example.ts @@ -1,4 +1,4 @@ -import { loadSync } from "https://deno.land/std@0.173.0/dotenv/mod.ts"; +import { loadSync } from "@std/dotenv"; import { config, getJson } from "../../mod.ts"; const { API_KEY: apiKey } = loadSync(); diff --git a/examples/deno/pagination_example.ts b/examples/deno/pagination_example.ts index 9c683f9..6999deb 100644 --- a/examples/deno/pagination_example.ts +++ b/examples/deno/pagination_example.ts @@ -1,4 +1,4 @@ -import { loadSync } from "https://deno.land/std@0.173.0/dotenv/mod.ts"; +import { loadSync } from "@std/dotenv"; import { config, getJson } from "../../mod.ts"; const { API_KEY: apiKey } = loadSync(); diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index 8e817cd..69762b0 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -1,4 +1,4 @@ -import { build, emptyDir } from "https://deno.land/x/dnt@0.40.0/mod.ts"; +import { build, emptyDir } from "@deno/dnt"; import { version } from "../version.ts"; await emptyDir("./npm"); diff --git a/src/utils.ts b/src/utils.ts index 8bb21f9..a490688 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -80,7 +80,7 @@ export function execute( }); return new Promise((resolve, reject) => { - let timer: number; + let timer: ReturnType; const handleResponse = (resp: http.IncomingMessage) => { resp.setEncoding("utf8"); diff --git a/tests/serpapi_test.ts b/tests/serpapi_test.ts index 47f1e2e..c63922d 100644 --- a/tests/serpapi_test.ts +++ b/tests/serpapi_test.ts @@ -1,11 +1,5 @@ -import { loadSync } from "https://deno.land/std@0.170.0/dotenv/mod.ts"; -import { - afterAll, - afterEach, - beforeAll, - describe, - it, -} from "https://deno.land/std@0.170.0/testing/bdd.ts"; +import { loadSync } from "@std/dotenv"; +import { afterAll, afterEach, beforeAll, describe, it } from "@std/testing/bdd"; import { assert, assertArrayIncludes, @@ -14,14 +8,14 @@ import { assertInstanceOf, assertRejects, assertStringIncludes, -} from "https://deno.land/std@0.170.0/testing/asserts.ts"; +} from "@std/testing/asserts"; import { assertSpyCallArg, assertSpyCalls, spy, Stub, stub, -} from "https://deno.land/std@0.170.0/testing/mock.ts"; +} from "@std/testing/mock"; import { _internals } from "../src/utils.ts"; import { BaseResponse, diff --git a/tests/utils_test.ts b/tests/utils_test.ts index 4ebfa97..46f444d 100644 --- a/tests/utils_test.ts +++ b/tests/utils_test.ts @@ -1,18 +1,13 @@ import http from "node:http"; import qs from "node:querystring"; -import { loadSync } from "https://deno.land/std@0.170.0/dotenv/mod.ts"; -import { - afterAll, - beforeAll, - describe, - it, -} from "https://deno.land/std@0.170.0/testing/bdd.ts"; -import { Stub, stub } from "https://deno.land/std@0.170.0/testing/mock.ts"; +import { loadSync } from "@std/dotenv"; +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { Stub, stub } from "@std/testing/mock"; import { assertEquals, assertInstanceOf, assertMatch, -} from "https://deno.land/std@0.170.0/testing/asserts.ts"; +} from "@std/testing/asserts"; import { _internals, buildRequestOptions, diff --git a/tests/validators_test.ts b/tests/validators_test.ts index f401d74..770ebfa 100644 --- a/tests/validators_test.ts +++ b/tests/validators_test.ts @@ -1,12 +1,5 @@ -import { - afterEach, - describe, - it, -} from "https://deno.land/std@0.170.0/testing/bdd.ts"; -import { - assertEquals, - assertThrows, -} from "https://deno.land/std@0.170.0/testing/asserts.ts"; +import { afterEach, describe, it } from "@std/testing/bdd"; +import { assertEquals, assertThrows } from "@std/testing/asserts"; import { validateApiKey, validateTimeout } from "../src/validators.ts"; import { config, InvalidTimeoutError, MissingApiKeyError } from "../mod.ts";