From 11dce39428b1da4c971d2ebc07cdcc6742443dea Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Tue, 28 Apr 2026 21:57:10 -0400 Subject: [PATCH] fix(core): parse Kijiji StandardListing records --- packages/core/src/scrapers/kijiji.ts | 34 +++- packages/core/test/kijiji-integration.test.ts | 154 ++++++++++++++++++ 2 files changed, 180 insertions(+), 8 deletions(-) diff --git a/packages/core/src/scrapers/kijiji.ts b/packages/core/src/scrapers/kijiji.ts index 9c3ecc2..54015db 100644 --- a/packages/core/src/scrapers/kijiji.ts +++ b/packages/core/src/scrapers/kijiji.ts @@ -46,6 +46,17 @@ interface ApolloSearchItem { [k: string]: unknown; } +type ListingAttribute = { + canonicalName?: string; + canonicalValues?: string[]; +}; + +type ListingAttributes = + | ListingAttribute[] + | { + all?: ListingAttribute[]; + }; + interface ApolloListingRoot { url?: string; title?: string; @@ -68,7 +79,7 @@ interface ApolloListingRoot { adSource?: string; flags?: { topAd?: boolean; priceDrop?: boolean }; posterInfo?: { posterId?: string; rating?: number }; - attributes?: Array<{ canonicalName?: string; canonicalValues?: string[] }>; + attributes?: ListingAttributes; [k: string]: unknown; } @@ -323,13 +334,22 @@ function findApolloListingKey( predicate: (value: Record) => boolean, ): string | undefined { return Object.keys(apolloState).find((key) => { - if (!key.startsWith("Listing:")) return false; + if (!isListingRecordKey(key)) return false; const value = apolloState[key]; return isRecord(value) && predicate(value); }); } +function isListingRecordKey(key: string): boolean { + return key.startsWith("Listing:") || key.startsWith("StandardListing:"); +} + +function getListingAttributes(attributes: ListingAttributes | undefined) { + if (Array.isArray(attributes)) return attributes; + return attributes?.all ?? []; +} + /** * Slugifies a string for Kijiji search URLs */ @@ -532,7 +552,7 @@ export function parseSearch( const results: SearchListing[] = []; for (const [key, value] of Object.entries(apolloState)) { - if (!key.startsWith("Listing:")) continue; + if (!isListingRecordKey(key)) continue; if (!isRecord(value)) continue; const item = value as ApolloSearchItem; @@ -689,11 +709,9 @@ export async function parseDetailedListing( // Extract attributes as key-value pairs const attributeMap: Record = {}; - if (Array.isArray(attributes)) { - for (const attr of attributes) { - if (attr?.canonicalName && Array.isArray(attr.canonicalValues)) { - attributeMap[attr.canonicalName] = attr.canonicalValues; - } + for (const attr of getListingAttributes(attributes)) { + if (attr.canonicalName && Array.isArray(attr.canonicalValues)) { + attributeMap[attr.canonicalName] = attr.canonicalValues; } } diff --git a/packages/core/test/kijiji-integration.test.ts b/packages/core/test/kijiji-integration.test.ts index f638233..8227437 100644 --- a/packages/core/test/kijiji-integration.test.ts +++ b/packages/core/test/kijiji-integration.test.ts @@ -149,6 +149,48 @@ describe("HTML Parsing Integration", () => { expect(results[0]?.name).toBe("iPhone 13 Pro"); }); + test("should parse current StandardListing search records", () => { + const mockHtml = ` + + + + `; + + const results = parseSearch(mockHtml, "https://www.kijiji.ca"); + expect(results).toEqual([ + { + name: "iPhone 13", + listingLink: + "https://www.kijiji.ca/v-cell-phone/city-of-toronto/iphone-13/123", + }, + { + name: "iPhone 14", + listingLink: + "https://www.kijiji.ca/v-cell-phone/city-of-toronto/iphone-14/456", + }, + ]); + }); + test("should return empty array for invalid HTML", () => { const results = parseSearch( "Invalid", @@ -303,6 +345,118 @@ describe("HTML Parsing Integration", () => { expect(result).toBeNull(); }); + test("should parse current StandardListing detail records", async () => { + const mockHtml = ` + + + + `; + + const result = await parseDetailedListing( + mockHtml, + "https://www.kijiji.ca", + ); + expect(result).toEqual({ + url: "https://www.kijiji.ca/v-cell-phone/city-of-toronto/iphone-13/123", + title: "iPhone 13", + description: "Lightly used iPhone 13", + listingPrice: { + amountFormatted: "$450.00", + cents: 45000, + currency: "CAD", + }, + listingType: "OFFER", + listingStatus: "ACTIVE", + creationDate: "2026-04-20T10:00:00.000Z", + endDate: undefined, + numberOfViews: 12, + address: "Toronto, ON", + images: ["https://media.kijiji.ca/api/v1/image1.jpg"], + categoryId: 760, + adSource: "ORGANIC", + flags: { + topAd: false, + priceDrop: false, + }, + attributes: { + forsaleby: ["ownr"], + phonebrand: ["apple"], + }, + location: { + id: 1700273, + name: "City of Toronto", + coordinates: { + latitude: 43.6532, + longitude: -79.3832, + }, + }, + sellerInfo: { + posterId: "user123", + rating: 4.5, + }, + }); + }); + test("should handle missing optional fields", async () => { const mockHtml = `