chore: biome lint

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

No files matched your search

+106 -103
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);
}); });
} }
+394 -338
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; }
} }
}
+1021 -938
View File
File diff suppressed because it is too large. Load diff
+189 -161
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}`);
+734 -672
View File
File diff suppressed because it is too large. Load diff
+827 -741
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+136 -132
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");
}); });
}); });
+302 -276
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,
}); });
});
});
});
+44 -44
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");
}); });
}); });
}); });
+6 -4
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
-4
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/*"]
} }