@@ -12,131 +12,134 @@
|
||||
* bun run scripts/parse-facebook-cookies.ts "cookie_string" --output my-cookies.json
|
||||
*/
|
||||
|
||||
import { parseFacebookCookieString } from '../src/facebook';
|
||||
import { parseFacebookCookieString } from "../src/facebook";
|
||||
|
||||
interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
sameSite?: "strict" | "lax" | "none" | "unspecified";
|
||||
expirationDate?: number;
|
||||
storeId?: string;
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
sameSite?: "strict" | "lax" | "none" | "unspecified";
|
||||
expirationDate?: number;
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
function parseFacebookCookieStringCLI(cookieString: string): Cookie[] {
|
||||
if (!cookieString || !cookieString.trim()) {
|
||||
console.error('❌ Error: Empty or invalid cookie string provided');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cookieString || !cookieString.trim()) {
|
||||
console.error("❌ Error: Empty or invalid cookie string provided");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cookies = parseFacebookCookieString(cookieString);
|
||||
const cookies = parseFacebookCookieString(cookieString);
|
||||
|
||||
if (cookies.length === 0) {
|
||||
console.error('❌ Error: No valid cookies found in input string');
|
||||
console.error('Expected format: "name1=value1; name2=value2;"');
|
||||
process.exit(1);
|
||||
}
|
||||
if (cookies.length === 0) {
|
||||
console.error("❌ Error: No valid cookies found in input string");
|
||||
console.error('Expected format: "name1=value1; name2=value2;"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return cookies;
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 && process.stdin.isTTY === false) {
|
||||
// Read from stdin
|
||||
let input = '';
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk;
|
||||
}
|
||||
input = input.trim();
|
||||
if (args.length === 0 && process.stdin.isTTY === false) {
|
||||
// Read from stdin
|
||||
let input = "";
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk;
|
||||
}
|
||||
input = input.trim();
|
||||
|
||||
if (!input) {
|
||||
console.error('❌ Error: No input provided via stdin');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!input) {
|
||||
console.error("❌ Error: No input provided via stdin");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cookies = parseFacebookCookieStringCLI(input);
|
||||
await writeOutput(cookies, './cookies/facebook.json');
|
||||
return;
|
||||
}
|
||||
const cookies = parseFacebookCookieStringCLI(input);
|
||||
await writeOutput(cookies, "./cookies/facebook.json");
|
||||
return;
|
||||
}
|
||||
|
||||
let cookieString = '';
|
||||
let outputPath = './cookies/facebook.json';
|
||||
let inputPath = '';
|
||||
let cookieString = "";
|
||||
let outputPath = "./cookies/facebook.json";
|
||||
let inputPath = "";
|
||||
|
||||
// Parse command line arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
// Parse command line arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--input' || arg === '-i') {
|
||||
inputPath = args[i + 1];
|
||||
i++; // Skip next arg
|
||||
} else if (arg === '--output' || arg === '-o') {
|
||||
outputPath = args[i + 1];
|
||||
i++; // Skip next arg
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
showHelp();
|
||||
return;
|
||||
} else if (!arg.startsWith('-')) {
|
||||
// Assume this is the cookie string
|
||||
cookieString = arg;
|
||||
} else {
|
||||
console.error(`❌ Unknown option: ${arg}`);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (arg === "--input" || arg === "-i") {
|
||||
inputPath = args[i + 1];
|
||||
i++; // Skip next arg
|
||||
} else if (arg === "--output" || arg === "-o") {
|
||||
outputPath = args[i + 1];
|
||||
i++; // Skip next arg
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
showHelp();
|
||||
return;
|
||||
} else if (!arg.startsWith("-")) {
|
||||
// Assume this is the cookie string
|
||||
cookieString = arg;
|
||||
} else {
|
||||
console.error(`❌ Unknown option: ${arg}`);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Read from file if specified
|
||||
if (inputPath) {
|
||||
try {
|
||||
const file = Bun.file(inputPath);
|
||||
if (!(await file.exists())) {
|
||||
console.error(`❌ Error: Input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
cookieString = await file.text();
|
||||
} catch (error) {
|
||||
console.error(`❌ Error reading input file: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
// Read from file if specified
|
||||
if (inputPath) {
|
||||
try {
|
||||
const file = Bun.file(inputPath);
|
||||
if (!(await file.exists())) {
|
||||
console.error(`❌ Error: Input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
cookieString = await file.text();
|
||||
} catch (error) {
|
||||
console.error(`❌ Error reading input file: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookieString.trim()) {
|
||||
console.error('❌ Error: No cookie string provided');
|
||||
console.error('Provide cookie string as argument, --input file, or via stdin');
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cookieString.trim()) {
|
||||
console.error("❌ Error: No cookie string provided");
|
||||
console.error(
|
||||
"Provide cookie string as argument, --input file, or via stdin",
|
||||
);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cookies = parseFacebookCookieStringCLI(cookieString);
|
||||
await writeOutput(cookies, outputPath);
|
||||
const cookies = parseFacebookCookieStringCLI(cookieString);
|
||||
await writeOutput(cookies, outputPath);
|
||||
}
|
||||
|
||||
async function writeOutput(cookies: Cookie[], outputPath: string) {
|
||||
try {
|
||||
await Bun.write(outputPath, JSON.stringify(cookies, null, 2));
|
||||
console.log(`✅ Successfully parsed ${cookies.length} Facebook cookies`);
|
||||
console.log(`📁 Saved to: ${outputPath}`);
|
||||
try {
|
||||
await Bun.write(outputPath, JSON.stringify(cookies, null, 2));
|
||||
console.log(`✅ Successfully parsed ${cookies.length} Facebook cookies`);
|
||||
console.log(`📁 Saved to: ${outputPath}`);
|
||||
|
||||
// Show summary of parsed cookies
|
||||
console.log('\n📋 Parsed cookies:');
|
||||
for (const cookie of cookies) {
|
||||
console.log(` • ${cookie.name}: ${cookie.value.substring(0, 20)}${cookie.value.length > 20 ? '...' : ''}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error writing to output file: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Show summary of parsed cookies
|
||||
console.log("\n📋 Parsed cookies:");
|
||||
for (const cookie of cookies) {
|
||||
console.log(
|
||||
` • ${cookie.name}: ${cookie.value.substring(0, 20)}${cookie.value.length > 20 ? "..." : ""}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error writing to output file: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
console.log(`
|
||||
Facebook Cookie Parser CLI
|
||||
|
||||
Parses Facebook cookie strings into JSON format for the marketplace scraper.
|
||||
@@ -173,8 +176,8 @@ OUTPUT:
|
||||
|
||||
// Run the CLI
|
||||
if (import.meta.main) {
|
||||
main().catch(error => {
|
||||
console.error(`❌ Unexpected error: ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch((error) => {
|
||||
console.error(`❌ Unexpected error: ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
730
src/ebay.ts
730
src/ebay.ts
@@ -1,97 +1,103 @@
|
||||
import cliProgress from "cli-progress";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { parseHTML } from "linkedom";
|
||||
import cliProgress from "cli-progress";
|
||||
|
||||
// ----------------------------- Types -----------------------------
|
||||
|
||||
type HTMLString = string;
|
||||
|
||||
type ListingDetails = {
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
listingPrice?: {
|
||||
amountFormatted: string;
|
||||
cents?: number;
|
||||
currency?: string;
|
||||
};
|
||||
listingType?: string;
|
||||
listingStatus?: string;
|
||||
creationDate?: string;
|
||||
endDate?: string;
|
||||
numberOfViews?: number;
|
||||
address?: string | null;
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
listingPrice?: {
|
||||
amountFormatted: string;
|
||||
cents?: number;
|
||||
currency?: string;
|
||||
};
|
||||
listingType?: string;
|
||||
listingStatus?: string;
|
||||
creationDate?: string;
|
||||
endDate?: string;
|
||||
numberOfViews?: number;
|
||||
address?: string | null;
|
||||
};
|
||||
|
||||
// ----------------------------- Utilities -----------------------------
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
async function delay(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns cents to localized currency string.
|
||||
*/
|
||||
function formatCentsToCurrency(
|
||||
num: number | string | undefined,
|
||||
locale = "en-US",
|
||||
num: number | string | undefined,
|
||||
locale = "en-US",
|
||||
): string {
|
||||
if (num == null) return "";
|
||||
const cents = typeof num === "string" ? Number.parseInt(num, 10) : num;
|
||||
if (Number.isNaN(cents)) return "";
|
||||
const dollars = cents / 100;
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: true,
|
||||
});
|
||||
return formatter.format(dollars);
|
||||
if (num == null) return "";
|
||||
const cents = typeof num === "string" ? Number.parseInt(num, 10) : num;
|
||||
if (Number.isNaN(cents)) return "";
|
||||
const dollars = cents / 100;
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: true,
|
||||
});
|
||||
return formatter.format(dollars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse eBay currency string like "$1.50 CAD" or "CA $1.50" into cents
|
||||
*/
|
||||
function parseEbayPrice(priceText: string): { cents: number; currency: string } | null {
|
||||
if (!priceText || typeof priceText !== 'string') return null;
|
||||
function parseEbayPrice(
|
||||
priceText: string,
|
||||
): { cents: number; currency: string } | null {
|
||||
if (!priceText || typeof priceText !== "string") return null;
|
||||
|
||||
// Clean up the price text and extract currency and amount
|
||||
const cleaned = priceText.trim();
|
||||
// Clean up the price text and extract currency and amount
|
||||
const cleaned = priceText.trim();
|
||||
|
||||
// Find all numbers in the string (including decimals)
|
||||
const numberMatches = cleaned.match(/[\d,]+\.?\d*/);
|
||||
if (!numberMatches) return null;
|
||||
// Find all numbers in the string (including decimals)
|
||||
const numberMatches = cleaned.match(/[\d,]+\.?\d*/);
|
||||
if (!numberMatches) return null;
|
||||
|
||||
const amountStr = numberMatches[0].replace(/,/g, '');
|
||||
const dollars = parseFloat(amountStr);
|
||||
if (isNaN(dollars)) return null;
|
||||
const amountStr = numberMatches[0].replace(/,/g, "");
|
||||
const dollars = Number.parseFloat(amountStr);
|
||||
if (Number.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.
|
||||
let currency = 'USD'; // Default
|
||||
// Extract currency - look for common formats like "CAD", "USD", "C $", "$CA", etc.
|
||||
let currency = "USD"; // Default
|
||||
|
||||
if (cleaned.toUpperCase().includes('CAD') || cleaned.includes('CA$') || cleaned.includes('C $')) {
|
||||
currency = 'CAD';
|
||||
} else if (cleaned.toUpperCase().includes('USD') || cleaned.includes('$')) {
|
||||
currency = 'USD';
|
||||
}
|
||||
if (
|
||||
cleaned.toUpperCase().includes("CAD") ||
|
||||
cleaned.includes("CA$") ||
|
||||
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 {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly url: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
}
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly url: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- HTTP Client -----------------------------
|
||||
@@ -102,69 +108,71 @@ class HttpError extends Error {
|
||||
- Respects X-RateLimit-Reset when present (seconds)
|
||||
*/
|
||||
async function fetchHtml(
|
||||
url: string,
|
||||
DELAY_MS: number,
|
||||
opts?: {
|
||||
maxRetries?: number;
|
||||
retryBaseMs?: number;
|
||||
onRateInfo?: (remaining: string | null, reset: string | null) => void;
|
||||
},
|
||||
url: string,
|
||||
DELAY_MS: number,
|
||||
opts?: {
|
||||
maxRetries?: number;
|
||||
retryBaseMs?: number;
|
||||
onRateInfo?: (remaining: string | null, reset: string | null) => void;
|
||||
},
|
||||
): Promise<HTMLString> {
|
||||
const maxRetries = opts?.maxRetries ?? 3;
|
||||
const retryBaseMs = opts?.retryBaseMs ?? 500;
|
||||
const maxRetries = opts?.maxRetries ?? 3;
|
||||
const retryBaseMs = opts?.retryBaseMs ?? 500;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
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",
|
||||
"accept-language": "en-CA,en-US;q=0.9,en;q=0.8",
|
||||
"cache-control": "no-cache",
|
||||
"upgrade-insecure-requests": "1",
|
||||
"user-agent":
|
||||
"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++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
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",
|
||||
"accept-language": "en-CA,en-US;q=0.9,en;q=0.8",
|
||||
"cache-control": "no-cache",
|
||||
"upgrade-insecure-requests": "1",
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
const rateLimitRemaining = res.headers.get("X-RateLimit-Remaining");
|
||||
const rateLimitReset = res.headers.get("X-RateLimit-Reset");
|
||||
opts?.onRateInfo?.(rateLimitRemaining, rateLimitReset);
|
||||
const rateLimitRemaining = res.headers.get("X-RateLimit-Remaining");
|
||||
const rateLimitReset = res.headers.get("X-RateLimit-Reset");
|
||||
opts?.onRateInfo?.(rateLimitRemaining, rateLimitReset);
|
||||
|
||||
if (!res.ok) {
|
||||
// Respect 429 reset if provided
|
||||
if (res.status === 429) {
|
||||
const resetSeconds = rateLimitReset ? Number(rateLimitReset) : NaN;
|
||||
const waitMs = Number.isFinite(resetSeconds)
|
||||
? Math.max(0, resetSeconds * 1000)
|
||||
: (attempt + 1) * retryBaseMs;
|
||||
await delay(waitMs);
|
||||
continue;
|
||||
}
|
||||
// Retry on 5xx
|
||||
if (res.status >= 500 && res.status < 600 && attempt < maxRetries) {
|
||||
await delay((attempt + 1) * retryBaseMs);
|
||||
continue;
|
||||
}
|
||||
throw new HttpError(
|
||||
`Request failed with status ${res.status}`,
|
||||
res.status,
|
||||
url,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Respect 429 reset if provided
|
||||
if (res.status === 429) {
|
||||
const resetSeconds = rateLimitReset
|
||||
? Number(rateLimitReset)
|
||||
: Number.NaN;
|
||||
const waitMs = Number.isFinite(resetSeconds)
|
||||
? Math.max(0, resetSeconds * 1000)
|
||||
: (attempt + 1) * retryBaseMs;
|
||||
await delay(waitMs);
|
||||
continue;
|
||||
}
|
||||
// Retry on 5xx
|
||||
if (res.status >= 500 && res.status < 600 && attempt < maxRetries) {
|
||||
await delay((attempt + 1) * retryBaseMs);
|
||||
continue;
|
||||
}
|
||||
throw new HttpError(
|
||||
`Request failed with status ${res.status}`,
|
||||
res.status,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
||||
await delay(DELAY_MS);
|
||||
return html;
|
||||
} catch (err) {
|
||||
if (attempt >= maxRetries) throw err;
|
||||
await delay((attempt + 1) * retryBaseMs);
|
||||
}
|
||||
}
|
||||
const html = await res.text();
|
||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
||||
await delay(DELAY_MS);
|
||||
return html;
|
||||
} catch (err) {
|
||||
if (attempt >= maxRetries) throw err;
|
||||
await delay((attempt + 1) * retryBaseMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Exhausted retries without response");
|
||||
throw new Error("Exhausted retries without response");
|
||||
}
|
||||
|
||||
// ----------------------------- Parsing -----------------------------
|
||||
@@ -173,273 +181,321 @@ async function fetchHtml(
|
||||
Parse eBay search page HTML and extract listings using DOM selectors
|
||||
*/
|
||||
function parseEbayListings(
|
||||
htmlString: HTMLString,
|
||||
keywords: string[],
|
||||
exclusions: string[],
|
||||
strictMode: boolean
|
||||
htmlString: HTMLString,
|
||||
keywords: string[],
|
||||
exclusions: string[],
|
||||
strictMode: boolean,
|
||||
): ListingDetails[] {
|
||||
const { document } = parseHTML(htmlString);
|
||||
const results: ListingDetails[] = [];
|
||||
const { document } = parseHTML(htmlString);
|
||||
const results: ListingDetails[] = [];
|
||||
|
||||
// Find all listing links by looking for eBay item URLs (/itm/)
|
||||
const linkElements = document.querySelectorAll('a[href*="itm/"]');
|
||||
// Find all listing links by looking for eBay item URLs (/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) {
|
||||
try {
|
||||
// Get href attribute
|
||||
let href = linkElement.getAttribute('href');
|
||||
if (!href) continue;
|
||||
// Make href absolute
|
||||
if (!href.startsWith("http")) {
|
||||
href = href.startsWith("//")
|
||||
? `https:${href}`
|
||||
: `https://www.ebay.com${href}`;
|
||||
}
|
||||
|
||||
// Make href absolute
|
||||
if (!href.startsWith('http')) {
|
||||
href = href.startsWith('//') ? `https:${href}` : `https://www.ebay.com${href}`;
|
||||
}
|
||||
// Find the container - go up several levels to find the item container
|
||||
// Modern eBay uses complex nested structures
|
||||
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
|
||||
// Modern eBay uses complex nested structures
|
||||
let container = linkElement.parentElement?.parentElement?.parentElement;
|
||||
if (!container) {
|
||||
// Try a different level
|
||||
container = linkElement.parentElement?.parentElement;
|
||||
}
|
||||
if (!container) continue;
|
||||
// Extract title - look for heading or title-related elements near the link
|
||||
// Modern eBay often uses h3, span, or div with text content near the link
|
||||
let titleElement = container.querySelector(
|
||||
'h3, [role="heading"], .s-item__title span',
|
||||
);
|
||||
|
||||
// Extract title - look for heading or title-related elements near the link
|
||||
// Modern eBay often uses h3, span, or div with text content near the link
|
||||
let titleElement = container.querySelector('h3, [role="heading"], .s-item__title span');
|
||||
// If no direct title element, try finding text content around the link
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no direct title element, try finding text content around the link
|
||||
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();
|
||||
|
||||
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
|
||||
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'
|
||||
];
|
||||
for (const uiString of uiStrings) {
|
||||
const uiIndex = title.indexOf(uiString);
|
||||
if (uiIndex !== -1) {
|
||||
title = title.substring(0, uiIndex).trim();
|
||||
break; // Only remove one UI string per title
|
||||
}
|
||||
}
|
||||
|
||||
for (const uiString of uiStrings) {
|
||||
const uiIndex = title.indexOf(uiString);
|
||||
if (uiIndex !== -1) {
|
||||
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.length < 10) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If the title became empty or too short after cleaning, skip this item
|
||||
if (title.length < 10) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!title) continue;
|
||||
|
||||
if (!title) continue;
|
||||
// Skip irrelevant eBay ads
|
||||
if (title === "Shop on eBay" || title.length < 3) continue;
|
||||
|
||||
// Skip irrelevant eBay ads
|
||||
if (title === "Shop on eBay" || title.length < 3) continue;
|
||||
// Extract price - look for eBay's price classes, preferring sale/discount prices
|
||||
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
|
||||
let priceElement = container.querySelector('[class*="s-item__price"], .s-item__price, [class*="price"]');
|
||||
// If no direct price class, look for spans containing $ (but not titles)
|
||||
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?.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)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// For discounted items, eBay shows both original and sale price
|
||||
// Prefer sale/current price over original/strikethrough price
|
||||
if (priceElement) {
|
||||
// Check if this element or its parent contains multiple price elements
|
||||
const priceContainer =
|
||||
priceElement.closest('[class*="s-item__price"]') ||
|
||||
priceElement.parentElement;
|
||||
|
||||
// For discounted items, eBay shows both original and sale price
|
||||
// Prefer sale/current price over original/strikethrough price
|
||||
if (priceElement) {
|
||||
// Check if this element or its parent contains multiple price elements
|
||||
const priceContainer = priceElement.closest('[class*="s-item__price"]') || priceElement.parentElement;
|
||||
if (priceContainer) {
|
||||
// Look for all price elements within this container, including strikethrough prices
|
||||
const allPriceElements = priceContainer.querySelectorAll(
|
||||
'[class*="s-item__price"], span, b, em, strong, s, del, strike',
|
||||
);
|
||||
|
||||
if (priceContainer) {
|
||||
// Look for all price elements within this container, including strikethrough prices
|
||||
const allPriceElements = priceContainer.querySelectorAll('[class*="s-item__price"], span, b, em, strong, s, del, strike');
|
||||
// Filter to only elements that actually contain prices (not labels)
|
||||
const actualPrices: HTMLElement[] = [];
|
||||
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)
|
||||
const actualPrices: HTMLElement[] = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Prefer non-strikethrough prices (sale prices) over strikethrough ones (original prices)
|
||||
if (actualPrices.length > 1) {
|
||||
// First, look for prices that are NOT struck through
|
||||
const nonStrikethroughPrices = actualPrices.filter((el) => {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
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 (actualPrices.length > 1) {
|
||||
// First, look for prices that are NOT struck through
|
||||
const nonStrikethroughPrices = actualPrices.filter(el => {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
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;
|
||||
});
|
||||
if (nonStrikethroughPrices.length > 0) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nonStrikethroughPrices.length > 0) {
|
||||
// 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();
|
||||
|
||||
let 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
|
||||
const priceInfo = parseEbayPrice(priceText);
|
||||
if (!priceInfo) continue;
|
||||
// Apply exclusion filters
|
||||
if (
|
||||
exclusions.some((exclusion) =>
|
||||
title.toLowerCase().includes(exclusion.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply exclusion filters
|
||||
if (exclusions.some(exclusion => title.toLowerCase().includes(exclusion.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
// Apply strict mode filter (title must contain at least one keyword)
|
||||
if (
|
||||
strictMode &&
|
||||
!keywords.some((keyword) =>
|
||||
title?.toLowerCase().includes(keyword.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply strict mode filter (title must contain at least one keyword)
|
||||
if (strictMode && !keywords.some(keyword => title!.toLowerCase().includes(keyword.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
const listing: ListingDetails = {
|
||||
url: href,
|
||||
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: ListingDetails = {
|
||||
url: href,
|
||||
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
|
||||
};
|
||||
results.push(listing);
|
||||
} catch (err) {
|
||||
console.warn(`Error parsing eBay listing: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
results.push(listing);
|
||||
} catch (err) {
|
||||
console.warn(`Error parsing eBay listing: ${err}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
return results;
|
||||
}
|
||||
|
||||
// ----------------------------- Main -----------------------------
|
||||
|
||||
export default async function fetchEbayItems(
|
||||
SEARCH_QUERY: string,
|
||||
REQUESTS_PER_SECOND = 1,
|
||||
opts: {
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
strictMode?: boolean;
|
||||
exclusions?: string[];
|
||||
keywords?: string[];
|
||||
} = {},
|
||||
SEARCH_QUERY: string,
|
||||
REQUESTS_PER_SECOND = 1,
|
||||
opts: {
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
strictMode?: boolean;
|
||||
exclusions?: string[];
|
||||
keywords?: string[];
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
minPrice = 0,
|
||||
maxPrice = Number.MAX_SAFE_INTEGER,
|
||||
strictMode = false,
|
||||
exclusions = [],
|
||||
keywords = [SEARCH_QUERY] // Default to search query if no keywords provided
|
||||
} = opts;
|
||||
const {
|
||||
minPrice = 0,
|
||||
maxPrice = Number.MAX_SAFE_INTEGER,
|
||||
strictMode = false,
|
||||
exclusions = [],
|
||||
keywords = [SEARCH_QUERY], // Default to search query if no keywords provided
|
||||
} = opts;
|
||||
|
||||
// Build eBay search URL - use Canadian site and tracking parameters like real browser
|
||||
const searchUrl = `https://www.ebay.ca/sch/i.html?_nkw=${encodeURIComponent(SEARCH_QUERY)}^&_sacat=0^&_from=R40^&_trksid=p4432023.m570.l1313`;
|
||||
// Build eBay search URL - use Canadian site and tracking parameters like real browser
|
||||
const searchUrl = `https://www.ebay.ca/sch/i.html?_nkw=${encodeURIComponent(SEARCH_QUERY)}^&_sacat=0^&_from=R40^&_trksid=p4432023.m570.l1313`;
|
||||
|
||||
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 {
|
||||
// Use custom headers modeled after real browser requests to bypass bot detection
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Referer': 'https://www.ebay.ca/',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Priority': 'u=0, i'
|
||||
};
|
||||
try {
|
||||
// Use custom headers modeled after real browser requests to bypass bot detection
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
Referer: "https://www.ebay.ca/",
|
||||
Connection: "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-User": "?1",
|
||||
Priority: "u=0, i",
|
||||
};
|
||||
|
||||
const res = await fetch(searchUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
const res = await fetch(searchUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new HttpError(
|
||||
`Request failed with status ${res.status}`,
|
||||
res.status,
|
||||
searchUrl,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new HttpError(
|
||||
`Request failed with status ${res.status}`,
|
||||
res.status,
|
||||
searchUrl,
|
||||
);
|
||||
}
|
||||
|
||||
const searchHtml = await res.text();
|
||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
||||
await delay(DELAY_MS);
|
||||
const searchHtml = await res.text();
|
||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
||||
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)
|
||||
const filteredListings = listings.filter(listing => {
|
||||
const cents = listing.listingPrice?.cents;
|
||||
return cents && cents >= minPrice && cents <= maxPrice;
|
||||
});
|
||||
// Filter by price range (additional safety check)
|
||||
const filteredListings = listings.filter((listing) => {
|
||||
const cents = listing.listingPrice?.cents;
|
||||
return cents && cents >= minPrice && cents <= maxPrice;
|
||||
});
|
||||
|
||||
console.log(`Parsed ${filteredListings.length} eBay listings.`);
|
||||
return filteredListings;
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
console.error(
|
||||
`Failed to fetch eBay search (${err.status}): ${err.message}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
console.log(`Parsed ${filteredListings.length} eBay listings.`);
|
||||
return filteredListings;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
console.error(
|
||||
`Failed to fetch eBay search (${err.status}): ${err.message}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
1959
src/facebook.ts
1959
src/facebook.ts
File diff suppressed because it is too large
Load Diff
350
src/index.ts
350
src/index.ts
@@ -1,187 +1,215 @@
|
||||
import fetchKijijiItems from "@/kijiji";
|
||||
import fetchFacebookItems from "@/facebook";
|
||||
import fetchEbayItems from "@/ebay";
|
||||
import fetchFacebookItems from "@/facebook";
|
||||
import fetchKijijiItems from "@/kijiji";
|
||||
|
||||
const PORT = process.env.PORT || 4005;
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
idleTimeout: 0,
|
||||
routes: {
|
||||
// Static routes
|
||||
"/api/status": new Response("OK"),
|
||||
port: PORT,
|
||||
idleTimeout: 0,
|
||||
routes: {
|
||||
// Static routes
|
||||
"/api/status": new Response("OK"),
|
||||
|
||||
// Dynamic routes
|
||||
"/api/kijiji": async (req: Request) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
// Dynamic routes
|
||||
"/api/kijiji": async (req: Request) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
|
||||
const SEARCH_QUERY =
|
||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||
if (!SEARCH_QUERY)
|
||||
return Response.json(
|
||||
{
|
||||
message:
|
||||
"Request didn't have 'query' header or 'q' search parameter!",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
const SEARCH_QUERY =
|
||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||
if (!SEARCH_QUERY)
|
||||
return Response.json(
|
||||
{
|
||||
message:
|
||||
"Request didn't have 'query' header or 'q' search parameter!",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
// Parse optional parameters with enhanced defaults
|
||||
const location = reqUrl.searchParams.get("location");
|
||||
const category = reqUrl.searchParams.get("category");
|
||||
const maxPagesParam = reqUrl.searchParams.get("maxPages");
|
||||
const maxPages = maxPagesParam
|
||||
? Number.parseInt(maxPagesParam, 10)
|
||||
: 5; // Default: 5 pages
|
||||
const sortBy = reqUrl.searchParams.get("sortBy") as 'relevancy' | 'date' | 'price' | 'distance' | undefined;
|
||||
const sortOrder = reqUrl.searchParams.get("sortOrder") as 'asc' | 'desc' | undefined;
|
||||
// Parse optional parameters with enhanced defaults
|
||||
const location = reqUrl.searchParams.get("location");
|
||||
const category = reqUrl.searchParams.get("category");
|
||||
const maxPagesParam = reqUrl.searchParams.get("maxPages");
|
||||
const maxPages = maxPagesParam ? Number.parseInt(maxPagesParam, 10) : 5; // Default: 5 pages
|
||||
const sortBy = reqUrl.searchParams.get("sortBy") as
|
||||
| "relevancy"
|
||||
| "date"
|
||||
| "price"
|
||||
| "distance"
|
||||
| undefined;
|
||||
const sortOrder = reqUrl.searchParams.get("sortOrder") as
|
||||
| "asc"
|
||||
| "desc"
|
||||
| undefined;
|
||||
|
||||
// Build search options
|
||||
const locationValue = location ? (/^\d+$/.test(location) ? Number(location) : location) : 1700272;
|
||||
const categoryValue = category ? (/^\d+$/.test(category) ? Number(category) : category) : 0;
|
||||
// Build search options
|
||||
const locationValue = location
|
||||
? /^\d+$/.test(location)
|
||||
? Number(location)
|
||||
: location
|
||||
: 1700272;
|
||||
const categoryValue = category
|
||||
? /^\d+$/.test(category)
|
||||
? Number(category)
|
||||
: category
|
||||
: 0;
|
||||
|
||||
const searchOptions: import("@/kijiji").SearchOptions = {
|
||||
location: locationValue,
|
||||
category: categoryValue,
|
||||
keywords: SEARCH_QUERY,
|
||||
sortBy: sortBy || 'relevancy',
|
||||
sortOrder: sortOrder || 'desc',
|
||||
maxPages,
|
||||
};
|
||||
const searchOptions: import("@/kijiji").SearchOptions = {
|
||||
location: locationValue,
|
||||
category: categoryValue,
|
||||
keywords: SEARCH_QUERY,
|
||||
sortBy: sortBy || "relevancy",
|
||||
sortOrder: sortOrder || "desc",
|
||||
maxPages,
|
||||
};
|
||||
|
||||
// Build listing fetch options with enhanced defaults
|
||||
const listingOptions: import("@/kijiji").ListingFetchOptions = {
|
||||
includeImages: true, // Always include full image arrays
|
||||
sellerDataDepth: 'detailed', // Default: detailed seller info
|
||||
includeClientSideData: false, // GraphQL reviews disabled by default
|
||||
};
|
||||
// Build listing fetch options with enhanced defaults
|
||||
const listingOptions: import("@/kijiji").ListingFetchOptions = {
|
||||
includeImages: true, // Always include full image arrays
|
||||
sellerDataDepth: "detailed", // Default: detailed seller info
|
||||
includeClientSideData: false, // GraphQL reviews disabled by default
|
||||
};
|
||||
|
||||
try {
|
||||
const items = await fetchKijijiItems(SEARCH_QUERY, 1, undefined, searchOptions, listingOptions);
|
||||
if (!items || items.length === 0)
|
||||
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: `Scraping failed: ${errorMessage}`,
|
||||
query: SEARCH_QUERY,
|
||||
options: { searchOptions, listingOptions }
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
try {
|
||||
const items = await fetchKijijiItems(
|
||||
SEARCH_QUERY,
|
||||
1,
|
||||
undefined,
|
||||
searchOptions,
|
||||
listingOptions,
|
||||
);
|
||||
if (!items || items.length === 0)
|
||||
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: `Scraping failed: ${errorMessage}`,
|
||||
query: SEARCH_QUERY,
|
||||
options: { searchOptions, listingOptions },
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
"/api/facebook": async (req: Request) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
"/api/facebook": async (req: Request) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
|
||||
const SEARCH_QUERY =
|
||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||
if (!SEARCH_QUERY)
|
||||
return Response.json(
|
||||
{
|
||||
message:
|
||||
"Request didn't have 'query' header or 'q' search parameter!",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
const SEARCH_QUERY =
|
||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||
if (!SEARCH_QUERY)
|
||||
return Response.json(
|
||||
{
|
||||
message:
|
||||
"Request didn't have 'query' header or 'q' search parameter!",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const LOCATION = reqUrl.searchParams.get("location") || "toronto";
|
||||
const COOKIES_SOURCE = reqUrl.searchParams.get("cookies") || undefined;
|
||||
const LOCATION = reqUrl.searchParams.get("location") || "toronto";
|
||||
const COOKIES_SOURCE = reqUrl.searchParams.get("cookies") || undefined;
|
||||
|
||||
try {
|
||||
const items = await fetchFacebookItems(SEARCH_QUERY, 5, LOCATION, 25, COOKIES_SOURCE, "./cookies/facebook.json");
|
||||
if (!items || items.length === 0)
|
||||
return Response.json(
|
||||
{ message: "Search didn't return any results!" },
|
||||
{ status: 404 },
|
||||
);
|
||||
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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
try {
|
||||
const items = await fetchFacebookItems(
|
||||
SEARCH_QUERY,
|
||||
5,
|
||||
LOCATION,
|
||||
25,
|
||||
COOKIES_SOURCE,
|
||||
"./cookies/facebook.json",
|
||||
);
|
||||
if (!items || items.length === 0)
|
||||
return Response.json(
|
||||
{ message: "Search didn't return any results!" },
|
||||
{ status: 404 },
|
||||
);
|
||||
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 });
|
||||
}
|
||||
},
|
||||
|
||||
"/api/ebay": async (req: Request) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
"/api/ebay": async (req: Request) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
|
||||
const SEARCH_QUERY =
|
||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||
if (!SEARCH_QUERY)
|
||||
return Response.json(
|
||||
{
|
||||
message:
|
||||
"Request didn't have 'query' header or 'q' search parameter!",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
const SEARCH_QUERY =
|
||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||
if (!SEARCH_QUERY)
|
||||
return Response.json(
|
||||
{
|
||||
message:
|
||||
"Request didn't have 'query' header or 'q' search parameter!",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
// Parse optional parameters with defaults
|
||||
const minPriceParam = reqUrl.searchParams.get("minPrice");
|
||||
const minPrice = minPriceParam
|
||||
? Number.parseInt(minPriceParam, 10)
|
||||
: undefined;
|
||||
const maxPriceParam = reqUrl.searchParams.get("maxPrice");
|
||||
const maxPrice = maxPriceParam
|
||||
? Number.parseInt(maxPriceParam, 10)
|
||||
: undefined;
|
||||
const strictMode = reqUrl.searchParams.get("strictMode") === "true";
|
||||
const exclusionsParam = reqUrl.searchParams.get("exclusions");
|
||||
const exclusions = exclusionsParam ? exclusionsParam.split(",").map(s => s.trim()) : [];
|
||||
const keywordsParam = reqUrl.searchParams.get("keywords");
|
||||
const keywords = keywordsParam ? keywordsParam.split(",").map(s => s.trim()) : [SEARCH_QUERY];
|
||||
// Parse optional parameters with defaults
|
||||
const minPriceParam = reqUrl.searchParams.get("minPrice");
|
||||
const minPrice = minPriceParam
|
||||
? Number.parseInt(minPriceParam, 10)
|
||||
: undefined;
|
||||
const maxPriceParam = reqUrl.searchParams.get("maxPrice");
|
||||
const maxPrice = maxPriceParam
|
||||
? Number.parseInt(maxPriceParam, 10)
|
||||
: undefined;
|
||||
const strictMode = reqUrl.searchParams.get("strictMode") === "true";
|
||||
const exclusionsParam = reqUrl.searchParams.get("exclusions");
|
||||
const exclusions = exclusionsParam
|
||||
? exclusionsParam.split(",").map((s) => s.trim())
|
||||
: [];
|
||||
const keywordsParam = reqUrl.searchParams.get("keywords");
|
||||
const keywords = keywordsParam
|
||||
? keywordsParam.split(",").map((s) => s.trim())
|
||||
: [SEARCH_QUERY];
|
||||
|
||||
try {
|
||||
const items = await fetchEbayItems(SEARCH_QUERY, 5, {
|
||||
minPrice,
|
||||
maxPrice,
|
||||
strictMode,
|
||||
exclusions,
|
||||
keywords,
|
||||
});
|
||||
if (!items || items.length === 0)
|
||||
return Response.json(
|
||||
{ message: "Search didn't return any results!" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return Response.json(items, { status: 200 });
|
||||
} 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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
try {
|
||||
const items = await fetchEbayItems(SEARCH_QUERY, 5, {
|
||||
minPrice,
|
||||
maxPrice,
|
||||
strictMode,
|
||||
exclusions,
|
||||
keywords,
|
||||
});
|
||||
if (!items || items.length === 0)
|
||||
return Response.json(
|
||||
{ message: "Search didn't return any results!" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return Response.json(items, { status: 200 });
|
||||
} 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 });
|
||||
}
|
||||
},
|
||||
|
||||
// Wildcard route for all routes that start with "/api/" and aren't otherwise matched
|
||||
"/api/*": Response.json({ message: "Not found" }, { status: 404 }),
|
||||
// Wildcard route for all routes that start with "/api/" and aren't otherwise matched
|
||||
"/api/*": Response.json({ message: "Not found" }, { status: 404 }),
|
||||
|
||||
// // Serve a file by buffering it in memory
|
||||
// "/favicon.ico": new Response(await Bun.file("./favicon.ico").bytes(), {
|
||||
// headers: {
|
||||
// "Content-Type": "image/x-icon",
|
||||
// },
|
||||
// }),
|
||||
},
|
||||
// // Serve a file by buffering it in memory
|
||||
// "/favicon.ico": new Response(await Bun.file("./favicon.ico").bytes(), {
|
||||
// headers: {
|
||||
// "Content-Type": "image/x-icon",
|
||||
// },
|
||||
// }),
|
||||
},
|
||||
|
||||
// (optional) fallback for unmatched routes:
|
||||
// Required if Bun's version < 1.2.3
|
||||
fetch(req: Request) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
// (optional) fallback for unmatched routes:
|
||||
// Required if Bun's version < 1.2.3
|
||||
fetch(req: Request) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Serving on ${server.hostname}:${server.port}`);
|
||||
|
||||
1406
src/kijiji.ts
1406
src/kijiji.ts
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,162 +1,166 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
resolveLocationId,
|
||||
resolveCategoryId,
|
||||
buildSearchUrl,
|
||||
HttpError,
|
||||
NetworkError,
|
||||
ParseError,
|
||||
RateLimitError,
|
||||
ValidationError
|
||||
HttpError,
|
||||
NetworkError,
|
||||
ParseError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
buildSearchUrl,
|
||||
resolveCategoryId,
|
||||
resolveLocationId,
|
||||
} from "../src/kijiji";
|
||||
|
||||
describe("Location and Category Resolution", () => {
|
||||
describe("resolveLocationId", () => {
|
||||
test("should return numeric IDs as-is", () => {
|
||||
expect(resolveLocationId(1700272)).toBe(1700272);
|
||||
expect(resolveLocationId(0)).toBe(0);
|
||||
});
|
||||
describe("resolveLocationId", () => {
|
||||
test("should return numeric IDs as-is", () => {
|
||||
expect(resolveLocationId(1700272)).toBe(1700272);
|
||||
expect(resolveLocationId(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("should resolve string location names", () => {
|
||||
expect(resolveLocationId("canada")).toBe(0);
|
||||
expect(resolveLocationId("ontario")).toBe(9004);
|
||||
expect(resolveLocationId("toronto")).toBe(1700273);
|
||||
expect(resolveLocationId("gta")).toBe(1700272);
|
||||
});
|
||||
test("should resolve string location names", () => {
|
||||
expect(resolveLocationId("canada")).toBe(0);
|
||||
expect(resolveLocationId("ontario")).toBe(9004);
|
||||
expect(resolveLocationId("toronto")).toBe(1700273);
|
||||
expect(resolveLocationId("gta")).toBe(1700272);
|
||||
});
|
||||
|
||||
test("should handle case insensitive matching", () => {
|
||||
expect(resolveLocationId("Canada")).toBe(0);
|
||||
expect(resolveLocationId("ONTARIO")).toBe(9004);
|
||||
});
|
||||
test("should handle case insensitive matching", () => {
|
||||
expect(resolveLocationId("Canada")).toBe(0);
|
||||
expect(resolveLocationId("ONTARIO")).toBe(9004);
|
||||
});
|
||||
|
||||
test("should default to Canada for unknown locations", () => {
|
||||
expect(resolveLocationId("unknown")).toBe(0);
|
||||
expect(resolveLocationId("")).toBe(0);
|
||||
});
|
||||
test("should default to Canada for unknown locations", () => {
|
||||
expect(resolveLocationId("unknown")).toBe(0);
|
||||
expect(resolveLocationId("")).toBe(0);
|
||||
});
|
||||
|
||||
test("should handle undefined input", () => {
|
||||
expect(resolveLocationId(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
test("should handle undefined input", () => {
|
||||
expect(resolveLocationId(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCategoryId", () => {
|
||||
test("should return numeric IDs as-is", () => {
|
||||
expect(resolveCategoryId(132)).toBe(132);
|
||||
expect(resolveCategoryId(0)).toBe(0);
|
||||
});
|
||||
describe("resolveCategoryId", () => {
|
||||
test("should return numeric IDs as-is", () => {
|
||||
expect(resolveCategoryId(132)).toBe(132);
|
||||
expect(resolveCategoryId(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("should resolve string category names", () => {
|
||||
expect(resolveCategoryId("all")).toBe(0);
|
||||
expect(resolveCategoryId("phones")).toBe(132);
|
||||
expect(resolveCategoryId("electronics")).toBe(29659001);
|
||||
expect(resolveCategoryId("buy-sell")).toBe(10);
|
||||
});
|
||||
test("should resolve string category names", () => {
|
||||
expect(resolveCategoryId("all")).toBe(0);
|
||||
expect(resolveCategoryId("phones")).toBe(132);
|
||||
expect(resolveCategoryId("electronics")).toBe(29659001);
|
||||
expect(resolveCategoryId("buy-sell")).toBe(10);
|
||||
});
|
||||
|
||||
test("should handle case insensitive matching", () => {
|
||||
expect(resolveCategoryId("All")).toBe(0);
|
||||
expect(resolveCategoryId("PHONES")).toBe(132);
|
||||
});
|
||||
test("should handle case insensitive matching", () => {
|
||||
expect(resolveCategoryId("All")).toBe(0);
|
||||
expect(resolveCategoryId("PHONES")).toBe(132);
|
||||
});
|
||||
|
||||
test("should default to all categories for unknown categories", () => {
|
||||
expect(resolveCategoryId("unknown")).toBe(0);
|
||||
expect(resolveCategoryId("")).toBe(0);
|
||||
});
|
||||
test("should default to all categories for unknown categories", () => {
|
||||
expect(resolveCategoryId("unknown")).toBe(0);
|
||||
expect(resolveCategoryId("")).toBe(0);
|
||||
});
|
||||
|
||||
test("should handle undefined input", () => {
|
||||
expect(resolveCategoryId(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
test("should handle undefined input", () => {
|
||||
expect(resolveCategoryId(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL Construction", () => {
|
||||
describe("buildSearchUrl", () => {
|
||||
test("should build basic search URL", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: 1700272,
|
||||
category: 132,
|
||||
sortBy: 'relevancy',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
describe("buildSearchUrl", () => {
|
||||
test("should build basic search URL", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: 1700272,
|
||||
category: 132,
|
||||
sortBy: "relevancy",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
|
||||
expect(url).toContain("b-buy-sell/canada/iphone/k0c132l1700272");
|
||||
expect(url).toContain("sort=relevancyDesc");
|
||||
expect(url).toContain("order=DESC");
|
||||
});
|
||||
expect(url).toContain("b-buy-sell/canada/iphone/k0c132l1700272");
|
||||
expect(url).toContain("sort=relevancyDesc");
|
||||
expect(url).toContain("order=DESC");
|
||||
});
|
||||
|
||||
test("should handle pagination", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: 1700272,
|
||||
category: 132,
|
||||
page: 2,
|
||||
});
|
||||
test("should handle pagination", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: 1700272,
|
||||
category: 132,
|
||||
page: 2,
|
||||
});
|
||||
|
||||
expect(url).toContain("&page=2");
|
||||
});
|
||||
expect(url).toContain("&page=2");
|
||||
});
|
||||
|
||||
test("should handle different sort options", () => {
|
||||
const dateUrl = buildSearchUrl("iphone", {
|
||||
sortBy: 'date',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
expect(dateUrl).toContain("sort=DATE");
|
||||
expect(dateUrl).toContain("order=ASC");
|
||||
test("should handle different sort options", () => {
|
||||
const dateUrl = buildSearchUrl("iphone", {
|
||||
sortBy: "date",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
expect(dateUrl).toContain("sort=DATE");
|
||||
expect(dateUrl).toContain("order=ASC");
|
||||
|
||||
const priceUrl = buildSearchUrl("iphone", {
|
||||
sortBy: 'price',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
expect(priceUrl).toContain("sort=PRICE");
|
||||
expect(priceUrl).toContain("order=DESC");
|
||||
});
|
||||
const priceUrl = buildSearchUrl("iphone", {
|
||||
sortBy: "price",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
expect(priceUrl).toContain("sort=PRICE");
|
||||
expect(priceUrl).toContain("order=DESC");
|
||||
});
|
||||
|
||||
test("should handle string location/category inputs", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: "toronto",
|
||||
category: "phones",
|
||||
});
|
||||
test("should handle string location/category inputs", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: "toronto",
|
||||
category: "phones",
|
||||
});
|
||||
|
||||
expect(url).toContain("k0c132l1700273"); // phones + toronto
|
||||
});
|
||||
});
|
||||
expect(url).toContain("k0c132l1700273"); // phones + toronto
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Classes", () => {
|
||||
test("HttpError should store status and URL", () => {
|
||||
const error = new HttpError("Not found", 404, "https://example.com");
|
||||
expect(error.message).toBe("Not found");
|
||||
expect(error.status).toBe(404);
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.name).toBe("HttpError");
|
||||
});
|
||||
test("HttpError should store status and URL", () => {
|
||||
const error = new HttpError("Not found", 404, "https://example.com");
|
||||
expect(error.message).toBe("Not found");
|
||||
expect(error.status).toBe(404);
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.name).toBe("HttpError");
|
||||
});
|
||||
|
||||
test("NetworkError should store URL and cause", () => {
|
||||
const cause = new Error("Connection failed");
|
||||
const error = new NetworkError("Network error", "https://example.com", cause);
|
||||
expect(error.message).toBe("Network error");
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.name).toBe("NetworkError");
|
||||
});
|
||||
test("NetworkError should store URL and cause", () => {
|
||||
const cause = new Error("Connection failed");
|
||||
const error = new NetworkError(
|
||||
"Network error",
|
||||
"https://example.com",
|
||||
cause,
|
||||
);
|
||||
expect(error.message).toBe("Network error");
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.name).toBe("NetworkError");
|
||||
});
|
||||
|
||||
test("ParseError should store data", () => {
|
||||
const data = { invalid: "json" };
|
||||
const error = new ParseError("Invalid JSON", data);
|
||||
expect(error.message).toBe("Invalid JSON");
|
||||
expect(error.data).toBe(data);
|
||||
expect(error.name).toBe("ParseError");
|
||||
});
|
||||
test("ParseError should store data", () => {
|
||||
const data = { invalid: "json" };
|
||||
const error = new ParseError("Invalid JSON", data);
|
||||
expect(error.message).toBe("Invalid JSON");
|
||||
expect(error.data).toBe(data);
|
||||
expect(error.name).toBe("ParseError");
|
||||
});
|
||||
|
||||
test("RateLimitError should store URL and reset time", () => {
|
||||
const error = new RateLimitError("Rate limited", "https://example.com", 60);
|
||||
expect(error.message).toBe("Rate limited");
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.resetTime).toBe(60);
|
||||
expect(error.name).toBe("RateLimitError");
|
||||
});
|
||||
test("RateLimitError should store URL and reset time", () => {
|
||||
const error = new RateLimitError("Rate limited", "https://example.com", 60);
|
||||
expect(error.message).toBe("Rate limited");
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.resetTime).toBe(60);
|
||||
expect(error.name).toBe("RateLimitError");
|
||||
});
|
||||
|
||||
test("ValidationError should work without field", () => {
|
||||
const error = new ValidationError("Invalid value");
|
||||
expect(error.message).toBe("Invalid value");
|
||||
expect(error.name).toBe("ValidationError");
|
||||
});
|
||||
test("ValidationError should work without field", () => {
|
||||
const error = new ValidationError("Invalid value");
|
||||
expect(error.message).toBe("Invalid value");
|
||||
expect(error.name).toBe("ValidationError");
|
||||
});
|
||||
});
|
||||
@@ -1,337 +1,363 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
|
||||
import { extractApolloState, parseSearch, parseDetailedListing } from "../src/kijiji";
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import {
|
||||
extractApolloState,
|
||||
parseDetailedListing,
|
||||
parseSearch,
|
||||
} from "../src/kijiji";
|
||||
|
||||
// Mock fetch globally
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("HTML Parsing Integration", () => {
|
||||
beforeEach(() => {
|
||||
// Mock fetch for all tests
|
||||
global.fetch = mock(() => {
|
||||
throw new Error("fetch should be mocked in individual tests");
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
// Mock fetch for all tests
|
||||
global.fetch = mock(() => {
|
||||
throw new Error("fetch should be mocked in individual tests");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("extractApolloState", () => {
|
||||
test("should extract Apollo state from valid HTML", () => {
|
||||
const mockHtml = '<html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"__APOLLO_STATE__":{"ROOT_QUERY":{"test":"value"}}}}}</script></head></html>';
|
||||
describe("extractApolloState", () => {
|
||||
test("should extract Apollo state from valid HTML", () => {
|
||||
const mockHtml =
|
||||
'<html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"__APOLLO_STATE__":{"ROOT_QUERY":{"test":"value"}}}}}</script></head></html>';
|
||||
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toEqual({
|
||||
ROOT_QUERY: { test: "value" }
|
||||
});
|
||||
});
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toEqual({
|
||||
ROOT_QUERY: { test: "value" },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null for HTML without Apollo state", () => {
|
||||
const mockHtml = '<html><body>No data here</body></html>';
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
test("should return null for HTML without Apollo state", () => {
|
||||
const mockHtml = "<html><body>No data here</body></html>";
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null for malformed JSON", () => {
|
||||
const mockHtml = '<html><script id="__NEXT_DATA__" type="application/json">{"invalid": json}</script></html>';
|
||||
test("should return null for malformed JSON", () => {
|
||||
const mockHtml =
|
||||
'<html><script id="__NEXT_DATA__" type="application/json">{"invalid": json}</script></html>';
|
||||
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle missing __NEXT_DATA__ element", () => {
|
||||
const mockHtml = '<html><body><div>Content</div></body></html>';
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
test("should handle missing __NEXT_DATA__ element", () => {
|
||||
const mockHtml = "<html><body><div>Content</div></body></html>";
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSearch", () => {
|
||||
test("should parse search results from HTML", () => {
|
||||
const mockHtml = `
|
||||
describe("parseSearch", () => {
|
||||
test("should parse search results from HTML", () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
},
|
||||
"Listing:456": {
|
||||
url: "/v-samsung/k0l0",
|
||||
title: "Samsung Galaxy",
|
||||
},
|
||||
"ROOT_QUERY": { test: "value" }
|
||||
}
|
||||
}
|
||||
}
|
||||
})}
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
},
|
||||
"Listing:456": {
|
||||
url: "/v-samsung/k0l0",
|
||||
title: "Samsung Galaxy",
|
||||
},
|
||||
ROOT_QUERY: { test: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({
|
||||
name: "iPhone 13 Pro",
|
||||
listingLink: "https://www.kijiji.ca/v-iphone/k0l0"
|
||||
});
|
||||
expect(results[1]).toEqual({
|
||||
name: "Samsung Galaxy",
|
||||
listingLink: "https://www.kijiji.ca/v-samsung/k0l0"
|
||||
});
|
||||
});
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({
|
||||
name: "iPhone 13 Pro",
|
||||
listingLink: "https://www.kijiji.ca/v-iphone/k0l0",
|
||||
});
|
||||
expect(results[1]).toEqual({
|
||||
name: "Samsung Galaxy",
|
||||
listingLink: "https://www.kijiji.ca/v-samsung/k0l0",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle absolute URLs", () => {
|
||||
const mockHtml = `
|
||||
test("should handle absolute URLs", () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "https://www.kijiji.ca/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})}
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "https://www.kijiji.ca/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results[0].listingLink).toBe("https://www.kijiji.ca/v-iphone/k0l0");
|
||||
});
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results[0].listingLink).toBe(
|
||||
"https://www.kijiji.ca/v-iphone/k0l0",
|
||||
);
|
||||
});
|
||||
|
||||
test("should filter out invalid listings", () => {
|
||||
const mockHtml = `
|
||||
test("should filter out invalid listings", () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
},
|
||||
"Listing:456": {
|
||||
url: "/v-samsung/k0l0",
|
||||
// Missing title
|
||||
},
|
||||
"Other:789": {
|
||||
url: "/v-other/k0l0",
|
||||
title: "Other Item",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})}
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
},
|
||||
"Listing:456": {
|
||||
url: "/v-samsung/k0l0",
|
||||
// Missing title
|
||||
},
|
||||
"Other:789": {
|
||||
url: "/v-other/k0l0",
|
||||
title: "Other Item",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe("iPhone 13 Pro");
|
||||
});
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe("iPhone 13 Pro");
|
||||
});
|
||||
|
||||
test("should return empty array for invalid HTML", () => {
|
||||
const results = parseSearch("<html><body>Invalid</body></html>", "https://www.kijiji.ca");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
test("should return empty array for invalid HTML", () => {
|
||||
const results = parseSearch(
|
||||
"<html><body>Invalid</body></html>",
|
||||
"https://www.kijiji.ca",
|
||||
);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDetailedListing", () => {
|
||||
test("should parse detailed listing with all fields", async () => {
|
||||
const mockHtml = `
|
||||
describe("parseDetailedListing", () => {
|
||||
test("should parse detailed listing with all fields", async () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone-13-pro/k0l0",
|
||||
title: "iPhone 13 Pro 256GB",
|
||||
description: "Excellent condition iPhone 13 Pro",
|
||||
price: {
|
||||
amount: 80000,
|
||||
currency: "CAD",
|
||||
type: "FIXED"
|
||||
},
|
||||
type: "OFFER",
|
||||
status: "ACTIVE",
|
||||
activationDate: "2024-01-15T10:00:00.000Z",
|
||||
endDate: "2025-01-15T10:00:00.000Z",
|
||||
metrics: { views: 150 },
|
||||
location: {
|
||||
address: "Toronto, ON",
|
||||
id: 1700273,
|
||||
name: "Toronto",
|
||||
coordinates: {
|
||||
latitude: 43.6532,
|
||||
longitude: -79.3832
|
||||
}
|
||||
},
|
||||
imageUrls: [
|
||||
"https://media.kijiji.ca/api/v1/image1.jpg",
|
||||
"https://media.kijiji.ca/api/v1/image2.jpg"
|
||||
],
|
||||
imageCount: 2,
|
||||
categoryId: 132,
|
||||
adSource: "ORGANIC",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: true
|
||||
},
|
||||
posterInfo: {
|
||||
posterId: "user123",
|
||||
rating: 4.8
|
||||
},
|
||||
attributes: [
|
||||
{ canonicalName: "forsaleby", canonicalValues: ["ownr"] },
|
||||
{ canonicalName: "phonecarrier", canonicalValues: ["unlocked"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})}
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone-13-pro/k0l0",
|
||||
title: "iPhone 13 Pro 256GB",
|
||||
description: "Excellent condition iPhone 13 Pro",
|
||||
price: {
|
||||
amount: 80000,
|
||||
currency: "CAD",
|
||||
type: "FIXED",
|
||||
},
|
||||
type: "OFFER",
|
||||
status: "ACTIVE",
|
||||
activationDate: "2024-01-15T10:00:00.000Z",
|
||||
endDate: "2025-01-15T10:00:00.000Z",
|
||||
metrics: { views: 150 },
|
||||
location: {
|
||||
address: "Toronto, ON",
|
||||
id: 1700273,
|
||||
name: "Toronto",
|
||||
coordinates: {
|
||||
latitude: 43.6532,
|
||||
longitude: -79.3832,
|
||||
},
|
||||
},
|
||||
imageUrls: [
|
||||
"https://media.kijiji.ca/api/v1/image1.jpg",
|
||||
"https://media.kijiji.ca/api/v1/image2.jpg",
|
||||
],
|
||||
imageCount: 2,
|
||||
categoryId: 132,
|
||||
adSource: "ORGANIC",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: true,
|
||||
},
|
||||
posterInfo: {
|
||||
posterId: "user123",
|
||||
rating: 4.8,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
canonicalName: "forsaleby",
|
||||
canonicalValues: ["ownr"],
|
||||
},
|
||||
{
|
||||
canonicalName: "phonecarrier",
|
||||
canonicalValues: ["unlocked"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca");
|
||||
expect(result).toEqual({
|
||||
url: "https://www.kijiji.ca/v-iphone-13-pro/k0l0",
|
||||
title: "iPhone 13 Pro 256GB",
|
||||
description: "Excellent condition iPhone 13 Pro",
|
||||
listingPrice: {
|
||||
amountFormatted: "$800.00",
|
||||
cents: 80000,
|
||||
currency: "CAD"
|
||||
},
|
||||
listingType: "OFFER",
|
||||
listingStatus: "ACTIVE",
|
||||
creationDate: "2024-01-15T10:00:00.000Z",
|
||||
endDate: "2025-01-15T10:00:00.000Z",
|
||||
numberOfViews: 150,
|
||||
address: "Toronto, ON",
|
||||
images: [
|
||||
"https://media.kijiji.ca/api/v1/image1.jpg",
|
||||
"https://media.kijiji.ca/api/v1/image2.jpg"
|
||||
],
|
||||
categoryId: 132,
|
||||
adSource: "ORGANIC",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: true
|
||||
},
|
||||
attributes: {
|
||||
forsaleby: ["ownr"],
|
||||
phonecarrier: ["unlocked"]
|
||||
},
|
||||
location: {
|
||||
id: 1700273,
|
||||
name: "Toronto",
|
||||
coordinates: {
|
||||
latitude: 43.6532,
|
||||
longitude: -79.3832
|
||||
}
|
||||
},
|
||||
sellerInfo: {
|
||||
posterId: "user123",
|
||||
rating: 4.8
|
||||
}
|
||||
});
|
||||
});
|
||||
const result = await parseDetailedListing(
|
||||
mockHtml,
|
||||
"https://www.kijiji.ca",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
url: "https://www.kijiji.ca/v-iphone-13-pro/k0l0",
|
||||
title: "iPhone 13 Pro 256GB",
|
||||
description: "Excellent condition iPhone 13 Pro",
|
||||
listingPrice: {
|
||||
amountFormatted: "$800.00",
|
||||
cents: 80000,
|
||||
currency: "CAD",
|
||||
},
|
||||
listingType: "OFFER",
|
||||
listingStatus: "ACTIVE",
|
||||
creationDate: "2024-01-15T10:00:00.000Z",
|
||||
endDate: "2025-01-15T10:00:00.000Z",
|
||||
numberOfViews: 150,
|
||||
address: "Toronto, ON",
|
||||
images: [
|
||||
"https://media.kijiji.ca/api/v1/image1.jpg",
|
||||
"https://media.kijiji.ca/api/v1/image2.jpg",
|
||||
],
|
||||
categoryId: 132,
|
||||
adSource: "ORGANIC",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: true,
|
||||
},
|
||||
attributes: {
|
||||
forsaleby: ["ownr"],
|
||||
phonecarrier: ["unlocked"],
|
||||
},
|
||||
location: {
|
||||
id: 1700273,
|
||||
name: "Toronto",
|
||||
coordinates: {
|
||||
latitude: 43.6532,
|
||||
longitude: -79.3832,
|
||||
},
|
||||
},
|
||||
sellerInfo: {
|
||||
posterId: "user123",
|
||||
rating: 4.8,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null for contact-based pricing", async () => {
|
||||
const mockHtml = `
|
||||
test("should return null for contact-based pricing", async () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone for Sale",
|
||||
price: {
|
||||
type: "CONTACT",
|
||||
amount: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})}
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone for Sale",
|
||||
price: {
|
||||
type: "CONTACT",
|
||||
amount: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
const result = await parseDetailedListing(
|
||||
mockHtml,
|
||||
"https://www.kijiji.ca",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle missing optional fields", async () => {
|
||||
const mockHtml = `
|
||||
test("should handle missing optional fields", async () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13",
|
||||
price: { amount: 50000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})}
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13",
|
||||
price: { amount: 50000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca");
|
||||
expect(result).toEqual({
|
||||
url: "https://www.kijiji.ca/v-iphone/k0l0",
|
||||
title: "iPhone 13",
|
||||
description: undefined,
|
||||
listingPrice: {
|
||||
amountFormatted: "$500.00",
|
||||
cents: 50000,
|
||||
currency: undefined
|
||||
},
|
||||
listingType: undefined,
|
||||
listingStatus: undefined,
|
||||
creationDate: undefined,
|
||||
endDate: undefined,
|
||||
numberOfViews: undefined,
|
||||
address: null,
|
||||
images: [],
|
||||
categoryId: 0,
|
||||
adSource: "UNKNOWN",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: false
|
||||
},
|
||||
attributes: {},
|
||||
location: {
|
||||
id: 0,
|
||||
name: "Unknown",
|
||||
coordinates: undefined
|
||||
},
|
||||
sellerInfo: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
const result = await parseDetailedListing(
|
||||
mockHtml,
|
||||
"https://www.kijiji.ca",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
url: "https://www.kijiji.ca/v-iphone/k0l0",
|
||||
title: "iPhone 13",
|
||||
description: undefined,
|
||||
listingPrice: {
|
||||
amountFormatted: "$500.00",
|
||||
cents: 50000,
|
||||
currency: undefined,
|
||||
},
|
||||
listingType: undefined,
|
||||
listingStatus: undefined,
|
||||
creationDate: undefined,
|
||||
endDate: undefined,
|
||||
numberOfViews: undefined,
|
||||
address: null,
|
||||
images: [],
|
||||
categoryId: 0,
|
||||
adSource: "UNKNOWN",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: false,
|
||||
},
|
||||
attributes: {},
|
||||
location: {
|
||||
id: 0,
|
||||
name: "Unknown",
|
||||
coordinates: undefined,
|
||||
},
|
||||
sellerInfo: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +1,54 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { slugify, formatCentsToCurrency } from "../src/kijiji";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { formatCentsToCurrency, slugify } from "../src/kijiji";
|
||||
|
||||
describe("Utility Functions", () => {
|
||||
describe("slugify", () => {
|
||||
test("should convert basic strings to slugs", () => {
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
expect(slugify("iPhone 13 Pro")).toBe("iphone-13-pro");
|
||||
});
|
||||
describe("slugify", () => {
|
||||
test("should convert basic strings to slugs", () => {
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
expect(slugify("iPhone 13 Pro")).toBe("iphone-13-pro");
|
||||
});
|
||||
|
||||
test("should handle special characters", () => {
|
||||
expect(slugify("Café & Restaurant")).toBe("cafe-restaurant");
|
||||
expect(slugify("100% New")).toBe("100-new");
|
||||
});
|
||||
test("should handle special characters", () => {
|
||||
expect(slugify("Café & Restaurant")).toBe("cafe-restaurant");
|
||||
expect(slugify("100% New")).toBe("100-new");
|
||||
});
|
||||
|
||||
test("should handle empty and edge cases", () => {
|
||||
expect(slugify("")).toBe("");
|
||||
expect(slugify(" ")).toBe("-");
|
||||
expect(slugify("---")).toBe("-");
|
||||
});
|
||||
test("should handle empty and edge cases", () => {
|
||||
expect(slugify("")).toBe("");
|
||||
expect(slugify(" ")).toBe("-");
|
||||
expect(slugify("---")).toBe("-");
|
||||
});
|
||||
|
||||
test("should preserve numbers and valid characters", () => {
|
||||
expect(slugify("iPhone 13")).toBe("iphone-13");
|
||||
expect(slugify("item123")).toBe("item123");
|
||||
});
|
||||
});
|
||||
test("should preserve numbers and valid characters", () => {
|
||||
expect(slugify("iPhone 13")).toBe("iphone-13");
|
||||
expect(slugify("item123")).toBe("item123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCentsToCurrency", () => {
|
||||
test("should format valid cent values", () => {
|
||||
expect(formatCentsToCurrency(100)).toBe("$1.00");
|
||||
expect(formatCentsToCurrency(1999)).toBe("$19.99");
|
||||
expect(formatCentsToCurrency(0)).toBe("$0.00");
|
||||
});
|
||||
describe("formatCentsToCurrency", () => {
|
||||
test("should format valid cent values", () => {
|
||||
expect(formatCentsToCurrency(100)).toBe("$1.00");
|
||||
expect(formatCentsToCurrency(1999)).toBe("$19.99");
|
||||
expect(formatCentsToCurrency(0)).toBe("$0.00");
|
||||
});
|
||||
|
||||
test("should handle string inputs", () => {
|
||||
expect(formatCentsToCurrency("100")).toBe("$1.00");
|
||||
expect(formatCentsToCurrency("1999")).toBe("$19.99");
|
||||
});
|
||||
test("should handle string inputs", () => {
|
||||
expect(formatCentsToCurrency("100")).toBe("$1.00");
|
||||
expect(formatCentsToCurrency("1999")).toBe("$19.99");
|
||||
});
|
||||
|
||||
test("should handle null/undefined inputs", () => {
|
||||
expect(formatCentsToCurrency(null)).toBe("");
|
||||
expect(formatCentsToCurrency(undefined)).toBe("");
|
||||
});
|
||||
test("should handle null/undefined inputs", () => {
|
||||
expect(formatCentsToCurrency(null)).toBe("");
|
||||
expect(formatCentsToCurrency(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("should handle invalid inputs", () => {
|
||||
expect(formatCentsToCurrency("invalid")).toBe("");
|
||||
expect(formatCentsToCurrency(Number.NaN)).toBe("");
|
||||
});
|
||||
test("should handle invalid inputs", () => {
|
||||
expect(formatCentsToCurrency("invalid")).toBe("");
|
||||
expect(formatCentsToCurrency(Number.NaN)).toBe("");
|
||||
});
|
||||
|
||||
test("should use en-US locale formatting", () => {
|
||||
expect(formatCentsToCurrency(123456)).toBe("$1,234.56");
|
||||
});
|
||||
});
|
||||
test("should use en-US locale formatting", () => {
|
||||
expect(formatCentsToCurrency(123456)).toBe("$1,234.56");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import { expect } from "bun:test";
|
||||
// This file is loaded before any tests run due to bunfig.toml preload
|
||||
|
||||
// Mock fetch globally for tests
|
||||
global.fetch = global.fetch || (() => {
|
||||
throw new Error('fetch is not available in test environment');
|
||||
});
|
||||
global.fetch =
|
||||
global.fetch ||
|
||||
(() => {
|
||||
throw new Error("fetch is not available in test environment");
|
||||
});
|
||||
|
||||
// Add any global test utilities here
|
||||
@@ -7,25 +7,21 @@
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitAny": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user