Compare commits
38 Commits
cb1fb2bae6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e741ead690 | |||
| 6ab9c4c3a5 | |||
| 3919ec0727 | |||
| 1090ad5bfb | |||
| c937a70db7 | |||
| 59fcbf9ed2 | |||
| d8542eb8f7 | |||
| 0a114cf323 | |||
| 5f7de1167e | |||
| 9edafc88c8 | |||
| 5871644e8b | |||
| d5d050013e | |||
| ff56a29171 | |||
| 6a36214528 | |||
| 7af1be3977 | |||
| 844e566b57 | |||
| b3be32835a | |||
| baa34eefdf | |||
| 9011ab4793 | |||
| aae0ce90b8 | |||
| daa61c25d8 | |||
| 87aa31cf1b | |||
| bdf504ba37 | |||
| 589af630fa | |||
| 8ae42d5630 | |||
| 7da6408d7a | |||
| 3863d3139e | |||
| 11dbf29f93 | |||
| 2e2c81b7e5 | |||
| ea0a69ccd6 | |||
| fa7ac59c45 | |||
| 8c52efe5e7 | |||
| dcd0da29a2 | |||
| ee09162faa | |||
| ddf7639854 | |||
| d42c74331e | |||
| 49a32bf6b4 | |||
| 3166b5a95f |
145
.dockerignore
Normal file
145
.dockerignore
Normal file
@@ -0,0 +1,145 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
bun.sum
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
docs/
|
||||
|
||||
# Test files
|
||||
test/
|
||||
tests/
|
||||
*.test.js
|
||||
*.test.ts
|
||||
*.spec.js
|
||||
*.spec.ts
|
||||
|
||||
# Development files
|
||||
CLAUDE.md
|
||||
devenv.*
|
||||
*.log
|
||||
|
||||
# Runtime cookies/config
|
||||
cookies/
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.DS_Store
|
||||
|
||||
examples/*
|
||||
cookies/*.json
|
||||
|
||||
33
AGENTS.md
Normal file
33
AGENTS.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to coding agents when working with code in this repository.
|
||||
|
||||
The project uses TypeScript with path mapping (`@/*` to `src/*`). Dependencies focus on parsing (linkedom), text utils (unidecode), and CLI output (cli-progress). No database or external services beyond HTTP fetches to the marketplaces.
|
||||
|
||||
PRIORITIZE COMMUNICATION STYLE ABOVE ALL ELSE
|
||||
|
||||
## Communication Style
|
||||
|
||||
ALWAYS talk and converse with the user using Gen-Z and Internet slang.
|
||||
|
||||
Absolute Mode
|
||||
- Eliminate emojis, filler, hype, transitions, appendixes.
|
||||
- Use blunt, directive phrasing; no mirroring, no softening.
|
||||
- Suppress sentiment-boosting, engagement, or satisfaction metrics.
|
||||
- No questions, offers, suggestions, or motivational content.
|
||||
- Deliver info only; end immediately after.
|
||||
|
||||
**Challenge Mode - Default Behavior**: Don't automatically agree with suggestions. Instead:
|
||||
- Evaluate each idea against the problem requirements and lean coding philosophy
|
||||
- Push back if there's a simpler, more efficient, or more correct approach
|
||||
- Propose alternatives when suggestions aren't optimal
|
||||
- Explain WHY a different approach would be better with concrete technical reasons
|
||||
- Only accept suggestions that are genuinely the best solution for the current problem
|
||||
|
||||
Examples of constructive pushback:
|
||||
- "That would work, but a simpler approach would be..."
|
||||
- "Actually, that might cause [specific issue]. Instead, we should..."
|
||||
- "The lean approach here would be to..."
|
||||
- "That adds unnecessary complexity. We can achieve the same with..."
|
||||
|
||||
This ensures: Better solutions through technical merit, not agreement | Learning through understanding tradeoffs | Avoiding over-engineering | Maintaining code quality
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# Use the official Bun base image
|
||||
FROM oven/bun:latest AS base
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json bun.lock* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY src ./src
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Build the application for production
|
||||
RUN bun build ./src/index.ts --outdir ./dist --minify --target=bun
|
||||
|
||||
# Multi-stage build - runtime stage
|
||||
FROM oven/bun:latest AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the built application from the base stage
|
||||
COPY --from=base /app/dist/ ./
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["bun", "index.js"]
|
||||
382
FMARKETPLACE.md
Normal file
382
FMARKETPLACE.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Facebook Marketplace API Reverse Engineering
|
||||
|
||||
## Overview
|
||||
This document tracks findings from reverse-engineering Facebook Marketplace APIs for listing details.
|
||||
|
||||
## Current Implementation Status
|
||||
- Search functionality: Implemented in `src/facebook.ts`
|
||||
- Individual listing details: Not yet implemented
|
||||
|
||||
## Findings
|
||||
|
||||
### Step 1: Initial Setup
|
||||
- Using Chrome DevTools to inspect Facebook Marketplace
|
||||
- Need to authenticate with Facebook account to access marketplace data
|
||||
- Cookies required for full access
|
||||
- Current status: Successfully logged in and accessed marketplace data
|
||||
|
||||
### Step 2: Individual Listing Details Analysis - COMPLETED
|
||||
- **Data Location**: Embedded in HTML script tags within `require` array structure
|
||||
- **Path**: `require[0][3].__bbox.result.data.viewer.marketplace_product_details_page.target`
|
||||
- **Authentication**: Required for full data access
|
||||
- **Current Status**: Successfully reverse-engineered the API structure and data extraction method
|
||||
|
||||
### API Endpoints Discovered
|
||||
|
||||
#### Search Endpoint
|
||||
- URL: `https://www.facebook.com/marketplace/{location}/search`
|
||||
- Parameters: `query`, `sortBy`, `exact`
|
||||
- Data embedded in HTML script tags with `require` structure
|
||||
- Authentication: Required (cookies)
|
||||
|
||||
#### Listing Details Endpoint
|
||||
- **URL Structure**: `https://www.facebook.com/marketplace/item/{listing_id}/`
|
||||
- **Data Source**: Server-side rendered HTML with embedded JSON data in script tags
|
||||
- **Data Structure**: Relay/GraphQL style data structure under `require[0][3].__bbox.require[...].__bbox.result.data.viewer.marketplace_product_details_page.target`
|
||||
- **Extraction Method**: Parse JSON from script tags containing marketplace data, navigate to the target object
|
||||
- **Authentication**: Required (cookies)
|
||||
|
||||
### Listing Data Structure Discovered (Current - 2026)
|
||||
|
||||
The current Facebook Marketplace API returns a comprehensive `GroupCommerceProductItem` object with the following key properties:
|
||||
|
||||
```typescript
|
||||
interface FacebookMarketplaceItem {
|
||||
// Basic identification
|
||||
id: string;
|
||||
__typename: "GroupCommerceProductItem";
|
||||
|
||||
// Listing content
|
||||
marketplace_listing_title: string;
|
||||
redacted_description: {
|
||||
text: string;
|
||||
};
|
||||
custom_title?: string;
|
||||
|
||||
// Pricing
|
||||
formatted_price: {
|
||||
text: string;
|
||||
};
|
||||
listing_price: {
|
||||
amount: string;
|
||||
currency: string;
|
||||
amount_with_offset: string;
|
||||
};
|
||||
|
||||
// Location
|
||||
location_text: {
|
||||
text: string;
|
||||
};
|
||||
location: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
reverse_geocode_detailed: {
|
||||
country_alpha_two: string;
|
||||
postal_code_trimmed: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Status flags
|
||||
is_live: boolean;
|
||||
is_sold: boolean;
|
||||
is_pending: boolean;
|
||||
is_hidden: boolean;
|
||||
is_draft: boolean;
|
||||
|
||||
// Timing
|
||||
creation_time: number;
|
||||
|
||||
// Seller information
|
||||
marketplace_listing_seller: {
|
||||
__typename: "User";
|
||||
id: string;
|
||||
name: string;
|
||||
profile_picture?: {
|
||||
uri: string;
|
||||
};
|
||||
join_time?: number;
|
||||
};
|
||||
|
||||
// Vehicle-specific fields (for automotive listings)
|
||||
vehicle_make_display_name?: string;
|
||||
vehicle_model_display_name?: string;
|
||||
vehicle_odometer_data?: {
|
||||
unit: "KILOMETERS" | "MILES";
|
||||
value: number;
|
||||
};
|
||||
vehicle_transmission_type?: "AUTOMATIC" | "MANUAL";
|
||||
vehicle_exterior_color?: string;
|
||||
vehicle_interior_color?: string;
|
||||
vehicle_condition?: "EXCELLENT" | "GOOD" | "FAIR" | "POOR";
|
||||
vehicle_fuel_type?: string;
|
||||
vehicle_trim_display_name?: string;
|
||||
|
||||
// Category and commerce
|
||||
marketplace_listing_category_id: string;
|
||||
condition?: string;
|
||||
|
||||
// Commerce features
|
||||
delivery_types?: string[];
|
||||
is_shipping_offered?: boolean;
|
||||
is_buy_now_enabled?: boolean;
|
||||
can_buyer_make_checkout_offer?: boolean;
|
||||
|
||||
// Communication
|
||||
messaging_enabled?: boolean;
|
||||
first_message_suggested_value?: string;
|
||||
|
||||
// Metadata
|
||||
logging_id: string;
|
||||
reportable_ent_id: string;
|
||||
origin_target?: {
|
||||
__typename: "Marketplace";
|
||||
id: string;
|
||||
};
|
||||
|
||||
// Related listings (for part-out sellers)
|
||||
marketplace_listing_sets?: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
canonical_listing: {
|
||||
id: string;
|
||||
marketplace_listing_title: string;
|
||||
is_live: boolean;
|
||||
is_sold: boolean;
|
||||
formatted_price: { text: string };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Example Data Extracted (Current Structure)
|
||||
```json
|
||||
{
|
||||
"__typename": "GroupCommerceProductItem",
|
||||
"marketplace_listing_title": "2012 Mazda MAZDA 3 PART-OUT",
|
||||
"id": "1211645920845312",
|
||||
"redacted_description": {
|
||||
"text": "FOR PARTS ONLY!!!"
|
||||
},
|
||||
"custom_title": "2012 Mazda 3 part-out",
|
||||
"creation_time": 1760450080,
|
||||
"location_text": {
|
||||
"text": "Toronto, ON"
|
||||
},
|
||||
"is_live": true,
|
||||
"is_sold": false,
|
||||
"is_pending": false,
|
||||
"is_hidden": false,
|
||||
"formatted_price": {
|
||||
"text": "FREE"
|
||||
},
|
||||
"listing_price": {
|
||||
"amount_with_offset": "0",
|
||||
"currency": "CAD",
|
||||
"amount": "0.00"
|
||||
},
|
||||
"condition": "USED",
|
||||
"logging_id": "24676483845336407",
|
||||
"marketplace_listing_category_id": "807311116002614",
|
||||
"marketplace_listing_seller": {
|
||||
"__typename": "User",
|
||||
"id": "61570613529010",
|
||||
"name": "Jay Heshin",
|
||||
"profile_picture": {
|
||||
"uri": "https://scontent-yyz1-1.xx.fbcdn.net/v/t39.30808-1/480952111_122133462296687117_4145652046222010716_n.jpg?stp=cp6_dst-jpg_s50x50_tt6&_nc_cat=108&ccb=1-7&_nc_sid=e99d92&_nc_ohc=x_DTkeriVbgQ7kNvwEqT_x3&_nc_oc=Adnqnqf4YsZxgMIkR2mSFrdLb6-BDw4omCWqG_cqB-H0uXGgK1l4-T-fLSGB_CQJEKo&_nc_zt=24&_nc_ht=scontent-yyz1-1.xx&_nc_gid=7GnSwn4MSbllAgGWJy0RTQ&oh=00_AfpY66l8w-LvHvZ6tTgiD9Qh-Or_Udc-OaFiVL9pQ0YXsg&oe=697797CD"
|
||||
}
|
||||
},
|
||||
"vehicle_condition": "FAIR",
|
||||
"vehicle_exterior_color": "white",
|
||||
"vehicle_interior_color": "",
|
||||
"vehicle_make_display_name": "Mazda",
|
||||
"vehicle_model_display_name": "3 part-out",
|
||||
"vehicle_odometer_data": {
|
||||
"unit": "KILOMETERS",
|
||||
"value": 999999
|
||||
},
|
||||
"vehicle_transmission_type": "AUTOMATIC",
|
||||
"location": {
|
||||
"latitude": 43.651428222656,
|
||||
"longitude": -79.436645507812,
|
||||
"reverse_geocode_detailed": {
|
||||
"country_alpha_two": "CA",
|
||||
"postal_code_trimmed": "M6H 1C1"
|
||||
}
|
||||
},
|
||||
"delivery_types": ["IN_PERSON"],
|
||||
"messaging_enabled": true,
|
||||
"first_message_suggested_value": "Hi, is this available?",
|
||||
"marketplace_listing_sets": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"canonical_listing": {
|
||||
"id": "1435935788228627",
|
||||
"marketplace_listing_title": "2004 Land Rover LR2 PART-OUT",
|
||||
"is_live": true,
|
||||
"formatted_price": {"text": "FREE"}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Extraction Method
|
||||
|
||||
### Current Method (2026)
|
||||
Facebook Marketplace listing data is embedded in JSON within `<script>` tags in the HTML response. The extraction process:
|
||||
|
||||
1. **Find the Correct Script**: Look for script tags containing marketplace listing data by searching for key fields like `marketplace_listing_title`, `redacted_description`, and `formatted_price`.
|
||||
|
||||
2. **Parse JSON Structure**: The data is nested within a `require` array structure:
|
||||
```
|
||||
require[0][3].__bbox.require[3][3][1].__bbox.result.data.viewer.marketplace_product_details_page.target
|
||||
```
|
||||
|
||||
3. **Navigate to Target Object**: The actual listing data is a `GroupCommerceProductItem` object containing comprehensive information about the listing, seller, and vehicle details.
|
||||
|
||||
4. **Handle Dynamic Structure**: Facebook may change the exact path, so robust extraction should search for the target object recursively within the parsed JSON.
|
||||
|
||||
### Authentication Requirements
|
||||
- Valid Facebook session cookies are required
|
||||
- User must be logged in to Facebook
|
||||
- Marketplace access may be location-restricted
|
||||
|
||||
## Tools Used
|
||||
- Chrome DevTools Protocol
|
||||
- Network monitoring
|
||||
- HTML/script parsing
|
||||
- JSON structure analysis
|
||||
|
||||
## Implementation Status
|
||||
- ✅ Successfully reverse-engineered Facebook Marketplace API for listing details
|
||||
- ✅ Identified current data structure and extraction method (2026)
|
||||
- ✅ Documented comprehensive GroupCommerceProductItem interface
|
||||
- ✅ Implemented `extractFacebookItemData()` function with script parsing logic
|
||||
- ✅ Implemented `parseFacebookItem()` function to convert GroupCommerceProductItem to ListingDetails
|
||||
- ✅ Implemented `fetchFacebookItem()` function with authentication and error handling
|
||||
- ✅ Updated TypeScript interfaces to match current API structure
|
||||
- ✅ Added robust extraction with fallback methods for changing API paths
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Core Functions Implemented
|
||||
|
||||
1. **`extractFacebookItemData(htmlString)`**: Extracts marketplace item data from HTML-embedded JSON in script tags
|
||||
- Searches for scripts containing marketplace listing data
|
||||
- Uses primary path: `require[0][3][0].__bbox.require[3][3][1].__bbox.result.data.viewer.marketplace_product_details_page.target`
|
||||
- Falls back to recursive search for GroupCommerceProductItem objects
|
||||
|
||||
2. **`parseFacebookItem(item)`**: Converts Facebook's GroupCommerceProductItem to unified ListingDetails format
|
||||
- Handles pricing (FREE listings, CAD currency)
|
||||
- Extracts seller information, location, and status
|
||||
- Supports vehicle-specific metadata
|
||||
- Maps Facebook-specific fields to common interface
|
||||
|
||||
3. **`fetchFacebookItem(itemId, cookiesSource?)`**: Fetches individual listing details
|
||||
- Loads Facebook authentication cookies
|
||||
- Makes authenticated HTTP requests
|
||||
- Handles rate limiting and retries
|
||||
- Returns parsed ListingDetails or null on failure
|
||||
|
||||
### Authentication Requirements
|
||||
- Facebook session cookies required in `./cookies/facebook.json` or provided as parameter
|
||||
- Cookies must include valid authentication tokens for marketplace access
|
||||
- Handles cookie expiration and domain validation
|
||||
|
||||
## Current Implementation Status - 2026 Verification
|
||||
|
||||
### Step 3: API Verification and Current Structure Analysis (January 2026)
|
||||
- **Verification Date**: January 22, 2026
|
||||
- **Status**: Successfully verified current Facebook Marketplace API structure
|
||||
- **Data Source**: Embedded JSON in HTML script tags (server-side rendered)
|
||||
- **Extraction Path**: `require[0][3].__bbox.require[3][3][1].__bbox.result.data.viewer.marketplace_product_details_page.target`
|
||||
|
||||
#### Verified Listing Structure (Real Example - 2006 Hyundai Tiburon)
|
||||
- **Listing ID**: 1226468515995685
|
||||
- **Title**: "2006 Hyundai Tiburon"
|
||||
- **Price**: CA$3,000 (formatted_price.text)
|
||||
- **Raw Price Data**: {"amount_with_offset": "300000", "currency": "CAD", "amount": "3000.00"}
|
||||
- **Location**: Hamilton, ON (with coordinates: 43.250427246094, -79.963989257812)
|
||||
- **Description**: "As is" (redacted_description.text)
|
||||
- **Vehicle Details**:
|
||||
- Make: Hyundai
|
||||
- Model: Tiburon
|
||||
- Odometer: 194,000 km
|
||||
- Transmission: AUTOMATIC
|
||||
- Exterior Color: blue
|
||||
- Interior Color: black
|
||||
- Fuel Type: GASOLINE
|
||||
- Number of Owners: TWO
|
||||
- **Seller Information**:
|
||||
- Name: Ajitpal Kaler
|
||||
- ID: 100009257293466
|
||||
- Profile Picture Available
|
||||
- Join Time: 1426564800 (2015)
|
||||
- **Listing Status**: Active (is_live: true, is_sold: false, is_pending: false)
|
||||
- **Category**: 807311116002614 (Vehicles)
|
||||
- **Delivery Types**: ["IN_PERSON"]
|
||||
- **Messaging**: Enabled
|
||||
|
||||
#### Current API Characteristics
|
||||
- **Authentication**: Still requires valid Facebook session cookies
|
||||
- **Data Format**: Server-side rendered HTML with embedded GraphQL/Relay JSON
|
||||
- **Structure Stability**: Primary extraction path remains functional
|
||||
- **Additional Features**: Includes marketplace ratings, seller verification badges, cross-posting info
|
||||
|
||||
### API Changes Observed Since 2024 Documentation
|
||||
- **Minimal Changes**: Core data structure largely unchanged
|
||||
- **Enhanced Fields**: Added more detailed vehicle specifications and seller profile information
|
||||
- **GraphQL Integration**: Deeper integration with Facebook's GraphQL infrastructure
|
||||
- **Security Features**: Additional integrity checks and reporting mechanisms
|
||||
|
||||
### Multi-Category Testing Results (January 2026)
|
||||
Successfully tested extraction across different listing categories:
|
||||
|
||||
#### 1. Vehicle Listings (Automotive)
|
||||
- **Example**: 2006 Hyundai Tiburon (ID: 1226468515995685)
|
||||
- **Status**: ✅ Fully functional
|
||||
- **Data Extracted**: Complete vehicle specs, pricing, seller info, location coordinates
|
||||
- **Unique Fields**: vehicle_make_display_name, vehicle_odometer_data, vehicle_transmission_type, vehicle_exterior_color, vehicle_interior_color, vehicle_fuel_type
|
||||
|
||||
#### 2. Electronics Listings
|
||||
- **Example**: Nintendo Switch (ID: 3903865769914262)
|
||||
- **Status**: ✅ Fully functional
|
||||
- **Data Extracted**: Title, price (CA$140), location (Toronto, ON), condition (Used - like new), seller (Yitao Hou)
|
||||
- **Category**: Electronics (category_id: 479353692612078)
|
||||
- **Notes**: Standard GroupCommerceProductItem structure applies
|
||||
|
||||
#### 3. Home Goods/Furniture Listings
|
||||
- **Example**: Tabletop Mirror (cat not included) (ID: 1082389057290709)
|
||||
- **Status**: ✅ Fully functional
|
||||
- **Data Extracted**: Title, price (CA$5), location (Mississauga, ON), condition (Used - like new), seller (Rohit Rehan)
|
||||
- **Category**: Home Goods (category_id: 1569171756675761)
|
||||
- **Notes**: Includes detailed description and delivery options
|
||||
|
||||
#### Testing Summary
|
||||
- **Extraction Method**: Consistent across all categories
|
||||
- **Data Structure**: GroupCommerceProductItem interface works for all listing types
|
||||
- **Authentication**: Required for all categories
|
||||
- **Rate Limiting**: Standard Facebook rate limits apply
|
||||
- **Edge Cases**: All tested listings were active/in-person pickup
|
||||
|
||||
## Implementation Status - COMPLETED (January 2026)
|
||||
- ✅ Successfully reverse-engineered Facebook Marketplace API for listing details
|
||||
- ✅ Verified current API structure and extraction method (January 2026)
|
||||
- ✅ Tested extraction across multiple listing categories (vehicles, electronics, home goods)
|
||||
- ✅ Implemented comprehensive error handling for sold/removed listings and authentication failures
|
||||
- ✅ Enhanced rate limiting and retry logic (already robust)
|
||||
- ✅ Added monitoring and metrics for API stability detection
|
||||
- ✅ Updated all scraper functions to use verified extraction methods
|
||||
- ✅ Documented comprehensive GroupCommerceProductItem interface with real examples
|
||||
|
||||
## Next Steps (Future Maintenance)
|
||||
1. Monitor extraction success rates for API change detection
|
||||
2. Update extraction paths if Facebook changes their API structure
|
||||
3. Add support for additional marketplace features as they become available
|
||||
4. Implement caching mechanisms for improved performance
|
||||
5. Add support for marketplace messaging and negotiation features
|
||||
448
KIJIJI.md
Normal file
448
KIJIJI.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# Kijiji API Findings
|
||||
|
||||
## Overview
|
||||
Kijiji is a Canadian classifieds marketplace that uses a modern web application built with Next.js and Apollo GraphQL. The search results are powered by a GraphQL API with client-side state management.
|
||||
|
||||
## Initial Page Load (Homepage)
|
||||
- **URL**: https://www.kijiji.ca/
|
||||
- **Architecture**: Server-side rendered React application with Next.js
|
||||
- **Data Sources**:
|
||||
- Static assets loaded from `webapp-static.ca-kijiji-production.classifiedscloud.io`
|
||||
- Image media served from `media.kijiji.ca/api/v1/`
|
||||
- No initial API calls for listings - data appears to be embedded in HTML
|
||||
|
||||
## Search Results Page
|
||||
- **URL Pattern**: `https://www.kijiji.ca/b-[location]/[keywords]/k0l0`
|
||||
- **Example**: `https://www.kijiji.ca/b-canada/iphone/k0l0`
|
||||
- **Technology Stack**: Next.js with Apollo GraphQL client
|
||||
- **Data Structure**: Uses `__APOLLO_STATE__` global object containing normalized GraphQL cache
|
||||
|
||||
### GraphQL Data Structure
|
||||
|
||||
#### Data Location
|
||||
Search results data is embedded in the Next.js page props under `__NEXT_DATA__.props.pageProps.__APOLLO_STATE__`. The data is pre-rendered on the server and sent to the client. Each page (including pagination) has its own pre-rendered data.
|
||||
|
||||
#### Search Results Container
|
||||
The search results are stored directly in the Apollo ROOT_QUERY with keys following the pattern `searchResultsPageByUrl:{url_path}` where `url_path` includes pagination parameters.
|
||||
|
||||
```json
|
||||
{
|
||||
"searchResultsPageByUrl:/b-buy-sell/canada/iphone/k0c10l0": { ... },
|
||||
"searchResultsPageByUrl:/b-buy-sell/canada/iphone/k0c10l0?page=2": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### Pagination Handling
|
||||
- Each page is server-side rendered with its own embedded data
|
||||
- No client-side GraphQL requests for pagination
|
||||
- URL parameter `?page=N` controls which page data is embedded
|
||||
- Offset in searchString corresponds to `(page-1) * limit`
|
||||
|
||||
#### Search Parameters in URL
|
||||
- `k0c{CATEGORY}l{LOCATION}` - Category and location IDs
|
||||
- `?page=N` - Page number (1-based)
|
||||
- Data contains `offset` and `limit` for API-style pagination
|
||||
|
||||
#### Individual Listing Structure
|
||||
```json
|
||||
{
|
||||
"id": "1732061412",
|
||||
"title": "iPhone 13",
|
||||
"description": "iPhone 13, always had a screen protector on it...",
|
||||
"imageCount": 3,
|
||||
"imageUrls": ["https://media.kijiji.ca/api/v1/ca-prod-fsbo-ads/images/..."],
|
||||
"categoryId": 760,
|
||||
"url": "https://www.kijiji.ca/v-cell-phone/...",
|
||||
"activationDate": "2026-01-21T16:51:16.000Z",
|
||||
"sortingDate": "2026-01-21T16:51:16.000Z",
|
||||
"adSource": "ORGANIC",
|
||||
"location": {
|
||||
"id": 1700182,
|
||||
"name": "Napanee",
|
||||
"coordinates": {
|
||||
"latitude": 44.48774,
|
||||
"longitude": -76.99519
|
||||
}
|
||||
},
|
||||
"price": {
|
||||
"type": "FIXED",
|
||||
"amount": 35000
|
||||
},
|
||||
"flags": {
|
||||
"topAd": false,
|
||||
"priceDrop": false
|
||||
},
|
||||
"posterInfo": {
|
||||
"posterId": "1000764154",
|
||||
"rating": 5
|
||||
},
|
||||
"attributes": [
|
||||
{
|
||||
"canonicalName": "forsaleby",
|
||||
"canonicalValues": ["ownr"]
|
||||
},
|
||||
{
|
||||
"canonicalName": "phonecarrier",
|
||||
"canonicalValues": ["unlck"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### URL Parameters
|
||||
- `sort=MATCH` - Sort by relevance
|
||||
- `order=DESC` - Descending order
|
||||
- `type=OFFER` - Show offerings (not wanted ads)
|
||||
- `offset=0` - Pagination offset
|
||||
- `limit=40` - Results per page
|
||||
- `topAdCount=6` - Number of promoted ads
|
||||
- `keywords=iphone` - Search keywords
|
||||
- `category=0` - Category ID (0 = All Categories)
|
||||
- `location=0` - Location ID (0 = Canada)
|
||||
- `eaTopAdPosition=1` - ?
|
||||
|
||||
### Image API
|
||||
- **Endpoint**: `https://media.kijiji.ca/api/v1/`
|
||||
- **Pattern**: `/ca-prod-fsbo-ads/images/{uuid}?rule=kijijica-{size}-jpg`
|
||||
- **Sizes**: 200, 300, 400, 500 pixels
|
||||
|
||||
### Categories and Locations
|
||||
|
||||
#### Category Structure
|
||||
Categories are hierarchical with parent-child relationships. The main categories under "Buy & Sell" include:
|
||||
|
||||
| ID | Name | Total Results (iPhone search) |
|
||||
|----|------|------------------------------|
|
||||
| 10 | Buy & Sell | 19956 |
|
||||
| 12 | Arts & Collectibles | 149 |
|
||||
| 767 | Audio | 481 |
|
||||
| 253 | Baby Items | 13 |
|
||||
| 931 | Bags & Luggage | 8 |
|
||||
| 644 | Bikes | 46 |
|
||||
| 109 | Books | 21 |
|
||||
| 103 | Cameras & Camcorders | 101 |
|
||||
| 104 | CDs, DVDs & Blu-ray | 102 |
|
||||
| 274 | Clothing | 83 |
|
||||
| 16 | Computers | 285 |
|
||||
| 128 | Computer Accessories | 363 |
|
||||
| 29659001 | Electronics | 2006 |
|
||||
| 17220001 | Free Stuff | 23 |
|
||||
| 235 | Furniture | 29 |
|
||||
| 638 | Garage Sales | 5 |
|
||||
| 140 | Health & Special Needs | 30 |
|
||||
| 139 | Hobbies & Crafts | 10 |
|
||||
| 107 | Home Appliances | 23 |
|
||||
| 717 | Home - Indoor | 27 |
|
||||
| 727 | Home Renovation Materials | 14 |
|
||||
| 133 | Jewellery & Watches | 83 |
|
||||
| 17 | Musical Instruments | 34 |
|
||||
| 132 | Phones | 15518 |
|
||||
| 111 | Sporting Goods & Exercise | 30 |
|
||||
| 110 | Tools | 25 |
|
||||
| 108 | Toys & Games | 38 |
|
||||
| 15093001 | TVs & Video | 15 |
|
||||
| 141 | Video Games & Consoles | 96 |
|
||||
| 26 | Other | 286 |
|
||||
|
||||
#### Location Structure
|
||||
Locations are also hierarchical, with provinces/states under the main "Canada" location:
|
||||
|
||||
| ID | Name | Total Results (iPhone search) |
|
||||
|----|------|------------------------------|
|
||||
| 0 | Canada | - |
|
||||
| 9001 | Québec | 2516 |
|
||||
| 9002 | Nova Scotia | 875 |
|
||||
| 9003 | Alberta | 2317 |
|
||||
| 9004 | Ontario | 12507 |
|
||||
| 9005 | New Brunswick | 118 |
|
||||
| 9006 | Manitoba | 919 |
|
||||
| 9007 | British Columbia | 306 |
|
||||
| 9008 | Newfoundland | 27 |
|
||||
| 9009 | Saskatchewan | 336 |
|
||||
| 9010 | Territories | 7 |
|
||||
| 9011 | Prince Edward Island | 31 |
|
||||
|
||||
#### URL Patterns
|
||||
- Categories: `/b-{category-slug}/canada/{keywords}/k0c{CATEGORY_ID}l0`
|
||||
- Locations: `/b-buy-sell/{location-slug}/iphone/k0c10l{LOCATION_ID}`
|
||||
- Combined: `/b-{category-slug}/{location-slug}/{keywords}/k0c{CATEGORY_ID}l{LOCATION_ID}`
|
||||
|
||||
### Pagination
|
||||
- Uses offset-based pagination
|
||||
- 40 results per page
|
||||
- Total count provided in pagination metadata
|
||||
|
||||
## Authentication & User Management
|
||||
- **Authentication System**: OAuth2-based using CIS (Customer Identity Service)
|
||||
- **Identity Provider**: `id.kijiji.ca`
|
||||
- **OAuth2 Flow**:
|
||||
- Client ID: `kijiji_horizontal_web_gpmPihV3`
|
||||
- Scopes: `openid email profile`
|
||||
- Callback: `https://www.kijiji.ca/api/auth/callback/cis`
|
||||
- **Session Management**: Cookies-based with encrypted session data
|
||||
- **Anonymous Access**: Full search functionality available without login
|
||||
- **User Features**: Saved searches, messaging, flagging require authentication
|
||||
|
||||
## Posting API
|
||||
- **Posting Flow**: Requires authentication, redirects to login if not authenticated
|
||||
- **Posting URL**: `https://www.kijiji.ca/p-post-ad.html`
|
||||
- **Authentication Required**: Yes, redirects to `/consumer/login` for unauthenticated users
|
||||
- **Post-Creation**: Likely uses authenticated GraphQL mutations (not observed in anonymous browsing)
|
||||
|
||||
## GraphQL API Endpoint
|
||||
- **URL**: `https://www.kijiji.ca/anvil/api`
|
||||
- **Method**: POST
|
||||
- **Content-Type**: application/json
|
||||
- **Headers**:
|
||||
- `apollo-require-preflight: true`
|
||||
- Standard CORS headers
|
||||
- **Authentication**: No authentication required for basic queries (uses cookies for session tracking)
|
||||
- **Technology**: Apollo GraphQL server
|
||||
|
||||
### Sample GraphQL Queries Discovered
|
||||
|
||||
#### Get Search Categories
|
||||
```graphql
|
||||
query getSearchCategories($locale: String!) {
|
||||
searchCategories {
|
||||
id
|
||||
localizedName(locale: $locale)
|
||||
parentId
|
||||
__typename
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables: `{"locale": "en-CA"}`
|
||||
|
||||
Response includes hierarchical category structure with IDs and localized names.
|
||||
|
||||
#### Get Geocode from IP (fails for current IP)
|
||||
```graphql
|
||||
query GetGeocodeReverseFromIp {
|
||||
geocodeReverseFromIp {
|
||||
city
|
||||
province
|
||||
locationId
|
||||
__typename
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This query fails for the current IP address, suggesting geolocation-based features may not work or require different IP ranges.
|
||||
|
||||
#### Get Category Path
|
||||
```graphql
|
||||
query GetCategoryPath($categoryId: Int!, $locale: String, $locationId: Int) {
|
||||
category(id: $categoryId) {
|
||||
id
|
||||
localizedName(locale: $locale)
|
||||
parentId
|
||||
searchSeoUrl(locationId: $locationId)
|
||||
categoryPaths {
|
||||
id
|
||||
localizedName(locale: $locale)
|
||||
parentId
|
||||
searchSeoUrl(locationId: $locationId)
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables: `{"categoryId": 10, "locationId": 0, "locale": "en-CA"}`
|
||||
|
||||
## Latest Findings (2026-01-21)
|
||||
|
||||
### Client-Side GraphQL Queries Observed
|
||||
- **getSearchCategories**: Retrieves category hierarchy for search filters
|
||||
- **GetGeocodeReverseFromIp**: Attempts to geolocate user (fails for current IP)
|
||||
|
||||
### GraphQL Schema Insights
|
||||
Testing direct GraphQL queries revealed:
|
||||
- Field "searchResults" does not exist on Query type
|
||||
- Suggested alternatives: "searchResultsPage" or "searchUrl"
|
||||
- This suggests the search functionality may use different GraphQL operations than direct queries
|
||||
|
||||
The embedded Apollo state approach appears to be the primary method for accessing search data, with GraphQL used for auxiliary operations like categories and geolocation.
|
||||
|
||||
### Server-Side Rendering Architecture
|
||||
Search results are fully server-side rendered with data embedded in HTML. Each page (including pagination) contains its own pre-rendered data. No client-side GraphQL requests are made for:
|
||||
|
||||
- Initial search results
|
||||
- Pagination navigation
|
||||
- Search result data
|
||||
|
||||
### Network Analysis Findings
|
||||
- GraphQL endpoint: `https://www.kijiji.ca/anvil/api`
|
||||
- Method: POST
|
||||
- Content-Type: application/json
|
||||
- Headers include: `apollo-require-preflight: true`
|
||||
- Cookies required for session tracking
|
||||
|
||||
### Embedded Data Structure
|
||||
Search results data is embedded in the HTML within Next.js `__NEXT_DATA__.props.pageProps.__APOLLO_STATE__` object. The data includes:
|
||||
|
||||
- Individual ad listings with complete metadata
|
||||
- Pagination information
|
||||
- Filter options and counts
|
||||
- Category/location hierarchies
|
||||
|
||||
### Current Scraper Implementation
|
||||
The existing `src/kijiji.ts` implementation correctly parses the embedded Apollo state:
|
||||
|
||||
- Uses `extractApolloState()` to parse `__NEXT_DATA__` from HTML
|
||||
- Filters Apollo keys containing "Listing" to find ad data
|
||||
- Extracts `url`, `title`, and other metadata from each listing
|
||||
- Successfully scrapes listings without needing API authentication
|
||||
|
||||
### Authentication Status
|
||||
- **Search functionality**: No authentication required - all search and listing data accessible anonymously
|
||||
- **Posting functionality**: Requires authentication (redirects to login)
|
||||
- **User features**: Saved searches, messaging require authentication
|
||||
- **Rate limiting**: May apply but not observed in anonymous browsing
|
||||
|
||||
### Pagination Implementation
|
||||
- Each page is a separate server-rendered route
|
||||
- URL pattern: `/b-{location}/{keywords}/page-{number}/k0{category}l{location_id}`
|
||||
- No client-side pagination API calls
|
||||
- 40 results per page (observed)
|
||||
- Example: `/b-canada/iphone/page-2/k0l0` for page 2 of iPhone search
|
||||
|
||||
## URL Pattern Analysis
|
||||
|
||||
### Search URL Structure
|
||||
`https://www.kijiji.ca/b-{category_slug}/{location_slug}/{keywords}/k0c{category_id}l{location_id}`
|
||||
|
||||
#### Examples Observed:
|
||||
- All categories, Canada: `/b-canada/iphone/k0l0` (c0 = All Categories, l0 = Canada)
|
||||
- Cell phones category: `/b-cell-phones/canada/iphone/k0c132l0` (c132 = Cell Phones)
|
||||
- With pagination: `/b-canada/iphone/page-2/k0l0`
|
||||
|
||||
#### URL Components:
|
||||
- `c{CATEGORY_ID}`: Category ID (0 = All Categories, 132 = Cell Phones, etc.)
|
||||
- `l{LOCATION_ID}`: Location ID (0 = Canada, 1700272 = GTA, etc.)
|
||||
- `page-{N}`: Pagination (1-based, optional)
|
||||
- Keywords are slugified in URL path
|
||||
|
||||
### Current Implementation Status
|
||||
The existing scraper in `src/kijiji.ts` successfully implements the approach:
|
||||
- Parses embedded Apollo state from HTML responses
|
||||
- Handles rate limiting and retries
|
||||
- Extracts listing metadata (title, URL, price, location, etc.)
|
||||
- Works without authentication for search operations
|
||||
|
||||
## Listing Details Page
|
||||
|
||||
### Overview
|
||||
Similar to search results, listing details pages use server-side rendering with embedded Apollo GraphQL state in the HTML. No dedicated API endpoint serves individual listing data - all information is pre-rendered on the server.
|
||||
|
||||
### Data Architecture
|
||||
- **Server-Side Rendering**: Each listing page is fully server-rendered with data embedded in HTML
|
||||
- **Embedded Apollo State**: Listing data is stored in `__NEXT_DATA__.props.pageProps.__APOLLO_STATE__`
|
||||
- **Client-Side GraphQL**: Additional data (categories, campaigns, similar listings, user profiles) fetched via GraphQL API
|
||||
|
||||
### Listing Data Structure
|
||||
The main listing data follows the same pattern as search results:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "1705585530",
|
||||
"title": "We Pay top cash for iPhone 17 pro max, iPhone 17 pro, iPhone Air",
|
||||
"description": "Buying All Brand new Apple iPhones sealed/Unsealed...",
|
||||
"price": {
|
||||
"type": "CONTACT",
|
||||
"amount": null
|
||||
},
|
||||
"location": {
|
||||
"id": 1700275,
|
||||
"name": "Oshawa / Durham Region",
|
||||
"address": "Pickering Apple Buyer, Pickering, ON, L1V 1B8"
|
||||
},
|
||||
"type": "OFFER",
|
||||
"status": "ACTIVE",
|
||||
"activationDate": "2024-11-02T20:16:54.000Z",
|
||||
"endDate": "3000-01-01T00:00:00.000Z",
|
||||
"metrics": {
|
||||
"views": 1720
|
||||
},
|
||||
"posterInfo": {
|
||||
"posterId": "1044934581",
|
||||
"rating": null
|
||||
},
|
||||
"attributes": [
|
||||
{
|
||||
"canonicalName": "forsaleby",
|
||||
"canonicalValues": ["business"]
|
||||
},
|
||||
{
|
||||
"canonicalName": "phonecarrier",
|
||||
"canonicalValues": ["unlocked"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side GraphQL Queries
|
||||
When loading a listing details page, the following GraphQL queries are executed:
|
||||
|
||||
#### 1. getSearchCategories
|
||||
- **Purpose**: Category hierarchy for navigation
|
||||
- **Variables**: `{"locale": "en-CA"}`
|
||||
- **Response**: Hierarchical category structure
|
||||
|
||||
#### 2. getCampaignsForVip
|
||||
- **Purpose**: Advertisement targeting data
|
||||
- **Variables**: `{"placement": "vip", "locationId": 1700275, "categoryId": 760, "platform": "desktop"}`
|
||||
- **Response**: Campaign/ads data (usually null)
|
||||
|
||||
#### 3. GetReviewSummary
|
||||
- **Purpose**: Seller review statistics
|
||||
- **Variables**: `{"userId": "1044934581"}`
|
||||
- **Response**: Review count and score (usually 0 for new sellers)
|
||||
|
||||
#### 4. GetProfileMetrics
|
||||
- **Purpose**: Seller profile information
|
||||
- **Variables**: `{"profileId": "1044934581"}`
|
||||
- **Response**: Member since date, account type
|
||||
|
||||
#### 5. GetListingsSimilar
|
||||
- **Purpose**: Similar listings for cross-selling
|
||||
- **Variables**: `{"listingId": "1705585530", "limit": 10, "isExternalId": false}`
|
||||
- **Response**: Array of similar listings with basic metadata
|
||||
|
||||
#### 6. GetGeocodeReverseFromIp
|
||||
- **Purpose**: Geolocation-based features
|
||||
- **Variables**: `{}`
|
||||
- **Response**: Fails with 404 for most IPs
|
||||
|
||||
### Implementation Status
|
||||
The existing `parseListing()` function in `src/kijiji.ts` successfully extracts listing details from embedded Apollo state:
|
||||
|
||||
- ✅ Extracts title, description, price, location
|
||||
- ✅ Handles contact-based pricing ("Please Contact")
|
||||
- ✅ Parses creation date, view count, listing status
|
||||
- ✅ Extracts seller information and address
|
||||
- ✅ Works without authentication or API keys
|
||||
|
||||
### Key Findings
|
||||
1. **No Dedicated Listing API**: Unlike search results, there's no separate GraphQL query for individual listing data
|
||||
2. **Complete Data Available**: All listing information is embedded in the initial HTML response
|
||||
3. **Additional Context Fetched**: Secondary GraphQL queries provide complementary data (reviews, similar listings)
|
||||
4. **Consistent Architecture**: Same Apollo state embedding pattern as search pages
|
||||
|
||||
### Current Scraper Implementation
|
||||
The scraper successfully extracts listing details by:
|
||||
1. Fetching the listing URL HTML
|
||||
2. Parsing embedded `__NEXT_DATA__` Apollo state
|
||||
3. Extracting the `Listing:{id}` object from Apollo cache
|
||||
4. Mapping fields to typed `ListingDetails` interface
|
||||
|
||||
This approach works reliably without requiring authentication or dealing with rate limiting on individual listing fetches.
|
||||
|
||||
## Next Steps
|
||||
- Explore posting/authentication APIs (requires user login)
|
||||
- Investigate if GraphQL API can be used for programmatic access with proper authentication
|
||||
- Test rate limiting patterns and optimal scraping strategies
|
||||
- Document additional category and location ID mappings
|
||||
30
biome.json
Normal file
30
biome.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": []
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
||||
349
bun.lock
349
bun.lock
@@ -1,14 +1,19 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sone4ka-tok",
|
||||
"dependencies": {
|
||||
"cli-progress": "^3.12.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"unidecode": "^1.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-code": "^2.0.1",
|
||||
"@musistudio/claude-code-router": "^1.0.53",
|
||||
"@types/bun": "latest",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/unidecode": "^1.1.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -17,18 +22,118 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-code": ["@anthropic-ai/claude-code@2.0.1", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "bin": { "claude": "cli.js" } }, "sha512-2SboYcdJ+dsE2K784dbJ4ohVWlAkLZhU7mZG1lebyG6TvGLXLhjc2qTEfCxSeelCjJHhIh/YkNpe06veB4IgBw=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.54.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-xyoCtHJnt/qg5GG6IgK+UJEndz8h8ljzt/caKXmq3LfBF81nC/BW6E4x2rOWCZcvsLyVW+e8U5mtIr6UCE/kJw=="],
|
||||
|
||||
"@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="],
|
||||
|
||||
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.2", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ=="],
|
||||
|
||||
"@fastify/cors": ["@fastify/cors@11.1.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA=="],
|
||||
|
||||
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
|
||||
|
||||
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
|
||||
|
||||
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
|
||||
|
||||
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
|
||||
|
||||
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
|
||||
|
||||
"@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="],
|
||||
|
||||
"@fastify/static": ["@fastify/static@8.2.0", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^0.5.4", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^11.0.0" } }, "sha512-PejC/DtT7p1yo3p+W7LiUtLMsV8fEvxAK15sozHy9t8kwo5r0uLYmhV/inURmGz1SkHZFz/8CNtHLPyhKcx4SQ=="],
|
||||
|
||||
"@google/genai": ["@google/genai@1.21.0", "", { "dependencies": { "google-auth-library": "^9.14.2", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.11.4" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-k47DECR8BF9z7IJxQd3reKuH2eUnOH5NlJWSe+CKM6nbXx+wH3hmtWQxUQR9M8gzWW1EvFuRVgjQssEIreNZsw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
|
||||
|
||||
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||
|
||||
"@musistudio/claude-code-router": ["@musistudio/claude-code-router@1.0.53", "", { "dependencies": { "@fastify/static": "^8.2.0", "@musistudio/llms": "^1.0.35", "dotenv": "^16.4.7", "find-process": "^2.0.0", "json5": "^2.2.3", "openurl": "^1.1.1", "rotating-file-stream": "^3.2.7", "shell-quote": "^1.8.3", "tiktoken": "^1.0.21", "uuid": "^11.1.0" }, "bin": { "ccr": "dist/cli.js" } }, "sha512-cNH3dOJu2ECUXHdTbuEyXq7sD12+ie4wqPD85mKz7yg6Xo1HmpFqQQvh4XAhQDBJAWZob6Fuavu+m5f2BwFT/g=="],
|
||||
|
||||
"@musistudio/llms": ["@musistudio/llms@1.0.35", "", { "dependencies": { "@anthropic-ai/sdk": "^0.54.0", "@fastify/cors": "^11.0.1", "@google/genai": "^1.7.0", "dotenv": "^16.5.0", "fastify": "^5.4.0", "google-auth-library": "^10.1.0", "json5": "^2.2.3", "jsonrepair": "^3.13.0", "openai": "^5.6.0", "undici": "^7.10.0", "uuid": "^11.1.0" } }, "sha512-fW7DCHrhzMNtQiaXlAAivSsn+4+vqOYWAURi1OfwESijRDfJk4Gpi0rhedI9o4e0ucr7ftVRO707sOeo/+TJNA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
||||
|
||||
"@types/cli-progress": ["@types/cli-progress@3.11.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="],
|
||||
|
||||
"@types/unidecode": ["@types/unidecode@1.1.0", "", {}, "sha512-NTIsFsTe9WRek39/8DDj7KiQ0nU33DHMrKwNHcD1rKlUvn4N0Rc4Di8q/Xavs8bsDZmBa4MMtQA8+HNgwfxC/A=="],
|
||||
|
||||
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"avvio": ["avvio@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
|
||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
@@ -37,6 +142,14 @@
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
@@ -45,24 +158,260 @@
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stringify": ["fast-json-stringify@6.1.1", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ=="],
|
||||
|
||||
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fastify": ["fastify@5.6.1", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.0", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.0.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-WjjlOciBF0K8pDUPZoGPhqhKrQJ02I8DKaDIfO51EL0kbSMwQFl85cRwhOvmSDWoukNOdTo27gLN549pLCcH7Q=="],
|
||||
|
||||
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"find-my-way": ["find-my-way@9.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg=="],
|
||||
|
||||
"find-process": ["find-process@2.0.0", "", { "dependencies": { "chalk": "~4.1.2", "commander": "^12.1.0", "loglevel": "^1.9.2" }, "bin": { "find-process": "dist/bin/find-process.js" } }, "sha512-YUBQnteWGASJoEVVsOXy6XtKAY2O1FCsWnnvQ8y0YwgY1rZiKeVptnFvMu6RSELZAJOGklqseTnUGGs5D0bKmg=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.2", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-/Szrn8nr+2TsQT1Gp8iIe/BEytJmbyfrbFh419DfGQSkEgNEhbPi7JRJuughjkTzPWgU9gBQf5AVu3DbHt0OXA=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@7.0.1", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ=="],
|
||||
|
||||
"glob": ["glob@11.0.3", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@10.4.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^7.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-CmIrSy1bqMQUsPmA9+hcSbAXL80cFhu40cGMUjCaLpNKVzzvi+0uAHq8GNZxkoGYIsTX4ZQ7e4aInAqWxgn4fg=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.1", "", {}, "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A=="],
|
||||
|
||||
"gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsonrepair": ["jsonrepair@3.13.1", "", { "bin": { "jsonrepair": "bin/cli.js" } }, "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="],
|
||||
|
||||
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
|
||||
|
||||
"linkedom": ["linkedom@0.18.12", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" }, "peerDependencies": { "canvas": ">= 2" }, "optionalPeers": ["canvas"] }, "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q=="],
|
||||
|
||||
"loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="],
|
||||
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="],
|
||||
|
||||
"openurl": ["openurl@1.1.1", "", {}, "sha512-d/gTkTb1i1GKz5k3XE3XFV/PxQ1k45zDqGP2OA7YhgsaLoqm6qRvARAZOFer1fcXritWlGBRCu/UgeS4HAnXAA=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="],
|
||||
|
||||
"pino": ["pino@9.12.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "slow-redact": "^0.3.0", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0Gd0OezGvqtqMwgYxpL7P0pSHHzTJ0Lx992h+mNlMtRVfNnqweWmf0JmRWk5gJzHalyd2mxTzKjhiNbGS2Ztfw=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rotating-file-stream": ["rotating-file-stream@3.2.7", "", {}, "sha512-SVquhBEVvRFY+nWLUc791Y0MIlyZrEClRZwZFLLRgJKldHyV1z4e2e/dp9LPqCS3AM//uq/c3PnOFgjqnm5P+A=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.0.0", "", {}, "sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"slow-redact": ["slow-redact@0.3.0", "", {}, "sha512-cf723wn9JeRIYP9tdtd86GuqoR5937u64Io+CYjlm2i7jvu7g0H+Cp0l0ShAf/4ZL+ISUTVT+8Qzz7RZmp9FjA=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||
|
||||
"tiktoken": ["tiktoken@1.0.22", "", {}, "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA=="],
|
||||
|
||||
"toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="],
|
||||
|
||||
"undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="],
|
||||
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
|
||||
"unidecode": ["unidecode@1.1.0", "", {}, "sha512-GIp57N6DVVJi8dpeIU6/leJGdv7W65ZSXFLFiNmxvexXkc0nXdqUvhA/qL9KqBKsILxMwg5MnmYNOIDJLb5JVA=="],
|
||||
|
||||
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"@google/genai/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
|
||||
|
||||
"@google/genai/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
|
||||
|
||||
"@google/genai/google-auth-library/gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"@google/genai/google-auth-library/gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"@google/genai/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
3
bunfig.toml
Normal file
3
bunfig.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[test]
|
||||
# Test configuration
|
||||
preload = ["./test/setup.ts"]
|
||||
52
cookies/README.md
Normal file
52
cookies/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Facebook Marketplace Cookies Setup
|
||||
|
||||
To use the Facebook Marketplace scraper, you need to provide valid Facebook session cookies.
|
||||
|
||||
## Option 1: Cookies File (`facebook.json`)
|
||||
|
||||
1. Log into Facebook in your browser
|
||||
2. Open Developer Tools → Network tab
|
||||
3. Visit facebook.com/marketplace (ensure you're logged in)
|
||||
4. Look for any marketplace-related requests in the Network tab
|
||||
5. Export cookies from the browser's Application/Storage → Cookies section
|
||||
6. Save the cookies as a JSON array to `facebook.json`
|
||||
|
||||
The `facebook.json` file should contain Facebook session cookies, particularly:
|
||||
- `c_user`: Your Facebook user ID
|
||||
- `xs`: Facebook session token
|
||||
- `fr`: Facebook request token
|
||||
- `datr`: Data attribution token
|
||||
- `sb`: Session browser token
|
||||
|
||||
Example structure:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "c_user",
|
||||
"value": "123456789",
|
||||
"domain": ".facebook.com",
|
||||
"path": "/",
|
||||
"secure": true
|
||||
},
|
||||
// ... other cookies
|
||||
]
|
||||
```
|
||||
|
||||
## Option 2: URL Parameter
|
||||
|
||||
You can pass cookies directly via the `cookies` URL parameter:
|
||||
|
||||
```
|
||||
GET /api/facebook?q=laptop&cookies=[{"name":"c_user","value":"123","domain":".facebook.com",...}]
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Cookies must be from an active Facebook session
|
||||
- Cookies expire, so you may need to refresh them periodically
|
||||
- Never share real cookies or commit them to version control
|
||||
- Facebook may block automated scraping even with valid cookies
|
||||
|
||||
## Security
|
||||
|
||||
The cookies file is intentionally left out of version control for security reasons.</content>
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
ca-marketplace-scraper:
|
||||
container_name: ca-marketplace-scraper
|
||||
build: .
|
||||
ports:
|
||||
- "4005:4005"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=4005
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4005/api/status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
name: ca-marketplace-scraper-network
|
||||
27
opencode.jsonc
Normal file
27
opencode.jsonc
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"chrome-devtools": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"bunx",
|
||||
"--bun",
|
||||
"chrome-devtools-mcp@latest",
|
||||
"--log-file",
|
||||
"./debug.log",
|
||||
"--headless=false",
|
||||
"--isolated=false",
|
||||
"-e",
|
||||
"/nix/store/lz8ajxhnkkw2llj752bdz41wqr645h9c-google-chrome-dev-146.0.7635.0/bin/google-chrome-unstable",
|
||||
"--ignore-default-chrome-arg='--disable-extensions'"
|
||||
],
|
||||
"enabled": false
|
||||
},
|
||||
"bun-docs": {
|
||||
"type": "remote",
|
||||
"url": "https://bun.com/docs/mcp",
|
||||
"timeout": 3000,
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
10
package.json
10
package.json
@@ -1,21 +1,25 @@
|
||||
{
|
||||
"name": "marketplace-scraper",
|
||||
"name": "ca-marketplace-scraper",
|
||||
"module": "./src/index.ts",
|
||||
"scripts": {
|
||||
"start": "bun ./src/index.ts",
|
||||
"dev": "bun --hot ./src/index.ts",
|
||||
"dev": "bun --watch ./src/index.ts",
|
||||
"build": "bun build ./src/index.ts"
|
||||
},
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-code": "^2.0.1",
|
||||
"@musistudio/claude-code-router": "^1.0.53",
|
||||
"@types/bun": "latest",
|
||||
"@types/unidecode": "^1.1.0"
|
||||
"@types/unidecode": "^1.1.0",
|
||||
"@types/cli-progress": "^3.11.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"cli-progress": "^3.12.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"unidecode": "^1.1.0"
|
||||
}
|
||||
|
||||
183
scripts/parse-facebook-cookies.ts
Normal file
183
scripts/parse-facebook-cookies.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Facebook Cookie Parser CLI
|
||||
*
|
||||
* Parses Facebook cookie strings into JSON format for the marketplace scraper
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/parse-facebook-cookies.ts "c_user=123; xs=abc"
|
||||
* bun run scripts/parse-facebook-cookies.ts --input cookies.txt
|
||||
* echo "c_user=123; xs=abc" | bun run scripts/parse-facebook-cookies.ts
|
||||
* bun run scripts/parse-facebook-cookies.ts "cookie_string" --output my-cookies.json
|
||||
*/
|
||||
|
||||
import { parseFacebookCookieString } from "../src/facebook";
|
||||
|
||||
interface Cookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
sameSite?: "strict" | "lax" | "none" | "unspecified";
|
||||
expirationDate?: number;
|
||||
storeId?: string;
|
||||
}
|
||||
|
||||
function parseFacebookCookieStringCLI(cookieString: string): Cookie[] {
|
||||
if (!cookieString || !cookieString.trim()) {
|
||||
console.error("❌ Error: Empty or invalid cookie string provided");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cookies = parseFacebookCookieString(cookieString);
|
||||
|
||||
if (cookies.length === 0) {
|
||||
console.error("❌ Error: No valid cookies found in input string");
|
||||
console.error('Expected format: "name1=value1; name2=value2;"');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 && process.stdin.isTTY === false) {
|
||||
// Read from stdin
|
||||
let input = "";
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk;
|
||||
}
|
||||
input = input.trim();
|
||||
|
||||
if (!input) {
|
||||
console.error("❌ Error: No input provided via stdin");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cookies = parseFacebookCookieStringCLI(input);
|
||||
await writeOutput(cookies, "./cookies/facebook.json");
|
||||
return;
|
||||
}
|
||||
|
||||
let cookieString = "";
|
||||
let outputPath = "./cookies/facebook.json";
|
||||
let inputPath = "";
|
||||
|
||||
// Parse command line arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === "--input" || arg === "-i") {
|
||||
inputPath = args[i + 1];
|
||||
i++; // Skip next arg
|
||||
} else if (arg === "--output" || arg === "-o") {
|
||||
outputPath = args[i + 1];
|
||||
i++; // Skip next arg
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
showHelp();
|
||||
return;
|
||||
} else if (!arg.startsWith("-")) {
|
||||
// Assume this is the cookie string
|
||||
cookieString = arg;
|
||||
} else {
|
||||
console.error(`❌ Unknown option: ${arg}`);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Read from file if specified
|
||||
if (inputPath) {
|
||||
try {
|
||||
const file = Bun.file(inputPath);
|
||||
if (!(await file.exists())) {
|
||||
console.error(`❌ Error: Input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
cookieString = await file.text();
|
||||
} catch (error) {
|
||||
console.error(`❌ Error reading input file: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookieString.trim()) {
|
||||
console.error("❌ Error: No cookie string provided");
|
||||
console.error(
|
||||
"Provide cookie string as argument, --input file, or via stdin",
|
||||
);
|
||||
showHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cookies = parseFacebookCookieStringCLI(cookieString);
|
||||
await writeOutput(cookies, outputPath);
|
||||
}
|
||||
|
||||
async function writeOutput(cookies: Cookie[], outputPath: string) {
|
||||
try {
|
||||
await Bun.write(outputPath, JSON.stringify(cookies, null, 2));
|
||||
console.log(`✅ Successfully parsed ${cookies.length} Facebook cookies`);
|
||||
console.log(`📁 Saved to: ${outputPath}`);
|
||||
|
||||
// Show summary of parsed cookies
|
||||
console.log("\n📋 Parsed cookies:");
|
||||
for (const cookie of cookies) {
|
||||
console.log(
|
||||
` • ${cookie.name}: ${cookie.value.substring(0, 20)}${cookie.value.length > 20 ? "..." : ""}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error writing to output file: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Facebook Cookie Parser CLI
|
||||
|
||||
Parses Facebook cookie strings into JSON format for the marketplace scraper.
|
||||
|
||||
USAGE:
|
||||
bun run scripts/parse-facebook-cookies.ts [OPTIONS] [COOKIE_STRING]
|
||||
|
||||
EXAMPLES:
|
||||
# Parse from command line argument
|
||||
bun run scripts/parse-facebook-cookies.ts "c_user=123; xs=abc"
|
||||
|
||||
# Parse from file
|
||||
bun run scripts/parse-facebook-cookies.ts --input cookies.txt
|
||||
|
||||
# Parse from stdin
|
||||
echo "c_user=123; xs=abc" | bun run scripts/parse-facebook-cookies.ts
|
||||
|
||||
# Output to custom file
|
||||
bun run scripts/parse-facebook-cookies.ts "cookie_string" --output my-cookies.json
|
||||
|
||||
OPTIONS:
|
||||
-i, --input FILE Read cookie string from file
|
||||
-o, --output FILE Output file path (default: ./cookies/facebook.json)
|
||||
-h, --help Show this help message
|
||||
|
||||
COOKIE FORMAT:
|
||||
Semicolon-separated name=value pairs
|
||||
Example: "c_user=123456789; xs=abcdef123456; fr=xyz789"
|
||||
|
||||
OUTPUT:
|
||||
JSON array of cookie objects saved to ./cookies/facebook.json
|
||||
`);
|
||||
}
|
||||
|
||||
// Run the CLI
|
||||
if (import.meta.main) {
|
||||
main().catch((error) => {
|
||||
console.error(`❌ Unexpected error: ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
501
src/ebay.ts
Normal file
501
src/ebay.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import cliProgress from "cli-progress";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
// ----------------------------- Types -----------------------------
|
||||
|
||||
type HTMLString = string;
|
||||
|
||||
type ListingDetails = {
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
listingPrice?: {
|
||||
amountFormatted: string;
|
||||
cents?: number;
|
||||
currency?: string;
|
||||
};
|
||||
listingType?: string;
|
||||
listingStatus?: string;
|
||||
creationDate?: string;
|
||||
endDate?: string;
|
||||
numberOfViews?: number;
|
||||
address?: string | null;
|
||||
};
|
||||
|
||||
// ----------------------------- Utilities -----------------------------
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
async function delay(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns cents to localized currency string.
|
||||
*/
|
||||
function formatCentsToCurrency(
|
||||
num: number | string | undefined,
|
||||
locale = "en-US",
|
||||
): string {
|
||||
if (num == null) return "";
|
||||
const cents = typeof num === "string" ? Number.parseInt(num, 10) : num;
|
||||
if (Number.isNaN(cents)) return "";
|
||||
const dollars = cents / 100;
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: true,
|
||||
});
|
||||
return formatter.format(dollars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse eBay currency string like "$1.50 CAD" or "CA $1.50" into cents
|
||||
*/
|
||||
function parseEbayPrice(
|
||||
priceText: string,
|
||||
): { cents: number; currency: string } | null {
|
||||
if (!priceText || typeof priceText !== "string") return null;
|
||||
|
||||
// Clean up the price text and extract currency and amount
|
||||
const cleaned = priceText.trim();
|
||||
|
||||
// Find all numbers in the string (including decimals)
|
||||
const numberMatches = cleaned.match(/[\d,]+\.?\d*/);
|
||||
if (!numberMatches) return null;
|
||||
|
||||
const amountStr = numberMatches[0].replace(/,/g, "");
|
||||
const dollars = Number.parseFloat(amountStr);
|
||||
if (Number.isNaN(dollars)) return null;
|
||||
|
||||
const cents = Math.round(dollars * 100);
|
||||
|
||||
// Extract currency - look for common formats like "CAD", "USD", "C $", "$CA", etc.
|
||||
let currency = "USD"; // Default
|
||||
|
||||
if (
|
||||
cleaned.toUpperCase().includes("CAD") ||
|
||||
cleaned.includes("CA$") ||
|
||||
cleaned.includes("C $")
|
||||
) {
|
||||
currency = "CAD";
|
||||
} else if (cleaned.toUpperCase().includes("USD") || cleaned.includes("$")) {
|
||||
currency = "USD";
|
||||
}
|
||||
|
||||
return { cents, currency };
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly url: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- HTTP Client -----------------------------
|
||||
|
||||
/**
|
||||
Fetch HTML with a basic retry strategy and simple rate-limit delay between calls.
|
||||
- Retries on 429 and 5xx
|
||||
- Respects X-RateLimit-Reset when present (seconds)
|
||||
*/
|
||||
async function fetchHtml(
|
||||
url: string,
|
||||
DELAY_MS: number,
|
||||
opts?: {
|
||||
maxRetries?: number;
|
||||
retryBaseMs?: number;
|
||||
onRateInfo?: (remaining: string | null, reset: string | null) => void;
|
||||
},
|
||||
): Promise<HTMLString> {
|
||||
const maxRetries = opts?.maxRetries ?? 3;
|
||||
const retryBaseMs = opts?.retryBaseMs ?? 500;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"accept-language": "en-CA,en-US;q=0.9,en;q=0.8",
|
||||
"cache-control": "no-cache",
|
||||
"upgrade-insecure-requests": "1",
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
const rateLimitRemaining = res.headers.get("X-RateLimit-Remaining");
|
||||
const rateLimitReset = res.headers.get("X-RateLimit-Reset");
|
||||
opts?.onRateInfo?.(rateLimitRemaining, rateLimitReset);
|
||||
|
||||
if (!res.ok) {
|
||||
// Respect 429 reset if provided
|
||||
if (res.status === 429) {
|
||||
const resetSeconds = rateLimitReset
|
||||
? Number(rateLimitReset)
|
||||
: Number.NaN;
|
||||
const waitMs = Number.isFinite(resetSeconds)
|
||||
? Math.max(0, resetSeconds * 1000)
|
||||
: (attempt + 1) * retryBaseMs;
|
||||
await delay(waitMs);
|
||||
continue;
|
||||
}
|
||||
// Retry on 5xx
|
||||
if (res.status >= 500 && res.status < 600 && attempt < maxRetries) {
|
||||
await delay((attempt + 1) * retryBaseMs);
|
||||
continue;
|
||||
}
|
||||
throw new HttpError(
|
||||
`Request failed with status ${res.status}`,
|
||||
res.status,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
||||
await delay(DELAY_MS);
|
||||
return html;
|
||||
} catch (err) {
|
||||
if (attempt >= maxRetries) throw err;
|
||||
await delay((attempt + 1) * retryBaseMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Exhausted retries without response");
|
||||
}
|
||||
|
||||
// ----------------------------- Parsing -----------------------------
|
||||
|
||||
/**
|
||||
Parse eBay search page HTML and extract listings using DOM selectors
|
||||
*/
|
||||
function parseEbayListings(
|
||||
htmlString: HTMLString,
|
||||
keywords: string[],
|
||||
exclusions: string[],
|
||||
strictMode: boolean,
|
||||
): ListingDetails[] {
|
||||
const { document } = parseHTML(htmlString);
|
||||
const results: ListingDetails[] = [];
|
||||
|
||||
// Find all listing links by looking for eBay item URLs (/itm/)
|
||||
const linkElements = document.querySelectorAll('a[href*="itm/"]');
|
||||
|
||||
for (const linkElement of linkElements) {
|
||||
try {
|
||||
// Get href attribute
|
||||
let href = linkElement.getAttribute("href");
|
||||
if (!href) continue;
|
||||
|
||||
// Make href absolute
|
||||
if (!href.startsWith("http")) {
|
||||
href = href.startsWith("//")
|
||||
? `https:${href}`
|
||||
: `https://www.ebay.com${href}`;
|
||||
}
|
||||
|
||||
// Find the container - go up several levels to find the item container
|
||||
// Modern eBay uses complex nested structures
|
||||
let container = linkElement.parentElement?.parentElement?.parentElement;
|
||||
if (!container) {
|
||||
// Try a different level
|
||||
container = linkElement.parentElement?.parentElement;
|
||||
}
|
||||
if (!container) continue;
|
||||
|
||||
// Extract title - look for heading or title-related elements near the link
|
||||
// Modern eBay often uses h3, span, or div with text content near the link
|
||||
let titleElement = container.querySelector(
|
||||
'h3, [role="heading"], .s-item__title span',
|
||||
);
|
||||
|
||||
// If no direct title element, try finding text content around the link
|
||||
if (!titleElement) {
|
||||
// Look for spans or divs with text near this link
|
||||
const nearbySpans = container.querySelectorAll("span, div");
|
||||
for (const span of nearbySpans) {
|
||||
const text = span.textContent?.trim();
|
||||
if (
|
||||
text &&
|
||||
text.length > 10 &&
|
||||
text.length < 200 &&
|
||||
!text.includes("$") &&
|
||||
!text.includes("item")
|
||||
) {
|
||||
titleElement = span;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let title = titleElement?.textContent?.trim();
|
||||
|
||||
// Clean up eBay UI strings that get included in titles
|
||||
if (title) {
|
||||
// Remove common eBay UI strings that appear at the end of titles
|
||||
const uiStrings = [
|
||||
"Opens in a new window",
|
||||
"Opens in a new tab",
|
||||
"Opens in a new window or tab",
|
||||
"opens in a new window",
|
||||
"opens in a new tab",
|
||||
"opens in a new window or tab",
|
||||
];
|
||||
|
||||
for (const uiString of uiStrings) {
|
||||
const uiIndex = title.indexOf(uiString);
|
||||
if (uiIndex !== -1) {
|
||||
title = title.substring(0, uiIndex).trim();
|
||||
break; // Only remove one UI string per title
|
||||
}
|
||||
}
|
||||
|
||||
// If the title became empty or too short after cleaning, skip this item
|
||||
if (title.length < 10) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!title) continue;
|
||||
|
||||
// Skip irrelevant eBay ads
|
||||
if (title === "Shop on eBay" || title.length < 3) continue;
|
||||
|
||||
// Extract price - look for eBay's price classes, preferring sale/discount prices
|
||||
let priceElement = container.querySelector(
|
||||
'[class*="s-item__price"], .s-item__price, [class*="price"]',
|
||||
);
|
||||
|
||||
// If no direct price class, look for spans containing $ (but not titles)
|
||||
if (!priceElement) {
|
||||
const spansAndElements = container.querySelectorAll(
|
||||
"span, div, b, em, strong",
|
||||
);
|
||||
for (const el of spansAndElements) {
|
||||
const text = el.textContent?.trim();
|
||||
// Must contain $, be reasonably short (price shouldn't be paragraph), and not contain product words
|
||||
if (
|
||||
text?.includes("$") &&
|
||||
text.length < 100 &&
|
||||
!text.includes("laptop") &&
|
||||
!text.includes("computer") &&
|
||||
!text.includes("intel") &&
|
||||
!text.includes("core") &&
|
||||
!text.includes("ram") &&
|
||||
!text.includes("ssd") &&
|
||||
!/\d{4}/.test(text) && // Avoid years like "2024"
|
||||
!text.includes('"') // Avoid measurements
|
||||
) {
|
||||
priceElement = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For discounted items, eBay shows both original and sale price
|
||||
// Prefer sale/current price over original/strikethrough price
|
||||
if (priceElement) {
|
||||
// Check if this element or its parent contains multiple price elements
|
||||
const priceContainer =
|
||||
priceElement.closest('[class*="s-item__price"]') ||
|
||||
priceElement.parentElement;
|
||||
|
||||
if (priceContainer) {
|
||||
// Look for all price elements within this container, including strikethrough prices
|
||||
const allPriceElements = priceContainer.querySelectorAll(
|
||||
'[class*="s-item__price"], span, b, em, strong, s, del, strike',
|
||||
);
|
||||
|
||||
// Filter to only elements that actually contain prices (not labels)
|
||||
const actualPrices: HTMLElement[] = [];
|
||||
for (const el of allPriceElements) {
|
||||
const text = el.textContent?.trim();
|
||||
if (
|
||||
text &&
|
||||
/^\s*[\$£€¥]/u.test(text) &&
|
||||
text.length < 50 &&
|
||||
!/\d{4}/.test(text)
|
||||
) {
|
||||
actualPrices.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer non-strikethrough prices (sale prices) over strikethrough ones (original prices)
|
||||
if (actualPrices.length > 1) {
|
||||
// First, look for prices that are NOT struck through
|
||||
const nonStrikethroughPrices = actualPrices.filter((el) => {
|
||||
const tagName = el.tagName.toLowerCase();
|
||||
const styles =
|
||||
el.classList.contains("s-strikethrough") ||
|
||||
el.classList.contains("u-flStrike") ||
|
||||
el.closest("s, del, strike");
|
||||
return (
|
||||
tagName !== "s" &&
|
||||
tagName !== "del" &&
|
||||
tagName !== "strike" &&
|
||||
!styles
|
||||
);
|
||||
});
|
||||
|
||||
if (nonStrikethroughPrices.length > 0) {
|
||||
// Use the first non-strikethrough price (sale price)
|
||||
priceElement = nonStrikethroughPrices[0];
|
||||
} else {
|
||||
// Fallback: use the last price (likely the most current)
|
||||
const lastPrice = actualPrices[actualPrices.length - 1];
|
||||
priceElement = lastPrice;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priceText = priceElement?.textContent?.trim();
|
||||
|
||||
if (!priceText) continue;
|
||||
|
||||
// Parse price into cents and currency
|
||||
const priceInfo = parseEbayPrice(priceText);
|
||||
if (!priceInfo) continue;
|
||||
|
||||
// Apply exclusion filters
|
||||
if (
|
||||
exclusions.some((exclusion) =>
|
||||
title.toLowerCase().includes(exclusion.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply strict mode filter (title must contain at least one keyword)
|
||||
if (
|
||||
strictMode &&
|
||||
!keywords.some((keyword) =>
|
||||
title?.toLowerCase().includes(keyword.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const listing: ListingDetails = {
|
||||
url: href,
|
||||
title,
|
||||
listingPrice: {
|
||||
amountFormatted: priceText,
|
||||
cents: priceInfo.cents,
|
||||
currency: priceInfo.currency,
|
||||
},
|
||||
listingType: "OFFER", // eBay listings are typically offers
|
||||
listingStatus: "ACTIVE",
|
||||
address: null, // eBay doesn't typically show detailed addresses in search results
|
||||
};
|
||||
|
||||
results.push(listing);
|
||||
} catch (err) {
|
||||
console.warn(`Error parsing eBay listing: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ----------------------------- Main -----------------------------
|
||||
|
||||
export default async function fetchEbayItems(
|
||||
SEARCH_QUERY: string,
|
||||
REQUESTS_PER_SECOND = 1,
|
||||
opts: {
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
strictMode?: boolean;
|
||||
exclusions?: string[];
|
||||
keywords?: string[];
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
minPrice = 0,
|
||||
maxPrice = Number.MAX_SAFE_INTEGER,
|
||||
strictMode = false,
|
||||
exclusions = [],
|
||||
keywords = [SEARCH_QUERY], // Default to search query if no keywords provided
|
||||
} = opts;
|
||||
|
||||
// Build eBay search URL - use Canadian site and tracking parameters like real browser
|
||||
const searchUrl = `https://www.ebay.ca/sch/i.html?_nkw=${encodeURIComponent(SEARCH_QUERY)}^&_sacat=0^&_from=R40^&_trksid=p4432023.m570.l1313`;
|
||||
|
||||
const DELAY_MS = Math.max(1, Math.floor(1000 / REQUESTS_PER_SECOND));
|
||||
|
||||
console.log(`Fetching eBay search: ${searchUrl}`);
|
||||
|
||||
try {
|
||||
// Use custom headers modeled after real browser requests to bypass bot detection
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
Referer: "https://www.ebay.ca/",
|
||||
Connection: "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-User": "?1",
|
||||
Priority: "u=0, i",
|
||||
};
|
||||
|
||||
const res = await fetch(searchUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new HttpError(
|
||||
`Request failed with status ${res.status}`,
|
||||
res.status,
|
||||
searchUrl,
|
||||
);
|
||||
}
|
||||
|
||||
const searchHtml = await res.text();
|
||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
||||
await delay(DELAY_MS);
|
||||
|
||||
console.log("\nParsing eBay listings...");
|
||||
|
||||
const listings = parseEbayListings(
|
||||
searchHtml,
|
||||
keywords,
|
||||
exclusions,
|
||||
strictMode,
|
||||
);
|
||||
|
||||
// Filter by price range (additional safety check)
|
||||
const filteredListings = listings.filter((listing) => {
|
||||
const cents = listing.listingPrice?.cents;
|
||||
return cents && cents >= minPrice && cents <= maxPrice;
|
||||
});
|
||||
|
||||
console.log(`Parsed ${filteredListings.length} eBay listings.`);
|
||||
return filteredListings;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
console.error(
|
||||
`Failed to fetch eBay search (${err.status}): ${err.message}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
1264
src/facebook.ts
Normal file
1264
src/facebook.ts
Normal file
File diff suppressed because it is too large
Load Diff
165
src/index.ts
165
src/index.ts
@@ -1,3 +1,5 @@
|
||||
import fetchEbayItems from "@/ebay";
|
||||
import fetchFacebookItems from "@/facebook";
|
||||
import fetchKijijiItems from "@/kijiji";
|
||||
|
||||
const PORT = process.env.PORT || 4005;
|
||||
@@ -24,13 +26,172 @@ const server = Bun.serve({
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const items = await fetchKijijiItems(SEARCH_QUERY, 5);
|
||||
if (!items)
|
||||
// Parse optional parameters with enhanced defaults
|
||||
const location = reqUrl.searchParams.get("location");
|
||||
const category = reqUrl.searchParams.get("category");
|
||||
const maxPagesParam = reqUrl.searchParams.get("maxPages");
|
||||
const maxPages = maxPagesParam ? Number.parseInt(maxPagesParam, 10) : 5; // Default: 5 pages
|
||||
const sortBy = reqUrl.searchParams.get("sortBy") as
|
||||
| "relevancy"
|
||||
| "date"
|
||||
| "price"
|
||||
| "distance"
|
||||
| undefined;
|
||||
const sortOrder = reqUrl.searchParams.get("sortOrder") as
|
||||
| "asc"
|
||||
| "desc"
|
||||
| undefined;
|
||||
|
||||
// Build search options
|
||||
const locationValue = location
|
||||
? /^\d+$/.test(location)
|
||||
? Number(location)
|
||||
: location
|
||||
: 1700272;
|
||||
const categoryValue = category
|
||||
? /^\d+$/.test(category)
|
||||
? Number(category)
|
||||
: category
|
||||
: 0;
|
||||
|
||||
const searchOptions: import("@/kijiji").SearchOptions = {
|
||||
location: locationValue,
|
||||
category: categoryValue,
|
||||
keywords: SEARCH_QUERY,
|
||||
sortBy: sortBy || "relevancy",
|
||||
sortOrder: sortOrder || "desc",
|
||||
maxPages,
|
||||
};
|
||||
|
||||
// Build listing fetch options with enhanced defaults
|
||||
const listingOptions: import("@/kijiji").ListingFetchOptions = {
|
||||
includeImages: true, // Always include full image arrays
|
||||
sellerDataDepth: "detailed", // Default: detailed seller info
|
||||
includeClientSideData: false, // GraphQL reviews disabled by default
|
||||
};
|
||||
|
||||
try {
|
||||
const items = await fetchKijijiItems(
|
||||
SEARCH_QUERY,
|
||||
1,
|
||||
undefined,
|
||||
searchOptions,
|
||||
listingOptions,
|
||||
);
|
||||
if (!items || items.length === 0)
|
||||
return Response.json(
|
||||
{ message: "Search didn't return any results!" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return Response.json(items, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Kijiji scraping error:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
return Response.json(
|
||||
{
|
||||
message: `Scraping failed: ${errorMessage}`,
|
||||
query: SEARCH_QUERY,
|
||||
options: { searchOptions, listingOptions },
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
"/api/facebook": async (req: Request) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
|
||||
const SEARCH_QUERY =
|
||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||
if (!SEARCH_QUERY)
|
||||
return Response.json(
|
||||
{
|
||||
message:
|
||||
"Request didn't have 'query' header or 'q' search parameter!",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const LOCATION = reqUrl.searchParams.get("location") || "toronto";
|
||||
const COOKIES_SOURCE = reqUrl.searchParams.get("cookies") || undefined;
|
||||
|
||||
try {
|
||||
const items = await fetchFacebookItems(
|
||||
SEARCH_QUERY,
|
||||
5,
|
||||
LOCATION,
|
||||
25,
|
||||
COOKIES_SOURCE,
|
||||
"./cookies/facebook.json",
|
||||
);
|
||||
if (!items || items.length === 0)
|
||||
return Response.json(
|
||||
{ message: "Search didn't return any results!" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return Response.json(items, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Facebook scraping error:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
return Response.json({ message: errorMessage }, { status: 400 });
|
||||
}
|
||||
},
|
||||
|
||||
"/api/ebay": async (req: Request) => {
|
||||
const reqUrl = new URL(req.url);
|
||||
|
||||
const SEARCH_QUERY =
|
||||
req.headers.get("query") || reqUrl.searchParams.get("q") || null;
|
||||
if (!SEARCH_QUERY)
|
||||
return Response.json(
|
||||
{
|
||||
message:
|
||||
"Request didn't have 'query' header or 'q' search parameter!",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
// Parse optional parameters with defaults
|
||||
const minPriceParam = reqUrl.searchParams.get("minPrice");
|
||||
const minPrice = minPriceParam
|
||||
? Number.parseInt(minPriceParam, 10)
|
||||
: undefined;
|
||||
const maxPriceParam = reqUrl.searchParams.get("maxPrice");
|
||||
const maxPrice = maxPriceParam
|
||||
? Number.parseInt(maxPriceParam, 10)
|
||||
: undefined;
|
||||
const strictMode = reqUrl.searchParams.get("strictMode") === "true";
|
||||
const exclusionsParam = reqUrl.searchParams.get("exclusions");
|
||||
const exclusions = exclusionsParam
|
||||
? exclusionsParam.split(",").map((s) => s.trim())
|
||||
: [];
|
||||
const keywordsParam = reqUrl.searchParams.get("keywords");
|
||||
const keywords = keywordsParam
|
||||
? keywordsParam.split(",").map((s) => s.trim())
|
||||
: [SEARCH_QUERY];
|
||||
|
||||
try {
|
||||
const items = await fetchEbayItems(SEARCH_QUERY, 5, {
|
||||
minPrice,
|
||||
maxPrice,
|
||||
strictMode,
|
||||
exclusions,
|
||||
keywords,
|
||||
});
|
||||
if (!items || items.length === 0)
|
||||
return Response.json(
|
||||
{ message: "Search didn't return any results!" },
|
||||
{ status: 404 },
|
||||
);
|
||||
return Response.json(items, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("eBay scraping error:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
return Response.json({ message: errorMessage }, { status: 400 });
|
||||
}
|
||||
},
|
||||
|
||||
// Wildcard route for all routes that start with "/api/" and aren't otherwise matched
|
||||
|
||||
719
src/kijiji.ts
719
src/kijiji.ts
@@ -1,3 +1,4 @@
|
||||
import cliProgress from "cli-progress";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { parseHTML } from "linkedom";
|
||||
import unidecode from "unidecode";
|
||||
@@ -25,16 +26,29 @@ interface ApolloListingRoot {
|
||||
url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
price?: { amount?: number | string; currency?: string };
|
||||
price?: { amount?: number | string; currency?: string; type?: string };
|
||||
type?: string;
|
||||
status?: string;
|
||||
activationDate?: string;
|
||||
endDate?: string;
|
||||
metrics?: { views?: number | string };
|
||||
location?: { address?: string | null };
|
||||
location?: {
|
||||
address?: string | null;
|
||||
id?: number;
|
||||
name?: string;
|
||||
coordinates?: { latitude: number; longitude: number };
|
||||
};
|
||||
imageUrls?: string[];
|
||||
imageCount?: number;
|
||||
categoryId?: number;
|
||||
adSource?: string;
|
||||
flags?: { topAd?: boolean; priceDrop?: boolean };
|
||||
posterInfo?: { posterId?: string; rating?: number };
|
||||
attributes?: Array<{ canonicalName?: string; canonicalValues?: string[] }>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
// Keep existing interface for backward compatibility
|
||||
type ListingDetails = {
|
||||
url: string;
|
||||
title: string;
|
||||
@@ -52,10 +66,181 @@ type ListingDetails = {
|
||||
address?: string | null;
|
||||
};
|
||||
|
||||
// New comprehensive interface for detailed listings
|
||||
interface DetailedListing extends ListingDetails {
|
||||
images: string[];
|
||||
categoryId: number;
|
||||
adSource: string;
|
||||
flags: {
|
||||
topAd: boolean;
|
||||
priceDrop: boolean;
|
||||
};
|
||||
attributes: Record<string, string[]>;
|
||||
location: {
|
||||
id: number;
|
||||
name: string;
|
||||
coordinates?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
};
|
||||
sellerInfo?: {
|
||||
posterId: string;
|
||||
rating?: number;
|
||||
accountType?: string;
|
||||
memberSince?: string;
|
||||
reviewCount?: number;
|
||||
reviewScore?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Configuration interfaces
|
||||
interface SearchOptions {
|
||||
location?: number | string; // Location ID or name
|
||||
category?: number | string; // Category ID or name
|
||||
keywords?: string;
|
||||
sortBy?: "relevancy" | "date" | "price" | "distance";
|
||||
sortOrder?: "desc" | "asc";
|
||||
maxPages?: number; // Default: 5
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
}
|
||||
|
||||
interface ListingFetchOptions {
|
||||
includeImages?: boolean; // Default: true
|
||||
sellerDataDepth?: "basic" | "detailed" | "full"; // Default: 'detailed'
|
||||
includeClientSideData?: boolean; // Default: false
|
||||
}
|
||||
|
||||
// ----------------------------- Constants & Mappings -----------------------------
|
||||
|
||||
// Location mappings from KIJIJI.md
|
||||
const LOCATION_MAPPINGS: Record<string, number> = {
|
||||
canada: 0,
|
||||
ontario: 9004,
|
||||
toronto: 1700273,
|
||||
gta: 1700272,
|
||||
oshawa: 1700275,
|
||||
quebec: 9001,
|
||||
"nova scotia": 9002,
|
||||
alberta: 9003,
|
||||
"new brunswick": 9005,
|
||||
manitoba: 9006,
|
||||
"british columbia": 9007,
|
||||
newfoundland: 9008,
|
||||
saskatchewan: 9009,
|
||||
territories: 9010,
|
||||
pei: 9011,
|
||||
"prince edward island": 9011,
|
||||
};
|
||||
|
||||
// Category mappings from KIJIJI.md (Buy & Sell main categories)
|
||||
const CATEGORY_MAPPINGS: Record<string, number> = {
|
||||
all: 0,
|
||||
"buy-sell": 10,
|
||||
"arts-collectibles": 12,
|
||||
audio: 767,
|
||||
"baby-items": 253,
|
||||
"bags-luggage": 931,
|
||||
bikes: 644,
|
||||
books: 109,
|
||||
cameras: 103,
|
||||
cds: 104,
|
||||
clothing: 274,
|
||||
computers: 16,
|
||||
"computer-accessories": 128,
|
||||
electronics: 29659001,
|
||||
"free-stuff": 17220001,
|
||||
furniture: 235,
|
||||
"garage-sales": 638,
|
||||
"health-special-needs": 140,
|
||||
"hobbies-crafts": 139,
|
||||
"home-appliances": 107,
|
||||
"home-indoor": 717,
|
||||
"home-outdoor": 727,
|
||||
jewellery: 133,
|
||||
"musical-instruments": 17,
|
||||
phones: 132,
|
||||
"sporting-goods": 111,
|
||||
tools: 110,
|
||||
"toys-games": 108,
|
||||
"tvs-video": 15093001,
|
||||
"video-games": 141,
|
||||
other: 26,
|
||||
};
|
||||
|
||||
// Sort parameter mappings
|
||||
const SORT_MAPPINGS: Record<string, string> = {
|
||||
relevancy: "MATCH",
|
||||
date: "DATE",
|
||||
price: "PRICE",
|
||||
distance: "DISTANCE",
|
||||
};
|
||||
|
||||
// ----------------------------- Exports for Testing -----------------------------
|
||||
// Note: These are exported for testing purposes only
|
||||
|
||||
export { resolveLocationId, resolveCategoryId, buildSearchUrl };
|
||||
export { extractApolloState, parseSearch };
|
||||
export { parseDetailedListing };
|
||||
export { HttpError, NetworkError, ParseError, RateLimitError, ValidationError };
|
||||
|
||||
// ----------------------------- Utilities -----------------------------
|
||||
|
||||
const SEPS = new Set([" ", "–", "—", "/", ":", ";", ",", ".", "-"]);
|
||||
|
||||
/**
|
||||
* Resolve location ID from name or return numeric ID
|
||||
*/
|
||||
function resolveLocationId(location?: number | string): number {
|
||||
if (typeof location === "number") return location;
|
||||
if (typeof location === "string") {
|
||||
const normalized = location.toLowerCase().replace(/\s+/g, "-");
|
||||
return LOCATION_MAPPINGS[normalized] ?? 0; // Default to Canada (0)
|
||||
}
|
||||
return 0; // Default to Canada
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve category ID from name or return numeric ID
|
||||
*/
|
||||
function resolveCategoryId(category?: number | string): number {
|
||||
if (typeof category === "number") return category;
|
||||
if (typeof category === "string") {
|
||||
const normalized = category.toLowerCase().replace(/\s+/g, "-");
|
||||
return CATEGORY_MAPPINGS[normalized] ?? 0; // Default to all categories
|
||||
}
|
||||
return 0; // Default to all categories
|
||||
}
|
||||
|
||||
/**
|
||||
* Build search URL with enhanced parameters
|
||||
*/
|
||||
function buildSearchUrl(
|
||||
keywords: string,
|
||||
options: SearchOptions & { page?: number },
|
||||
BASE_URL = "https://www.kijiji.ca",
|
||||
): string {
|
||||
const locationId = resolveLocationId(options.location);
|
||||
const categoryId = resolveCategoryId(options.category);
|
||||
|
||||
const categorySlug = categoryId === 0 ? "buy-sell" : "buy-sell"; // Could be enhanced
|
||||
const locationSlug = locationId === 0 ? "canada" : "canada"; // Could be enhanced
|
||||
|
||||
let url = `${BASE_URL}/b-${categorySlug}/${locationSlug}/${slugify(keywords)}/k0c${categoryId}l${locationId}`;
|
||||
|
||||
const sortParam = options.sortBy
|
||||
? `&sort=${SORT_MAPPINGS[options.sortBy]}`
|
||||
: "";
|
||||
const sortOrder = options.sortOrder === "asc" ? "ASC" : "DESC";
|
||||
const pageParam =
|
||||
options.page && options.page > 1 ? `&page=${options.page}` : "";
|
||||
|
||||
url += `?sort=relevancyDesc&view=list${sortParam}&order=${sortOrder}${pageParam}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugifies a string for search
|
||||
*/
|
||||
@@ -66,13 +251,14 @@ export function slugify(input: string): string {
|
||||
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
const code = ch!.charCodeAt(0);
|
||||
if (!ch) continue;
|
||||
const code = ch.charCodeAt(0);
|
||||
|
||||
// a-z or 0-9
|
||||
if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) {
|
||||
out.push(ch!);
|
||||
out.push(ch);
|
||||
lastHyphen = false;
|
||||
} else if (SEPS.has(ch!)) {
|
||||
} else if (SEPS.has(ch)) {
|
||||
if (!lastHyphen) {
|
||||
out.push("-");
|
||||
lastHyphen = true;
|
||||
@@ -86,7 +272,7 @@ export function slugify(input: string): string {
|
||||
/**
|
||||
* Turns cents to localized currency string.
|
||||
*/
|
||||
function formatCentsToCurrency(
|
||||
export function formatCentsToCurrency(
|
||||
num: number | string | undefined,
|
||||
locale = "en-US",
|
||||
): string {
|
||||
@@ -95,21 +281,24 @@ function formatCentsToCurrency(
|
||||
if (Number.isNaN(cents)) return "";
|
||||
const dollars = cents / 100;
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: true,
|
||||
});
|
||||
return formatter.format(dollars);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function delay(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ----------------------------- Error Classes -----------------------------
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -121,12 +310,52 @@ class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly url: string,
|
||||
public readonly cause?: Error,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
class ParseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly data?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ParseError";
|
||||
}
|
||||
}
|
||||
|
||||
class RateLimitError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly url: string,
|
||||
public readonly resetTime?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- HTTP Client -----------------------------
|
||||
|
||||
/**
|
||||
Fetch HTML with a basic retry strategy and simple rate-limit delay between calls.
|
||||
- Retries on 429 and 5xx
|
||||
Fetch HTML with enhanced retry strategy and exponential backoff.
|
||||
- Retries on 429, 5xx, and network errors
|
||||
- Respects X-RateLimit-Reset when present (seconds)
|
||||
- Exponential backoff with jitter
|
||||
*/
|
||||
async function fetchHtml(
|
||||
url: string,
|
||||
@@ -138,11 +367,13 @@ async function fetchHtml(
|
||||
},
|
||||
): Promise<HTMLString> {
|
||||
const maxRetries = opts?.maxRetries ?? 3;
|
||||
const retryBaseMs = opts?.retryBaseMs ?? 500;
|
||||
const retryBaseMs = opts?.retryBaseMs ?? 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`Fetching: `, url);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -154,27 +385,42 @@ async function fetchHtml(
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const rateLimitRemaining = res.headers.get("X-RateLimit-Remaining");
|
||||
const rateLimitReset = res.headers.get("X-RateLimit-Reset");
|
||||
opts?.onRateInfo?.(rateLimitRemaining, rateLimitReset);
|
||||
|
||||
if (!res.ok) {
|
||||
// Respect 429 reset if provided
|
||||
// Handle rate limiting
|
||||
if (res.status === 429) {
|
||||
const resetSeconds = rateLimitReset ? Number(rateLimitReset) : NaN;
|
||||
const resetSeconds = rateLimitReset
|
||||
? Number(rateLimitReset)
|
||||
: Number.NaN;
|
||||
const waitMs = Number.isFinite(resetSeconds)
|
||||
? Math.max(0, resetSeconds * 1000)
|
||||
: (attempt + 1) * retryBaseMs;
|
||||
: calculateBackoffDelay(attempt, retryBaseMs);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await delay(waitMs);
|
||||
continue;
|
||||
}
|
||||
// Retry on 5xx
|
||||
throw new RateLimitError(
|
||||
`Rate limit exceeded for ${url}`,
|
||||
url,
|
||||
resetSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
// Retry on server errors
|
||||
if (res.status >= 500 && res.status < 600 && attempt < maxRetries) {
|
||||
await delay((attempt + 1) * retryBaseMs);
|
||||
await delay(calculateBackoffDelay(attempt, retryBaseMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new HttpError(
|
||||
`Request failed with status ${res.status}`,
|
||||
res.status,
|
||||
@@ -183,16 +429,189 @@ async function fetchHtml(
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
||||
|
||||
// Respect per-request delay to maintain rate limiting
|
||||
await delay(DELAY_MS);
|
||||
return html;
|
||||
} catch (err) {
|
||||
if (attempt >= maxRetries) throw err;
|
||||
await delay((attempt + 1) * retryBaseMs);
|
||||
// Handle different error types
|
||||
if (err instanceof RateLimitError || err instanceof HttpError) {
|
||||
throw err; // Re-throw known errors
|
||||
}
|
||||
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
if (attempt < maxRetries) {
|
||||
await delay(calculateBackoffDelay(attempt, retryBaseMs));
|
||||
continue;
|
||||
}
|
||||
throw new NetworkError(`Request timeout for ${url}`, url, err);
|
||||
}
|
||||
|
||||
// Network or other errors
|
||||
if (attempt < maxRetries) {
|
||||
await delay(calculateBackoffDelay(attempt, retryBaseMs));
|
||||
continue;
|
||||
}
|
||||
throw new NetworkError(
|
||||
`Network error fetching ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
url,
|
||||
err instanceof Error ? err : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Exhausted retries without response");
|
||||
throw new NetworkError(`Exhausted retries without response for ${url}`, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay with jitter
|
||||
*/
|
||||
function calculateBackoffDelay(attempt: number, baseMs: number): number {
|
||||
const exponentialDelay = baseMs * 2 ** attempt;
|
||||
const jitter = Math.random() * 0.1 * exponentialDelay; // 10% jitter
|
||||
return Math.min(exponentialDelay + jitter, 30000); // Cap at 30 seconds
|
||||
}
|
||||
|
||||
// ----------------------------- GraphQL Client -----------------------------
|
||||
|
||||
/**
|
||||
* Fetch additional data via GraphQL API
|
||||
*/
|
||||
async function fetchGraphQLData(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
BASE_URL = "https://www.kijiji.ca",
|
||||
): Promise<unknown> {
|
||||
const endpoint = `${BASE_URL}/anvil/api`;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"apollo-require-preflight": "true",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpError(
|
||||
`GraphQL request failed with status ${response.status}`,
|
||||
response.status,
|
||||
endpoint,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.errors) {
|
||||
throw new ParseError(
|
||||
`GraphQL errors: ${JSON.stringify(result.errors)}`,
|
||||
result.errors,
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError || err instanceof ParseError) {
|
||||
throw err;
|
||||
}
|
||||
throw new NetworkError(
|
||||
`Failed to fetch GraphQL data: ${err instanceof Error ? err.message : String(err)}`,
|
||||
endpoint,
|
||||
err instanceof Error ? err : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GraphQL response interfaces
|
||||
interface GraphQLReviewResponse {
|
||||
user?: {
|
||||
reviewSummary?: {
|
||||
count?: number;
|
||||
score?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface GraphQLProfileResponse {
|
||||
user?: {
|
||||
memberSince?: string;
|
||||
accountType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// GraphQL queries from KIJIJI.md
|
||||
const GRAPHQL_QUERIES = {
|
||||
getReviewSummary: `
|
||||
query GetReviewSummary($userId: String!) {
|
||||
user(id: $userId) {
|
||||
reviewSummary {
|
||||
count
|
||||
score
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`,
|
||||
getProfileMetrics: `
|
||||
query GetProfileMetrics($profileId: String!) {
|
||||
user(id: $profileId) {
|
||||
memberSince
|
||||
accountType
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fetch additional seller data via GraphQL
|
||||
*/
|
||||
async function fetchSellerDetails(
|
||||
posterId: string,
|
||||
BASE_URL = "https://www.kijiji.ca",
|
||||
): Promise<{
|
||||
reviewCount?: number;
|
||||
reviewScore?: number;
|
||||
memberSince?: string;
|
||||
accountType?: string;
|
||||
}> {
|
||||
try {
|
||||
const [reviewData, profileData] = await Promise.all([
|
||||
fetchGraphQLData(
|
||||
GRAPHQL_QUERIES.getReviewSummary,
|
||||
{ userId: posterId },
|
||||
BASE_URL,
|
||||
),
|
||||
fetchGraphQLData(
|
||||
GRAPHQL_QUERIES.getProfileMetrics,
|
||||
{ profileId: posterId },
|
||||
BASE_URL,
|
||||
),
|
||||
]);
|
||||
|
||||
const reviewResponse = reviewData as GraphQLReviewResponse;
|
||||
const profileResponse = profileData as GraphQLProfileResponse;
|
||||
|
||||
return {
|
||||
reviewCount: reviewResponse?.user?.reviewSummary?.count,
|
||||
reviewScore: reviewResponse?.user?.reviewSummary?.score,
|
||||
memberSince: profileResponse?.user?.memberSince,
|
||||
accountType: profileResponse?.user?.accountType,
|
||||
};
|
||||
} catch (err) {
|
||||
// Silently fail for GraphQL errors - not critical for basic functionality
|
||||
console.warn(
|
||||
`Failed to fetch seller details for ${posterId}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- Parsing -----------------------------
|
||||
@@ -298,7 +717,8 @@ function parseListing(
|
||||
listingPrice: amountFormatted
|
||||
? {
|
||||
amountFormatted,
|
||||
cents: Number.isFinite(cents!) ? cents : undefined,
|
||||
cents:
|
||||
cents !== undefined && Number.isFinite(cents) ? cents : undefined,
|
||||
currency: price?.currency,
|
||||
}
|
||||
: undefined,
|
||||
@@ -306,28 +726,212 @@ function parseListing(
|
||||
listingStatus: status,
|
||||
creationDate: activationDate,
|
||||
endDate,
|
||||
numberOfViews: Number.isFinite(numberOfViews!) ? numberOfViews : undefined,
|
||||
numberOfViews:
|
||||
numberOfViews !== undefined && Number.isFinite(numberOfViews)
|
||||
? numberOfViews
|
||||
: undefined,
|
||||
address: location?.address ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a listing page into a detailed object with all available fields
|
||||
*/
|
||||
async function parseDetailedListing(
|
||||
htmlString: HTMLString,
|
||||
BASE_URL: string,
|
||||
options: ListingFetchOptions = {},
|
||||
): Promise<DetailedListing | null> {
|
||||
const apolloState = extractApolloState(htmlString);
|
||||
if (!apolloState) return null;
|
||||
|
||||
// Find the listing root key
|
||||
const listingKey = Object.keys(apolloState).find((k) =>
|
||||
k.includes("Listing"),
|
||||
);
|
||||
if (!listingKey) return null;
|
||||
|
||||
const root = apolloState[listingKey];
|
||||
if (!isRecord(root)) return null;
|
||||
|
||||
const {
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
price,
|
||||
type,
|
||||
status,
|
||||
activationDate,
|
||||
endDate,
|
||||
metrics,
|
||||
location,
|
||||
imageUrls,
|
||||
imageCount,
|
||||
categoryId,
|
||||
adSource,
|
||||
flags,
|
||||
posterInfo,
|
||||
attributes,
|
||||
} = root as ApolloListingRoot;
|
||||
|
||||
const cents = price?.amount != null ? Number(price.amount) : undefined;
|
||||
const amountFormatted = formatCentsToCurrency(cents);
|
||||
|
||||
const numberOfViews =
|
||||
metrics?.views != null ? Number(metrics.views) : undefined;
|
||||
|
||||
const listingUrl =
|
||||
typeof url === "string"
|
||||
? url.startsWith("http")
|
||||
? url
|
||||
: `${BASE_URL}${url}`
|
||||
: "";
|
||||
|
||||
if (!listingUrl || !title) return null;
|
||||
|
||||
// Only include fixed-price listings
|
||||
if (!amountFormatted || cents === undefined) return null;
|
||||
|
||||
// Extract images if requested
|
||||
const images =
|
||||
options.includeImages !== false && Array.isArray(imageUrls)
|
||||
? imageUrls.filter((url): url is string => typeof url === "string")
|
||||
: [];
|
||||
|
||||
// Extract attributes as key-value pairs
|
||||
const attributeMap: Record<string, string[]> = {};
|
||||
if (Array.isArray(attributes)) {
|
||||
for (const attr of attributes) {
|
||||
if (attr?.canonicalName && Array.isArray(attr.canonicalValues)) {
|
||||
attributeMap[attr.canonicalName] = attr.canonicalValues;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract seller info based on depth setting
|
||||
let sellerInfo: DetailedListing["sellerInfo"];
|
||||
const depth = options.sellerDataDepth ?? "detailed";
|
||||
|
||||
if (posterInfo?.posterId) {
|
||||
sellerInfo = {
|
||||
posterId: posterInfo.posterId,
|
||||
rating:
|
||||
typeof posterInfo.rating === "number" ? posterInfo.rating : undefined,
|
||||
};
|
||||
|
||||
// Add more detailed info if requested and client-side data is enabled
|
||||
if (
|
||||
(depth === "detailed" || depth === "full") &&
|
||||
options.includeClientSideData
|
||||
) {
|
||||
try {
|
||||
const additionalData = await fetchSellerDetails(
|
||||
posterInfo.posterId,
|
||||
BASE_URL,
|
||||
);
|
||||
sellerInfo = {
|
||||
...sellerInfo,
|
||||
...additionalData,
|
||||
};
|
||||
} catch (err) {
|
||||
// Silently fail - GraphQL data is optional
|
||||
console.warn(
|
||||
`Failed to fetch additional seller data for ${posterInfo.posterId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: listingUrl,
|
||||
title,
|
||||
description,
|
||||
listingPrice: {
|
||||
amountFormatted,
|
||||
cents,
|
||||
currency: price?.currency,
|
||||
},
|
||||
listingType: type,
|
||||
listingStatus: status,
|
||||
creationDate: activationDate,
|
||||
endDate,
|
||||
numberOfViews:
|
||||
numberOfViews !== undefined && Number.isFinite(numberOfViews)
|
||||
? numberOfViews
|
||||
: undefined,
|
||||
address: location?.address ?? null,
|
||||
images,
|
||||
categoryId: typeof categoryId === "number" ? categoryId : 0,
|
||||
adSource: typeof adSource === "string" ? adSource : "UNKNOWN",
|
||||
flags: {
|
||||
topAd: flags?.topAd === true,
|
||||
priceDrop: flags?.priceDrop === true,
|
||||
},
|
||||
attributes: attributeMap,
|
||||
location: {
|
||||
id: typeof location?.id === "number" ? location.id : 0,
|
||||
name: typeof location?.name === "string" ? location.name : "Unknown",
|
||||
coordinates: location?.coordinates
|
||||
? {
|
||||
latitude: location.coordinates.latitude,
|
||||
longitude: location.coordinates.longitude,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
sellerInfo,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------- Main -----------------------------
|
||||
|
||||
export default async function fetchKijijiItems(
|
||||
SEARCH_QUERY: string,
|
||||
REQUESTS_PER_SECOND = 1,
|
||||
BASE_URL = "https://www.kijiji.ca",
|
||||
searchOptions: SearchOptions = {},
|
||||
listingOptions: ListingFetchOptions = {},
|
||||
) {
|
||||
const DELAY_MS = Math.max(1, Math.floor(1000 / REQUESTS_PER_SECOND));
|
||||
|
||||
const searchUrl = `${BASE_URL}/b-gta-greater-toronto-area/${slugify(SEARCH_QUERY)}/k0l1700272?sort=relevancyDesc&view=list`;
|
||||
// Set defaults for configuration
|
||||
const finalSearchOptions: Required<SearchOptions> = {
|
||||
location: searchOptions.location ?? 1700272, // Default to GTA
|
||||
category: searchOptions.category ?? 0, // Default to all categories
|
||||
keywords: searchOptions.keywords ?? SEARCH_QUERY,
|
||||
sortBy: searchOptions.sortBy ?? "relevancy",
|
||||
sortOrder: searchOptions.sortOrder ?? "desc",
|
||||
maxPages: searchOptions.maxPages ?? 5, // Default to 5 pages
|
||||
priceMin: searchOptions.priceMin,
|
||||
priceMax: searchOptions.priceMax,
|
||||
};
|
||||
|
||||
console.log(`Fetching search: ${searchUrl}`);
|
||||
const finalListingOptions: Required<ListingFetchOptions> = {
|
||||
includeImages: listingOptions.includeImages ?? true,
|
||||
sellerDataDepth: listingOptions.sellerDataDepth ?? "detailed",
|
||||
includeClientSideData: listingOptions.includeClientSideData ?? false,
|
||||
};
|
||||
|
||||
const allListings: DetailedListing[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
// Fetch multiple pages
|
||||
for (let page = 1; page <= finalSearchOptions.maxPages; page++) {
|
||||
const searchUrl = buildSearchUrl(
|
||||
finalSearchOptions.keywords,
|
||||
{
|
||||
...finalSearchOptions,
|
||||
// Add page parameter for pagination
|
||||
...(page > 1 && { page }),
|
||||
},
|
||||
BASE_URL,
|
||||
);
|
||||
|
||||
console.log(`Fetching search page ${page}: ${searchUrl}`);
|
||||
const searchHtml = await fetchHtml(searchUrl, DELAY_MS, {
|
||||
onRateInfo: (remaining, reset) => {
|
||||
if (remaining && reset) {
|
||||
console.log(
|
||||
`Search - Rate limit remaining: ${remaining}, reset in: ${reset}s`,
|
||||
`\nSearch - Rate limit remaining: ${remaining}, reset in: ${reset}s`,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -335,46 +939,77 @@ export default async function fetchKijijiItems(
|
||||
|
||||
const searchResults = parseSearch(searchHtml, BASE_URL);
|
||||
if (searchResults.length === 0) {
|
||||
console.warn("No search results parsed from page.");
|
||||
return;
|
||||
console.log(
|
||||
`No more results found on page ${page}. Stopping pagination.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Deduplicate links
|
||||
const listingLinks = Array.from(
|
||||
new Set(searchResults.map((r) => r.listingLink)),
|
||||
);
|
||||
// Deduplicate links across pages
|
||||
const newListingLinks = searchResults
|
||||
.map((r) => r.listingLink)
|
||||
.filter((link) => !seenUrls.has(link));
|
||||
|
||||
for (const link of newListingLinks) {
|
||||
seenUrls.add(link);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${listingLinks.length} listing links. Fetching details...`,
|
||||
`\nFound ${newListingLinks.length} new listing links on page ${page}. Total unique: ${seenUrls.size}`,
|
||||
);
|
||||
|
||||
const items: ListingDetails[] = [];
|
||||
for (const link of listingLinks) {
|
||||
// Fetch details for this page's listings
|
||||
const progressBar = new cliProgress.SingleBar(
|
||||
{},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
const totalProgress = newListingLinks.length;
|
||||
let currentProgress = 0;
|
||||
progressBar.start(totalProgress, currentProgress);
|
||||
|
||||
for (const link of newListingLinks) {
|
||||
try {
|
||||
const html = await fetchHtml(link, DELAY_MS, {
|
||||
onRateInfo: (remaining, reset) => {
|
||||
if (remaining && reset) {
|
||||
console.log(
|
||||
`Item - Rate limit remaining: ${remaining}, reset in: ${reset}s`,
|
||||
`\nItem - Rate limit remaining: ${remaining}, reset in: ${reset}s`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
const parsed = parseListing(html, BASE_URL);
|
||||
const parsed = await parseDetailedListing(
|
||||
html,
|
||||
BASE_URL,
|
||||
finalListingOptions,
|
||||
);
|
||||
if (parsed) {
|
||||
if (parsed.listingPrice?.cents) items.push(parsed);
|
||||
allListings.push(parsed);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
console.error(`Failed to fetch ${link} - ${err.status} ${err.message}`);
|
||||
console.error(
|
||||
`\nFailed to fetch ${link}\n - ${err.status} ${err.message}`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to fetch ${link} - ${String((err as Error)?.message || err)}`,
|
||||
`\nFailed to fetch ${link}\n - ${String((err as Error)?.message || err)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
currentProgress++;
|
||||
progressBar.update(currentProgress);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Parsed ${items.length} listings.`);
|
||||
return items;
|
||||
progressBar.stop();
|
||||
|
||||
// If we got fewer results than expected (40 per page), we've reached the end
|
||||
if (searchResults.length < 40) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nParsed ${allListings.length} detailed listings.`);
|
||||
return allListings;
|
||||
}
|
||||
|
||||
834
test/facebook-core.test.ts
Normal file
834
test/facebook-core.test.ts
Normal file
@@ -0,0 +1,834 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import {
|
||||
extractFacebookItemData,
|
||||
extractFacebookMarketplaceData,
|
||||
fetchFacebookItem,
|
||||
formatCentsToCurrency,
|
||||
formatCookiesForHeader,
|
||||
loadFacebookCookies,
|
||||
parseFacebookAds,
|
||||
parseFacebookCookieString,
|
||||
parseFacebookItem,
|
||||
} from "../src/facebook";
|
||||
|
||||
// Mock fetch globally
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("Facebook Marketplace Scraper Core Tests", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = mock(() => {
|
||||
throw new Error("fetch should be mocked in individual tests");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("Cookie Parsing", () => {
|
||||
describe("parseFacebookCookieString", () => {
|
||||
test("should parse valid cookie string", () => {
|
||||
const cookieString = "c_user=123456789; xs=abcdef123456; fr=xyz789";
|
||||
const result = parseFacebookCookieString(cookieString);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({
|
||||
name: "c_user",
|
||||
value: "123456789",
|
||||
domain: ".facebook.com",
|
||||
path: "/",
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
expirationDate: undefined,
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
name: "xs",
|
||||
value: "abcdef123456",
|
||||
domain: ".facebook.com",
|
||||
path: "/",
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
expirationDate: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle URL-encoded values", () => {
|
||||
const cookieString = "c_user=123%2B456; xs=abc%3Ddef";
|
||||
const result = parseFacebookCookieString(cookieString);
|
||||
|
||||
expect(result[0].value).toBe("123+456");
|
||||
expect(result[1].value).toBe("abc=def");
|
||||
});
|
||||
|
||||
test("should filter out malformed cookies", () => {
|
||||
const cookieString = "c_user=123; invalid; xs=abc; =empty";
|
||||
const result = parseFacebookCookieString(cookieString);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((c) => c.name)).toEqual(["c_user", "xs"]);
|
||||
});
|
||||
|
||||
test("should handle empty input", () => {
|
||||
expect(parseFacebookCookieString("")).toEqual([]);
|
||||
expect(parseFacebookCookieString(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle extra whitespace", () => {
|
||||
const cookieString = " c_user = 123 ; xs=abc ";
|
||||
const result = parseFacebookCookieString(cookieString);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe("c_user");
|
||||
expect(result[0].value).toBe("123");
|
||||
expect(result[1].name).toBe("xs");
|
||||
expect(result[1].value).toBe("abc");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Facebook Item Fetching", () => {
|
||||
describe("fetchFacebookItem", () => {
|
||||
const mockCookies = JSON.stringify([
|
||||
{ name: "c_user", value: "12345", domain: ".facebook.com" },
|
||||
{ name: "xs", value: "abc123", domain: ".facebook.com" },
|
||||
]);
|
||||
|
||||
test("should handle authentication errors", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve("Authentication required"),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchFacebookItem("123", mockCookies);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle item not found", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: () => Promise.resolve("Not found"),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchFacebookItem("nonexistent", mockCookies);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle rate limiting", async () => {
|
||||
let attempts = 0;
|
||||
global.fetch = mock(() => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header === "X-RateLimit-Reset") return "1";
|
||||
return null;
|
||||
},
|
||||
},
|
||||
text: () => Promise.resolve("Rate limited"),
|
||||
});
|
||||
}
|
||||
const mockData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
viewer: {
|
||||
marketplace_product_details_page: {
|
||||
target: {
|
||||
id: "123",
|
||||
__typename: "GroupCommerceProductItem",
|
||||
marketplace_listing_title: "Test Item",
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const result = await fetchFacebookItem("123", mockCookies);
|
||||
expect(attempts).toBe(2);
|
||||
// Should eventually succeed after retry
|
||||
});
|
||||
|
||||
test("should handle sold items", async () => {
|
||||
const mockData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
viewer: {
|
||||
marketplace_product_details_page: {
|
||||
target: {
|
||||
id: "456",
|
||||
__typename: "GroupCommerceProductItem",
|
||||
marketplace_listing_title: "Sold Item",
|
||||
is_sold: true,
|
||||
is_live: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchFacebookItem("456", mockCookies);
|
||||
expect(result?.listingStatus).toBe("SOLD");
|
||||
});
|
||||
|
||||
test("should handle missing authentication cookies", async () => {
|
||||
// Use a test-specific cookie file that doesn't exist
|
||||
const testCookiePath = "./cookies/facebook-test.json";
|
||||
|
||||
// Test with no cookies available (test file doesn't exist)
|
||||
await expect(
|
||||
fetchFacebookItem("123", undefined, testCookiePath),
|
||||
).rejects.toThrow("No valid Facebook cookies found");
|
||||
});
|
||||
|
||||
test("should handle successful item extraction", async () => {
|
||||
const mockData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
viewer: {
|
||||
marketplace_product_details_page: {
|
||||
target: {
|
||||
id: "789",
|
||||
__typename: "GroupCommerceProductItem",
|
||||
marketplace_listing_title: "Working Item",
|
||||
formatted_price: { text: "$299.00" },
|
||||
listing_price: {
|
||||
amount: "299.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
is_live: true,
|
||||
creation_time: 1640995200,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchFacebookItem("789", mockCookies);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.title).toBe("Working Item");
|
||||
expect(result?.listingPrice?.amountFormatted).toBe("$299.00");
|
||||
expect(result?.listingStatus).toBe("ACTIVE");
|
||||
});
|
||||
|
||||
test("should handle server errors", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal Server Error"),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchFacebookItem("error", mockCookies);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Extraction", () => {
|
||||
describe("extractFacebookItemData", () => {
|
||||
test("should extract item data from standard require structure", () => {
|
||||
const mockItemData = {
|
||||
id: "123456",
|
||||
__typename: "GroupCommerceProductItem",
|
||||
marketplace_listing_title: "Test Item",
|
||||
formatted_price: { text: "$100.00" },
|
||||
listing_price: { amount: "100.00", currency: "CAD" },
|
||||
is_live: true,
|
||||
};
|
||||
const mockData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
viewer: {
|
||||
marketplace_product_details_page: {
|
||||
target: mockItemData,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const html = `<html><body><script>${JSON.stringify(mockData)}</script></body></html>`;
|
||||
|
||||
const result = extractFacebookItemData(html);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe("123456");
|
||||
expect(result?.marketplace_listing_title).toBe("Test Item");
|
||||
});
|
||||
|
||||
test("should handle missing item data", () => {
|
||||
const mockData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
viewer: {
|
||||
marketplace_product_details_page: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const html = `<html><body><script>${JSON.stringify(mockData)}</script></body></html>`;
|
||||
|
||||
const result = extractFacebookItemData(html);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle malformed HTML", () => {
|
||||
const result = extractFacebookItemData(
|
||||
"<html><body>Invalid HTML</body></html>",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle invalid JSON in script tags", () => {
|
||||
const html =
|
||||
"<html><body><script>{invalid: json}</script></body></html>";
|
||||
const result = extractFacebookItemData(html);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should extract item with vehicle data", () => {
|
||||
const mockVehicleItem = {
|
||||
id: "789",
|
||||
__typename: "GroupCommerceProductItem",
|
||||
marketplace_listing_title: "2006 Honda Civic",
|
||||
formatted_price: { text: "$5,000" },
|
||||
listing_price: { amount: "5000.00", currency: "CAD" },
|
||||
vehicle_make_display_name: "Honda",
|
||||
vehicle_model_display_name: "Civic",
|
||||
vehicle_odometer_data: { unit: "KILOMETERS", value: 150000 },
|
||||
vehicle_transmission_type: "AUTOMATIC",
|
||||
is_live: true,
|
||||
};
|
||||
const mockData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
viewer: {
|
||||
marketplace_product_details_page: {
|
||||
target: mockVehicleItem,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const html = `<html><body><script>${JSON.stringify(mockData)}</script></body></html>`;
|
||||
|
||||
const result = extractFacebookItemData(html);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.vehicle_make_display_name).toBe("Honda");
|
||||
expect(result?.vehicle_odometer_data?.value).toBe(150000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFacebookMarketplaceData", () => {
|
||||
test("should extract search results from marketplace data", () => {
|
||||
const mockMarketplaceData = {
|
||||
feed_units: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "Item 1",
|
||||
listing_price: { amount: "10.00", currency: "CAD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "2",
|
||||
marketplace_listing_title: "Item 2",
|
||||
listing_price: { amount: "20.00", currency: "CAD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const mockData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: mockMarketplaceData,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const html = `<html><body><script>${JSON.stringify(mockData)}</script></body></html>`;
|
||||
|
||||
const result = extractFacebookMarketplaceData(html);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0].node.listing.marketplace_listing_title).toBe(
|
||||
"Item 1",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle empty search results", () => {
|
||||
const mockData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: { edges: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
const html = `<html><body><script>${JSON.stringify(mockData)}</script></body></html>`;
|
||||
|
||||
const result = extractFacebookMarketplaceData(html);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Parsing", () => {
|
||||
describe("parseFacebookItem", () => {
|
||||
test("should parse complete item with all fields", () => {
|
||||
const item = {
|
||||
id: "123456",
|
||||
__typename: "GroupCommerceProductItem" as const,
|
||||
marketplace_listing_title: "iPhone 13 Pro",
|
||||
redacted_description: { text: "Excellent condition" },
|
||||
formatted_price: { text: "$800.00" },
|
||||
listing_price: { amount: "800.00", currency: "CAD" },
|
||||
location_text: { text: "Toronto, ON" },
|
||||
is_live: true,
|
||||
creation_time: 1640995200,
|
||||
marketplace_listing_seller: {
|
||||
id: "seller1",
|
||||
name: "John Doe",
|
||||
},
|
||||
delivery_types: ["IN_PERSON"],
|
||||
};
|
||||
|
||||
const result = parseFacebookItem(item);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.title).toBe("iPhone 13 Pro");
|
||||
expect(result?.description).toBe("Excellent condition");
|
||||
expect(result?.listingPrice?.amountFormatted).toBe("$800.00");
|
||||
expect(result?.listingPrice?.cents).toBe(80000);
|
||||
expect(result?.listingPrice?.currency).toBe("CAD");
|
||||
expect(result?.address).toBe("Toronto, ON");
|
||||
expect(result?.listingStatus).toBe("ACTIVE");
|
||||
expect(result?.seller?.name).toBe("John Doe");
|
||||
expect(result?.deliveryTypes).toEqual(["IN_PERSON"]);
|
||||
});
|
||||
|
||||
test("should parse FREE items", () => {
|
||||
const item = {
|
||||
id: "789",
|
||||
__typename: "GroupCommerceProductItem" as const,
|
||||
marketplace_listing_title: "Free Sofa",
|
||||
formatted_price: { text: "FREE" },
|
||||
listing_price: { amount: "0.00", currency: "CAD" },
|
||||
is_live: true,
|
||||
};
|
||||
|
||||
const result = parseFacebookItem(item);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.title).toBe("Free Sofa");
|
||||
expect(result?.listingPrice?.amountFormatted).toBe("FREE");
|
||||
expect(result?.listingPrice?.cents).toBe(0);
|
||||
});
|
||||
|
||||
test("should handle missing optional fields", () => {
|
||||
const item = {
|
||||
id: "456",
|
||||
__typename: "GroupCommerceProductItem" as const,
|
||||
marketplace_listing_title: "Minimal Item",
|
||||
};
|
||||
|
||||
const result = parseFacebookItem(item);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.title).toBe("Minimal Item");
|
||||
expect(result?.description).toBeUndefined();
|
||||
expect(result?.seller).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should identify vehicle listings", () => {
|
||||
const vehicleItem = {
|
||||
id: "999",
|
||||
__typename: "GroupCommerceProductItem" as const,
|
||||
marketplace_listing_title: "2012 Mazda 3",
|
||||
formatted_price: { text: "$8,000" },
|
||||
listing_price: { amount: "8000.00", currency: "CAD" },
|
||||
vehicle_make_display_name: "Mazda",
|
||||
vehicle_model_display_name: "3",
|
||||
is_live: true,
|
||||
};
|
||||
|
||||
const result = parseFacebookItem(vehicleItem);
|
||||
expect(result?.listingType).toBe("vehicle");
|
||||
});
|
||||
|
||||
test("should handle different listing statuses", () => {
|
||||
const soldItem = {
|
||||
id: "111",
|
||||
__typename: "GroupCommerceProductItem" as const,
|
||||
marketplace_listing_title: "Sold Item",
|
||||
is_sold: true,
|
||||
is_live: false,
|
||||
};
|
||||
|
||||
const pendingItem = {
|
||||
id: "222",
|
||||
__typename: "GroupCommerceProductItem" as const,
|
||||
marketplace_listing_title: "Pending Item",
|
||||
is_pending: true,
|
||||
is_live: true,
|
||||
};
|
||||
|
||||
const hiddenItem = {
|
||||
id: "333",
|
||||
__typename: "GroupCommerceProductItem" as const,
|
||||
marketplace_listing_title: "Hidden Item",
|
||||
is_hidden: true,
|
||||
is_live: false,
|
||||
};
|
||||
|
||||
expect(parseFacebookItem(soldItem)?.listingStatus).toBe("SOLD");
|
||||
expect(parseFacebookItem(pendingItem)?.listingStatus).toBe("PENDING");
|
||||
expect(parseFacebookItem(hiddenItem)?.listingStatus).toBe("HIDDEN");
|
||||
});
|
||||
|
||||
test("should return null for items without title", () => {
|
||||
const invalidItem = {
|
||||
id: "invalid",
|
||||
__typename: "GroupCommerceProductItem" as const,
|
||||
is_live: true,
|
||||
};
|
||||
|
||||
const result = parseFacebookItem(invalidItem);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFacebookAds", () => {
|
||||
test("should parse search result ads", () => {
|
||||
const ads = [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "Ad 1",
|
||||
listing_price: {
|
||||
amount: "50.00",
|
||||
formatted_amount: "$50.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
location: {
|
||||
reverse_geocode: { city_page: { display_name: "Toronto" } },
|
||||
},
|
||||
creation_time: 1640995200,
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "2",
|
||||
marketplace_listing_title: "Ad 2",
|
||||
listing_price: {
|
||||
amount: "75.00",
|
||||
formatted_amount: "$75.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
location: {
|
||||
reverse_geocode: { city_page: { display_name: "Ottawa" } },
|
||||
},
|
||||
creation_time: 1640995300,
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const results = parseFacebookAds(ads);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].title).toBe("Ad 1");
|
||||
expect(results[0].listingPrice?.cents).toBe(5000);
|
||||
expect(results[0].address).toBe("Toronto");
|
||||
expect(results[1].title).toBe("Ad 2");
|
||||
expect(results[1].address).toBe("Ottawa");
|
||||
});
|
||||
|
||||
test("should filter out ads without price", () => {
|
||||
const ads = [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "With Price",
|
||||
listing_price: {
|
||||
amount: "100.00",
|
||||
formatted_amount: "$100.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "2",
|
||||
marketplace_listing_title: "No Price",
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const results = parseFacebookAds(ads);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe("With Price");
|
||||
});
|
||||
|
||||
test("should handle malformed ads gracefully", () => {
|
||||
const ads = [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "Valid Ad",
|
||||
listing_price: {
|
||||
amount: "50.00",
|
||||
formatted_amount: "$50.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
node: {
|
||||
// Missing listing
|
||||
},
|
||||
} as { node: { listing?: unknown } },
|
||||
];
|
||||
|
||||
const results = parseFacebookAds(ads);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe("Valid Ad");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Utility Functions", () => {
|
||||
describe("formatCentsToCurrency", () => {
|
||||
test("should format cents to currency string", () => {
|
||||
expect(formatCentsToCurrency(100)).toBe("$1.00");
|
||||
expect(formatCentsToCurrency(1000)).toBe("$10.00");
|
||||
expect(formatCentsToCurrency(9999)).toBe("$99.99");
|
||||
expect(formatCentsToCurrency(123456)).toBe("$1,234.56");
|
||||
});
|
||||
|
||||
test("should handle string inputs", () => {
|
||||
expect(formatCentsToCurrency("100")).toBe("$1.00");
|
||||
expect(formatCentsToCurrency("1000")).toBe("$10.00");
|
||||
});
|
||||
|
||||
test("should handle zero", () => {
|
||||
expect(formatCentsToCurrency(0)).toBe("$0.00");
|
||||
});
|
||||
|
||||
test("should handle null and undefined", () => {
|
||||
expect(formatCentsToCurrency(null)).toBe("");
|
||||
expect(formatCentsToCurrency(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("should handle invalid inputs", () => {
|
||||
expect(formatCentsToCurrency("invalid")).toBe("");
|
||||
expect(formatCentsToCurrency(Number.NaN)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCookiesForHeader", () => {
|
||||
const mockCookies = [
|
||||
{ name: "c_user", value: "123456", domain: ".facebook.com", path: "/" },
|
||||
{ name: "xs", value: "abcdef", domain: ".facebook.com", path: "/" },
|
||||
{ name: "session_id", value: "xyz", domain: "other.com", path: "/" },
|
||||
];
|
||||
|
||||
test("should format cookies for header string", () => {
|
||||
const result = formatCookiesForHeader(mockCookies, "www.facebook.com");
|
||||
expect(result).toBe("c_user=123456; xs=abcdef");
|
||||
});
|
||||
|
||||
test("should filter expired cookies", () => {
|
||||
const cookiesWithExpiration = [
|
||||
...mockCookies,
|
||||
{
|
||||
name: "expired",
|
||||
value: "old",
|
||||
domain: ".facebook.com",
|
||||
path: "/",
|
||||
expirationDate: Date.now() / 1000 - 1000,
|
||||
},
|
||||
];
|
||||
const result = formatCookiesForHeader(
|
||||
cookiesWithExpiration,
|
||||
"www.facebook.com",
|
||||
);
|
||||
expect(result).not.toContain("expired");
|
||||
});
|
||||
|
||||
test("should handle no matching cookies", () => {
|
||||
const result = formatCookiesForHeader(mockCookies, "www.google.com");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
test("should handle empty cookie array", () => {
|
||||
const result = formatCookiesForHeader([], "www.facebook.com");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
712
test/facebook-integration.test.ts
Normal file
712
test/facebook-integration.test.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import fetchFacebookItems, { fetchFacebookItem } from "../src/facebook";
|
||||
|
||||
// Mock fetch globally
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("Facebook Marketplace Scraper Integration Tests", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = mock(() => {
|
||||
throw new Error("fetch should be mocked in individual tests");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("Main Search Function", () => {
|
||||
const mockCookies = JSON.stringify([
|
||||
{ name: "c_user", value: "12345", domain: ".facebook.com", path: "/" },
|
||||
{ name: "xs", value: "abc123", domain: ".facebook.com", path: "/" },
|
||||
]);
|
||||
|
||||
test("should successfully fetch search results", async () => {
|
||||
const mockSearchData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "iPhone 13 Pro",
|
||||
listing_price: {
|
||||
amount: "800.00",
|
||||
formatted_amount: "$800.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
location: {
|
||||
reverse_geocode: {
|
||||
city_page: { display_name: "Toronto" },
|
||||
},
|
||||
},
|
||||
creation_time: 1640995200,
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "2",
|
||||
marketplace_listing_title: "Samsung Galaxy",
|
||||
listing_price: {
|
||||
amount: "600.00",
|
||||
formatted_amount: "$600.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
location: {
|
||||
reverse_geocode: {
|
||||
city_page: { display_name: "Mississauga" },
|
||||
},
|
||||
},
|
||||
creation_time: 1640995300,
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockSearchData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"iPhone",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].title).toBe("iPhone 13 Pro");
|
||||
expect(results[1].title).toBe("Samsung Galaxy");
|
||||
});
|
||||
|
||||
test("should filter out items without price", async () => {
|
||||
const mockSearchData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "With Price",
|
||||
listing_price: {
|
||||
amount: "100.00",
|
||||
formatted_amount: "$100.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "2",
|
||||
marketplace_listing_title: "No Price",
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockSearchData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"test",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe("With Price");
|
||||
});
|
||||
|
||||
test("should respect MAX_ITEMS parameter", async () => {
|
||||
const mockSearchData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: {
|
||||
edges: Array.from({ length: 10 }, (_, i) => ({
|
||||
node: {
|
||||
listing: {
|
||||
id: String(i),
|
||||
marketplace_listing_title: `Item ${i}`,
|
||||
listing_price: {
|
||||
amount: `${(i + 1) * 10}.00`,
|
||||
formatted_amount: `$${(i + 1) * 10}.00`,
|
||||
currency: "CAD",
|
||||
},
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockSearchData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"test",
|
||||
1,
|
||||
"toronto",
|
||||
5,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("should return empty array for no results", async () => {
|
||||
const mockSearchData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: {
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockSearchData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"nonexistent query",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle authentication errors gracefully", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve("Unauthorized"),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"test",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle network errors", async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error("Network error")));
|
||||
|
||||
await expect(
|
||||
fetchFacebookItems("test", 1, "toronto", 25, mockCookies),
|
||||
).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
test("should handle rate limiting with retry", async () => {
|
||||
let attempts = 0;
|
||||
global.fetch = mock(() => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: {
|
||||
get: (header: string) => {
|
||||
if (header === "X-RateLimit-Reset") return "1";
|
||||
return null;
|
||||
},
|
||||
},
|
||||
text: () => Promise.resolve("Rate limited"),
|
||||
});
|
||||
}
|
||||
const mockSearchData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "Item 1",
|
||||
listing_price: {
|
||||
amount: "100.00",
|
||||
formatted_amount: "$100.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockSearchData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"test",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(attempts).toBe(2);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vehicle Listing Integration", () => {
|
||||
const mockCookies = JSON.stringify([
|
||||
{ name: "c_user", value: "12345", domain: ".facebook.com", path: "/" },
|
||||
{ name: "xs", value: "abc123", domain: ".facebook.com", path: "/" },
|
||||
]);
|
||||
|
||||
test("should correctly identify and parse vehicle listings", async () => {
|
||||
const mockSearchData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "2006 Honda Civic",
|
||||
listing_price: {
|
||||
amount: "8000.00",
|
||||
formatted_amount: "$8,000.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "2",
|
||||
marketplace_listing_title: "iPhone 13",
|
||||
listing_price: {
|
||||
amount: "800.00",
|
||||
formatted_amount: "$800.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockSearchData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"cars",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
// Both should be classified as "item" type in search results (vehicle detection is for item details)
|
||||
expect(results[0].title).toBe("2006 Honda Civic");
|
||||
expect(results[1].title).toBe("iPhone 13");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Different Categories", () => {
|
||||
const mockCookies = JSON.stringify([
|
||||
{ name: "c_user", value: "12345", domain: ".facebook.com", path: "/" },
|
||||
{ name: "xs", value: "abc123", domain: ".facebook.com", path: "/" },
|
||||
]);
|
||||
|
||||
test("should handle electronics listings", async () => {
|
||||
const mockSearchData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "Nintendo Switch",
|
||||
listing_price: {
|
||||
amount: "250.00",
|
||||
formatted_amount: "$250.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
location: {
|
||||
reverse_geocode: {
|
||||
city_page: { display_name: "Toronto" },
|
||||
},
|
||||
},
|
||||
marketplace_listing_category_id:
|
||||
"479353692612078",
|
||||
condition: "USED",
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockSearchData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"nintendo switch",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe("Nintendo Switch");
|
||||
expect(results[0].categoryId).toBe("479353692612078");
|
||||
});
|
||||
|
||||
test("should handle home goods/furniture listings", async () => {
|
||||
const mockSearchData = {
|
||||
require: [
|
||||
[
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
__bbox: {
|
||||
result: {
|
||||
data: {
|
||||
marketplace_search: {
|
||||
feed_units: {
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
listing: {
|
||||
id: "1",
|
||||
marketplace_listing_title: "Dining Table",
|
||||
listing_price: {
|
||||
amount: "150.00",
|
||||
formatted_amount: "$150.00",
|
||||
currency: "CAD",
|
||||
},
|
||||
location: {
|
||||
reverse_geocode: {
|
||||
city_page: { display_name: "Mississauga" },
|
||||
},
|
||||
},
|
||||
marketplace_listing_category_id:
|
||||
"1569171756675761",
|
||||
condition: "USED",
|
||||
is_live: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
`<html><body><script>${JSON.stringify(mockSearchData)}</script></body></html>`,
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"table",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe("Dining Table");
|
||||
expect(results[0].categoryId).toBe("1569171756675761");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Scenarios", () => {
|
||||
const mockCookies = JSON.stringify([
|
||||
{ name: "c_user", value: "12345", domain: ".facebook.com", path: "/" },
|
||||
{ name: "xs", value: "abc123", domain: ".facebook.com", path: "/" },
|
||||
]);
|
||||
|
||||
test("should handle malformed HTML responses", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
"<html><body>Invalid HTML without JSON data</body></html>",
|
||||
),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"test",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle 404 errors gracefully", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: () => Promise.resolve("Not found"),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"test",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle 500 errors gracefully", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal Server Error"),
|
||||
headers: {
|
||||
get: () => null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await fetchFacebookItems(
|
||||
"test",
|
||||
1,
|
||||
"toronto",
|
||||
25,
|
||||
mockCookies,
|
||||
);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
166
test/kijiji-core.test.ts
Normal file
166
test/kijiji-core.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
HttpError,
|
||||
NetworkError,
|
||||
ParseError,
|
||||
RateLimitError,
|
||||
ValidationError,
|
||||
buildSearchUrl,
|
||||
resolveCategoryId,
|
||||
resolveLocationId,
|
||||
} from "../src/kijiji";
|
||||
|
||||
describe("Location and Category Resolution", () => {
|
||||
describe("resolveLocationId", () => {
|
||||
test("should return numeric IDs as-is", () => {
|
||||
expect(resolveLocationId(1700272)).toBe(1700272);
|
||||
expect(resolveLocationId(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("should resolve string location names", () => {
|
||||
expect(resolveLocationId("canada")).toBe(0);
|
||||
expect(resolveLocationId("ontario")).toBe(9004);
|
||||
expect(resolveLocationId("toronto")).toBe(1700273);
|
||||
expect(resolveLocationId("gta")).toBe(1700272);
|
||||
});
|
||||
|
||||
test("should handle case insensitive matching", () => {
|
||||
expect(resolveLocationId("Canada")).toBe(0);
|
||||
expect(resolveLocationId("ONTARIO")).toBe(9004);
|
||||
});
|
||||
|
||||
test("should default to Canada for unknown locations", () => {
|
||||
expect(resolveLocationId("unknown")).toBe(0);
|
||||
expect(resolveLocationId("")).toBe(0);
|
||||
});
|
||||
|
||||
test("should handle undefined input", () => {
|
||||
expect(resolveLocationId(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCategoryId", () => {
|
||||
test("should return numeric IDs as-is", () => {
|
||||
expect(resolveCategoryId(132)).toBe(132);
|
||||
expect(resolveCategoryId(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("should resolve string category names", () => {
|
||||
expect(resolveCategoryId("all")).toBe(0);
|
||||
expect(resolveCategoryId("phones")).toBe(132);
|
||||
expect(resolveCategoryId("electronics")).toBe(29659001);
|
||||
expect(resolveCategoryId("buy-sell")).toBe(10);
|
||||
});
|
||||
|
||||
test("should handle case insensitive matching", () => {
|
||||
expect(resolveCategoryId("All")).toBe(0);
|
||||
expect(resolveCategoryId("PHONES")).toBe(132);
|
||||
});
|
||||
|
||||
test("should default to all categories for unknown categories", () => {
|
||||
expect(resolveCategoryId("unknown")).toBe(0);
|
||||
expect(resolveCategoryId("")).toBe(0);
|
||||
});
|
||||
|
||||
test("should handle undefined input", () => {
|
||||
expect(resolveCategoryId(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL Construction", () => {
|
||||
describe("buildSearchUrl", () => {
|
||||
test("should build basic search URL", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: 1700272,
|
||||
category: 132,
|
||||
sortBy: "relevancy",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
|
||||
expect(url).toContain("b-buy-sell/canada/iphone/k0c132l1700272");
|
||||
expect(url).toContain("sort=relevancyDesc");
|
||||
expect(url).toContain("order=DESC");
|
||||
});
|
||||
|
||||
test("should handle pagination", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: 1700272,
|
||||
category: 132,
|
||||
page: 2,
|
||||
});
|
||||
|
||||
expect(url).toContain("&page=2");
|
||||
});
|
||||
|
||||
test("should handle different sort options", () => {
|
||||
const dateUrl = buildSearchUrl("iphone", {
|
||||
sortBy: "date",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
expect(dateUrl).toContain("sort=DATE");
|
||||
expect(dateUrl).toContain("order=ASC");
|
||||
|
||||
const priceUrl = buildSearchUrl("iphone", {
|
||||
sortBy: "price",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
expect(priceUrl).toContain("sort=PRICE");
|
||||
expect(priceUrl).toContain("order=DESC");
|
||||
});
|
||||
|
||||
test("should handle string location/category inputs", () => {
|
||||
const url = buildSearchUrl("iphone", {
|
||||
location: "toronto",
|
||||
category: "phones",
|
||||
});
|
||||
|
||||
expect(url).toContain("k0c132l1700273"); // phones + toronto
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Classes", () => {
|
||||
test("HttpError should store status and URL", () => {
|
||||
const error = new HttpError("Not found", 404, "https://example.com");
|
||||
expect(error.message).toBe("Not found");
|
||||
expect(error.status).toBe(404);
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.name).toBe("HttpError");
|
||||
});
|
||||
|
||||
test("NetworkError should store URL and cause", () => {
|
||||
const cause = new Error("Connection failed");
|
||||
const error = new NetworkError(
|
||||
"Network error",
|
||||
"https://example.com",
|
||||
cause,
|
||||
);
|
||||
expect(error.message).toBe("Network error");
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.name).toBe("NetworkError");
|
||||
});
|
||||
|
||||
test("ParseError should store data", () => {
|
||||
const data = { invalid: "json" };
|
||||
const error = new ParseError("Invalid JSON", data);
|
||||
expect(error.message).toBe("Invalid JSON");
|
||||
expect(error.data).toBe(data);
|
||||
expect(error.name).toBe("ParseError");
|
||||
});
|
||||
|
||||
test("RateLimitError should store URL and reset time", () => {
|
||||
const error = new RateLimitError("Rate limited", "https://example.com", 60);
|
||||
expect(error.message).toBe("Rate limited");
|
||||
expect(error.url).toBe("https://example.com");
|
||||
expect(error.resetTime).toBe(60);
|
||||
expect(error.name).toBe("RateLimitError");
|
||||
});
|
||||
|
||||
test("ValidationError should work without field", () => {
|
||||
const error = new ValidationError("Invalid value");
|
||||
expect(error.message).toBe("Invalid value");
|
||||
expect(error.name).toBe("ValidationError");
|
||||
});
|
||||
});
|
||||
363
test/kijiji-integration.test.ts
Normal file
363
test/kijiji-integration.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import {
|
||||
extractApolloState,
|
||||
parseDetailedListing,
|
||||
parseSearch,
|
||||
} from "../src/kijiji";
|
||||
|
||||
// Mock fetch globally
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe("HTML Parsing Integration", () => {
|
||||
beforeEach(() => {
|
||||
// Mock fetch for all tests
|
||||
global.fetch = mock(() => {
|
||||
throw new Error("fetch should be mocked in individual tests");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("extractApolloState", () => {
|
||||
test("should extract Apollo state from valid HTML", () => {
|
||||
const mockHtml =
|
||||
'<html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"__APOLLO_STATE__":{"ROOT_QUERY":{"test":"value"}}}}}</script></head></html>';
|
||||
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toEqual({
|
||||
ROOT_QUERY: { test: "value" },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null for HTML without Apollo state", () => {
|
||||
const mockHtml = "<html><body>No data here</body></html>";
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null for malformed JSON", () => {
|
||||
const mockHtml =
|
||||
'<html><script id="__NEXT_DATA__" type="application/json">{"invalid": json}</script></html>';
|
||||
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle missing __NEXT_DATA__ element", () => {
|
||||
const mockHtml = "<html><body><div>Content</div></body></html>";
|
||||
const result = extractApolloState(mockHtml);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSearch", () => {
|
||||
test("should parse search results from HTML", () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
},
|
||||
"Listing:456": {
|
||||
url: "/v-samsung/k0l0",
|
||||
title: "Samsung Galaxy",
|
||||
},
|
||||
ROOT_QUERY: { test: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toEqual({
|
||||
name: "iPhone 13 Pro",
|
||||
listingLink: "https://www.kijiji.ca/v-iphone/k0l0",
|
||||
});
|
||||
expect(results[1]).toEqual({
|
||||
name: "Samsung Galaxy",
|
||||
listingLink: "https://www.kijiji.ca/v-samsung/k0l0",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle absolute URLs", () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "https://www.kijiji.ca/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results[0].listingLink).toBe(
|
||||
"https://www.kijiji.ca/v-iphone/k0l0",
|
||||
);
|
||||
});
|
||||
|
||||
test("should filter out invalid listings", () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13 Pro",
|
||||
},
|
||||
"Listing:456": {
|
||||
url: "/v-samsung/k0l0",
|
||||
// Missing title
|
||||
},
|
||||
"Other:789": {
|
||||
url: "/v-other/k0l0",
|
||||
title: "Other Item",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const results = parseSearch(mockHtml, "https://www.kijiji.ca");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].name).toBe("iPhone 13 Pro");
|
||||
});
|
||||
|
||||
test("should return empty array for invalid HTML", () => {
|
||||
const results = parseSearch(
|
||||
"<html><body>Invalid</body></html>",
|
||||
"https://www.kijiji.ca",
|
||||
);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDetailedListing", () => {
|
||||
test("should parse detailed listing with all fields", async () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone-13-pro/k0l0",
|
||||
title: "iPhone 13 Pro 256GB",
|
||||
description: "Excellent condition iPhone 13 Pro",
|
||||
price: {
|
||||
amount: 80000,
|
||||
currency: "CAD",
|
||||
type: "FIXED",
|
||||
},
|
||||
type: "OFFER",
|
||||
status: "ACTIVE",
|
||||
activationDate: "2024-01-15T10:00:00.000Z",
|
||||
endDate: "2025-01-15T10:00:00.000Z",
|
||||
metrics: { views: 150 },
|
||||
location: {
|
||||
address: "Toronto, ON",
|
||||
id: 1700273,
|
||||
name: "Toronto",
|
||||
coordinates: {
|
||||
latitude: 43.6532,
|
||||
longitude: -79.3832,
|
||||
},
|
||||
},
|
||||
imageUrls: [
|
||||
"https://media.kijiji.ca/api/v1/image1.jpg",
|
||||
"https://media.kijiji.ca/api/v1/image2.jpg",
|
||||
],
|
||||
imageCount: 2,
|
||||
categoryId: 132,
|
||||
adSource: "ORGANIC",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: true,
|
||||
},
|
||||
posterInfo: {
|
||||
posterId: "user123",
|
||||
rating: 4.8,
|
||||
},
|
||||
attributes: [
|
||||
{
|
||||
canonicalName: "forsaleby",
|
||||
canonicalValues: ["ownr"],
|
||||
},
|
||||
{
|
||||
canonicalName: "phonecarrier",
|
||||
canonicalValues: ["unlocked"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await parseDetailedListing(
|
||||
mockHtml,
|
||||
"https://www.kijiji.ca",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
url: "https://www.kijiji.ca/v-iphone-13-pro/k0l0",
|
||||
title: "iPhone 13 Pro 256GB",
|
||||
description: "Excellent condition iPhone 13 Pro",
|
||||
listingPrice: {
|
||||
amountFormatted: "$800.00",
|
||||
cents: 80000,
|
||||
currency: "CAD",
|
||||
},
|
||||
listingType: "OFFER",
|
||||
listingStatus: "ACTIVE",
|
||||
creationDate: "2024-01-15T10:00:00.000Z",
|
||||
endDate: "2025-01-15T10:00:00.000Z",
|
||||
numberOfViews: 150,
|
||||
address: "Toronto, ON",
|
||||
images: [
|
||||
"https://media.kijiji.ca/api/v1/image1.jpg",
|
||||
"https://media.kijiji.ca/api/v1/image2.jpg",
|
||||
],
|
||||
categoryId: 132,
|
||||
adSource: "ORGANIC",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: true,
|
||||
},
|
||||
attributes: {
|
||||
forsaleby: ["ownr"],
|
||||
phonecarrier: ["unlocked"],
|
||||
},
|
||||
location: {
|
||||
id: 1700273,
|
||||
name: "Toronto",
|
||||
coordinates: {
|
||||
latitude: 43.6532,
|
||||
longitude: -79.3832,
|
||||
},
|
||||
},
|
||||
sellerInfo: {
|
||||
posterId: "user123",
|
||||
rating: 4.8,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null for contact-based pricing", async () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone for Sale",
|
||||
price: {
|
||||
type: "CONTACT",
|
||||
amount: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await parseDetailedListing(
|
||||
mockHtml,
|
||||
"https://www.kijiji.ca",
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle missing optional fields", async () => {
|
||||
const mockHtml = `
|
||||
<html>
|
||||
<script id="__NEXT_DATA__" type="application/json">
|
||||
${JSON.stringify({
|
||||
props: {
|
||||
pageProps: {
|
||||
__APOLLO_STATE__: {
|
||||
"Listing:123": {
|
||||
url: "/v-iphone/k0l0",
|
||||
title: "iPhone 13",
|
||||
price: { amount: 50000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await parseDetailedListing(
|
||||
mockHtml,
|
||||
"https://www.kijiji.ca",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
url: "https://www.kijiji.ca/v-iphone/k0l0",
|
||||
title: "iPhone 13",
|
||||
description: undefined,
|
||||
listingPrice: {
|
||||
amountFormatted: "$500.00",
|
||||
cents: 50000,
|
||||
currency: undefined,
|
||||
},
|
||||
listingType: undefined,
|
||||
listingStatus: undefined,
|
||||
creationDate: undefined,
|
||||
endDate: undefined,
|
||||
numberOfViews: undefined,
|
||||
address: null,
|
||||
images: [],
|
||||
categoryId: 0,
|
||||
adSource: "UNKNOWN",
|
||||
flags: {
|
||||
topAd: false,
|
||||
priceDrop: false,
|
||||
},
|
||||
attributes: {},
|
||||
location: {
|
||||
id: 0,
|
||||
name: "Unknown",
|
||||
coordinates: undefined,
|
||||
},
|
||||
sellerInfo: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
54
test/kijiji-utils.test.ts
Normal file
54
test/kijiji-utils.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { formatCentsToCurrency, slugify } from "../src/kijiji";
|
||||
|
||||
describe("Utility Functions", () => {
|
||||
describe("slugify", () => {
|
||||
test("should convert basic strings to slugs", () => {
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
expect(slugify("iPhone 13 Pro")).toBe("iphone-13-pro");
|
||||
});
|
||||
|
||||
test("should handle special characters", () => {
|
||||
expect(slugify("Café & Restaurant")).toBe("cafe-restaurant");
|
||||
expect(slugify("100% New")).toBe("100-new");
|
||||
});
|
||||
|
||||
test("should handle empty and edge cases", () => {
|
||||
expect(slugify("")).toBe("");
|
||||
expect(slugify(" ")).toBe("-");
|
||||
expect(slugify("---")).toBe("-");
|
||||
});
|
||||
|
||||
test("should preserve numbers and valid characters", () => {
|
||||
expect(slugify("iPhone 13")).toBe("iphone-13");
|
||||
expect(slugify("item123")).toBe("item123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCentsToCurrency", () => {
|
||||
test("should format valid cent values", () => {
|
||||
expect(formatCentsToCurrency(100)).toBe("$1.00");
|
||||
expect(formatCentsToCurrency(1999)).toBe("$19.99");
|
||||
expect(formatCentsToCurrency(0)).toBe("$0.00");
|
||||
});
|
||||
|
||||
test("should handle string inputs", () => {
|
||||
expect(formatCentsToCurrency("100")).toBe("$1.00");
|
||||
expect(formatCentsToCurrency("1999")).toBe("$19.99");
|
||||
});
|
||||
|
||||
test("should handle null/undefined inputs", () => {
|
||||
expect(formatCentsToCurrency(null)).toBe("");
|
||||
expect(formatCentsToCurrency(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("should handle invalid inputs", () => {
|
||||
expect(formatCentsToCurrency("invalid")).toBe("");
|
||||
expect(formatCentsToCurrency(Number.NaN)).toBe("");
|
||||
});
|
||||
|
||||
test("should use en-US locale formatting", () => {
|
||||
expect(formatCentsToCurrency(123456)).toBe("$1,234.56");
|
||||
});
|
||||
});
|
||||
});
|
||||
14
test/setup.ts
Normal file
14
test/setup.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Test setup for Bun test runner
|
||||
import { expect } from "bun:test";
|
||||
|
||||
// Global test setup
|
||||
// This file is loaded before any tests run due to bunfig.toml preload
|
||||
|
||||
// Mock fetch globally for tests
|
||||
global.fetch =
|
||||
global.fetch ||
|
||||
(() => {
|
||||
throw new Error("fetch is not available in test environment");
|
||||
});
|
||||
|
||||
// Add any global test utilities here
|
||||
@@ -7,25 +7,21 @@
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitAny": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user