Compare commits

..

7 Commits

7 changed files with 575 additions and 427 deletions

View File

@@ -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}`);

View File

@@ -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 });
}
} }

View File

@@ -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 });
}
} }

View File

@@ -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 });
}
} }

View File

@@ -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; }
}
} }

View File

@@ -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",

View File

@@ -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",