fix: resolve biome lint errors and warnings
This commit is contained in:
64
biome.json
64
biome.json
@@ -1,34 +1,34 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": true
|
"useIgnoreFile": true
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!!**/dist"]
|
"includes": ["**", "!!**/dist"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "space"
|
"indentStyle": "space"
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"quoteStyle": "double"
|
"quoteStyle": "double"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"actions": {
|
"actions": {
|
||||||
"source": {
|
"source": {
|
||||||
"organizeImports": "on"
|
"organizeImports": "on"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.11"
|
"@biomejs/biome": "2.3.11"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
* Health check endpoint
|
* Health check endpoint
|
||||||
*/
|
*/
|
||||||
export function statusRoute(): Response {
|
export function statusRoute(): Response {
|
||||||
return new Response("OK", { status: 200 });
|
return new Response("OK", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,42 @@
|
|||||||
// Export all scrapers
|
// Export all scrapers
|
||||||
export {
|
|
||||||
default as fetchKijijiItems,
|
|
||||||
slugify,
|
|
||||||
resolveLocationId,
|
|
||||||
resolveCategoryId,
|
|
||||||
buildSearchUrl,
|
|
||||||
extractApolloState,
|
|
||||||
parseSearch,
|
|
||||||
parseDetailedListing,
|
|
||||||
HttpError,
|
|
||||||
NetworkError,
|
|
||||||
ParseError,
|
|
||||||
RateLimitError,
|
|
||||||
ValidationError,
|
|
||||||
} from "./scrapers/kijiji";
|
|
||||||
export type {
|
|
||||||
KijijiListingDetails,
|
|
||||||
DetailedListing,
|
|
||||||
SearchOptions,
|
|
||||||
ListingFetchOptions,
|
|
||||||
} from "./scrapers/kijiji";
|
|
||||||
|
|
||||||
export {
|
|
||||||
default as fetchFacebookItems,
|
|
||||||
fetchFacebookItem,
|
|
||||||
parseFacebookCookieString,
|
|
||||||
ensureFacebookCookies,
|
|
||||||
extractFacebookMarketplaceData,
|
|
||||||
extractFacebookItemData,
|
|
||||||
parseFacebookAds,
|
|
||||||
parseFacebookItem,
|
|
||||||
} from "./scrapers/facebook";
|
|
||||||
export type { FacebookListingDetails } from "./scrapers/facebook";
|
|
||||||
|
|
||||||
export { default as fetchEbayItems } from "./scrapers/ebay";
|
|
||||||
export type { EbayListingDetails } from "./scrapers/ebay";
|
export type { EbayListingDetails } from "./scrapers/ebay";
|
||||||
|
export { default as fetchEbayItems } from "./scrapers/ebay";
|
||||||
// Export shared utilities
|
export type { FacebookListingDetails } from "./scrapers/facebook";
|
||||||
export * from "./utils/http";
|
export {
|
||||||
export * from "./utils/delay";
|
default as fetchFacebookItems,
|
||||||
export * from "./utils/format";
|
ensureFacebookCookies,
|
||||||
|
extractFacebookItemData,
|
||||||
|
extractFacebookMarketplaceData,
|
||||||
|
fetchFacebookItem,
|
||||||
|
parseFacebookAds,
|
||||||
|
parseFacebookCookieString,
|
||||||
|
parseFacebookItem,
|
||||||
|
} from "./scrapers/facebook";
|
||||||
|
export type {
|
||||||
|
DetailedListing,
|
||||||
|
KijijiListingDetails,
|
||||||
|
ListingFetchOptions,
|
||||||
|
SearchOptions,
|
||||||
|
} from "./scrapers/kijiji";
|
||||||
|
export {
|
||||||
|
buildSearchUrl,
|
||||||
|
default as fetchKijijiItems,
|
||||||
|
extractApolloState,
|
||||||
|
HttpError,
|
||||||
|
NetworkError,
|
||||||
|
ParseError,
|
||||||
|
parseDetailedListing,
|
||||||
|
parseSearch,
|
||||||
|
RateLimitError,
|
||||||
|
resolveCategoryId,
|
||||||
|
resolveLocationId,
|
||||||
|
slugify,
|
||||||
|
ValidationError,
|
||||||
|
} from "./scrapers/kijiji";
|
||||||
// Export shared types
|
// Export shared types
|
||||||
export * from "./types/common";
|
export * from "./types/common";
|
||||||
|
export * from "./utils/delay";
|
||||||
|
export * from "./utils/format";
|
||||||
|
// Export shared utilities
|
||||||
|
export * from "./utils/http";
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { parseHTML } from "linkedom";
|
|
||||||
import type { HTMLString } from "../types/common";
|
|
||||||
import { delay } from "../utils/delay";
|
|
||||||
import { formatCentsToCurrency } from "../utils/format";
|
|
||||||
import { isRecord } from "../utils/http";
|
|
||||||
|
|
||||||
// ----------------------------- Types -----------------------------
|
// ----------------------------- Types -----------------------------
|
||||||
|
|
||||||
@@ -43,7 +38,7 @@ function parseEbayPrice(
|
|||||||
|
|
||||||
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 (Number.isNaN(dollars)) return null;
|
||||||
|
|
||||||
const cents = Math.round(dollars * 100);
|
const cents = Math.round(dollars * 100);
|
||||||
|
|
||||||
@@ -185,8 +180,7 @@ function parseEbayListings(
|
|||||||
const text = el.textContent?.trim();
|
const text = el.textContent?.trim();
|
||||||
// Must contain $, be reasonably short (price shouldn't be paragraph), and not contain product words
|
// Must contain $, be reasonably short (price shouldn't be paragraph), and not contain product words
|
||||||
if (
|
if (
|
||||||
text &&
|
text?.includes("$") &&
|
||||||
text.includes("$") &&
|
|
||||||
text.length < 100 &&
|
text.length < 100 &&
|
||||||
!text.includes("laptop") &&
|
!text.includes("laptop") &&
|
||||||
!text.includes("computer") &&
|
!text.includes("computer") &&
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,18 +3,18 @@ export type HTMLString = string;
|
|||||||
|
|
||||||
/** Currency price object with formatting options */
|
/** Currency price object with formatting options */
|
||||||
export interface Price {
|
export interface Price {
|
||||||
amountFormatted: string;
|
amountFormatted: string;
|
||||||
cents: number;
|
cents: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Base listing details common across all marketplaces */
|
/** Base listing details common across all marketplaces */
|
||||||
export interface ListingDetails {
|
export interface ListingDetails {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
listingPrice: Price;
|
listingPrice: Price;
|
||||||
listingType: string;
|
listingType: string;
|
||||||
listingStatus: string;
|
listingStatus: string;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
creationDate?: string;
|
creationDate?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
* @returns A promise that resolves after the specified delay
|
* @returns A promise that resolves after the specified delay
|
||||||
*/
|
*/
|
||||||
export function delay(ms: number): Promise<void> {
|
export function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,21 @@
|
|||||||
* @param locale - Locale string for formatting (e.g., 'en-CA', 'en-US')
|
* @param locale - Locale string for formatting (e.g., 'en-CA', 'en-US')
|
||||||
* @returns Formatted currency string
|
* @returns Formatted currency string
|
||||||
*/
|
*/
|
||||||
export function formatCentsToCurrency(cents: number, locale: string = "en-CA"): string {
|
export function formatCentsToCurrency(
|
||||||
try {
|
cents: number,
|
||||||
const formatter = new Intl.NumberFormat(locale, {
|
locale: string = "en-CA",
|
||||||
style: "currency",
|
): string {
|
||||||
currency: "CAD",
|
try {
|
||||||
minimumFractionDigits: 2,
|
const formatter = new Intl.NumberFormat(locale, {
|
||||||
maximumFractionDigits: 2,
|
style: "currency",
|
||||||
});
|
currency: "CAD",
|
||||||
return formatter.format(cents / 100);
|
minimumFractionDigits: 2,
|
||||||
} catch (error) {
|
maximumFractionDigits: 2,
|
||||||
// Fallback if locale is not supported
|
});
|
||||||
const dollars = (cents / 100).toFixed(2);
|
return formatter.format(cents / 100);
|
||||||
return `$${dollars}`;
|
} catch {
|
||||||
}
|
// Fallback if locale is not supported
|
||||||
|
const dollars = (cents / 100).toFixed(2);
|
||||||
|
return `$${dollars}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,79 @@
|
|||||||
/** Custom error class for HTTP-related failures */
|
/** Custom error class for HTTP-related failures */
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly statusCode: number,
|
public readonly statusCode: number,
|
||||||
public readonly url?: string
|
public readonly url?: string,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "HttpError";
|
this.name = "HttpError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Error class for network failures (timeouts, connection issues) */
|
/** Error class for network failures (timeouts, connection issues) */
|
||||||
export class NetworkError extends Error {
|
export class NetworkError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly url: string,
|
public readonly url: string,
|
||||||
public readonly cause?: Error
|
public readonly cause?: Error,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "NetworkError";
|
this.name = "NetworkError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Error class for parsing failures */
|
/** Error class for parsing failures */
|
||||||
export class ParseError extends Error {
|
export class ParseError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly data?: unknown
|
public readonly data?: unknown,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ParseError";
|
this.name = "ParseError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Error class for rate limiting */
|
/** Error class for rate limiting */
|
||||||
export class RateLimitError extends Error {
|
export class RateLimitError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly url: string,
|
public readonly url: string,
|
||||||
public readonly resetTime?: number
|
public readonly resetTime?: number,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "RateLimitError";
|
this.name = "RateLimitError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Error class for validation failures */
|
/** Error class for validation failures */
|
||||||
export class ValidationError extends Error {
|
export class ValidationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ValidationError";
|
this.name = "ValidationError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type guard to check if a value is a record (object) */
|
/** Type guard to check if a value is a record (object) */
|
||||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate exponential backoff delay with jitter
|
* Calculate exponential backoff delay with jitter
|
||||||
*/
|
*/
|
||||||
function calculateBackoffDelay(attempt: number, baseMs: number): number {
|
function calculateBackoffDelay(attempt: number, baseMs: number): number {
|
||||||
const exponentialDelay = baseMs * 2 ** attempt;
|
const exponentialDelay = baseMs * 2 ** attempt;
|
||||||
const jitter = Math.random() * 0.1 * exponentialDelay; // 10% jitter
|
const jitter = Math.random() * 0.1 * exponentialDelay; // 10% jitter
|
||||||
return Math.min(exponentialDelay + jitter, 30000); // Cap at 30 seconds
|
return Math.min(exponentialDelay + jitter, 30000); // Cap at 30 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for fetchHtml */
|
/** Options for fetchHtml */
|
||||||
export interface FetchHtmlOptions {
|
export interface FetchHtmlOptions {
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
retryBaseMs?: number;
|
retryBaseMs?: number;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
onRateInfo?: (remaining: string | null, reset: string | null) => void;
|
onRateInfo?: (remaining: string | null, reset: string | null) => void;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,116 +85,116 @@ export interface FetchHtmlOptions {
|
|||||||
* @throws HttpError, NetworkError, or RateLimitError on failure
|
* @throws HttpError, NetworkError, or RateLimitError on failure
|
||||||
*/
|
*/
|
||||||
export async function fetchHtml(
|
export async function fetchHtml(
|
||||||
url: string,
|
url: string,
|
||||||
delayMs: number,
|
delayMs: number,
|
||||||
opts?: FetchHtmlOptions
|
opts?: FetchHtmlOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const maxRetries = opts?.maxRetries ?? 3;
|
const maxRetries = opts?.maxRetries ?? 3;
|
||||||
const retryBaseMs = opts?.retryBaseMs ?? 1000;
|
const retryBaseMs = opts?.retryBaseMs ?? 1000;
|
||||||
const timeoutMs = opts?.timeoutMs ?? 30000;
|
const timeoutMs = opts?.timeoutMs ?? 30000;
|
||||||
|
|
||||||
const defaultHeaders: Record<string, string> = {
|
const defaultHeaders: Record<string, string> = {
|
||||||
accept:
|
accept:
|
||||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||||
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
|
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
"upgrade-insecure-requests": "1",
|
"upgrade-insecure-requests": "1",
|
||||||
"user-agent":
|
"user-agent":
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { ...defaultHeaders, ...opts?.headers },
|
headers: { ...defaultHeaders, ...opts?.headers },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
const rateLimitRemaining = res.headers.get("X-RateLimit-Remaining");
|
const rateLimitRemaining = res.headers.get("X-RateLimit-Remaining");
|
||||||
const rateLimitReset = res.headers.get("X-RateLimit-Reset");
|
const rateLimitReset = res.headers.get("X-RateLimit-Reset");
|
||||||
opts?.onRateInfo?.(rateLimitRemaining, rateLimitReset);
|
opts?.onRateInfo?.(rateLimitRemaining, rateLimitReset);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Handle rate limiting
|
// Handle rate limiting
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
const resetSeconds = rateLimitReset
|
const resetSeconds = rateLimitReset
|
||||||
? Number(rateLimitReset)
|
? Number(rateLimitReset)
|
||||||
: Number.NaN;
|
: Number.NaN;
|
||||||
const waitMs = Number.isFinite(resetSeconds)
|
const waitMs = Number.isFinite(resetSeconds)
|
||||||
? Math.max(0, resetSeconds * 1000)
|
? Math.max(0, resetSeconds * 1000)
|
||||||
: calculateBackoffDelay(attempt, retryBaseMs);
|
: calculateBackoffDelay(attempt, retryBaseMs);
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new RateLimitError(
|
throw new RateLimitError(
|
||||||
`Rate limit exceeded for ${url}`,
|
`Rate limit exceeded for ${url}`,
|
||||||
url,
|
url,
|
||||||
resetSeconds
|
resetSeconds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry on server errors
|
// Retry on server errors
|
||||||
if (res.status >= 500 && res.status < 600 && attempt < maxRetries) {
|
if (res.status >= 500 && res.status < 600 && attempt < maxRetries) {
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
setTimeout(resolve, calculateBackoffDelay(attempt, retryBaseMs))
|
setTimeout(resolve, calculateBackoffDelay(attempt, retryBaseMs)),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
`Request failed with status ${res.status}`,
|
`Request failed with status ${res.status}`,
|
||||||
res.status,
|
res.status,
|
||||||
url
|
url,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
|
|
||||||
// Respect per-request delay to maintain rate limiting
|
// Respect per-request delay to maintain rate limiting
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
return html;
|
return html;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Re-throw known errors
|
// Re-throw known errors
|
||||||
if (
|
if (
|
||||||
err instanceof RateLimitError ||
|
err instanceof RateLimitError ||
|
||||||
err instanceof HttpError ||
|
err instanceof HttpError ||
|
||||||
err instanceof NetworkError
|
err instanceof NetworkError
|
||||||
) {
|
) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
setTimeout(resolve, calculateBackoffDelay(attempt, retryBaseMs))
|
setTimeout(resolve, calculateBackoffDelay(attempt, retryBaseMs)),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new NetworkError(`Request timeout for ${url}`, url, err);
|
throw new NetworkError(`Request timeout for ${url}`, url, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network or other errors
|
// Network or other errors
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
setTimeout(resolve, calculateBackoffDelay(attempt, retryBaseMs))
|
setTimeout(resolve, calculateBackoffDelay(attempt, retryBaseMs)),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new NetworkError(
|
throw new NetworkError(
|
||||||
`Network error fetching ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
`Network error fetching ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
url,
|
url,
|
||||||
err instanceof Error ? err : undefined
|
err instanceof Error ? err : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new NetworkError(`Exhausted retries without response for ${url}`, url);
|
throw new NetworkError(`Exhausted retries without response for ${url}`, url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,206 +1,219 @@
|
|||||||
import { fetchKijijiItems, fetchFacebookItems, fetchEbayItems } from "@marketplace-scrapers/core";
|
import {
|
||||||
|
fetchEbayItems,
|
||||||
|
fetchFacebookItems,
|
||||||
|
fetchKijijiItems,
|
||||||
|
} from "@marketplace-scrapers/core";
|
||||||
import { tools } from "./tools";
|
import { tools } from "./tools";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle MCP JSON-RPC 2.0 protocol requests
|
* Handle MCP JSON-RPC 2.0 protocol requests
|
||||||
*/
|
*/
|
||||||
export async function handleMcpRequest(req: Request): Promise<Response> {
|
export async function handleMcpRequest(req: Request): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
// Validate JSON-RPC 2.0 format
|
// Validate JSON-RPC 2.0 format
|
||||||
if (!body.jsonrpc || body.jsonrpc !== "2.0" || !body.method) {
|
if (!body.jsonrpc || body.jsonrpc !== "2.0" || !body.method) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
error: { code: -32600, message: "Invalid Request" },
|
error: { code: -32600, message: "Invalid Request" },
|
||||||
id: body.id,
|
id: body.id,
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { method, params, id } = body;
|
const { method, params, id } = body;
|
||||||
|
|
||||||
// Handle initialize method
|
// Handle initialize method
|
||||||
if (method === "initialize") {
|
if (method === "initialize") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
result: {
|
result: {
|
||||||
protocolVersion: "2025-06-18",
|
protocolVersion: "2025-06-18",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {
|
tools: {
|
||||||
listChanged: true,
|
listChanged: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
name: "marketplace-scrapers",
|
name: "marketplace-scrapers",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
},
|
},
|
||||||
instructions: "Use search_kijiji, search_facebook, or search_ebay tools to find listings across Canadian marketplaces",
|
instructions:
|
||||||
},
|
"Use search_kijiji, search_facebook, or search_ebay tools to find listings across Canadian marketplaces",
|
||||||
});
|
},
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle tools/list method
|
// Handle tools/list method
|
||||||
if (method === "tools/list") {
|
if (method === "tools/list") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
result: {
|
result: {
|
||||||
tools,
|
tools,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle notifications (messages without id field should not get a response)
|
// Handle notifications (messages without id field should not get a response)
|
||||||
if (!id) {
|
if (!id) {
|
||||||
// Notifications don't require a response
|
// Notifications don't require a response
|
||||||
if (method === "notifications/initialized") {
|
if (method === "notifications/initialized") {
|
||||||
// Client initialized successfully, no response needed
|
// Client initialized successfully, no response needed
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
if (method === "notifications/progress") {
|
if (method === "notifications/progress") {
|
||||||
// Progress notifications, no response needed
|
// Progress notifications, no response needed
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
// Unknown notification - still no response for notifications
|
// Unknown notification - still no response for notifications
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tools/call method
|
// Handle tools/call method
|
||||||
if (method === "tools/call") {
|
if (method === "tools/call") {
|
||||||
const { name, arguments: args } = params || {};
|
const { name, arguments: args } = params || {};
|
||||||
|
|
||||||
if (!name || !args) {
|
if (!name || !args) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
error: { code: -32602, message: "Invalid params: name and arguments required" },
|
error: {
|
||||||
},
|
code: -32602,
|
||||||
{ status: 400 }
|
message: "Invalid params: name and arguments required",
|
||||||
);
|
},
|
||||||
}
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Route tool calls to appropriate handlers
|
// Route tool calls to appropriate handlers
|
||||||
try {
|
try {
|
||||||
let result;
|
let result: unknown;
|
||||||
|
|
||||||
if (name === "search_kijiji") {
|
if (name === "search_kijiji") {
|
||||||
const query = args.query;
|
const query = args.query;
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
error: { code: -32602, message: "query parameter is required" },
|
error: { code: -32602, message: "query parameter is required" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const searchOptions = {
|
const searchOptions = {
|
||||||
location: args.location,
|
location: args.location,
|
||||||
category: args.category,
|
category: args.category,
|
||||||
keywords: args.keywords,
|
keywords: args.keywords,
|
||||||
sortBy: args.sortBy,
|
sortBy: args.sortBy,
|
||||||
sortOrder: args.sortOrder,
|
sortOrder: args.sortOrder,
|
||||||
maxPages: args.maxPages || 5,
|
maxPages: args.maxPages || 5,
|
||||||
priceMin: args.priceMin,
|
priceMin: args.priceMin,
|
||||||
priceMax: args.priceMax,
|
priceMax: args.priceMax,
|
||||||
};
|
};
|
||||||
const items = await fetchKijijiItems(
|
const items = await fetchKijijiItems(
|
||||||
query,
|
query,
|
||||||
1,
|
1,
|
||||||
"https://www.kijiji.ca",
|
"https://www.kijiji.ca",
|
||||||
searchOptions,
|
searchOptions,
|
||||||
{}
|
{},
|
||||||
);
|
);
|
||||||
result = items || [];
|
result = items || [];
|
||||||
} else if (name === "search_facebook") {
|
} else if (name === "search_facebook") {
|
||||||
const query = args.query;
|
const query = args.query;
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
error: { code: -32602, message: "query parameter is required" },
|
error: { code: -32602, message: "query parameter is required" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const items = await fetchFacebookItems(
|
const items = await fetchFacebookItems(
|
||||||
query,
|
query,
|
||||||
1,
|
1,
|
||||||
args.location || "toronto",
|
args.location || "toronto",
|
||||||
args.maxItems || 25,
|
args.maxItems || 25,
|
||||||
args.cookiesSource,
|
args.cookiesSource,
|
||||||
undefined
|
undefined,
|
||||||
);
|
);
|
||||||
result = items || [];
|
result = items || [];
|
||||||
} else if (name === "search_ebay") {
|
} else if (name === "search_ebay") {
|
||||||
const query = args.query;
|
const query = args.query;
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
error: { code: -32602, message: "query parameter is required" },
|
error: { code: -32602, message: "query parameter is required" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const items = await fetchEbayItems(query, 1, {
|
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,
|
||||||
exclusions: args.exclusions || [],
|
exclusions: args.exclusions || [],
|
||||||
keywords: args.keywords || [query],
|
keywords: args.keywords || [query],
|
||||||
buyItNowOnly: args.buyItNowOnly !== false,
|
buyItNowOnly: args.buyItNowOnly !== false,
|
||||||
canadaOnly: args.canadaOnly !== false,
|
canadaOnly: args.canadaOnly !== false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = args.maxItems ? items.slice(0, args.maxItems) : items;
|
const results = args.maxItems ? items.slice(0, args.maxItems) : items;
|
||||||
result = results || [];
|
result = results || [];
|
||||||
} else {
|
} else {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
error: { code: -32601, message: `Unknown tool: ${name}` },
|
error: { code: -32601, message: `Unknown tool: ${name}` },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
result: {
|
result: {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(result, null, 2),
|
text: JSON.stringify(result, null, 2),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage =
|
||||||
return Response.json({
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
jsonrpc: "2.0",
|
return Response.json({
|
||||||
id,
|
jsonrpc: "2.0",
|
||||||
error: { code: -32603, message: `Tool execution failed: ${errorMessage}` },
|
id,
|
||||||
});
|
error: {
|
||||||
}
|
code: -32603,
|
||||||
}
|
message: `Tool execution failed: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Method not found
|
// Method not found
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id,
|
id,
|
||||||
error: { code: -32601, message: `Method not found: ${method}` },
|
error: { code: -32601, message: `Method not found: ${method}` },
|
||||||
},
|
},
|
||||||
{ status: 404 }
|
{ status: 404 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage =
|
||||||
return Response.json(
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
{
|
return Response.json(
|
||||||
jsonrpc: "2.0",
|
{
|
||||||
error: { code: -32700, message: `Parse error: ${errorMessage}` },
|
jsonrpc: "2.0",
|
||||||
},
|
error: { code: -32700, message: `Parse error: ${errorMessage}` },
|
||||||
{ status: 400 }
|
},
|
||||||
);
|
{ status: 400 },
|
||||||
}
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const serverCard = {
|
export const serverCard = {
|
||||||
$schema: "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
|
$schema:
|
||||||
version: "1.0",
|
"https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
|
||||||
protocolVersion: "2025-06-18",
|
version: "1.0",
|
||||||
serverInfo: {
|
protocolVersion: "2025-06-18",
|
||||||
name: "marketplace-scrapers",
|
serverInfo: {
|
||||||
title: "Marketplace Scrapers MCP Server",
|
name: "marketplace-scrapers",
|
||||||
version: "1.0.0",
|
title: "Marketplace Scrapers MCP Server",
|
||||||
},
|
version: "1.0.0",
|
||||||
transport: {
|
},
|
||||||
type: "streamable-http",
|
transport: {
|
||||||
endpoint: "/mcp",
|
type: "streamable-http",
|
||||||
},
|
endpoint: "/mcp",
|
||||||
capabilities: {
|
},
|
||||||
tools: {
|
capabilities: {
|
||||||
listChanged: true,
|
tools: {
|
||||||
},
|
listChanged: true,
|
||||||
},
|
},
|
||||||
description: "Scrapes marketplace listings from Kijiji, Facebook Marketplace, and eBay",
|
},
|
||||||
tools: "dynamic",
|
description:
|
||||||
|
"Scrapes marketplace listings from Kijiji, Facebook Marketplace, and eBay",
|
||||||
|
tools: "dynamic",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,135 +3,138 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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",
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "Location name or ID (e.g., 'toronto', 'gta', 'ontario')",
|
description:
|
||||||
},
|
"Location name or ID (e.g., 'toronto', 'gta', 'ontario')",
|
||||||
category: {
|
},
|
||||||
type: "string",
|
category: {
|
||||||
description: "Category name or ID (e.g., 'computers', 'furniture', 'bikes')",
|
type: "string",
|
||||||
},
|
description:
|
||||||
keywords: {
|
"Category name or ID (e.g., 'computers', 'furniture', 'bikes')",
|
||||||
type: "string",
|
},
|
||||||
description: "Additional keywords to filter results",
|
keywords: {
|
||||||
},
|
type: "string",
|
||||||
sortBy: {
|
description: "Additional keywords to filter results",
|
||||||
type: "string",
|
},
|
||||||
description: "Sort results by field",
|
sortBy: {
|
||||||
enum: ["relevancy", "date", "price", "distance"],
|
type: "string",
|
||||||
default: "relevancy",
|
description: "Sort results by field",
|
||||||
},
|
enum: ["relevancy", "date", "price", "distance"],
|
||||||
sortOrder: {
|
default: "relevancy",
|
||||||
type: "string",
|
},
|
||||||
description: "Sort order",
|
sortOrder: {
|
||||||
enum: ["asc", "desc"],
|
type: "string",
|
||||||
default: "desc",
|
description: "Sort order",
|
||||||
},
|
enum: ["asc", "desc"],
|
||||||
maxPages: {
|
default: "desc",
|
||||||
type: "number",
|
},
|
||||||
description: "Maximum pages to fetch (~40 items per page)",
|
maxPages: {
|
||||||
default: 5,
|
type: "number",
|
||||||
},
|
description: "Maximum pages to fetch (~40 items per page)",
|
||||||
priceMin: {
|
default: 5,
|
||||||
type: "number",
|
},
|
||||||
description: "Minimum price in cents",
|
priceMin: {
|
||||||
},
|
type: "number",
|
||||||
priceMax: {
|
description: "Minimum price in cents",
|
||||||
type: "number",
|
},
|
||||||
description: "Maximum price in cents",
|
priceMax: {
|
||||||
},
|
type: "number",
|
||||||
},
|
description: "Maximum price in cents",
|
||||||
required: ["query"],
|
},
|
||||||
},
|
},
|
||||||
},
|
required: ["query"],
|
||||||
{
|
},
|
||||||
name: "search_facebook",
|
},
|
||||||
description: "Search Facebook Marketplace for listings matching a query",
|
{
|
||||||
inputSchema: {
|
name: "search_facebook",
|
||||||
type: "object",
|
description: "Search Facebook Marketplace for listings matching a query",
|
||||||
properties: {
|
inputSchema: {
|
||||||
query: {
|
type: "object",
|
||||||
type: "string",
|
properties: {
|
||||||
description: "Search query for Facebook Marketplace listings",
|
query: {
|
||||||
},
|
type: "string",
|
||||||
location: {
|
description: "Search query for Facebook Marketplace listings",
|
||||||
type: "string",
|
},
|
||||||
description: "Location for search (e.g., 'toronto')",
|
location: {
|
||||||
default: "toronto",
|
type: "string",
|
||||||
},
|
description: "Location for search (e.g., 'toronto')",
|
||||||
maxItems: {
|
default: "toronto",
|
||||||
type: "number",
|
},
|
||||||
description: "Maximum number of items to return",
|
maxItems: {
|
||||||
default: 5,
|
type: "number",
|
||||||
},
|
description: "Maximum number of items to return",
|
||||||
cookiesSource: {
|
default: 5,
|
||||||
type: "string",
|
},
|
||||||
description: "Optional Facebook session cookies source",
|
cookiesSource: {
|
||||||
},
|
type: "string",
|
||||||
},
|
description: "Optional Facebook session cookies source",
|
||||||
required: ["query"],
|
},
|
||||||
},
|
},
|
||||||
},
|
required: ["query"],
|
||||||
{
|
},
|
||||||
name: "search_ebay",
|
},
|
||||||
description: "Search eBay for listings matching a query (default: Buy It Now only, Canada only)",
|
{
|
||||||
inputSchema: {
|
name: "search_ebay",
|
||||||
type: "object",
|
description:
|
||||||
properties: {
|
"Search eBay for listings matching a query (default: Buy It Now only, Canada only)",
|
||||||
query: {
|
inputSchema: {
|
||||||
type: "string",
|
type: "object",
|
||||||
description: "Search query for eBay listings",
|
properties: {
|
||||||
},
|
query: {
|
||||||
minPrice: {
|
type: "string",
|
||||||
type: "number",
|
description: "Search query for eBay listings",
|
||||||
description: "Minimum price filter",
|
},
|
||||||
},
|
minPrice: {
|
||||||
maxPrice: {
|
type: "number",
|
||||||
type: "number",
|
description: "Minimum price filter",
|
||||||
description: "Maximum price filter",
|
},
|
||||||
},
|
maxPrice: {
|
||||||
strictMode: {
|
type: "number",
|
||||||
type: "boolean",
|
description: "Maximum price filter",
|
||||||
description: "Enable strict search mode",
|
},
|
||||||
default: false,
|
strictMode: {
|
||||||
},
|
type: "boolean",
|
||||||
exclusions: {
|
description: "Enable strict search mode",
|
||||||
type: "array",
|
default: false,
|
||||||
items: { type: "string" },
|
},
|
||||||
description: "Terms to exclude from results",
|
exclusions: {
|
||||||
},
|
type: "array",
|
||||||
keywords: {
|
items: { type: "string" },
|
||||||
type: "array",
|
description: "Terms to exclude from results",
|
||||||
items: { type: "string" },
|
},
|
||||||
description: "Keywords to include in search",
|
keywords: {
|
||||||
},
|
type: "array",
|
||||||
buyItNowOnly: {
|
items: { type: "string" },
|
||||||
type: "boolean",
|
description: "Keywords to include in search",
|
||||||
description: "Include only Buy It Now listings (exclude auctions)",
|
},
|
||||||
default: true,
|
buyItNowOnly: {
|
||||||
},
|
type: "boolean",
|
||||||
canadaOnly: {
|
description: "Include only Buy It Now listings (exclude auctions)",
|
||||||
type: "boolean",
|
default: true,
|
||||||
description: "Include only Canadian sellers/listings",
|
},
|
||||||
default: true,
|
canadaOnly: {
|
||||||
},
|
type: "boolean",
|
||||||
maxItems: {
|
description: "Include only Canadian sellers/listings",
|
||||||
type: "number",
|
default: true,
|
||||||
description: "Maximum number of items to return",
|
},
|
||||||
default: 5,
|
maxItems: {
|
||||||
},
|
type: "number",
|
||||||
},
|
description: "Maximum number of items to return",
|
||||||
required: ["query"],
|
default: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user