feat(search): add Xquik as an X search provider
Xquik needs a GET request with x-api-key auth and a tweets envelope normalizer, neither of which the generic search fallback provides. Adds a dedicated request builder and normalizer, cursor pagination passthrough, result-based credit usage reporting, and a validateUrl probe so key validation hits the no-charge credits endpoint.
This commit is contained in:
@@ -347,6 +347,33 @@ function buildSearxngRequest(config, params) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildXquikRequest(config, params) {
|
||||
const apiKey = params.token;
|
||||
if (!apiKey) throw new Error("Xquik requires an API key");
|
||||
|
||||
const queryType = getProviderSetting(params, "queryType");
|
||||
if (queryType && !["Latest", "Top"].includes(queryType)) {
|
||||
throw new Error("Xquik queryType must be Latest or Top");
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams({
|
||||
q: params.query,
|
||||
limit: String(params.maxResults),
|
||||
});
|
||||
const cursor = getProviderSetting(params, "cursor");
|
||||
if (cursor) qp.set("cursor", cursor);
|
||||
if (queryType) qp.set("queryType", queryType);
|
||||
if (params.language) qp.set("language", params.language);
|
||||
|
||||
return {
|
||||
url: `${resolveBaseUrl(config, params)}?${qp}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "x-api-key": apiKey },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dispatcher ──────────────────────────────────────────────────────────
|
||||
|
||||
const BUILDERS = {
|
||||
@@ -360,6 +387,7 @@ const BUILDERS = {
|
||||
"searchapi": buildSearchApiRequest,
|
||||
"youcom": buildYouComRequest,
|
||||
"searxng": buildSearxngRequest,
|
||||
"xquik": buildXquikRequest,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -111,6 +111,13 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
||||
const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType);
|
||||
const results = normalized.results.slice(0, params.maxResults);
|
||||
const duration = Date.now() - startTime;
|
||||
const usage = {
|
||||
queries_used: 1,
|
||||
search_cost_usd: providerConfig.costPerQuery ?? null,
|
||||
};
|
||||
if (Number.isFinite(providerConfig.creditsPerResult)) {
|
||||
usage.provider_credits_used = results.length * providerConfig.creditsPerResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -119,7 +126,8 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
||||
query: params.query,
|
||||
results,
|
||||
answer: null,
|
||||
usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 },
|
||||
usage,
|
||||
...(normalized.pagination ? { pagination: normalized.pagination } : {}),
|
||||
metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults },
|
||||
errors: []
|
||||
}
|
||||
|
||||
@@ -199,6 +199,47 @@ function normalizeSearxng(data, _query, _searchType) {
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeXquik(data, _query, _searchType) {
|
||||
const now = new Date().toISOString();
|
||||
const items = Array.isArray(data.tweets) ? data.tweets : [];
|
||||
const results = items.map((item, idx) => {
|
||||
const username = typeof item?.author?.username === "string" ? item.author.username : "";
|
||||
const authorName = typeof item?.author?.name === "string" ? item.author.name : "";
|
||||
const tweetId = typeof item?.id === "string" ? item.id : String(item?.id || "");
|
||||
const url = username && tweetId
|
||||
? `https://x.com/${encodeURIComponent(username)}/status/${encodeURIComponent(tweetId)}`
|
||||
: tweetId
|
||||
? `https://x.com/i/web/status/${encodeURIComponent(tweetId)}`
|
||||
: "";
|
||||
const author = username ? `@${username}` : authorName || null;
|
||||
const title = author ? `${author} on X` : "X post";
|
||||
const imageUrl = Array.isArray(item?.media)
|
||||
? item.media.find((media) => typeof media?.mediaUrl === "string")?.mediaUrl
|
||||
: null;
|
||||
|
||||
return makeResult("xquik", {
|
||||
title,
|
||||
url,
|
||||
snippet: typeof item?.text === "string" ? item.text : "",
|
||||
published_at: typeof item?.createdAt === "string" ? item.createdAt : null,
|
||||
author,
|
||||
image_url: imageUrl || null,
|
||||
source_type: "x_post",
|
||||
full_text: typeof item?.text === "string" ? item.text : undefined,
|
||||
text_format: "text",
|
||||
}, idx, now);
|
||||
});
|
||||
const nextCursor = typeof data.next_cursor === "string" && data.next_cursor ? data.next_cursor : null;
|
||||
return {
|
||||
results,
|
||||
totalResults: null,
|
||||
pagination: {
|
||||
has_more: data.has_next_page === true,
|
||||
next_cursor: nextCursor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NORMALIZERS = {
|
||||
"serper": normalizeSerper,
|
||||
"brave-search": normalizeBrave,
|
||||
@@ -210,11 +251,12 @@ const NORMALIZERS = {
|
||||
"searchapi": normalizeSearchApi,
|
||||
"youcom": normalizeYouCom,
|
||||
"searxng": normalizeSearxng,
|
||||
"xquik": normalizeXquik,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatch to the appropriate normalizer based on providerId.
|
||||
* @returns {{results: Array, totalResults: number|null}}
|
||||
* @returns {{results: Array, totalResults: number|null, pagination?: object}}
|
||||
*/
|
||||
export function normalizeSearchResponse(providerId, data, query, searchType) {
|
||||
const fn = NORMALIZERS[providerId];
|
||||
|
||||
@@ -121,6 +121,7 @@ import p118 from "./selfhosted-tts.js";
|
||||
import p119 from "./selfhosted-embedding.js";
|
||||
import p120 from "./fish-audio.js";
|
||||
import p121 from "./alitp-intl.js";
|
||||
import p122 from "./xquik.js";
|
||||
|
||||
export default [
|
||||
p0,
|
||||
@@ -243,4 +244,5 @@ export default [
|
||||
p119,
|
||||
p120,
|
||||
p121,
|
||||
p122,
|
||||
];
|
||||
|
||||
35
open-sse/providers/registry/xquik.js
Normal file
35
open-sse/providers/registry/xquik.js
Normal file
@@ -0,0 +1,35 @@
|
||||
export default {
|
||||
id: "xquik",
|
||||
alias: "xquik",
|
||||
display: {
|
||||
name: "Xquik",
|
||||
icon: "tag",
|
||||
color: "#5C3327",
|
||||
textIcon: "XQ",
|
||||
website: "https://docs.xquik.com/api-reference/x/search-tweets",
|
||||
notice: {
|
||||
apiKeyUrl: "https://xquik.com",
|
||||
text: "Searches public X posts. Billing uses 1 Xquik credit per returned post."
|
||||
}
|
||||
},
|
||||
category: "apikey",
|
||||
authType: "apikey",
|
||||
serviceKinds: [
|
||||
"webSearch"
|
||||
],
|
||||
searchConfig: {
|
||||
baseUrl: "https://xquik.com/api/v1/x/tweets/search",
|
||||
validateUrl: "https://xquik.com/api/v1/credits",
|
||||
method: "GET",
|
||||
authType: "apikey",
|
||||
authHeader: "x-api-key",
|
||||
searchTypes: [
|
||||
"x"
|
||||
],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 100,
|
||||
timeoutMs: 10000,
|
||||
cacheTTLMs: 60000,
|
||||
creditsPerResult: 1
|
||||
}
|
||||
};
|
||||
BIN
public/providers/xquik.png
Normal file
BIN
public/providers/xquik.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: 9router-web-search
|
||||
description: Web search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity. Use when the user wants to search the web, look up information, find articles, or query a search engine.
|
||||
description: Web and X search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity / Xquik. Use when the user wants to search the web, find articles, or search public X posts.
|
||||
---
|
||||
|
||||
# 9Router — Web Search
|
||||
@@ -26,7 +26,7 @@ IDs end in `/search` (e.g. `tavily/search`). Combos (`owned_by:"combo"`) chain p
|
||||
| `model` (or `provider`) | yes | from `/v1/models/web` (e.g. `tavily` or `brave`) |
|
||||
| `query` | yes | search query |
|
||||
| `max_results` | no | default 5 |
|
||||
| `search_type` | no | `web` (default) / `news` |
|
||||
| `search_type` | no | `web` (default) / `news` / `x` for Xquik |
|
||||
| `country`, `language`, `time_range`, `domain_filter` | no | provider-dependent |
|
||||
|
||||
## Examples
|
||||
@@ -49,6 +49,26 @@ const r = await fetch(`${process.env.NINEROUTER_URL}/v1/search`, {
|
||||
console.log(await r.json());
|
||||
```
|
||||
|
||||
X search with Xquik:
|
||||
|
||||
```bash
|
||||
curl -X POST $NINEROUTER_URL/v1/search \
|
||||
-H "Authorization: Bearer $NINEROUTER_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"xquik","query":"from:github release","max_results":10,"provider_options":{"queryType":"Latest"}}'
|
||||
```
|
||||
|
||||
Add the Xquik API key in 9Router's provider settings. Xquik charges 1 credit per returned post. Continue a search by passing `pagination.next_cursor` as `provider_options.cursor`.
|
||||
|
||||
Xquik responses include provider pagination and credit usage:
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": { "has_more": true, "next_cursor": "cursor-2" },
|
||||
"usage": { "queries_used": 1, "search_cost_usd": null, "provider_credits_used": 10 }
|
||||
}
|
||||
```
|
||||
|
||||
## Response shape
|
||||
|
||||
```json
|
||||
@@ -87,5 +107,6 @@ All accept `query` + `max_results`. Optional fields vary:
|
||||
| `searchapi` | country, language, pagination | — |
|
||||
| `youcom` | country, language, time_range, domain_filter, full_page | — |
|
||||
| `searxng` | language, time_range | Self-hosted, **noAuth** |
|
||||
| `xquik` | X/Twitter search operators, language, cursor pagination | `queryType: Latest/Top`, `cursor` (options) |
|
||||
|
||||
Provider IS the model — `"provider":"tavily" ≡ "model":"tavily"`.
|
||||
|
||||
@@ -20,7 +20,7 @@ async function probeWebProvider(provider, apiKey) {
|
||||
if (!cfg) return null;
|
||||
if (cfg.authType === "none") return true; // no-auth (e.g. searxng)
|
||||
|
||||
let url = cfg.baseUrl;
|
||||
let url = cfg.validateUrl || cfg.baseUrl;
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
let body;
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export const SKILLS = [
|
||||
{
|
||||
id: "9router-web-search",
|
||||
name: "Web Search",
|
||||
description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.",
|
||||
description: "Web and X search via Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com / Xquik.",
|
||||
endpoint: "/v1/search",
|
||||
icon: "search",
|
||||
},
|
||||
|
||||
154
tests/unit/xquik-search-provider.test.js
Normal file
154
tests/unit/xquik-search-provider.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
||||
import { buildSearchRequest } from "../../open-sse/handlers/search/callers.js";
|
||||
import { handleSearchCore } from "../../open-sse/handlers/search/index.js";
|
||||
import { normalizeSearchResponse } from "../../open-sse/handlers/search/normalizers.js";
|
||||
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers.js";
|
||||
|
||||
const CONFIG = {
|
||||
id: "xquik",
|
||||
baseUrl: "https://xquik.com/api/v1/x/tweets/search",
|
||||
method: "GET",
|
||||
authType: "apikey",
|
||||
searchTypes: ["x"],
|
||||
defaultMaxResults: 5,
|
||||
maxMaxResults: 100,
|
||||
creditsPerResult: 1,
|
||||
};
|
||||
|
||||
const PARAMS = {
|
||||
query: "from:github release notes",
|
||||
searchType: "x",
|
||||
maxResults: 10,
|
||||
token: "xq_test_key",
|
||||
language: "en",
|
||||
providerOptions: { queryType: "Latest", cursor: "next page" },
|
||||
};
|
||||
|
||||
const RESPONSE = {
|
||||
tweets: [
|
||||
{
|
||||
id: "1234567890",
|
||||
text: "Release notes are live.",
|
||||
createdAt: "2026-08-25T12:00:00Z",
|
||||
author: { username: "github", name: "GitHub" },
|
||||
media: [{ mediaUrl: "https://pbs.twimg.com/media/example.jpg", type: "photo" }],
|
||||
},
|
||||
],
|
||||
has_next_page: true,
|
||||
next_cursor: "cursor-2",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Xquik search provider", () => {
|
||||
it("registers a dedicated X search provider with no-charge key validation", () => {
|
||||
const entry = REGISTRY.find((candidate) => candidate.id === "xquik");
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
category: "apikey",
|
||||
serviceKinds: ["webSearch"],
|
||||
searchConfig: {
|
||||
authHeader: "x-api-key",
|
||||
validateUrl: "https://xquik.com/api/v1/credits",
|
||||
searchTypes: ["x"],
|
||||
creditsPerResult: 1,
|
||||
},
|
||||
});
|
||||
expect(AI_PROVIDERS.xquik?.searchConfig).toEqual(entry.searchConfig);
|
||||
expect(getProvidersByKind("webSearch").map((provider) => provider.id)).toContain("xquik");
|
||||
});
|
||||
|
||||
it("builds the documented GET request without putting the key in the URL", () => {
|
||||
const request = buildSearchRequest(CONFIG, PARAMS);
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(url.origin + url.pathname).toBe("https://xquik.com/api/v1/x/tweets/search");
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual({
|
||||
q: "from:github release notes",
|
||||
limit: "10",
|
||||
cursor: "next page",
|
||||
queryType: "Latest",
|
||||
language: "en",
|
||||
});
|
||||
expect(url.search).not.toContain("xq_test_key");
|
||||
expect(request.init).toEqual({
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "x-api-key": "xq_test_key" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported query types before contacting Xquik", () => {
|
||||
expect(() => buildSearchRequest(CONFIG, {
|
||||
...PARAMS,
|
||||
providerOptions: { queryType: "Popular" },
|
||||
})).toThrow("Xquik queryType must be Latest or Top");
|
||||
});
|
||||
|
||||
it("normalizes posts and preserves cursor pagination", () => {
|
||||
const normalized = normalizeSearchResponse("xquik", RESPONSE, PARAMS.query, "x");
|
||||
|
||||
expect(normalized.totalResults).toBeNull();
|
||||
expect(normalized.pagination).toEqual({ has_more: true, next_cursor: "cursor-2" });
|
||||
expect(normalized.results).toHaveLength(1);
|
||||
expect(normalized.results[0]).toMatchObject({
|
||||
title: "@github on X",
|
||||
url: "https://x.com/github/status/1234567890",
|
||||
display_url: "x.com/github/status/1234567890",
|
||||
snippet: "Release notes are live.",
|
||||
published_at: "2026-08-25T12:00:00Z",
|
||||
metadata: {
|
||||
author: "@github",
|
||||
source_type: "x_post",
|
||||
image_url: "https://pbs.twimg.com/media/example.jpg",
|
||||
},
|
||||
citation: { provider: "xquik", rank: 1 },
|
||||
});
|
||||
expect(normalized.results[0].content).toEqual({
|
||||
format: "text",
|
||||
text: "Release notes are live.",
|
||||
length: 23,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the stable status URL when author data is unavailable", () => {
|
||||
const normalized = normalizeSearchResponse("xquik", {
|
||||
tweets: [{ id: "9876543210", text: "Author data is unavailable." }],
|
||||
has_next_page: false,
|
||||
next_cursor: "",
|
||||
}, PARAMS.query, "x");
|
||||
|
||||
expect(normalized.results[0]).toMatchObject({
|
||||
title: "X post",
|
||||
url: "https://x.com/i/web/status/9876543210",
|
||||
metadata: { author: null, source_type: "x_post" },
|
||||
});
|
||||
expect(normalized.pagination).toEqual({ has_more: false, next_cursor: null });
|
||||
});
|
||||
|
||||
it("reports Xquik credits without claiming an unknown USD cost", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(RESPONSE), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})));
|
||||
|
||||
const result = await handleSearchCore({
|
||||
body: { query: PARAMS.query, max_results: 10, provider_options: PARAMS.providerOptions },
|
||||
provider: { id: "xquik" },
|
||||
providerConfig: CONFIG,
|
||||
credentials: { apiKey: "xq_test_key" },
|
||||
});
|
||||
const payload = await result.response.json();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(payload.usage).toEqual({
|
||||
queries_used: 1,
|
||||
search_cost_usd: null,
|
||||
provider_credits_used: 1,
|
||||
});
|
||||
expect(payload.pagination).toEqual({ has_more: true, next_cursor: "cursor-2" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user