chore: biome lint

Signed-off-by: Dmytro Stanchiev <git@dmytros.dev>
This commit is contained in:
2026-01-22 22:34:05 -05:00
parent 3919ec0727
commit 6ab9c4c3a5
12 changed files with 4426 additions and 3885 deletions

View File

@@ -12,131 +12,134 @@
* bun run scripts/parse-facebook-cookies.ts "cookie_string" --output my-cookies.json * 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 { interface Cookie {
name: string; name: string;
value: string; value: string;
domain: string; domain: string;
path: string; path: string;
secure?: boolean; secure?: boolean;
httpOnly?: boolean; httpOnly?: boolean;
sameSite?: "strict" | "lax" | "none" | "unspecified"; sameSite?: "strict" | "lax" | "none" | "unspecified";
expirationDate?: number; expirationDate?: number;
storeId?: string; storeId?: string;
} }
function parseFacebookCookieStringCLI(cookieString: string): Cookie[] { function parseFacebookCookieStringCLI(cookieString: string): Cookie[] {
if (!cookieString || !cookieString.trim()) { if (!cookieString || !cookieString.trim()) {
console.error('❌ Error: Empty or invalid cookie string provided'); console.error("❌ Error: Empty or invalid cookie string provided");
process.exit(1); process.exit(1);
} }
const cookies = parseFacebookCookieString(cookieString); const cookies = parseFacebookCookieString(cookieString);
if (cookies.length === 0) { if (cookies.length === 0) {
console.error('❌ Error: No valid cookies found in input string'); console.error("❌ Error: No valid cookies found in input string");
console.error('Expected format: "name1=value1; name2=value2;"'); console.error('Expected format: "name1=value1; name2=value2;"');
process.exit(1); process.exit(1);
} }
return cookies; return cookies;
} }
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
if (args.length === 0 && process.stdin.isTTY === false) { if (args.length === 0 && process.stdin.isTTY === false) {
// Read from stdin // Read from stdin
let input = ''; let input = "";
for await (const chunk of process.stdin) { for await (const chunk of process.stdin) {
input += chunk; input += chunk;
} }
input = input.trim(); input = input.trim();
if (!input) { if (!input) {
console.error('❌ Error: No input provided via stdin'); console.error("❌ Error: No input provided via stdin");
process.exit(1); process.exit(1);
} }
const cookies = parseFacebookCookieStringCLI(input); const cookies = parseFacebookCookieStringCLI(input);
await writeOutput(cookies, './cookies/facebook.json'); await writeOutput(cookies, "./cookies/facebook.json");
return; return;
} }
let cookieString = ''; let cookieString = "";
let outputPath = './cookies/facebook.json'; let outputPath = "./cookies/facebook.json";
let inputPath = ''; let inputPath = "";
// Parse command line arguments // Parse command line arguments
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]; const arg = args[i];
if (arg === '--input' || arg === '-i') { if (arg === "--input" || arg === "-i") {
inputPath = args[i + 1]; inputPath = args[i + 1];
i++; // Skip next arg i++; // Skip next arg
} else if (arg === '--output' || arg === '-o') { } else if (arg === "--output" || arg === "-o") {
outputPath = args[i + 1]; outputPath = args[i + 1];
i++; // Skip next arg i++; // Skip next arg
} else if (arg === '--help' || arg === '-h') { } else if (arg === "--help" || arg === "-h") {
showHelp(); showHelp();
return; return;
} else if (!arg.startsWith('-')) { } else if (!arg.startsWith("-")) {
// Assume this is the cookie string // Assume this is the cookie string
cookieString = arg; cookieString = arg;
} else { } else {
console.error(`❌ Unknown option: ${arg}`); console.error(`❌ Unknown option: ${arg}`);
showHelp(); showHelp();
process.exit(1); process.exit(1);
} }
} }
// Read from file if specified // Read from file if specified
if (inputPath) { if (inputPath) {
try { try {
const file = Bun.file(inputPath); const file = Bun.file(inputPath);
if (!(await file.exists())) { if (!(await file.exists())) {
console.error(`❌ Error: Input file not found: ${inputPath}`); console.error(`❌ Error: Input file not found: ${inputPath}`);
process.exit(1); process.exit(1);
} }
cookieString = await file.text(); cookieString = await file.text();
} catch (error) { } catch (error) {
console.error(`❌ Error reading input file: ${error}`); console.error(`❌ Error reading input file: ${error}`);
process.exit(1); process.exit(1);
} }
} }
if (!cookieString.trim()) { if (!cookieString.trim()) {
console.error('❌ Error: No cookie string provided'); console.error("❌ Error: No cookie string provided");
console.error('Provide cookie string as argument, --input file, or via stdin'); console.error(
showHelp(); "Provide cookie string as argument, --input file, or via stdin",
process.exit(1); );
} showHelp();
process.exit(1);
}
const cookies = parseFacebookCookieStringCLI(cookieString); const cookies = parseFacebookCookieStringCLI(cookieString);
await writeOutput(cookies, outputPath); await writeOutput(cookies, outputPath);
} }
async function writeOutput(cookies: Cookie[], outputPath: string) { async function writeOutput(cookies: Cookie[], outputPath: string) {
try { try {
await Bun.write(outputPath, JSON.stringify(cookies, null, 2)); await Bun.write(outputPath, JSON.stringify(cookies, null, 2));
console.log(`✅ Successfully parsed ${cookies.length} Facebook cookies`); console.log(`✅ Successfully parsed ${cookies.length} Facebook cookies`);
console.log(`📁 Saved to: ${outputPath}`); console.log(`📁 Saved to: ${outputPath}`);
// Show summary of parsed cookies // Show summary of parsed cookies
console.log('\n📋 Parsed cookies:'); console.log("\n📋 Parsed cookies:");
for (const cookie of cookies) { for (const cookie of cookies) {
console.log(`${cookie.name}: ${cookie.value.substring(0, 20)}${cookie.value.length > 20 ? '...' : ''}`); console.log(
} `${cookie.name}: ${cookie.value.substring(0, 20)}${cookie.value.length > 20 ? "..." : ""}`,
);
} catch (error) { }
console.error(`❌ Error writing to output file: ${error}`); } catch (error) {
process.exit(1); console.error(`❌ Error writing to output file: ${error}`);
} process.exit(1);
}
} }
function showHelp() { function showHelp() {
console.log(` console.log(`
Facebook Cookie Parser CLI Facebook Cookie Parser CLI
Parses Facebook cookie strings into JSON format for the marketplace scraper. Parses Facebook cookie strings into JSON format for the marketplace scraper.
@@ -173,8 +176,8 @@ OUTPUT:
// Run the CLI // Run the CLI
if (import.meta.main) { if (import.meta.main) {
main().catch(error => { main().catch((error) => {
console.error(`❌ Unexpected error: ${error}`); console.error(`❌ Unexpected error: ${error}`);
process.exit(1); process.exit(1);
}); });
} }

View File

@@ -1,97 +1,103 @@
import cliProgress from "cli-progress";
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { parseHTML } from "linkedom"; import { parseHTML } from "linkedom";
import cliProgress from "cli-progress";
// ----------------------------- Types ----------------------------- // ----------------------------- Types -----------------------------
type HTMLString = string; type HTMLString = string;
type ListingDetails = { type ListingDetails = {
url: string; url: string;
title: string; title: string;
description?: string; description?: string;
listingPrice?: { listingPrice?: {
amountFormatted: string; amountFormatted: string;
cents?: number; cents?: number;
currency?: string; currency?: string;
}; };
listingType?: string; listingType?: string;
listingStatus?: string; listingStatus?: string;
creationDate?: string; creationDate?: string;
endDate?: string; endDate?: string;
numberOfViews?: number; numberOfViews?: number;
address?: string | null; address?: string | null;
}; };
// ----------------------------- Utilities ----------------------------- // ----------------------------- Utilities -----------------------------
function isRecord(value: unknown): value is Record<string, unknown> { 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> { 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. * Turns cents to localized currency string.
*/ */
function formatCentsToCurrency( function formatCentsToCurrency(
num: number | string | undefined, num: number | string | undefined,
locale = "en-US", locale = "en-US",
): string { ): string {
if (num == null) return ""; if (num == null) return "";
const cents = typeof num === "string" ? Number.parseInt(num, 10) : num; const cents = typeof num === "string" ? Number.parseInt(num, 10) : num;
if (Number.isNaN(cents)) return ""; if (Number.isNaN(cents)) return "";
const dollars = cents / 100; const dollars = cents / 100;
const formatter = new Intl.NumberFormat(locale, { const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
useGrouping: true, useGrouping: true,
}); });
return formatter.format(dollars); return formatter.format(dollars);
} }
/** /**
* Parse eBay currency string like "$1.50 CAD" or "CA $1.50" into cents * Parse eBay currency string like "$1.50 CAD" or "CA $1.50" into cents
*/ */
function parseEbayPrice(priceText: string): { cents: number; currency: string } | null { function parseEbayPrice(
if (!priceText || typeof priceText !== 'string') return null; priceText: string,
): { cents: number; currency: string } | null {
if (!priceText || typeof priceText !== "string") return null;
// Clean up the price text and extract currency and amount // Clean up the price text and extract currency and amount
const cleaned = priceText.trim(); const cleaned = priceText.trim();
// Find all numbers in the string (including decimals) // Find all numbers in the string (including decimals)
const numberMatches = cleaned.match(/[\d,]+\.?\d*/); const numberMatches = cleaned.match(/[\d,]+\.?\d*/);
if (!numberMatches) return null; if (!numberMatches) return null;
const amountStr = numberMatches[0].replace(/,/g, ''); const amountStr = numberMatches[0].replace(/,/g, "");
const dollars = parseFloat(amountStr); const dollars = Number.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);
// Extract currency - look for common formats like "CAD", "USD", "C $", "$CA", etc. // Extract currency - look for common formats like "CAD", "USD", "C $", "$CA", etc.
let currency = 'USD'; // Default let currency = "USD"; // Default
if (cleaned.toUpperCase().includes('CAD') || cleaned.includes('CA$') || cleaned.includes('C $')) { if (
currency = 'CAD'; cleaned.toUpperCase().includes("CAD") ||
} else if (cleaned.toUpperCase().includes('USD') || cleaned.includes('$')) { cleaned.includes("CA$") ||
currency = 'USD'; cleaned.includes("C $")
} ) {
currency = "CAD";
} else if (cleaned.toUpperCase().includes("USD") || cleaned.includes("$")) {
currency = "USD";
}
return { cents, currency }; return { cents, currency };
} }
class HttpError extends Error { class HttpError extends Error {
constructor( constructor(
message: string, message: string,
public readonly status: number, public readonly status: number,
public readonly url: string, public readonly url: string,
) { ) {
super(message); super(message);
this.name = "HttpError"; this.name = "HttpError";
} }
} }
// ----------------------------- HTTP Client ----------------------------- // ----------------------------- HTTP Client -----------------------------
@@ -102,69 +108,71 @@ class HttpError extends Error {
- Respects X-RateLimit-Reset when present (seconds) - Respects X-RateLimit-Reset when present (seconds)
*/ */
async function fetchHtml( async function fetchHtml(
url: string, url: string,
DELAY_MS: number, DELAY_MS: number,
opts?: { opts?: {
maxRetries?: number; maxRetries?: number;
retryBaseMs?: number; retryBaseMs?: number;
onRateInfo?: (remaining: string | null, reset: string | null) => void; onRateInfo?: (remaining: string | null, reset: string | null) => void;
}, },
): Promise<HTMLString> { ): Promise<HTMLString> {
const maxRetries = opts?.maxRetries ?? 3; const maxRetries = opts?.maxRetries ?? 3;
const retryBaseMs = opts?.retryBaseMs ?? 500; const retryBaseMs = opts?.retryBaseMs ?? 500;
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: "GET", method: "GET",
headers: { headers: {
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-CA,en-US;q=0.9,en;q=0.8", "accept-language": "en-CA,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",
}, },
}); });
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) {
// Respect 429 reset if provided // Respect 429 reset if provided
if (res.status === 429) { if (res.status === 429) {
const resetSeconds = rateLimitReset ? Number(rateLimitReset) : NaN; const resetSeconds = rateLimitReset
const waitMs = Number.isFinite(resetSeconds) ? Number(rateLimitReset)
? Math.max(0, resetSeconds * 1000) : Number.NaN;
: (attempt + 1) * retryBaseMs; const waitMs = Number.isFinite(resetSeconds)
await delay(waitMs); ? Math.max(0, resetSeconds * 1000)
continue; : (attempt + 1) * retryBaseMs;
} await delay(waitMs);
// Retry on 5xx continue;
if (res.status >= 500 && res.status < 600 && attempt < maxRetries) { }
await delay((attempt + 1) * retryBaseMs); // Retry on 5xx
continue; if (res.status >= 500 && res.status < 600 && attempt < maxRetries) {
} await delay((attempt + 1) * retryBaseMs);
throw new HttpError( continue;
`Request failed with status ${res.status}`, }
res.status, throw new HttpError(
url, `Request failed with status ${res.status}`,
); res.status,
} url,
);
}
const html = await res.text(); const html = await res.text();
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND // Respect per-request delay to keep at or under REQUESTS_PER_SECOND
await delay(DELAY_MS); await delay(DELAY_MS);
return html; return html;
} catch (err) { } catch (err) {
if (attempt >= maxRetries) throw err; if (attempt >= maxRetries) throw err;
await delay((attempt + 1) * retryBaseMs); await delay((attempt + 1) * retryBaseMs);
} }
} }
throw new Error("Exhausted retries without response"); throw new Error("Exhausted retries without response");
} }
// ----------------------------- Parsing ----------------------------- // ----------------------------- Parsing -----------------------------
@@ -173,273 +181,321 @@ async function fetchHtml(
Parse eBay search page HTML and extract listings using DOM selectors Parse eBay search page HTML and extract listings using DOM selectors
*/ */
function parseEbayListings( function parseEbayListings(
htmlString: HTMLString, htmlString: HTMLString,
keywords: string[], keywords: string[],
exclusions: string[], exclusions: string[],
strictMode: boolean strictMode: boolean,
): ListingDetails[] { ): ListingDetails[] {
const { document } = parseHTML(htmlString); const { document } = parseHTML(htmlString);
const results: ListingDetails[] = []; const results: ListingDetails[] = [];
// Find all listing links by looking for eBay item URLs (/itm/) // Find all listing links by looking for eBay item URLs (/itm/)
const linkElements = document.querySelectorAll('a[href*="itm/"]'); const linkElements = document.querySelectorAll('a[href*="itm/"]');
for (const linkElement of linkElements) {
try {
// Get href attribute
let href = linkElement.getAttribute("href");
if (!href) continue;
for (const linkElement of linkElements) { // Make href absolute
try { if (!href.startsWith("http")) {
// Get href attribute href = href.startsWith("//")
let href = linkElement.getAttribute('href'); ? `https:${href}`
if (!href) continue; : `https://www.ebay.com${href}`;
}
// Make href absolute // Find the container - go up several levels to find the item container
if (!href.startsWith('http')) { // Modern eBay uses complex nested structures
href = href.startsWith('//') ? `https:${href}` : `https://www.ebay.com${href}`; let container = linkElement.parentElement?.parentElement?.parentElement;
} if (!container) {
// Try a different level
container = linkElement.parentElement?.parentElement;
}
if (!container) continue;
// Find the container - go up several levels to find the item container // Extract title - look for heading or title-related elements near the link
// Modern eBay uses complex nested structures // Modern eBay often uses h3, span, or div with text content near the link
let container = linkElement.parentElement?.parentElement?.parentElement; let titleElement = container.querySelector(
if (!container) { 'h3, [role="heading"], .s-item__title span',
// Try a different level );
container = linkElement.parentElement?.parentElement;
}
if (!container) continue;
// Extract title - look for heading or title-related elements near the link // If no direct title element, try finding text content around the link
// Modern eBay often uses h3, span, or div with text content near the link if (!titleElement) {
let titleElement = container.querySelector('h3, [role="heading"], .s-item__title span'); // Look for spans or divs with text near this link
const nearbySpans = container.querySelectorAll("span, div");
for (const span of nearbySpans) {
const text = span.textContent?.trim();
if (
text &&
text.length > 10 &&
text.length < 200 &&
!text.includes("$") &&
!text.includes("item")
) {
titleElement = span;
break;
}
}
}
// If no direct title element, try finding text content around the link let title = titleElement?.textContent?.trim();
if (!titleElement) {
// Look for spans or divs with text near this link
const nearbySpans = container.querySelectorAll('span, div');
for (const span of nearbySpans) {
const text = span.textContent?.trim();
if (text && text.length > 10 && text.length < 200 && !text.includes('$') && !text.includes('item')) {
titleElement = span;
break;
}
}
}
let title = titleElement?.textContent?.trim(); // Clean up eBay UI strings that get included in titles
if (title) {
// Remove common eBay UI strings that appear at the end of titles
const uiStrings = [
"Opens in a new window",
"Opens in a new tab",
"Opens in a new window or tab",
"opens in a new window",
"opens in a new tab",
"opens in a new window or tab",
];
// Clean up eBay UI strings that get included in titles for (const uiString of uiStrings) {
if (title) { const uiIndex = title.indexOf(uiString);
// Remove common eBay UI strings that appear at the end of titles if (uiIndex !== -1) {
const uiStrings = [ title = title.substring(0, uiIndex).trim();
'Opens in a new window', break; // Only remove one UI string per title
'Opens in a new tab', }
'Opens in a new window or tab', }
'opens in a new window',
'opens in a new tab',
'opens in a new window or tab'
];
for (const uiString of uiStrings) { // If the title became empty or too short after cleaning, skip this item
const uiIndex = title.indexOf(uiString); if (title.length < 10) {
if (uiIndex !== -1) { continue;
title = title.substring(0, uiIndex).trim(); }
break; // Only remove one UI string per title }
}
}
// If the title became empty or too short after cleaning, skip this item if (!title) continue;
if (title.length < 10) {
continue;
}
}
if (!title) continue; // Skip irrelevant eBay ads
if (title === "Shop on eBay" || title.length < 3) continue;
// Skip irrelevant eBay ads // Extract price - look for eBay's price classes, preferring sale/discount prices
if (title === "Shop on eBay" || title.length < 3) continue; let priceElement = container.querySelector(
'[class*="s-item__price"], .s-item__price, [class*="price"]',
);
// Extract price - look for eBay's price classes, preferring sale/discount prices // If no direct price class, look for spans containing $ (but not titles)
let priceElement = container.querySelector('[class*="s-item__price"], .s-item__price, [class*="price"]'); if (!priceElement) {
const spansAndElements = container.querySelectorAll(
"span, div, b, em, strong",
);
for (const el of spansAndElements) {
const text = el.textContent?.trim();
// Must contain $, be reasonably short (price shouldn't be paragraph), and not contain product words
if (
text?.includes("$") &&
text.length < 100 &&
!text.includes("laptop") &&
!text.includes("computer") &&
!text.includes("intel") &&
!text.includes("core") &&
!text.includes("ram") &&
!text.includes("ssd") &&
!/\d{4}/.test(text) && // Avoid years like "2024"
!text.includes('"') // Avoid measurements
) {
priceElement = el;
break;
}
}
}
// If no direct price class, look for spans containing $ (but not titles) // For discounted items, eBay shows both original and sale price
if (!priceElement) { // Prefer sale/current price over original/strikethrough price
const spansAndElements = container.querySelectorAll('span, div, b, em, strong'); if (priceElement) {
for (const el of spansAndElements) { // Check if this element or its parent contains multiple price elements
const text = el.textContent?.trim(); const priceContainer =
// Must contain $, be reasonably short (price shouldn't be paragraph), and not contain product words priceElement.closest('[class*="s-item__price"]') ||
if (text && text.includes('$') && text.length < 100 && priceElement.parentElement;
!text.includes('laptop') && !text.includes('computer') && !text.includes('intel') &&
!text.includes('core') && !text.includes('ram') && !text.includes('ssd') &&
! /\d{4}/.test(text) && // Avoid years like "2024"
!text.includes('"') // Avoid measurements
) {
priceElement = el;
break;
}
}
}
// For discounted items, eBay shows both original and sale price if (priceContainer) {
// Prefer sale/current price over original/strikethrough price // Look for all price elements within this container, including strikethrough prices
if (priceElement) { const allPriceElements = priceContainer.querySelectorAll(
// Check if this element or its parent contains multiple price elements '[class*="s-item__price"], span, b, em, strong, s, del, strike',
const priceContainer = priceElement.closest('[class*="s-item__price"]') || priceElement.parentElement; );
if (priceContainer) { // Filter to only elements that actually contain prices (not labels)
// Look for all price elements within this container, including strikethrough prices const actualPrices: HTMLElement[] = [];
const allPriceElements = priceContainer.querySelectorAll('[class*="s-item__price"], span, b, em, strong, s, del, strike'); for (const el of allPriceElements) {
const text = el.textContent?.trim();
if (
text &&
/^\s*[\$£¥]/u.test(text) &&
text.length < 50 &&
!/\d{4}/.test(text)
) {
actualPrices.push(el);
}
}
// Filter to only elements that actually contain prices (not labels) // Prefer non-strikethrough prices (sale prices) over strikethrough ones (original prices)
const actualPrices: HTMLElement[] = []; if (actualPrices.length > 1) {
for (const el of allPriceElements) { // First, look for prices that are NOT struck through
const text = el.textContent?.trim(); const nonStrikethroughPrices = actualPrices.filter((el) => {
if (text && /^\s*[\$£¥]/u.test(text) && text.length < 50 && !/\d{4}/.test(text)) { const tagName = el.tagName.toLowerCase();
actualPrices.push(el); const styles =
} el.classList.contains("s-strikethrough") ||
} el.classList.contains("u-flStrike") ||
el.closest("s, del, strike");
return (
tagName !== "s" &&
tagName !== "del" &&
tagName !== "strike" &&
!styles
);
});
// Prefer non-strikethrough prices (sale prices) over strikethrough ones (original prices) if (nonStrikethroughPrices.length > 0) {
if (actualPrices.length > 1) { // Use the first non-strikethrough price (sale price)
// First, look for prices that are NOT struck through priceElement = nonStrikethroughPrices[0];
const nonStrikethroughPrices = actualPrices.filter(el => { } else {
const tagName = el.tagName.toLowerCase(); // Fallback: use the last price (likely the most current)
const styles = el.classList.contains('s-strikethrough') || el.classList.contains('u-flStrike') || const lastPrice = actualPrices[actualPrices.length - 1];
el.closest('s, del, strike'); priceElement = lastPrice;
return tagName !== 's' && tagName !== 'del' && tagName !== 'strike' && !styles; }
}); }
}
}
if (nonStrikethroughPrices.length > 0) { const priceText = priceElement?.textContent?.trim();
// Use the first non-strikethrough price (sale price)
priceElement = nonStrikethroughPrices[0];
} else {
// Fallback: use the last price (likely the most current)
const lastPrice = actualPrices[actualPrices.length - 1];
priceElement = lastPrice;
}
}
}
}
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 // Apply exclusion filters
const priceInfo = parseEbayPrice(priceText); if (
if (!priceInfo) continue; exclusions.some((exclusion) =>
title.toLowerCase().includes(exclusion.toLowerCase()),
)
) {
continue;
}
// Apply exclusion filters // Apply strict mode filter (title must contain at least one keyword)
if (exclusions.some(exclusion => title.toLowerCase().includes(exclusion.toLowerCase()))) { if (
continue; strictMode &&
} !keywords.some((keyword) =>
title?.toLowerCase().includes(keyword.toLowerCase()),
)
) {
continue;
}
// Apply strict mode filter (title must contain at least one keyword) const listing: ListingDetails = {
if (strictMode && !keywords.some(keyword => title!.toLowerCase().includes(keyword.toLowerCase()))) { url: href,
continue; title,
} listingPrice: {
amountFormatted: priceText,
cents: priceInfo.cents,
currency: priceInfo.currency,
},
listingType: "OFFER", // eBay listings are typically offers
listingStatus: "ACTIVE",
address: null, // eBay doesn't typically show detailed addresses in search results
};
const listing: ListingDetails = { results.push(listing);
url: href, } catch (err) {
title, console.warn(`Error parsing eBay listing: ${err}`);
listingPrice: { }
amountFormatted: priceText, }
cents: priceInfo.cents,
currency: priceInfo.currency,
},
listingType: "OFFER", // eBay listings are typically offers
listingStatus: "ACTIVE",
address: null, // eBay doesn't typically show detailed addresses in search results
};
results.push(listing); return results;
} catch (err) {
console.warn(`Error parsing eBay listing: ${err}`);
continue;
}
}
return results;
} }
// ----------------------------- Main ----------------------------- // ----------------------------- Main -----------------------------
export default async function fetchEbayItems( export default async function fetchEbayItems(
SEARCH_QUERY: string, SEARCH_QUERY: string,
REQUESTS_PER_SECOND = 1, REQUESTS_PER_SECOND = 1,
opts: { opts: {
minPrice?: number; minPrice?: number;
maxPrice?: number; maxPrice?: number;
strictMode?: boolean; strictMode?: boolean;
exclusions?: string[]; exclusions?: string[];
keywords?: string[]; keywords?: string[];
} = {}, } = {},
) { ) {
const { const {
minPrice = 0, minPrice = 0,
maxPrice = Number.MAX_SAFE_INTEGER, maxPrice = Number.MAX_SAFE_INTEGER,
strictMode = false, strictMode = false,
exclusions = [], exclusions = [],
keywords = [SEARCH_QUERY] // Default to search query if no keywords provided keywords = [SEARCH_QUERY], // Default to search query if no keywords provided
} = opts; } = opts;
// Build eBay search URL - use Canadian site and tracking parameters like real browser // 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 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 { try {
// Use custom headers modeled after real browser requests to bypass bot detection // Use custom headers modeled after real browser requests to bypass bot detection
const headers: Record<string, string> = { const headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0', "User-Agent":
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0",
'Accept-Language': 'en-US,en;q=0.5', Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
'Accept-Encoding': 'gzip, deflate, br', "Accept-Language": "en-US,en;q=0.5",
'Referer': 'https://www.ebay.ca/', "Accept-Encoding": "gzip, deflate, br",
'Connection': 'keep-alive', Referer: "https://www.ebay.ca/",
'Upgrade-Insecure-Requests': '1', Connection: "keep-alive",
'Sec-Fetch-Dest': 'document', "Upgrade-Insecure-Requests": "1",
'Sec-Fetch-Mode': 'navigate', "Sec-Fetch-Dest": "document",
'Sec-Fetch-Site': 'same-origin', "Sec-Fetch-Mode": "navigate",
'Sec-Fetch-User': '?1', "Sec-Fetch-Site": "same-origin",
'Priority': 'u=0, i' "Sec-Fetch-User": "?1",
}; Priority: "u=0, i",
};
const res = await fetch(searchUrl, { const res = await fetch(searchUrl, {
method: "GET", method: "GET",
headers, headers,
}); });
if (!res.ok) { if (!res.ok) {
throw new HttpError( throw new HttpError(
`Request failed with status ${res.status}`, `Request failed with status ${res.status}`,
res.status, res.status,
searchUrl, searchUrl,
); );
} }
const searchHtml = await res.text(); const searchHtml = await res.text();
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND // Respect per-request delay to keep at or under REQUESTS_PER_SECOND
await delay(DELAY_MS); await delay(DELAY_MS);
console.log(`\nParsing eBay listings...`); console.log("\nParsing eBay listings...");
const listings = parseEbayListings(searchHtml, keywords, exclusions, strictMode); const listings = parseEbayListings(
searchHtml,
keywords,
exclusions,
strictMode,
);
// Filter by price range (additional safety check) // Filter by price range (additional safety check)
const filteredListings = listings.filter(listing => { const filteredListings = listings.filter((listing) => {
const cents = listing.listingPrice?.cents; const cents = listing.listingPrice?.cents;
return cents && cents >= minPrice && cents <= maxPrice; return cents && cents >= minPrice && cents <= maxPrice;
}); });
console.log(`Parsed ${filteredListings.length} eBay listings.`); console.log(`Parsed ${filteredListings.length} eBay listings.`);
return filteredListings; return filteredListings;
} catch (err) {
} catch (err) { if (err instanceof HttpError) {
if (err instanceof HttpError) { console.error(
console.error( `Failed to fetch eBay search (${err.status}): ${err.message}`,
`Failed to fetch eBay search (${err.status}): ${err.message}`, );
); return [];
return []; }
} throw err;
throw err; }
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,187 +1,215 @@
import fetchKijijiItems from "@/kijiji";
import fetchFacebookItems from "@/facebook";
import fetchEbayItems from "@/ebay"; import fetchEbayItems from "@/ebay";
import fetchFacebookItems from "@/facebook";
import fetchKijijiItems from "@/kijiji";
const PORT = process.env.PORT || 4005; const PORT = process.env.PORT || 4005;
const server = Bun.serve({ const server = Bun.serve({
port: PORT, port: PORT,
idleTimeout: 0, idleTimeout: 0,
routes: { routes: {
// Static routes // Static routes
"/api/status": new Response("OK"), "/api/status": new Response("OK"),
// Dynamic routes // Dynamic routes
"/api/kijiji": async (req: Request) => { "/api/kijiji": async (req: Request) => {
const reqUrl = new URL(req.url); const reqUrl = new URL(req.url);
const SEARCH_QUERY = const SEARCH_QUERY =
req.headers.get("query") || reqUrl.searchParams.get("q") || null; req.headers.get("query") || reqUrl.searchParams.get("q") || null;
if (!SEARCH_QUERY) if (!SEARCH_QUERY)
return Response.json( return Response.json(
{ {
message: message:
"Request didn't have 'query' header or 'q' search parameter!", "Request didn't have 'query' header or 'q' search parameter!",
}, },
{ status: 400 }, { status: 400 },
); );
// Parse optional parameters with enhanced defaults // Parse optional parameters with enhanced defaults
const location = reqUrl.searchParams.get("location"); const location = reqUrl.searchParams.get("location");
const category = reqUrl.searchParams.get("category"); const category = reqUrl.searchParams.get("category");
const maxPagesParam = reqUrl.searchParams.get("maxPages"); const maxPagesParam = reqUrl.searchParams.get("maxPages");
const maxPages = maxPagesParam const maxPages = maxPagesParam ? Number.parseInt(maxPagesParam, 10) : 5; // Default: 5 pages
? Number.parseInt(maxPagesParam, 10) const sortBy = reqUrl.searchParams.get("sortBy") as
: 5; // Default: 5 pages | "relevancy"
const sortBy = reqUrl.searchParams.get("sortBy") as 'relevancy' | 'date' | 'price' | 'distance' | undefined; | "date"
const sortOrder = reqUrl.searchParams.get("sortOrder") as 'asc' | 'desc' | undefined; | "price"
| "distance"
| undefined;
const sortOrder = reqUrl.searchParams.get("sortOrder") as
| "asc"
| "desc"
| undefined;
// Build search options // Build search options
const locationValue = location ? (/^\d+$/.test(location) ? Number(location) : location) : 1700272; const locationValue = location
const categoryValue = category ? (/^\d+$/.test(category) ? Number(category) : category) : 0; ? /^\d+$/.test(location)
? Number(location)
: location
: 1700272;
const categoryValue = category
? /^\d+$/.test(category)
? Number(category)
: category
: 0;
const searchOptions: import("@/kijiji").SearchOptions = { const searchOptions: import("@/kijiji").SearchOptions = {
location: locationValue, location: locationValue,
category: categoryValue, category: categoryValue,
keywords: SEARCH_QUERY, keywords: SEARCH_QUERY,
sortBy: sortBy || 'relevancy', sortBy: sortBy || "relevancy",
sortOrder: sortOrder || 'desc', sortOrder: sortOrder || "desc",
maxPages, maxPages,
}; };
// Build listing fetch options with enhanced defaults // Build listing fetch options with enhanced defaults
const listingOptions: import("@/kijiji").ListingFetchOptions = { const listingOptions: import("@/kijiji").ListingFetchOptions = {
includeImages: true, // Always include full image arrays includeImages: true, // Always include full image arrays
sellerDataDepth: 'detailed', // Default: detailed seller info sellerDataDepth: "detailed", // Default: detailed seller info
includeClientSideData: false, // GraphQL reviews disabled by default includeClientSideData: false, // GraphQL reviews disabled by default
}; };
try { try {
const items = await fetchKijijiItems(SEARCH_QUERY, 1, undefined, searchOptions, listingOptions); const items = await fetchKijijiItems(
if (!items || items.length === 0) SEARCH_QUERY,
return Response.json( 1,
{ message: "Search didn't return any results!" }, undefined,
{ status: 404 }, searchOptions,
); listingOptions,
return Response.json(items, { status: 200 }); );
} catch (error) { if (!items || items.length === 0)
console.error("Kijiji scraping error:", error); return Response.json(
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; { message: "Search didn't return any results!" },
return Response.json( { status: 404 },
{ );
message: `Scraping failed: ${errorMessage}`, return Response.json(items, { status: 200 });
query: SEARCH_QUERY, } catch (error) {
options: { searchOptions, listingOptions } console.error("Kijiji scraping error:", error);
}, const errorMessage =
{ status: 500 }, 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) => { "/api/facebook": async (req: Request) => {
const reqUrl = new URL(req.url); const reqUrl = new URL(req.url);
const SEARCH_QUERY = const SEARCH_QUERY =
req.headers.get("query") || reqUrl.searchParams.get("q") || null; req.headers.get("query") || reqUrl.searchParams.get("q") || null;
if (!SEARCH_QUERY) if (!SEARCH_QUERY)
return Response.json( return Response.json(
{ {
message: message:
"Request didn't have 'query' header or 'q' search parameter!", "Request didn't have 'query' header or 'q' search parameter!",
}, },
{ status: 400 }, { status: 400 },
); );
const LOCATION = reqUrl.searchParams.get("location") || "toronto"; const LOCATION = reqUrl.searchParams.get("location") || "toronto";
const COOKIES_SOURCE = reqUrl.searchParams.get("cookies") || undefined; const COOKIES_SOURCE = reqUrl.searchParams.get("cookies") || undefined;
try { try {
const items = await fetchFacebookItems(SEARCH_QUERY, 5, LOCATION, 25, COOKIES_SOURCE, "./cookies/facebook.json"); const items = await fetchFacebookItems(
if (!items || items.length === 0) SEARCH_QUERY,
return Response.json( 5,
{ message: "Search didn't return any results!" }, LOCATION,
{ status: 404 }, 25,
); COOKIES_SOURCE,
return Response.json(items, { status: 200 }); "./cookies/facebook.json",
} catch (error) { );
console.error("Facebook scraping error:", error); if (!items || items.length === 0)
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; return Response.json(
return Response.json( { message: "Search didn't return any results!" },
{ message: errorMessage }, { status: 404 },
{ status: 400 }, );
); return Response.json(items, { status: 200 });
} } catch (error) {
}, console.error("Facebook scraping error:", error);
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
return Response.json({ message: errorMessage }, { status: 400 });
}
},
"/api/ebay": async (req: Request) => { "/api/ebay": async (req: Request) => {
const reqUrl = new URL(req.url); const reqUrl = new URL(req.url);
const SEARCH_QUERY = const SEARCH_QUERY =
req.headers.get("query") || reqUrl.searchParams.get("q") || null; req.headers.get("query") || reqUrl.searchParams.get("q") || null;
if (!SEARCH_QUERY) if (!SEARCH_QUERY)
return Response.json( return Response.json(
{ {
message: message:
"Request didn't have 'query' header or 'q' search parameter!", "Request didn't have 'query' header or 'q' search parameter!",
}, },
{ status: 400 }, { status: 400 },
); );
// Parse optional parameters with defaults // Parse optional parameters with defaults
const minPriceParam = reqUrl.searchParams.get("minPrice"); const minPriceParam = reqUrl.searchParams.get("minPrice");
const minPrice = minPriceParam const minPrice = minPriceParam
? Number.parseInt(minPriceParam, 10) ? Number.parseInt(minPriceParam, 10)
: undefined; : undefined;
const maxPriceParam = reqUrl.searchParams.get("maxPrice"); const maxPriceParam = reqUrl.searchParams.get("maxPrice");
const maxPrice = maxPriceParam const maxPrice = maxPriceParam
? Number.parseInt(maxPriceParam, 10) ? Number.parseInt(maxPriceParam, 10)
: undefined; : undefined;
const strictMode = reqUrl.searchParams.get("strictMode") === "true"; const strictMode = reqUrl.searchParams.get("strictMode") === "true";
const exclusionsParam = reqUrl.searchParams.get("exclusions"); const exclusionsParam = reqUrl.searchParams.get("exclusions");
const exclusions = exclusionsParam ? exclusionsParam.split(",").map(s => s.trim()) : []; const exclusions = exclusionsParam
const keywordsParam = reqUrl.searchParams.get("keywords"); ? exclusionsParam.split(",").map((s) => s.trim())
const keywords = keywordsParam ? keywordsParam.split(",").map(s => s.trim()) : [SEARCH_QUERY]; : [];
const keywordsParam = reqUrl.searchParams.get("keywords");
const keywords = keywordsParam
? keywordsParam.split(",").map((s) => s.trim())
: [SEARCH_QUERY];
try { try {
const items = await fetchEbayItems(SEARCH_QUERY, 5, { const items = await fetchEbayItems(SEARCH_QUERY, 5, {
minPrice, minPrice,
maxPrice, maxPrice,
strictMode, strictMode,
exclusions, exclusions,
keywords, keywords,
}); });
if (!items || items.length === 0) if (!items || items.length === 0)
return Response.json( return Response.json(
{ message: "Search didn't return any results!" }, { message: "Search didn't return any results!" },
{ status: 404 }, { status: 404 },
); );
return Response.json(items, { status: 200 }); return Response.json(items, { status: 200 });
} catch (error) { } catch (error) {
console.error("eBay scraping error:", error); console.error("eBay scraping error:", error);
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; const errorMessage =
return Response.json( error instanceof Error ? error.message : "Unknown error occurred";
{ message: errorMessage }, return Response.json({ message: errorMessage }, { status: 400 });
{ status: 400 }, }
); },
}
},
// Wildcard route for all routes that start with "/api/" and aren't otherwise matched // Wildcard route for all routes that start with "/api/" and aren't otherwise matched
"/api/*": Response.json({ message: "Not found" }, { status: 404 }), "/api/*": Response.json({ message: "Not found" }, { status: 404 }),
// // Serve a file by buffering it in memory // // Serve a file by buffering it in memory
// "/favicon.ico": new Response(await Bun.file("./favicon.ico").bytes(), { // "/favicon.ico": new Response(await Bun.file("./favicon.ico").bytes(), {
// headers: { // headers: {
// "Content-Type": "image/x-icon", // "Content-Type": "image/x-icon",
// }, // },
// }), // }),
}, },
// (optional) fallback for unmatched routes: // (optional) fallback for unmatched routes:
// Required if Bun's version < 1.2.3 // Required if Bun's version < 1.2.3
fetch(req: Request) { fetch(req: Request) {
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 });
}, },
}); });
console.log(`Serving on ${server.hostname}:${server.port}`); console.log(`Serving on ${server.hostname}:${server.port}`);

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

View File

@@ -1,162 +1,166 @@
import { describe, test, expect } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
resolveLocationId, HttpError,
resolveCategoryId, NetworkError,
buildSearchUrl, ParseError,
HttpError, RateLimitError,
NetworkError, ValidationError,
ParseError, buildSearchUrl,
RateLimitError, resolveCategoryId,
ValidationError resolveLocationId,
} from "../src/kijiji"; } from "../src/kijiji";
describe("Location and Category Resolution", () => { describe("Location and Category Resolution", () => {
describe("resolveLocationId", () => { describe("resolveLocationId", () => {
test("should return numeric IDs as-is", () => { test("should return numeric IDs as-is", () => {
expect(resolveLocationId(1700272)).toBe(1700272); expect(resolveLocationId(1700272)).toBe(1700272);
expect(resolveLocationId(0)).toBe(0); expect(resolveLocationId(0)).toBe(0);
}); });
test("should resolve string location names", () => { test("should resolve string location names", () => {
expect(resolveLocationId("canada")).toBe(0); expect(resolveLocationId("canada")).toBe(0);
expect(resolveLocationId("ontario")).toBe(9004); expect(resolveLocationId("ontario")).toBe(9004);
expect(resolveLocationId("toronto")).toBe(1700273); expect(resolveLocationId("toronto")).toBe(1700273);
expect(resolveLocationId("gta")).toBe(1700272); expect(resolveLocationId("gta")).toBe(1700272);
}); });
test("should handle case insensitive matching", () => { test("should handle case insensitive matching", () => {
expect(resolveLocationId("Canada")).toBe(0); expect(resolveLocationId("Canada")).toBe(0);
expect(resolveLocationId("ONTARIO")).toBe(9004); expect(resolveLocationId("ONTARIO")).toBe(9004);
}); });
test("should default to Canada for unknown locations", () => { test("should default to Canada for unknown locations", () => {
expect(resolveLocationId("unknown")).toBe(0); expect(resolveLocationId("unknown")).toBe(0);
expect(resolveLocationId("")).toBe(0); expect(resolveLocationId("")).toBe(0);
}); });
test("should handle undefined input", () => { test("should handle undefined input", () => {
expect(resolveLocationId(undefined)).toBe(0); expect(resolveLocationId(undefined)).toBe(0);
}); });
}); });
describe("resolveCategoryId", () => { describe("resolveCategoryId", () => {
test("should return numeric IDs as-is", () => { test("should return numeric IDs as-is", () => {
expect(resolveCategoryId(132)).toBe(132); expect(resolveCategoryId(132)).toBe(132);
expect(resolveCategoryId(0)).toBe(0); expect(resolveCategoryId(0)).toBe(0);
}); });
test("should resolve string category names", () => { test("should resolve string category names", () => {
expect(resolveCategoryId("all")).toBe(0); expect(resolveCategoryId("all")).toBe(0);
expect(resolveCategoryId("phones")).toBe(132); expect(resolveCategoryId("phones")).toBe(132);
expect(resolveCategoryId("electronics")).toBe(29659001); expect(resolveCategoryId("electronics")).toBe(29659001);
expect(resolveCategoryId("buy-sell")).toBe(10); expect(resolveCategoryId("buy-sell")).toBe(10);
}); });
test("should handle case insensitive matching", () => { test("should handle case insensitive matching", () => {
expect(resolveCategoryId("All")).toBe(0); expect(resolveCategoryId("All")).toBe(0);
expect(resolveCategoryId("PHONES")).toBe(132); expect(resolveCategoryId("PHONES")).toBe(132);
}); });
test("should default to all categories for unknown categories", () => { test("should default to all categories for unknown categories", () => {
expect(resolveCategoryId("unknown")).toBe(0); expect(resolveCategoryId("unknown")).toBe(0);
expect(resolveCategoryId("")).toBe(0); expect(resolveCategoryId("")).toBe(0);
}); });
test("should handle undefined input", () => { test("should handle undefined input", () => {
expect(resolveCategoryId(undefined)).toBe(0); expect(resolveCategoryId(undefined)).toBe(0);
}); });
}); });
}); });
describe("URL Construction", () => { describe("URL Construction", () => {
describe("buildSearchUrl", () => { describe("buildSearchUrl", () => {
test("should build basic search URL", () => { test("should build basic search URL", () => {
const url = buildSearchUrl("iphone", { const url = buildSearchUrl("iphone", {
location: 1700272, location: 1700272,
category: 132, category: 132,
sortBy: 'relevancy', sortBy: "relevancy",
sortOrder: 'desc', sortOrder: "desc",
}); });
expect(url).toContain("b-buy-sell/canada/iphone/k0c132l1700272"); expect(url).toContain("b-buy-sell/canada/iphone/k0c132l1700272");
expect(url).toContain("sort=relevancyDesc"); expect(url).toContain("sort=relevancyDesc");
expect(url).toContain("order=DESC"); expect(url).toContain("order=DESC");
}); });
test("should handle pagination", () => { test("should handle pagination", () => {
const url = buildSearchUrl("iphone", { const url = buildSearchUrl("iphone", {
location: 1700272, location: 1700272,
category: 132, category: 132,
page: 2, page: 2,
}); });
expect(url).toContain("&page=2"); expect(url).toContain("&page=2");
}); });
test("should handle different sort options", () => { test("should handle different sort options", () => {
const dateUrl = buildSearchUrl("iphone", { const dateUrl = buildSearchUrl("iphone", {
sortBy: 'date', sortBy: "date",
sortOrder: 'asc', sortOrder: "asc",
}); });
expect(dateUrl).toContain("sort=DATE"); expect(dateUrl).toContain("sort=DATE");
expect(dateUrl).toContain("order=ASC"); expect(dateUrl).toContain("order=ASC");
const priceUrl = buildSearchUrl("iphone", { const priceUrl = buildSearchUrl("iphone", {
sortBy: 'price', sortBy: "price",
sortOrder: 'desc', sortOrder: "desc",
}); });
expect(priceUrl).toContain("sort=PRICE"); expect(priceUrl).toContain("sort=PRICE");
expect(priceUrl).toContain("order=DESC"); expect(priceUrl).toContain("order=DESC");
}); });
test("should handle string location/category inputs", () => { test("should handle string location/category inputs", () => {
const url = buildSearchUrl("iphone", { const url = buildSearchUrl("iphone", {
location: "toronto", location: "toronto",
category: "phones", category: "phones",
}); });
expect(url).toContain("k0c132l1700273"); // phones + toronto expect(url).toContain("k0c132l1700273"); // phones + toronto
}); });
}); });
}); });
describe("Error Classes", () => { describe("Error Classes", () => {
test("HttpError should store status and URL", () => { test("HttpError should store status and URL", () => {
const error = new HttpError("Not found", 404, "https://example.com"); const error = new HttpError("Not found", 404, "https://example.com");
expect(error.message).toBe("Not found"); expect(error.message).toBe("Not found");
expect(error.status).toBe(404); expect(error.status).toBe(404);
expect(error.url).toBe("https://example.com"); expect(error.url).toBe("https://example.com");
expect(error.name).toBe("HttpError"); expect(error.name).toBe("HttpError");
}); });
test("NetworkError should store URL and cause", () => { test("NetworkError should store URL and cause", () => {
const cause = new Error("Connection failed"); const cause = new Error("Connection failed");
const error = new NetworkError("Network error", "https://example.com", cause); const error = new NetworkError(
expect(error.message).toBe("Network error"); "Network error",
expect(error.url).toBe("https://example.com"); "https://example.com",
expect(error.cause).toBe(cause); cause,
expect(error.name).toBe("NetworkError"); );
}); 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", () => { test("ParseError should store data", () => {
const data = { invalid: "json" }; const data = { invalid: "json" };
const error = new ParseError("Invalid JSON", data); const error = new ParseError("Invalid JSON", data);
expect(error.message).toBe("Invalid JSON"); expect(error.message).toBe("Invalid JSON");
expect(error.data).toBe(data); expect(error.data).toBe(data);
expect(error.name).toBe("ParseError"); expect(error.name).toBe("ParseError");
}); });
test("RateLimitError should store URL and reset time", () => { test("RateLimitError should store URL and reset time", () => {
const error = new RateLimitError("Rate limited", "https://example.com", 60); const error = new RateLimitError("Rate limited", "https://example.com", 60);
expect(error.message).toBe("Rate limited"); expect(error.message).toBe("Rate limited");
expect(error.url).toBe("https://example.com"); expect(error.url).toBe("https://example.com");
expect(error.resetTime).toBe(60); expect(error.resetTime).toBe(60);
expect(error.name).toBe("RateLimitError"); expect(error.name).toBe("RateLimitError");
}); });
test("ValidationError should work without field", () => { test("ValidationError should work without field", () => {
const error = new ValidationError("Invalid value"); const error = new ValidationError("Invalid value");
expect(error.message).toBe("Invalid value"); expect(error.message).toBe("Invalid value");
expect(error.name).toBe("ValidationError"); expect(error.name).toBe("ValidationError");
}); });
}); });

