Compare commits
7 Commits
da23ca1c3f
...
441ff436c4
| Author | SHA1 | Date | |
|---|---|---|---|
| 441ff436c4 | |||
| 1f53ec912a | |||
| 053efd815b | |||
| d619fa5d77 | |||
| 050fd0adba | |||
| 7b106c91ce | |||
| 6e0487f8f3 |
@@ -1,30 +1,30 @@
|
|||||||
import { statusRoute } from "./routes/status";
|
|
||||||
import { kijijiRoute } from "./routes/kijiji";
|
|
||||||
import { facebookRoute } from "./routes/facebook";
|
|
||||||
import { ebayRoute } from "./routes/ebay";
|
import { ebayRoute } from "./routes/ebay";
|
||||||
|
import { facebookRoute } from "./routes/facebook";
|
||||||
|
import { kijijiRoute } from "./routes/kijiji";
|
||||||
|
import { statusRoute } from "./routes/status";
|
||||||
|
|
||||||
const PORT = process.env.PORT || 4005;
|
const PORT = process.env.PORT || 4005;
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: PORT as number | string,
|
port: PORT as number | string,
|
||||||
idleTimeout: 0,
|
idleTimeout: 0,
|
||||||
routes: {
|
routes: {
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
"/api/status": statusRoute,
|
"/api/status": statusRoute,
|
||||||
|
|
||||||
// Marketplace search endpoints
|
// Marketplace search endpoints
|
||||||
"/api/kijiji": kijijiRoute,
|
"/api/kijiji": kijijiRoute,
|
||||||
"/api/facebook": facebookRoute,
|
"/api/facebook": facebookRoute,
|
||||||
"/api/ebay": ebayRoute,
|
"/api/ebay": ebayRoute,
|
||||||
|
|
||||||
// Fallback for unmatched /api routes
|
// Fallback for unmatched /api routes
|
||||||
"/api/*": Response.json({ message: "Not found" }, { status: 404 }),
|
"/api/*": Response.json({ message: "Not found" }, { status: 404 }),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fallback for all other routes
|
// Fallback for all other routes
|
||||||
fetch(req: Request) {
|
fetch(_req: Request) {
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`API Server running on ${server.hostname}:${server.port}`);
|
console.log(`API Server running on ${server.hostname}:${server.port}`);
|
||||||
|
|||||||
@@ -5,56 +5,61 @@ import { fetchEbayItems } from "@marketplace-scrapers/core";
|
|||||||
* Search eBay for listings (default: Buy It Now only, Canada only)
|
* Search eBay for listings (default: Buy It Now only, Canada only)
|
||||||
*/
|
*/
|
||||||
export async function ebayRoute(req: Request): Promise<Response> {
|
export async function ebayRoute(req: Request): Promise<Response> {
|
||||||
const reqUrl = new URL(req.url);
|
try {
|
||||||
|
const reqUrl = new URL(req.url);
|
||||||
|
|
||||||
const SEARCH_QUERY =
|
const SEARCH_QUERY =
|
||||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||||
if (!SEARCH_QUERY)
|
if (!SEARCH_QUERY)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
"Request didn't have 'query' header or 'q' search parameter!",
|
"Request didn't have 'query' header or 'q' search parameter!",
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse optional parameters with defaults
|
const minPriceParam = reqUrl.searchParams.get("minPrice");
|
||||||
const minPrice = reqUrl.searchParams.get("minPrice")
|
const minPrice = minPriceParam ? parseInt(minPriceParam, 10) : undefined;
|
||||||
? parseInt(reqUrl.searchParams.get("minPrice")!)
|
const maxPriceParam = reqUrl.searchParams.get("maxPrice");
|
||||||
: undefined;
|
const maxPrice = maxPriceParam ? parseInt(maxPriceParam, 10) : undefined;
|
||||||
const maxPrice = reqUrl.searchParams.get("maxPrice")
|
const strictMode = reqUrl.searchParams.get("strictMode") === "true";
|
||||||
? parseInt(reqUrl.searchParams.get("maxPrice")!)
|
const buyItNowOnly = reqUrl.searchParams.get("buyItNowOnly") !== "false";
|
||||||
: undefined;
|
const canadaOnly = reqUrl.searchParams.get("canadaOnly") !== "false";
|
||||||
const strictMode = reqUrl.searchParams.get("strictMode") === "true";
|
const exclusionsParam = reqUrl.searchParams.get("exclusions");
|
||||||
const buyItNowOnly = reqUrl.searchParams.get("buyItNowOnly") !== "false";
|
const exclusions = exclusionsParam
|
||||||
const canadaOnly = reqUrl.searchParams.get("canadaOnly") !== "false";
|
? exclusionsParam.split(",").map((s) => s.trim())
|
||||||
const exclusionsParam = reqUrl.searchParams.get("exclusions");
|
: [];
|
||||||
const exclusions = exclusionsParam ? exclusionsParam.split(",").map(s => s.trim()) : [];
|
const keywordsParam = reqUrl.searchParams.get("keywords");
|
||||||
const keywordsParam = reqUrl.searchParams.get("keywords");
|
const keywords = keywordsParam
|
||||||
const keywords = keywordsParam ? keywordsParam.split(",").map(s => s.trim()) : [SEARCH_QUERY];
|
? keywordsParam.split(",").map((s) => s.trim())
|
||||||
|
: [SEARCH_QUERY];
|
||||||
|
|
||||||
try {
|
const maxItemsParam = reqUrl.searchParams.get("maxItems");
|
||||||
const items = await fetchEbayItems(SEARCH_QUERY, 5, {
|
const maxItems = maxItemsParam ? parseInt(maxItemsParam, 10) : undefined;
|
||||||
minPrice,
|
|
||||||
maxPrice,
|
const items = await fetchEbayItems(SEARCH_QUERY, 1, {
|
||||||
strictMode,
|
minPrice,
|
||||||
exclusions,
|
maxPrice,
|
||||||
keywords,
|
strictMode,
|
||||||
buyItNowOnly,
|
exclusions,
|
||||||
canadaOnly,
|
keywords,
|
||||||
});
|
buyItNowOnly,
|
||||||
if (!items || items.length === 0)
|
canadaOnly,
|
||||||
return Response.json(
|
});
|
||||||
{ message: "Search didn't return any results!" },
|
|
||||||
{ status: 404 },
|
const results = maxItems ? items.slice(0, maxItems) : items;
|
||||||
);
|
|
||||||
return Response.json(items, { status: 200 });
|
if (!results || results.length === 0)
|
||||||
} catch (error) {
|
return Response.json(
|
||||||
console.error("eBay scraping error:", error);
|
{ message: "Search didn't return any results!" },
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
{ status: 404 },
|
||||||
return Response.json(
|
);
|
||||||
{ message: errorMessage },
|
return Response.json(results, { status: 200 });
|
||||||
{ status: 400 },
|
} catch (error) {
|
||||||
);
|
console.error("eBay scraping error:", error);
|
||||||
}
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
return Response.json({ message: errorMessage }, { status: 400 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,36 +5,42 @@ import { fetchFacebookItems } from "@marketplace-scrapers/core";
|
|||||||
* Search Facebook Marketplace for listings
|
* Search Facebook Marketplace for listings
|
||||||
*/
|
*/
|
||||||
export async function facebookRoute(req: Request): Promise<Response> {
|
export async function facebookRoute(req: Request): Promise<Response> {
|
||||||
const reqUrl = new URL(req.url);
|
const reqUrl = new URL(req.url);
|
||||||
|
|
||||||
const SEARCH_QUERY =
|
const SEARCH_QUERY =
|
||||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||||
if (!SEARCH_QUERY)
|
if (!SEARCH_QUERY)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
message:
|
message: "Request didn't have 'query' header or 'q' search parameter!",
|
||||||
"Request didn't have 'query' header or 'q' search parameter!",
|
},
|
||||||
},
|
{ status: 400 },
|
||||||
{ status: 400 },
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const LOCATION = reqUrl.searchParams.get("location") || "toronto";
|
const LOCATION = reqUrl.searchParams.get("location") || "toronto";
|
||||||
const COOKIES_SOURCE = reqUrl.searchParams.get("cookies") || undefined;
|
const COOKIES_SOURCE = reqUrl.searchParams.get("cookies") || undefined;
|
||||||
|
const maxItemsParam = reqUrl.searchParams.get("maxItems");
|
||||||
|
const maxItems = maxItemsParam ? parseInt(maxItemsParam, 10) : 25;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await fetchFacebookItems(SEARCH_QUERY, 5, LOCATION, 25, COOKIES_SOURCE);
|
const items = await fetchFacebookItems(
|
||||||
if (!items || items.length === 0)
|
SEARCH_QUERY,
|
||||||
return Response.json(
|
1,
|
||||||
{ message: "Search didn't return any results!" },
|
LOCATION,
|
||||||
{ status: 404 },
|
maxItems,
|
||||||
);
|
COOKIES_SOURCE,
|
||||||
return Response.json(items, { status: 200 });
|
undefined,
|
||||||
} catch (error) {
|
);
|
||||||
console.error("Facebook scraping error:", error);
|
if (!items || items.length === 0)
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
return Response.json(
|
||||||
return Response.json(
|
{ message: "Search didn't return any results!" },
|
||||||
{ message: errorMessage },
|
{ status: 404 },
|
||||||
{ status: 400 },
|
);
|
||||||
);
|
return Response.json(items, { status: 200 });
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error("Facebook scraping error:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
return Response.json({ message: errorMessage }, { status: 400 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,33 +5,62 @@ import { fetchKijijiItems } from "@marketplace-scrapers/core";
|
|||||||
* Search Kijiji marketplace for listings
|
* Search Kijiji marketplace for listings
|
||||||
*/
|
*/
|
||||||
export async function kijijiRoute(req: Request): Promise<Response> {
|
export async function kijijiRoute(req: Request): Promise<Response> {
|
||||||
const reqUrl = new URL(req.url);
|
const reqUrl = new URL(req.url);
|
||||||
|
|
||||||
const SEARCH_QUERY =
|
const SEARCH_QUERY =
|
||||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||||
if (!SEARCH_QUERY)
|
if (!SEARCH_QUERY)
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
message:
|
message: "Request didn't have 'query' header or 'q' search parameter!",
|
||||||
"Request didn't have 'query' header or 'q' search parameter!",
|
},
|
||||||
},
|
{ status: 400 },
|
||||||
{ status: 400 },
|
);
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
const maxPagesParam = reqUrl.searchParams.get("maxPages");
|
||||||
const items = await fetchKijijiItems(SEARCH_QUERY, 5);
|
const maxPages = maxPagesParam ? parseInt(maxPagesParam, 10) : 5;
|
||||||
if (!items)
|
const priceMinParam = reqUrl.searchParams.get("priceMin");
|
||||||
return Response.json(
|
const priceMin = priceMinParam ? parseInt(priceMinParam, 10) : undefined;
|
||||||
{ message: "Search didn't return any results!" },
|
const priceMaxParam = reqUrl.searchParams.get("priceMax");
|
||||||
{ status: 404 },
|
const priceMax = priceMaxParam ? parseInt(priceMaxParam, 10) : undefined;
|
||||||
);
|
|
||||||
return Response.json(items, { status: 200 });
|
const searchOptions = {
|
||||||
} catch (error) {
|
location: reqUrl.searchParams.get("location") || undefined,
|
||||||
console.error("Kijiji scraping error:", error);
|
category: reqUrl.searchParams.get("category") || undefined,
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
keywords: reqUrl.searchParams.get("keywords") || undefined,
|
||||||
return Response.json(
|
sortBy: reqUrl.searchParams.get("sortBy") as
|
||||||
{ message: errorMessage },
|
| "relevancy"
|
||||||
{ status: 400 },
|
| "date"
|
||||||
);
|
| "price"
|
||||||
}
|
| "distance"
|
||||||
|
| undefined,
|
||||||
|
sortOrder: reqUrl.searchParams.get("sortOrder") as
|
||||||
|
| "desc"
|
||||||
|
| "asc"
|
||||||
|
| undefined,
|
||||||
|
maxPages,
|
||||||
|
priceMin,
|
||||||
|
priceMax,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await fetchKijijiItems(
|
||||||
|
SEARCH_QUERY,
|
||||||
|
1,
|
||||||
|
"https://www.kijiji.ca",
|
||||||
|
searchOptions,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (!items)
|
||||||
|
return Response.json(
|
||||||
|
{ message: "Search didn't return any results!" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
return Response.json(items, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Kijiji scraping error:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred";
|
||||||
|
return Response.json({ message: errorMessage }, { status: 400 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import { isRecord } from "../utils/http";
|
import type { HTMLString } from "../types/common";
|
||||||
import { delay } from "../utils/delay";
|
import { delay } from "../utils/delay";
|
||||||
import { formatCentsToCurrency } from "../utils/format";
|
import { formatCentsToCurrency } from "../utils/format";
|
||||||
import type { HTMLString } from "../types/common";
|
import { isRecord } from "../utils/http";
|
||||||
|
|
||||||
// ----------------------------- Types -----------------------------
|
// ----------------------------- Types -----------------------------
|
||||||
|
|
||||||
export interface EbayListingDetails {
|
export interface EbayListingDetails {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
listingPrice?: {
|
listingPrice?: {
|
||||||
amountFormatted: string;
|
amountFormatted: string;
|
||||||
cents?: number;
|
cents?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
};
|
};
|
||||||
listingType?: string;
|
listingType?: string;
|
||||||
listingStatus?: string;
|
listingStatus?: string;
|
||||||
creationDate?: string;
|
creationDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
numberOfViews?: number;
|
numberOfViews?: number;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------- Utilities -----------------------------
|
// ----------------------------- Utilities -----------------------------
|
||||||
@@ -29,43 +29,49 @@ export interface EbayListingDetails {
|
|||||||
/**
|
/**
|
||||||
* Parse eBay currency string like "$1.50 CAD" or "CA $1.50" into cents
|
* Parse eBay currency string like "$1.50 CAD" or "CA $1.50" into cents
|
||||||
*/
|
*/
|
||||||
function parseEbayPrice(priceText: string): { cents: number; currency: string } | null {
|
function parseEbayPrice(
|
||||||
if (!priceText || typeof priceText !== 'string') return null;
|
priceText: string,
|
||||||
|
): { cents: number; currency: string } | null {
|
||||||
|
if (!priceText || typeof priceText !== "string") return null;
|
||||||
|
|
||||||
// Clean up the price text and extract currency and amount
|
// Clean up the price text and extract currency and amount
|
||||||
const cleaned = priceText.trim();
|
const cleaned = priceText.trim();
|
||||||
|
|
||||||
// Find all numbers in the string (including decimals)
|
// Find all numbers in the string (including decimals)
|
||||||
const numberMatches = cleaned.match(/[\d,]+\.?\d*/);
|
const numberMatches = cleaned.match(/[\d,]+\.?\d*/);
|
||||||
if (!numberMatches) return null;
|
if (!numberMatches) return null;
|
||||||
|
|
||||||
const amountStr = numberMatches[0].replace(/,/g, '');
|
const amountStr = numberMatches[0].replace(/,/g, "");
|
||||||
const dollars = parseFloat(amountStr);
|
const dollars = parseFloat(amountStr);
|
||||||
if (isNaN(dollars)) return null;
|
if (isNaN(dollars)) return null;
|
||||||
|
|
||||||
const cents = Math.round(dollars * 100);
|
const cents = Math.round(dollars * 100);
|
||||||
|
|
||||||
// Extract currency - look for common formats like "CAD", "USD", "C $", "$CA", etc.
|
// Extract currency - look for common formats like "CAD", "USD", "C $", "$CA", etc.
|
||||||
let currency = 'USD'; // Default
|
let currency = "USD"; // Default
|
||||||
|
|
||||||
if (cleaned.toUpperCase().includes('CAD') || cleaned.includes('CA$') || cleaned.includes('C $')) {
|
if (
|
||||||
currency = 'CAD';
|
cleaned.toUpperCase().includes("CAD") ||
|
||||||
} else if (cleaned.toUpperCase().includes('USD') || cleaned.includes('$')) {
|
cleaned.includes("CA$") ||
|
||||||
currency = 'USD';
|
cleaned.includes("C $")
|
||||||
}
|
) {
|
||||||
|
currency = "CAD";
|
||||||
|
} else if (cleaned.toUpperCase().includes("USD") || cleaned.includes("$")) {
|
||||||
|
currency = "USD";
|
||||||
|
}
|
||||||
|
|
||||||
return { cents, currency };
|
return { cents, currency };
|
||||||
}
|
}
|
||||||
|
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
public readonly url: string,
|
public readonly url: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "HttpError";
|
this.name = "HttpError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------- Parsing -----------------------------
|
// ----------------------------- Parsing -----------------------------
|
||||||
@@ -74,290 +80,341 @@ class HttpError extends Error {
|
|||||||
Parse eBay search page HTML and extract listings using DOM selectors
|
Parse eBay search page HTML and extract listings using DOM selectors
|
||||||
*/
|
*/
|
||||||
function parseEbayListings(
|
function parseEbayListings(
|
||||||
htmlString: HTMLString,
|
htmlString: HTMLString,
|
||||||
keywords: string[],
|
keywords: string[],
|
||||||
exclusions: string[],
|
exclusions: string[],
|
||||||
strictMode: boolean
|
strictMode: boolean,
|
||||||
): EbayListingDetails[] {
|
): EbayListingDetails[] {
|
||||||
const { document } = parseHTML(htmlString);
|
const { document } = parseHTML(htmlString);
|
||||||
const results: EbayListingDetails[] = [];
|
const results: EbayListingDetails[] = [];
|
||||||
|
|
||||||
// Find all listing links by looking for eBay item URLs (/itm/)
|
// Find all listing links by looking for eBay item URLs (/itm/)
|
||||||
const linkElements = document.querySelectorAll('a[href*="itm/"]');
|
const linkElements = document.querySelectorAll('a[href*="itm/"]');
|
||||||
|
|
||||||
|
for (const linkElement of linkElements) {
|
||||||
|
try {
|
||||||
|
// Get href attribute
|
||||||
|
let href = linkElement.getAttribute("href");
|
||||||
|
if (!href) continue;
|
||||||
|
|
||||||
for (const linkElement of linkElements) {
|
// Make href absolute
|
||||||
try {
|
if (!href.startsWith("http")) {
|
||||||
// Get href attribute
|
href = href.startsWith("//")
|
||||||
let href = linkElement.getAttribute('href');
|
? `https:${href}`
|
||||||
if (!href) continue;
|
: `https://www.ebay.com${href}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Make href absolute
|
// Find the container - go up several levels to find the item container
|
||||||
if (!href.startsWith('http')) {
|
// Modern eBay uses complex nested structures
|
||||||
href = href.startsWith('//') ? `https:${href}` : `https://www.ebay.com${href}`;
|
let container = linkElement.parentElement?.parentElement?.parentElement;
|
||||||
}
|
if (!container) {
|
||||||
|
// Try a different level
|
||||||
|
container = linkElement.parentElement?.parentElement;
|
||||||
|
}
|
||||||
|
if (!container) continue;
|
||||||
|
|
||||||
// Find the container - go up several levels to find the item container
|
// Extract title - look for heading or title-related elements near the link
|
||||||
// Modern eBay uses complex nested structures
|
// Modern eBay often uses h3, span, or div with text content near the link
|
||||||
let container = linkElement.parentElement?.parentElement?.parentElement;
|
let titleElement = container.querySelector(
|
||||||
if (!container) {
|
'h3, [role="heading"], .s-item__title span',
|
||||||
// Try a different level
|
);
|
||||||
container = linkElement.parentElement?.parentElement;
|
|
||||||
}
|
|
||||||
if (!container) continue;
|
|
||||||
|
|
||||||
// Extract title - look for heading or title-related elements near the link
|
// If no direct title element, try finding text content around the link
|
||||||
// Modern eBay often uses h3, span, or div with text content near the link
|
if (!titleElement) {
|
||||||
let titleElement = container.querySelector('h3, [role="heading"], .s-item__title span');
|
// Look for spans or divs with text near this link
|
||||||
|
const nearbySpans = container.querySelectorAll("span, div");
|
||||||
|
for (const span of nearbySpans) {
|
||||||
|
const text = span.textContent?.trim();
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
text.length > 10 &&
|
||||||
|
text.length < 200 &&
|
||||||
|
!text.includes("$") &&
|
||||||
|
!text.includes("item")
|
||||||
|
) {
|
||||||
|
titleElement = span;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If no direct title element, try finding text content around the link
|
let title = titleElement?.textContent?.trim();
|
||||||
if (!titleElement) {
|
|
||||||
// Look for spans or divs with text near this link
|
|
||||||
const nearbySpans = container.querySelectorAll('span, div');
|
|
||||||
for (const span of nearbySpans) {
|
|
||||||
const text = span.textContent?.trim();
|
|
||||||
if (text && text.length > 10 && text.length < 200 && !text.includes('$') && !text.includes('item')) {
|
|
||||||
titleElement = span;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let title = titleElement?.textContent?.trim();
|
// Clean up eBay UI strings that get included in titles
|
||||||
|
if (title) {
|
||||||
|
// Remove common eBay UI strings that appear at the end of titles
|
||||||
|
const uiStrings = [
|
||||||
|
"Opens in a new window",
|
||||||
|
"Opens in a new tab",
|
||||||
|
"Opens in a new window or tab",
|
||||||
|
"opens in a new window",
|
||||||
|
"opens in a new tab",
|
||||||
|
"opens in a new window or tab",
|
||||||
|
];
|
||||||
|
|
||||||
// Clean up eBay UI strings that get included in titles
|
for (const uiString of uiStrings) {
|
||||||
if (title) {
|
const uiIndex = title.indexOf(uiString);
|
||||||
// Remove common eBay UI strings that appear at the end of titles
|
if (uiIndex !== -1) {
|
||||||
const uiStrings = [
|
title = title.substring(0, uiIndex).trim();
|
||||||
'Opens in a new window',
|
break; // Only remove one UI string per title
|
||||||
'Opens in a new tab',
|
}
|
||||||
'Opens in a new window or tab',
|
}
|
||||||
'opens in a new window',
|
|
||||||
'opens in a new tab',
|
|
||||||
'opens in a new window or tab'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const uiString of uiStrings) {
|
// If the title became empty or too short after cleaning, skip this item
|
||||||
const uiIndex = title.indexOf(uiString);
|
if (title.length < 10) {
|
||||||
if (uiIndex !== -1) {
|
continue;
|
||||||
title = title.substring(0, uiIndex).trim();
|
}
|
||||||
break; // Only remove one UI string per title
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the title became empty or too short after cleaning, skip this item
|
if (!title) continue;
|
||||||
if (title.length < 10) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title) continue;
|
// Skip irrelevant eBay ads
|
||||||
|
if (title === "Shop on eBay" || title.length < 3) continue;
|
||||||
|
|
||||||
// Skip irrelevant eBay ads
|
// Extract price - look for eBay's price classes, preferring sale/discount prices
|
||||||
if (title === "Shop on eBay" || title.length < 3) continue;
|
let priceElement = container.querySelector(
|
||||||
|
'[class*="s-item__price"], .s-item__price, [class*="price"]',
|
||||||
|
);
|
||||||
|
|
||||||
// Extract price - look for eBay's price classes, preferring sale/discount prices
|
// If no direct price class, look for spans containing $ (but not titles)
|
||||||
let priceElement = container.querySelector('[class*="s-item__price"], .s-item__price, [class*="price"]');
|
if (!priceElement) {
|
||||||
|
const spansAndElements = container.querySelectorAll(
|
||||||
|
"span, div, b, em, strong",
|
||||||
|
);
|
||||||
|
for (const el of spansAndElements) {
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
// Must contain $, be reasonably short (price shouldn't be paragraph), and not contain product words
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
text.includes("$") &&
|
||||||
|
text.length < 100 &&
|
||||||
|
!text.includes("laptop") &&
|
||||||
|
!text.includes("computer") &&
|
||||||
|
!text.includes("intel") &&
|
||||||
|
!text.includes("core") &&
|
||||||
|
!text.includes("ram") &&
|
||||||
|
!text.includes("ssd") &&
|
||||||
|
!/\d{4}/.test(text) && // Avoid years like "2024"
|
||||||
|
!text.includes('"') // Avoid measurements
|
||||||
|
) {
|
||||||
|
priceElement = el;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If no direct price class, look for spans containing $ (but not titles)
|
// For discounted items, eBay shows both original and sale price
|
||||||
if (!priceElement) {
|
// Prefer sale/current price over original/strikethrough price
|
||||||
const spansAndElements = container.querySelectorAll('span, div, b, em, strong');
|
if (priceElement) {
|
||||||
for (const el of spansAndElements) {
|
// Check if this element or its parent contains multiple price elements
|
||||||
const text = el.textContent?.trim();
|
const priceContainer =
|
||||||
// Must contain $, be reasonably short (price shouldn't be paragraph), and not contain product words
|
priceElement.closest('[class*="s-item__price"]') ||
|
||||||
if (text && text.includes('$') && text.length < 100 &&
|
priceElement.parentElement;
|
||||||
!text.includes('laptop') && !text.includes('computer') && !text.includes('intel') &&
|
|
||||||
!text.includes('core') && !text.includes('ram') && !text.includes('ssd') &&
|
|
||||||
! /\d{4}/.test(text) && // Avoid years like "2024"
|
|
||||||
!text.includes('"') // Avoid measurements
|
|
||||||
) {
|
|
||||||
priceElement = el;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For discounted items, eBay shows both original and sale price
|
if (priceContainer) {
|
||||||
// Prefer sale/current price over original/strikethrough price
|
// Look for all price elements within this container, including strikethrough prices
|
||||||
if (priceElement) {
|
const allPriceElements = priceContainer.querySelectorAll(
|
||||||
// Check if this element or its parent contains multiple price elements
|
'[class*="s-item__price"], span, b, em, strong, s, del, strike',
|
||||||
const priceContainer = priceElement.closest('[class*="s-item__price"]') || priceElement.parentElement;
|
);
|
||||||
|
|
||||||
if (priceContainer) {
|
// Filter to only elements that actually contain prices (not labels)
|
||||||
// Look for all price elements within this container, including strikethrough prices
|
const actualPrices: HTMLElement[] = [];
|
||||||
const allPriceElements = priceContainer.querySelectorAll('[class*="s-item__price"], span, b, em, strong, s, del, strike');
|
for (const el of allPriceElements) {
|
||||||
|
const text = el.textContent?.trim();
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
/^\s*[$£€¥]/u.test(text) &&
|
||||||
|
text.length < 50 &&
|
||||||
|
!/\d{4}/.test(text)
|
||||||
|
) {
|
||||||
|
actualPrices.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filter to only elements that actually contain prices (not labels)
|
// Prefer non-strikethrough prices (sale prices) over strikethrough ones (original prices)
|
||||||
const actualPrices: HTMLElement[] = [];
|
if (actualPrices.length > 1) {
|
||||||
for (const el of allPriceElements) {
|
// First, look for prices that are NOT struck through
|
||||||
const text = el.textContent?.trim();
|
const nonStrikethroughPrices = actualPrices.filter((el) => {
|
||||||
if (text && /^\s*[$£€¥]/u.test(text) && text.length < 50 && !/\d{4}/.test(text)) {
|
const tagName = el.tagName.toLowerCase();
|
||||||
actualPrices.push(el);
|
const styles =
|
||||||
}
|
el.classList.contains("s-strikethrough") ||
|
||||||
}
|
el.classList.contains("u-flStrike") ||
|
||||||
|
el.closest("s, del, strike");
|
||||||
|
return (
|
||||||
|
tagName !== "s" &&
|
||||||
|
tagName !== "del" &&
|
||||||
|
tagName !== "strike" &&
|
||||||
|
!styles
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Prefer non-strikethrough prices (sale prices) over strikethrough ones (original prices)
|
if (nonStrikethroughPrices.length > 0) {
|
||||||
if (actualPrices.length > 1) {
|
// Use the first non-strikethrough price (sale price)
|
||||||
// First, look for prices that are NOT struck through
|
priceElement = nonStrikethroughPrices[0];
|
||||||
const nonStrikethroughPrices = actualPrices.filter(el => {
|
} else {
|
||||||
const tagName = el.tagName.toLowerCase();
|
// Fallback: use the last price (likely the most current)
|
||||||
const styles = el.classList.contains('s-strikethrough') || el.classList.contains('u-flStrike') ||
|
const lastPrice = actualPrices[actualPrices.length - 1];
|
||||||
el.closest('s, del, strike');
|
priceElement = lastPrice;
|
||||||
return tagName !== 's' && tagName !== 'del' && tagName !== 'strike' && !styles;
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (nonStrikethroughPrices.length > 0) {
|
const priceText = priceElement?.textContent?.trim();
|
||||||
// Use the first non-strikethrough price (sale price)
|
|
||||||
priceElement = nonStrikethroughPrices[0];
|
|
||||||
} else {
|
|
||||||
// Fallback: use the last price (likely the most current)
|
|
||||||
const lastPrice = actualPrices[actualPrices.length - 1];
|
|
||||||
priceElement = lastPrice;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const priceText = priceElement?.textContent?.trim();
|
if (!priceText) continue;
|
||||||
|
|
||||||
if (!priceText) continue;
|
// Parse price into cents and currency
|
||||||
|
const priceInfo = parseEbayPrice(priceText);
|
||||||
|
if (!priceInfo) continue;
|
||||||
|
|
||||||
// Parse price into cents and currency
|
// Apply exclusion filters
|
||||||
const priceInfo = parseEbayPrice(priceText);
|
if (
|
||||||
if (!priceInfo) continue;
|
exclusions.some((exclusion) =>
|
||||||
|
title.toLowerCase().includes(exclusion.toLowerCase()),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply exclusion filters
|
// Apply strict mode filter (title must contain at least one keyword)
|
||||||
if (exclusions.some(exclusion => title.toLowerCase().includes(exclusion.toLowerCase()))) {
|
if (
|
||||||
continue;
|
strictMode &&
|
||||||
}
|
title &&
|
||||||
|
!keywords.some((keyword) =>
|
||||||
|
title.toLowerCase().includes(keyword.toLowerCase()),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply strict mode filter (title must contain at least one keyword)
|
const listing: EbayListingDetails = {
|
||||||
if (strictMode && !keywords.some(keyword => title!.toLowerCase().includes(keyword.toLowerCase()))) {
|
url: href,
|
||||||
continue;
|
title,
|
||||||
}
|
listingPrice: {
|
||||||
|
amountFormatted: priceText,
|
||||||
|
cents: priceInfo.cents,
|
||||||
|
currency: priceInfo.currency,
|
||||||
|
},
|
||||||
|
listingType: "OFFER", // eBay listings are typically offers
|
||||||
|
listingStatus: "ACTIVE",
|
||||||
|
address: null, // eBay doesn't typically show detailed addresses in search results
|
||||||
|
};
|
||||||
|
|
||||||
const listing: EbayListingDetails = {
|
results.push(listing);
|
||||||
url: href,
|
} catch (err) {
|
||||||
title,
|
console.warn(`Error parsing eBay listing: ${err}`);
|
||||||
listingPrice: {
|
}
|
||||||
amountFormatted: priceText,
|
}
|
||||||
cents: priceInfo.cents,
|
|
||||||
currency: priceInfo.currency,
|
|
||||||
},
|
|
||||||
listingType: "OFFER", // eBay listings are typically offers
|
|
||||||
listingStatus: "ACTIVE",
|
|
||||||
address: null, // eBay doesn't typically show detailed addresses in search results
|
|
||||||
};
|
|
||||||
|
|
||||||
results.push(listing);
|
return results;
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Error parsing eBay listing: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------- Main -----------------------------
|
// ----------------------------- Main -----------------------------
|
||||||
|
|
||||||
export default async function fetchEbayItems(
|
export default async function fetchEbayItems(
|
||||||
SEARCH_QUERY: string,
|
SEARCH_QUERY: string,
|
||||||
REQUESTS_PER_SECOND = 1,
|
REQUESTS_PER_SECOND = 1,
|
||||||
opts: {
|
opts: {
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
maxPrice?: number;
|
maxPrice?: number;
|
||||||
strictMode?: boolean;
|
strictMode?: boolean;
|
||||||
exclusions?: string[];
|
exclusions?: string[];
|
||||||
keywords?: string[];
|
keywords?: string[];
|
||||||
buyItNowOnly?: boolean;
|
buyItNowOnly?: boolean;
|
||||||
canadaOnly?: boolean;
|
canadaOnly?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
minPrice = 0,
|
minPrice = 0,
|
||||||
maxPrice = Number.MAX_SAFE_INTEGER,
|
maxPrice = Number.MAX_SAFE_INTEGER,
|
||||||
strictMode = false,
|
strictMode = false,
|
||||||
exclusions = [],
|
exclusions = [],
|
||||||
keywords = [SEARCH_QUERY], // Default to search query if no keywords provided
|
keywords = [SEARCH_QUERY], // Default to search query if no keywords provided
|
||||||
buyItNowOnly = true,
|
buyItNowOnly = true,
|
||||||
canadaOnly = true,
|
canadaOnly = true,
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
// Build eBay search URL - use Canadian site, Buy It Now filter, and Canada-only preference
|
// Build eBay search URL - use Canadian site, Buy It Now filter, and Canada-only preference
|
||||||
const urlParams = new URLSearchParams({
|
const urlParams = new URLSearchParams({
|
||||||
_nkw: SEARCH_QUERY,
|
_nkw: SEARCH_QUERY,
|
||||||
_sacat: "0",
|
_sacat: "0",
|
||||||
_from: "R40",
|
_from: "R40",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (buyItNowOnly) {
|
if (buyItNowOnly) {
|
||||||
urlParams.set("LH_BIN", "1");
|
urlParams.set("LH_BIN", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canadaOnly) {
|
if (canadaOnly) {
|
||||||
urlParams.set("LH_PrefLoc", "1");
|
urlParams.set("LH_PrefLoc", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchUrl = `https://www.ebay.ca/sch/i.html?${urlParams.toString()}`;
|
const searchUrl = `https://www.ebay.ca/sch/i.html?${urlParams.toString()}`;
|
||||||
|
|
||||||
const DELAY_MS = Math.max(1, Math.floor(1000 / REQUESTS_PER_SECOND));
|
const DELAY_MS = Math.max(1, Math.floor(1000 / REQUESTS_PER_SECOND));
|
||||||
|
|
||||||
console.log(`Fetching eBay search: ${searchUrl}`);
|
console.log(`Fetching eBay search: ${searchUrl}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use custom headers modeled after real browser requests to bypass bot detection
|
// Use custom headers modeled after real browser requests to bypass bot detection
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0',
|
"User-Agent":
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
"Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0",
|
||||||
'Accept-Language': 'en-US,en;q=0.5',
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
"Accept-Language": "en-US,en;q=0.5",
|
||||||
'Referer': 'https://www.ebay.ca/',
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
'Connection': 'keep-alive',
|
Referer: "https://www.ebay.ca/",
|
||||||
'Upgrade-Insecure-Requests': '1',
|
Connection: "keep-alive",
|
||||||
'Sec-Fetch-Dest': 'document',
|
"Upgrade-Insecure-Requests": "1",
|
||||||
'Sec-Fetch-Mode': 'navigate',
|
"Sec-Fetch-Dest": "document",
|
||||||
'Sec-Fetch-Site': 'same-origin',
|
"Sec-Fetch-Mode": "navigate",
|
||||||
'Sec-Fetch-User': '?1',
|
"Sec-Fetch-Site": "same-origin",
|
||||||
'Priority': 'u=0, i'
|
"Sec-Fetch-User": "?1",
|
||||||
};
|
Priority: "u=0, i",
|
||||||
|
};
|
||||||
|
|
||||||
const res = await fetch(searchUrl, {
|
const res = await fetch(searchUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
`Request failed with status ${res.status}`,
|
`Request failed with status ${res.status}`,
|
||||||
res.status,
|
res.status,
|
||||||
searchUrl,
|
searchUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchHtml = await res.text();
|
const searchHtml = await res.text();
|
||||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
||||||
await delay(DELAY_MS);
|
await delay(DELAY_MS);
|
||||||
|
|
||||||
console.log(`\nParsing eBay listings...`);
|
console.log(`\nParsing eBay listings...`);
|
||||||
|
|
||||||
const listings = parseEbayListings(searchHtml, keywords, exclusions, strictMode);
|
const listings = parseEbayListings(
|
||||||
|
searchHtml,
|
||||||
|
keywords,
|
||||||
|
exclusions,
|
||||||
|
strictMode,
|
||||||
|
);
|
||||||
|
|
||||||
// Filter by price range (additional safety check)
|
// Filter by price range (additional safety check)
|
||||||
const filteredListings = listings.filter(listing => {
|
const filteredListings = listings.filter((listing) => {
|
||||||
const cents = listing.listingPrice?.cents;
|
const cents = listing.listingPrice?.cents;
|
||||||
return cents && cents >= minPrice && cents <= maxPrice;
|
return cents && cents >= minPrice && cents <= maxPrice;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Parsed ${filteredListings.length} eBay listings.`);
|
console.log(`Parsed ${filteredListings.length} eBay listings.`);
|
||||||
return filteredListings;
|
return filteredListings;
|
||||||
|
} catch (err) {
|
||||||
} catch (err) {
|
if (err instanceof HttpError) {
|
||||||
if (err instanceof HttpError) {
|
console.error(
|
||||||
console.error(
|
`Failed to fetch eBay search (${err.status}): ${err.message}`,
|
||||||
`Failed to fetch eBay search (${err.status}): ${err.message}`,
|
);
|
||||||
);
|
return [];
|
||||||
return [];
|
}
|
||||||
}
|
throw err;
|
||||||
throw err;
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,23 @@ export async function handleMcpRequest(req: Request): Promise<Response> {
|
|||||||
error: { code: -32602, message: "query parameter is required" },
|
error: { code: -32602, message: "query parameter is required" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const items = await fetchKijijiItems(query, args.maxItems || 5);
|
const searchOptions = {
|
||||||
|
location: args.location,
|
||||||
|
category: args.category,
|
||||||
|
keywords: args.keywords,
|
||||||
|
sortBy: args.sortBy,
|
||||||
|
sortOrder: args.sortOrder,
|
||||||
|
maxPages: args.maxPages || 5,
|
||||||
|
priceMin: args.priceMin,
|
||||||
|
priceMax: args.priceMax,
|
||||||
|
};
|
||||||
|
const items = await fetchKijijiItems(
|
||||||
|
query,
|
||||||
|
1,
|
||||||
|
"https://www.kijiji.ca",
|
||||||
|
searchOptions,
|
||||||
|
{}
|
||||||
|
);
|
||||||
result = items || [];
|
result = items || [];
|
||||||
} else if (name === "search_facebook") {
|
} else if (name === "search_facebook") {
|
||||||
const query = args.query;
|
const query = args.query;
|
||||||
@@ -110,10 +126,11 @@ export async function handleMcpRequest(req: Request): Promise<Response> {
|
|||||||
}
|
}
|
||||||
const items = await fetchFacebookItems(
|
const items = await fetchFacebookItems(
|
||||||
query,
|
query,
|
||||||
args.maxItems || 5,
|
1,
|
||||||
args.location || "toronto",
|
args.location || "toronto",
|
||||||
25,
|
args.maxItems || 25,
|
||||||
args.cookiesSource
|
args.cookiesSource,
|
||||||
|
undefined
|
||||||
);
|
);
|
||||||
result = items || [];
|
result = items || [];
|
||||||
} else if (name === "search_ebay") {
|
} else if (name === "search_ebay") {
|
||||||
@@ -125,7 +142,7 @@ export async function handleMcpRequest(req: Request): Promise<Response> {
|
|||||||
error: { code: -32602, message: "query parameter is required" },
|
error: { code: -32602, message: "query parameter is required" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const items = await fetchEbayItems(query, args.maxItems || 5, {
|
const items = await fetchEbayItems(query, 1, {
|
||||||
minPrice: args.minPrice,
|
minPrice: args.minPrice,
|
||||||
maxPrice: args.maxPrice,
|
maxPrice: args.maxPrice,
|
||||||
strictMode: args.strictMode || false,
|
strictMode: args.strictMode || false,
|
||||||
@@ -134,7 +151,9 @@ export async function handleMcpRequest(req: Request): Promise<Response> {
|
|||||||
buyItNowOnly: args.buyItNowOnly !== false,
|
buyItNowOnly: args.buyItNowOnly !== false,
|
||||||
canadaOnly: args.canadaOnly !== false,
|
canadaOnly: args.canadaOnly !== false,
|
||||||
});
|
});
|
||||||
result = items || [];
|
|
||||||
|
const results = args.maxItems ? items.slice(0, args.maxItems) : items;
|
||||||
|
result = results || [];
|
||||||
} else {
|
} else {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|||||||
@@ -3,25 +3,57 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const tools = [
|
export const tools = [
|
||||||
{
|
{
|
||||||
name: "search_kijiji",
|
name: "search_kijiji",
|
||||||
description: "Search Kijiji marketplace for listings matching a query",
|
description: "Search Kijiji marketplace for listings matching a query",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
query: {
|
query: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Search query for Kijiji listings",
|
description: "Search query for Kijiji listings",
|
||||||
},
|
},
|
||||||
maxItems: {
|
location: {
|
||||||
type: "number",
|
type: "string",
|
||||||
description: "Maximum number of items to return",
|
description: "Location name or ID (e.g., 'toronto', 'gta', 'ontario')",
|
||||||
default: 5,
|
},
|
||||||
|
category: {
|
||||||
|
type: "string",
|
||||||
|
description: "Category name or ID (e.g., 'computers', 'furniture', 'bikes')",
|
||||||
|
},
|
||||||
|
keywords: {
|
||||||
|
type: "string",
|
||||||
|
description: "Additional keywords to filter results",
|
||||||
|
},
|
||||||
|
sortBy: {
|
||||||
|
type: "string",
|
||||||
|
description: "Sort results by field",
|
||||||
|
enum: ["relevancy", "date", "price", "distance"],
|
||||||
|
default: "relevancy",
|
||||||
|
},
|
||||||
|
sortOrder: {
|
||||||
|
type: "string",
|
||||||
|
description: "Sort order",
|
||||||
|
enum: ["asc", "desc"],
|
||||||
|
default: "desc",
|
||||||
|
},
|
||||||
|
maxPages: {
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum pages to fetch (~40 items per page)",
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
priceMin: {
|
||||||
|
type: "number",
|
||||||
|
description: "Minimum price in cents",
|
||||||
|
},
|
||||||
|
priceMax: {
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum price in cents",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
required: ["query"],
|
||||||
},
|
},
|
||||||
required: ["query"],
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "search_facebook",
|
name: "search_facebook",
|
||||||
description: "Search Facebook Marketplace for listings matching a query",
|
description: "Search Facebook Marketplace for listings matching a query",
|
||||||
|
|||||||
Reference in New Issue
Block a user