View File

@@ -1,337 +1,363 @@
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { extractApolloState, parseSearch, parseDetailedListing } from "../src/kijiji"; import {
extractApolloState,
parseDetailedListing,
parseSearch,
} from "../src/kijiji";
// Mock fetch globally // Mock fetch globally
const originalFetch = global.fetch; const originalFetch = global.fetch;
describe("HTML Parsing Integration", () => { describe("HTML Parsing Integration", () => {
beforeEach(() => { beforeEach(() => {
// Mock fetch for all tests // Mock fetch for all tests
global.fetch = mock(() => { global.fetch = mock(() => {
throw new Error("fetch should be mocked in individual tests"); throw new Error("fetch should be mocked in individual tests");
}); });
}); });
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
}); });
describe("extractApolloState", () => { describe("extractApolloState", () => {
test("should extract Apollo state from valid HTML", () => { 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 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); const result = extractApolloState(mockHtml);
expect(result).toEqual({ expect(result).toEqual({
ROOT_QUERY: { test: "value" } ROOT_QUERY: { test: "value" },
}); });
}); });
test("should return null for HTML without Apollo state", () => { test("should return null for HTML without Apollo state", () => {
const mockHtml = '<html><body>No data here</body></html>'; const mockHtml = "<html><body>No data here</body></html>";
const result = extractApolloState(mockHtml); const result = extractApolloState(mockHtml);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("should return null for malformed JSON", () => { test("should return null for malformed JSON", () => {
const mockHtml = '<html><script id="__NEXT_DATA__" type="application/json">{"invalid": json}</script></html>'; const mockHtml =
'<html><script id="__NEXT_DATA__" type="application/json">{"invalid": json}</script></html>';
const result = extractApolloState(mockHtml); const result = extractApolloState(mockHtml);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("should handle missing __NEXT_DATA__ element", () => { test("should handle missing __NEXT_DATA__ element", () => {
const mockHtml = '<html><body><div>Content</div></body></html>'; const mockHtml = "<html><body><div>Content</div></body></html>";
const result = extractApolloState(mockHtml); const result = extractApolloState(mockHtml);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("parseSearch", () => { describe("parseSearch", () => {
test("should parse search results from HTML", () => { test("should parse search results from HTML", () => {
const mockHtml = ` const mockHtml = `
<html> <html>
<script id="__NEXT_DATA__" type="application/json"> <script id="__NEXT_DATA__" type="application/json">
${JSON.stringify({ ${JSON.stringify({
props: { props: {
pageProps: { pageProps: {
__APOLLO_STATE__: { __APOLLO_STATE__: {
"Listing:123": { "Listing:123": {
url: "/v-iphone/k0l0", url: "/v-iphone/k0l0",
title: "iPhone 13 Pro", title: "iPhone 13 Pro",
}, },
"Listing:456": { "Listing:456": {
url: "/v-samsung/k0l0", url: "/v-samsung/k0l0",
title: "Samsung Galaxy", title: "Samsung Galaxy",
}, },
"ROOT_QUERY": { test: "value" } ROOT_QUERY: { test: "value" },
} },
} },
} },
})} })}
</script> </script>
</html> </html>
`; `;
const results = parseSearch(mockHtml, "https://www.kijiji.ca"); const results = parseSearch(mockHtml, "https://www.kijiji.ca");
expect(results).toHaveLength(2); expect(results).toHaveLength(2);
expect(results[0]).toEqual({ expect(results[0]).toEqual({
name: "iPhone 13 Pro", name: "iPhone 13 Pro",
listingLink: "https://www.kijiji.ca/v-iphone/k0l0" listingLink: "https://www.kijiji.ca/v-iphone/k0l0",
}); });
expect(results[1]).toEqual({ expect(results[1]).toEqual({
name: "Samsung Galaxy", name: "Samsung Galaxy",
listingLink: "https://www.kijiji.ca/v-samsung/k0l0" listingLink: "https://www.kijiji.ca/v-samsung/k0l0",
}); });
}); });
test("should handle absolute URLs", () => { test("should handle absolute URLs", () => {
const mockHtml = ` const mockHtml = `
<html> <html>
<script id="__NEXT_DATA__" type="application/json"> <script id="__NEXT_DATA__" type="application/json">
${JSON.stringify({ ${JSON.stringify({
props: { props: {
pageProps: { pageProps: {
__APOLLO_STATE__: { __APOLLO_STATE__: {
"Listing:123": { "Listing:123": {
url: "https://www.kijiji.ca/v-iphone/k0l0", url: "https://www.kijiji.ca/v-iphone/k0l0",
title: "iPhone 13 Pro", title: "iPhone 13 Pro",
} },
} },
} },
} },
})} })}
</script> </script>
</html> </html>
`; `;
const results = parseSearch(mockHtml, "https://www.kijiji.ca"); const results = parseSearch(mockHtml, "https://www.kijiji.ca");
expect(results[0].listingLink).toBe("https://www.kijiji.ca/v-iphone/k0l0"); expect(results[0].listingLink).toBe(
}); "https://www.kijiji.ca/v-iphone/k0l0",
);
});
test("should filter out invalid listings", () => { test("should filter out invalid listings", () => {
const mockHtml = ` const mockHtml = `
<html> <html>
<script id="__NEXT_DATA__" type="application/json"> <script id="__NEXT_DATA__" type="application/json">
${JSON.stringify({ ${JSON.stringify({
props: { props: {
pageProps: { pageProps: {
__APOLLO_STATE__: { __APOLLO_STATE__: {
"Listing:123": { "Listing:123": {
url: "/v-iphone/k0l0", url: "/v-iphone/k0l0",
title: "iPhone 13 Pro", title: "iPhone 13 Pro",
}, },
"Listing:456": { "Listing:456": {
url: "/v-samsung/k0l0", url: "/v-samsung/k0l0",
// Missing title // Missing title
}, },
"Other:789": { "Other:789": {
url: "/v-other/k0l0", url: "/v-other/k0l0",
title: "Other Item", title: "Other Item",
} },
} },
} },
} },
})} })}
</script> </script>
</html> </html>
`; `;
const results = parseSearch(mockHtml, "https://www.kijiji.ca"); const results = parseSearch(mockHtml, "https://www.kijiji.ca");
expect(results).toHaveLength(1); expect(results).toHaveLength(1);
expect(results[0].name).toBe("iPhone 13 Pro"); expect(results[0].name).toBe("iPhone 13 Pro");
}); });
test("should return empty array for invalid HTML", () => { test("should return empty array for invalid HTML", () => {
const results = parseSearch("<html><body>Invalid</body></html>", "https://www.kijiji.ca"); const results = parseSearch(
expect(results).toEqual([]); "<html><body>Invalid</body></html>",
}); "https://www.kijiji.ca",
}); );
expect(results).toEqual([]);
});
});
describe("parseDetailedListing", () => { describe("parseDetailedListing", () => {
test("should parse detailed listing with all fields", async () => { test("should parse detailed listing with all fields", async () => {
const mockHtml = ` const mockHtml = `
<html> <html>
<script id="__NEXT_DATA__" type="application/json"> <script id="__NEXT_DATA__" type="application/json">
${JSON.stringify({ ${JSON.stringify({
props: { props: {
pageProps: { pageProps: {
__APOLLO_STATE__: { __APOLLO_STATE__: {
"Listing:123": { "Listing:123": {
url: "/v-iphone-13-pro/k0l0", url: "/v-iphone-13-pro/k0l0",
title: "iPhone 13 Pro 256GB", title: "iPhone 13 Pro 256GB",
description: "Excellent condition iPhone 13 Pro", description: "Excellent condition iPhone 13 Pro",
price: { price: {
amount: 80000, amount: 80000,
currency: "CAD", currency: "CAD",
type: "FIXED" type: "FIXED",
}, },
type: "OFFER", type: "OFFER",
status: "ACTIVE", status: "ACTIVE",
activationDate: "2024-01-15T10:00:00.000Z", activationDate: "2024-01-15T10:00:00.000Z",
endDate: "2025-01-15T10:00:00.000Z", endDate: "2025-01-15T10:00:00.000Z",
metrics: { views: 150 }, metrics: { views: 150 },
location: { location: {
address: "Toronto, ON", address: "Toronto, ON",
id: 1700273, id: 1700273,
name: "Toronto", name: "Toronto",
coordinates: { coordinates: {
latitude: 43.6532, latitude: 43.6532,
longitude: -79.3832 longitude: -79.3832,
} },
}, },
imageUrls: [ imageUrls: [
"https://media.kijiji.ca/api/v1/image1.jpg", "https://media.kijiji.ca/api/v1/image1.jpg",
"https://media.kijiji.ca/api/v1/image2.jpg" "https://media.kijiji.ca/api/v1/image2.jpg",
], ],
imageCount: 2, imageCount: 2,
categoryId: 132, categoryId: 132,
adSource: "ORGANIC", adSource: "ORGANIC",
flags: { flags: {
topAd: false, topAd: false,
priceDrop: true priceDrop: true,
}, },
posterInfo: { posterInfo: {
posterId: "user123", posterId: "user123",
rating: 4.8 rating: 4.8,
}, },
attributes: [ attributes: [
{ canonicalName: "forsaleby", canonicalValues: ["ownr"] }, {
{ canonicalName: "phonecarrier", canonicalValues: ["unlocked"] } canonicalName: "forsaleby",
] canonicalValues: ["ownr"],
} },
} {
} canonicalName: "phonecarrier",
} canonicalValues: ["unlocked"],
})} },
],
},
},
},
},
})}
</script> </script>
</html> </html>
`; `;
const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca"); const result = await parseDetailedListing(
expect(result).toEqual({ mockHtml,
url: "https://www.kijiji.ca/v-iphone-13-pro/k0l0", "https://www.kijiji.ca",
title: "iPhone 13 Pro 256GB", );
description: "Excellent condition iPhone 13 Pro", expect(result).toEqual({
listingPrice: { url: "https://www.kijiji.ca/v-iphone-13-pro/k0l0",
amountFormatted: "$800.00", title: "iPhone 13 Pro 256GB",
cents: 80000, description: "Excellent condition iPhone 13 Pro",
currency: "CAD" listingPrice: {
}, amountFormatted: "$800.00",
listingType: "OFFER", cents: 80000,
listingStatus: "ACTIVE", currency: "CAD",
creationDate: "2024-01-15T10:00:00.000Z", },
endDate: "2025-01-15T10:00:00.000Z", listingType: "OFFER",
numberOfViews: 150, listingStatus: "ACTIVE",
address: "Toronto, ON", creationDate: "2024-01-15T10:00:00.000Z",
images: [ endDate: "2025-01-15T10:00:00.000Z",
"https://media.kijiji.ca/api/v1/image1.jpg", numberOfViews: 150,
"https://media.kijiji.ca/api/v1/image2.jpg" address: "Toronto, ON",
], images: [
categoryId: 132, "https://media.kijiji.ca/api/v1/image1.jpg",
adSource: "ORGANIC", "https://media.kijiji.ca/api/v1/image2.jpg",
flags: { ],
topAd: false, categoryId: 132,
priceDrop: true adSource: "ORGANIC",
}, flags: {
attributes: { topAd: false,
forsaleby: ["ownr"], priceDrop: true,
phonecarrier: ["unlocked"] },
}, attributes: {
location: { forsaleby: ["ownr"],
id: 1700273, phonecarrier: ["unlocked"],
name: "Toronto", },
coordinates: { location: {
latitude: 43.6532, id: 1700273,
longitude: -79.3832 name: "Toronto",
} coordinates: {
}, latitude: 43.6532,
sellerInfo: { longitude: -79.3832,
posterId: "user123", },
rating: 4.8 },
} sellerInfo: {
}); posterId: "user123",
}); rating: 4.8,
},
});
});
test("should return null for contact-based pricing", async () => { test("should return null for contact-based pricing", async () => {
const mockHtml = ` const mockHtml = `
<html> <html>
<script id="__NEXT_DATA__" type="application/json"> <script id="__NEXT_DATA__" type="application/json">
${JSON.stringify({ ${JSON.stringify({
props: { props: {
pageProps: { pageProps: {
__APOLLO_STATE__: { __APOLLO_STATE__: {
"Listing:123": { "Listing:123": {
url: "/v-iphone/k0l0", url: "/v-iphone/k0l0",
title: "iPhone for Sale", title: "iPhone for Sale",
price: { price: {
type: "CONTACT", type: "CONTACT",
amount: null amount: null,
} },
} },
} },
} },
} },
})} })}
</script> </script>
</html> </html>
`; `;
const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca"); const result = await parseDetailedListing(
expect(result).toBeNull(); mockHtml,
}); "https://www.kijiji.ca",
);
expect(result).toBeNull();
});
test("should handle missing optional fields", async () => { test("should handle missing optional fields", async () => {
const mockHtml = ` const mockHtml = `
<html> <html>
<script id="__NEXT_DATA__" type="application/json"> <script id="__NEXT_DATA__" type="application/json">
${JSON.stringify({ ${JSON.stringify({
props: { props: {
pageProps: { pageProps: {
__APOLLO_STATE__: { __APOLLO_STATE__: {
"Listing:123": { "Listing:123": {
url: "/v-iphone/k0l0", url: "/v-iphone/k0l0",
title: "iPhone 13", title: "iPhone 13",
price: { amount: 50000 } price: { amount: 50000 },
} },
} },
} },
} },
})} })}
</script> </script>
</html> </html>
`; `;
const result = await parseDetailedListing(mockHtml, "https://www.kijiji.ca"); const result = await parseDetailedListing(
expect(result).toEqual({ mockHtml,
url: "https://www.kijiji.ca/v-iphone/k0l0", "https://www.kijiji.ca",
title: "iPhone 13", );
description: undefined, expect(result).toEqual({
listingPrice: { url: "https://www.kijiji.ca/v-iphone/k0l0",
amountFormatted: "$500.00", title: "iPhone 13",
cents: 50000, description: undefined,
currency: undefined listingPrice: {
}, amountFormatted: "$500.00",
listingType: undefined, cents: 50000,
listingStatus: undefined, currency: undefined,
creationDate: undefined, },
endDate: undefined, listingType: undefined,
numberOfViews: undefined, listingStatus: undefined,
address: null, creationDate: undefined,
images: [], endDate: undefined,
categoryId: 0, numberOfViews: undefined,
adSource: "UNKNOWN", address: null,
flags: { images: [],
topAd: false, categoryId: 0,
priceDrop: false adSource: "UNKNOWN",
}, flags: {
attributes: {}, topAd: false,
location: { priceDrop: false,
id: 0, },
name: "Unknown", attributes: {},
coordinates: undefined location: {
}, id: 0,
sellerInfo: undefined name: "Unknown",
}); coordinates: undefined,
}); },
}); sellerInfo: undefined,
});
});
});
}); });

View File

@@ -1,54 +1,54 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { slugify, formatCentsToCurrency } from "../src/kijiji"; import { formatCentsToCurrency, slugify } from "../src/kijiji";
describe("Utility Functions", () => { describe("Utility Functions", () => {
describe("slugify", () => { describe("slugify", () => {
test("should convert basic strings to slugs", () => { test("should convert basic strings to slugs", () => {
expect(slugify("Hello World")).toBe("hello-world"); expect(slugify("Hello World")).toBe("hello-world");
expect(slugify("iPhone 13 Pro")).toBe("iphone-13-pro"); expect(slugify("iPhone 13 Pro")).toBe("iphone-13-pro");
}); });
test("should handle special characters", () => { test("should handle special characters", () => {
expect(slugify("Café & Restaurant")).toBe("cafe-restaurant"); expect(slugify("Café & Restaurant")).toBe("cafe-restaurant");
expect(slugify("100% New")).toBe("100-new"); expect(slugify("100% New")).toBe("100-new");
}); });
test("should handle empty and edge cases", () => { test("should handle empty and edge cases", () => {
expect(slugify("")).toBe(""); expect(slugify("")).toBe("");
expect(slugify(" ")).toBe("-"); expect(slugify(" ")).toBe("-");
expect(slugify("---")).toBe("-"); expect(slugify("---")).toBe("-");
}); });
test("should preserve numbers and valid characters", () => { test("should preserve numbers and valid characters", () => {
expect(slugify("iPhone 13")).toBe("iphone-13"); expect(slugify("iPhone 13")).toBe("iphone-13");
expect(slugify("item123")).toBe("item123"); expect(slugify("item123")).toBe("item123");
}); });
}); });
describe("formatCentsToCurrency", () => { describe("formatCentsToCurrency", () => {
test("should format valid cent values", () => { test("should format valid cent values", () => {
expect(formatCentsToCurrency(100)).toBe("$1.00"); expect(formatCentsToCurrency(100)).toBe("$1.00");
expect(formatCentsToCurrency(1999)).toBe("$19.99"); expect(formatCentsToCurrency(1999)).toBe("$19.99");
expect(formatCentsToCurrency(0)).toBe("$0.00"); expect(formatCentsToCurrency(0)).toBe("$0.00");
}); });
test("should handle string inputs", () => { test("should handle string inputs", () => {
expect(formatCentsToCurrency("100")).toBe("$1.00"); expect(formatCentsToCurrency("100")).toBe("$1.00");
expect(formatCentsToCurrency("1999")).toBe("$19.99"); expect(formatCentsToCurrency("1999")).toBe("$19.99");
}); });
test("should handle null/undefined inputs", () => { test("should handle null/undefined inputs", () => {
expect(formatCentsToCurrency(null)).toBe(""); expect(formatCentsToCurrency(null)).toBe("");
expect(formatCentsToCurrency(undefined)).toBe(""); expect(formatCentsToCurrency(undefined)).toBe("");
}); });
test("should handle invalid inputs", () => { test("should handle invalid inputs", () => {
expect(formatCentsToCurrency("invalid")).toBe(""); expect(formatCentsToCurrency("invalid")).toBe("");
expect(formatCentsToCurrency(Number.NaN)).toBe(""); expect(formatCentsToCurrency(Number.NaN)).toBe("");
}); });
test("should use en-US locale formatting", () => { test("should use en-US locale formatting", () => {
expect(formatCentsToCurrency(123456)).toBe("$1,234.56"); expect(formatCentsToCurrency(123456)).toBe("$1,234.56");
}); });
}); });
}); });

View File

@@ -5,8 +5,10 @@ import { expect } from "bun:test";
// This file is loaded before any tests run due to bunfig.toml preload // This file is loaded before any tests run due to bunfig.toml preload
// Mock fetch globally for tests // Mock fetch globally for tests
global.fetch = global.fetch || (() => { global.fetch =
throw new Error('fetch is not available in test environment'); global.fetch ||
}); (() => {
throw new Error("fetch is not available in test environment");
});
// Add any global test utilities here // Add any global test utilities here

View File

@@ -7,25 +7,21 @@
"moduleDetection": "force", "moduleDetection": "force",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices // Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitAny": true, "noImplicitAny": true,
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false, "noPropertyAccessFromIndexSignature": false,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }