From e0b8fbc2545a646253f5de4f903187980809290c Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 12 Nov 2025 14:36:17 +0100 Subject: [PATCH 001/134] Node init --- crawler/.gitignore | 1 + crawler/package-lock.json | 295 ++++++++++++++++++++++++++++++++++++++ crawler/package.json | 14 ++ 3 files changed, 310 insertions(+) create mode 100644 crawler/package-lock.json create mode 100644 crawler/package.json diff --git a/crawler/.gitignore b/crawler/.gitignore index e69de29..b512c09 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/crawler/package-lock.json b/crawler/package-lock.json new file mode 100644 index 0000000..9281009 --- /dev/null +++ b/crawler/package-lock.json @@ -0,0 +1,295 @@ +{ + "name": "crawler", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crawler", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.13.2" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + } + } +} diff --git a/crawler/package.json b/crawler/package.json new file mode 100644 index 0000000..9cf3b83 --- /dev/null +++ b/crawler/package.json @@ -0,0 +1,14 @@ +{ + "name": "crawler", + "version": "1.0.0", + "description": "STAC Index crawler", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "axios": "^1.13.2" + } +} From ff72c8d97e47dff7dfdfe74dd070262a7b0f1154 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 12 Nov 2025 14:41:28 +0100 Subject: [PATCH 002/134] Basic Crawling of STAC Index API --- crawler/index.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 crawler/index.js diff --git a/crawler/index.js b/crawler/index.js new file mode 100644 index 0000000..75dcc65 --- /dev/null +++ b/crawler/index.js @@ -0,0 +1,19 @@ +// npm install axios cheerio +const axios = require('axios'); + +// specify the URL of the site to crawl +const targetUrl = 'https://www.stacindex.org/api/catalogs'; + +// define a crawler function +const crawler = async () => { + try { + // request the target website + const response = await axios.get(targetUrl); + console.log(response.data); + } catch (error) { + // handle any error that occurs during the HTTP request + console.error(`Error fetching ${targetUrl}: ${error.message}`); + } +}; + +crawler(); From 30530dac494778ac2010d015693b2ea79c67a62a Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 12 Nov 2025 18:49:52 +0100 Subject: [PATCH 003/134] feat: every object in the stac index, will be converted to an dynamic array --- crawler/index.js | 131 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 124 insertions(+), 7 deletions(-) diff --git a/crawler/index.js b/crawler/index.js index 75dcc65..89a2abf 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -1,19 +1,136 @@ -// npm install axios cheerio +/** + * @fileoverview STAC Index API crawler that fetches and processes catalog data + * @module crawler + */ + const axios = require('axios'); -// specify the URL of the site to crawl +/** + * URL of the STAC Index API endpoint + * @type {string} + */ const targetUrl = 'https://www.stacindex.org/api/catalogs'; -// define a crawler function +/** + * Fetches catalog data from the STAC Index API and processes it + * @async + * @function crawler + * @returns {Promise} + */ const crawler = async () => { try { - // request the target website const response = await axios.get(targetUrl); - console.log(response.data); + splitCatalogs(response.data); } catch (error) { - // handle any error that occurs during the HTTP request console.error(`Error fetching ${targetUrl}: ${error.message}`); } }; -crawler(); +/** + * Splits an array of catalogs into individual elements where each element is an array of its properties + * @param {Array} catalogs - Array of catalog objects from the STAC Index API + * @returns {Array} Array of arrays, where each inner array contains: + * [0] = index, [1] = id, [2] = url, [3] = slug, [4] = title, [5] = summary, + * [6] = access, [7] = created, [8] = updated, [9] = isPrivate, [10] = isApi, + * [11] = accessInfo, [12] = categories, ... (followed by any other dynamic properties) + * @throws {Error} Throws error if input is not an array + */ +function splitCatalogs(catalogs) { + if (!Array.isArray(catalogs)) { + throw new Error('Expected an array'); + } + + // Define the order of standard properties + const standardPropertyOrder = [ + 'index', 'id', 'url', 'slug', 'title', 'summary', + 'access', 'created', 'updated', 'isPrivate', 'isApi', 'accessInfo' + ]; + + // Split each element individually and convert to array format + const elements = catalogs.map((catalog, index) => { + + // Create object with all properties + const elementObj = { + index: index, + ...catalog, // All properties of the catalog object are automatically inherited + }; + + // Get all property names + const allProperties = Object.keys(elementObj); + + // Separate standard and dynamic properties + const standardProps = standardPropertyOrder.filter(prop => prop in elementObj); + const dynamicProps = allProperties.filter(prop => !standardPropertyOrder.includes(prop)); + + // Create array with standard properties first, then dynamic ones + const elementArray = []; + + // Add standard properties in defined order + standardProps.forEach(prop => { + elementArray.push(elementObj[prop]); + }); + + // Add dynamic properties + dynamicProps.forEach(prop => { + elementArray.push(elementObj[prop]); + }); + return elementArray; + }); + + // Output for demonstration + console.log(`Total: ${elements.length} elements found\n`); + + // Output first element as example with index mapping (remove later on) + + if (elements.length > 0) { + console.log('Example - First Element (as array):'); + console.log('Array indices and values:'); + const firstElement = elements[0]; + const allProps = [ + 'index', 'id', 'url', 'slug', 'title', 'summary', + 'access', 'created', 'updated', 'isPrivate', 'isApi', 'accessInfo' + ]; + firstElement.forEach((value, idx) => { + const propName = idx < allProps.length ? allProps[idx] : `dynamic_property_${idx}`; + console.log(` [${idx}] ${propName}: ${JSON.stringify(value)}`); + }); + console.log('\nAccess examples:'); + console.log(` elements[0][0] (index): ${elements[0][0]}`); + console.log(` elements[0][1] (id): ${elements[0][1]}`); + console.log(` elements[0][2] (url): ${elements[0][2]}`); + console.log(` elements[0][7] (created): ${elements[0][7]}`); + } + + return elements; +} + +/** + * Derives categories from a catalog object by checking various possible fields + * @param {Object} catalog - Catalog object to extract categories from + * @returns {Array} Array of category strings, empty array if none found + */ +function deriveCategories(catalog) { + if (!catalog || typeof catalog !== 'object') { + return []; + } + + if (Array.isArray(catalog.categories)) { + return catalog.categories.filter(Boolean).map(String); + } + + if (Array.isArray(catalog.keywords)) { + return catalog.keywords.filter(Boolean).map(String); + } + + if (Array.isArray(catalog.tags)) { + return catalog.tags.filter(Boolean).map(String); + } + + if (typeof catalog.access === 'string' && catalog.access.trim().length) { + return [catalog.access.trim()]; + } + + return []; +} + +crawler(); \ No newline at end of file From eb0c8fd3ab469c83288d1e280d9a1ba341dbecdd Mon Sep 17 00:00:00 2001 From: Jakob Date: Tue, 18 Nov 2025 17:55:36 +0100 Subject: [PATCH 004/134] added crawling to collection level, including nested catalogs --- crawler/index.js | 153 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/crawler/index.js b/crawler/index.js index 89a2abf..f3aafd0 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -20,7 +20,17 @@ const targetUrl = 'https://www.stacindex.org/api/catalogs'; const crawler = async () => { try { const response = await axios.get(targetUrl); - splitCatalogs(response.data); + const catalogs = splitCatalogs(response.data); + + // Crawl collections and nested catalogs for each catalog + console.log('\n Crawling collections and nested catalogs...\n'); + let totalCollections = 0; + for (const catalog of response.data.slice(0, 10)) { // Limit to first 10 catalogs + const stats = await crawlCatalogRecursive(catalog); + totalCollections += stats.collections; + } + + console.log(`\n Total collections found across all catalogs: ${totalCollections}`); } catch (error) { console.error(`Error fetching ${targetUrl}: ${error.message}`); } @@ -104,6 +114,147 @@ function splitCatalogs(catalogs) { return elements; } +/** + * Fetches and processes collections from a catalog + * @async + * @param {Object} catalog - Catalog object with url property + * @returns {Promise} Array of collections formatted as arrays + */ +async function getCollections(catalog) { + try { + // Try common STAC collection endpoints + const collectionUrls = [ + `${catalog.url}/collections`, + `${catalog.url}/collections/`, + `${catalog.url}/api/v1/collections` + ]; + + for (const url of collectionUrls) { + try { + const response = await axios.get(url, { timeout: 5000 }); + const collectionsData = Array.isArray(response.data) + ? response.data + : response.data.collections || []; + + if (collectionsData.length > 0) { + console.log(`\n Catalog: ${catalog.id}`); + console.log(` Found ${collectionsData.length} collections`); + + // Convert collections to array format similar to catalogs + const collections = collectionsData.map((col, index) => { + return [ + index, + col.id || 'Unknown', + col.url || col.links?.find(l => l.rel === 'self')?.href || 'N/A', + col.title || 'N/A', + col.description || col.summary || 'N/A', + col.extent?.spatial?.bbox?.[0] || 'N/A', + col.extent?.temporal?.interval?.[0] || 'N/A', + col.license || 'N/A', + col.keywords || [] + ]; + }); + + // Display collection details + if (collections.length > 0) { + console.log(' Collection details:'); + collections.forEach((col, idx) => { + console.log(` [${idx}] ${col[1]} - ${col[3]}`); + }); + } + + return collections; + } + } catch (e) { + // Try next URL + continue; + } + } + + console.log(`\n No collections found for catalog: ${catalog.id}`); + return []; + + } catch (error) { + console.error(`Error fetching collections for ${catalog.id}: ${error.message}`); + return []; + } +} + +/** + * Fetches nested catalogs within a catalog + * @async + * @param {Object} catalog - Catalog object with url property + * @returns {Promise} Array of nested catalogs + */ +async function getNestedCatalogs(catalog) { + try { + // Try common STAC nested catalog endpoints + const catalogUrls = [ + `${catalog.url}/catalogs`, + `${catalog.url}/catalogs/`, + `${catalog.url}/api/v1/catalogs` + ]; + + for (const url of catalogUrls) { + try { + const response = await axios.get(url, { timeout: 5000 }); + const catalogsData = Array.isArray(response.data) + ? response.data + : response.data.catalogs || []; + + if (catalogsData.length > 0) { + return catalogsData; + } + } catch (e) { + // Try next URL + continue; + } + } + + return []; + + } catch (error) { + console.error(`Error fetching nested catalogs for ${catalog.id}: ${error.message}`); + return []; + } +} + +/** + * Recursively crawls a catalog, its collections, and nested catalogs + * @async + * @param {Object} catalog - Catalog object to crawl + * @param {number} depth - Current depth level (for indentation) + * @returns {Promise} Stats object with collections count + */ +async function crawlCatalogRecursive(catalog, depth = 0) { + const indent = ' '.repeat(depth); + let stats = { collections: 0 }; + + try { + // First, try to get collections from this catalog + const collections = await getCollections(catalog); + stats.collections = collections.length; + + // Then, try to get nested catalogs + const nestedCatalogs = await getNestedCatalogs(catalog); + + if (nestedCatalogs.length > 0) { + console.log(`${indent} Nested catalogs found: ${nestedCatalogs.length}`); + + // Recursively crawl each nested catalog + for (const nestedCatalog of nestedCatalogs.slice(0, 5)) { // Limit to first 5 nested + const nestedStats = await crawlCatalogRecursive(nestedCatalog, depth + 1); + stats.collections += nestedStats.collections; + } + } + + } catch (error) { + console.error(`Error in recursive crawl for ${catalog.id}: ${error.message}`); + } + + return stats; +} + /** * Derives categories from a catalog object by checking various possible fields * @param {Object} catalog - Catalog object to extract categories from From 69b9ebb2d992a20747e14471e9fbc864c41ef8e3 Mon Sep 17 00:00:00 2001 From: Jakob Date: Tue, 18 Nov 2025 18:01:54 +0100 Subject: [PATCH 005/134] Closes #55; added crawling to collection level, including nested catalogs --- crawler/index.js | 153 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/crawler/index.js b/crawler/index.js index 89a2abf..f3aafd0 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -20,7 +20,17 @@ const targetUrl = 'https://www.stacindex.org/api/catalogs'; const crawler = async () => { try { const response = await axios.get(targetUrl); - splitCatalogs(response.data); + const catalogs = splitCatalogs(response.data); + + // Crawl collections and nested catalogs for each catalog + console.log('\n Crawling collections and nested catalogs...\n'); + let totalCollections = 0; + for (const catalog of response.data.slice(0, 10)) { // Limit to first 10 catalogs + const stats = await crawlCatalogRecursive(catalog); + totalCollections += stats.collections; + } + + console.log(`\n Total collections found across all catalogs: ${totalCollections}`); } catch (error) { console.error(`Error fetching ${targetUrl}: ${error.message}`); } @@ -104,6 +114,147 @@ function splitCatalogs(catalogs) { return elements; } +/** + * Fetches and processes collections from a catalog + * @async + * @param {Object} catalog - Catalog object with url property + * @returns {Promise} Array of collections formatted as arrays + */ +async function getCollections(catalog) { + try { + // Try common STAC collection endpoints + const collectionUrls = [ + `${catalog.url}/collections`, + `${catalog.url}/collections/`, + `${catalog.url}/api/v1/collections` + ]; + + for (const url of collectionUrls) { + try { + const response = await axios.get(url, { timeout: 5000 }); + const collectionsData = Array.isArray(response.data) + ? response.data + : response.data.collections || []; + + if (collectionsData.length > 0) { + console.log(`\n Catalog: ${catalog.id}`); + console.log(` Found ${collectionsData.length} collections`); + + // Convert collections to array format similar to catalogs + const collections = collectionsData.map((col, index) => { + return [ + index, + col.id || 'Unknown', + col.url || col.links?.find(l => l.rel === 'self')?.href || 'N/A', + col.title || 'N/A', + col.description || col.summary || 'N/A', + col.extent?.spatial?.bbox?.[0] || 'N/A', + col.extent?.temporal?.interval?.[0] || 'N/A', + col.license || 'N/A', + col.keywords || [] + ]; + }); + + // Display collection details + if (collections.length > 0) { + console.log(' Collection details:'); + collections.forEach((col, idx) => { + console.log(` [${idx}] ${col[1]} - ${col[3]}`); + }); + } + + return collections; + } + } catch (e) { + // Try next URL + continue; + } + } + + console.log(`\n No collections found for catalog: ${catalog.id}`); + return []; + + } catch (error) { + console.error(`Error fetching collections for ${catalog.id}: ${error.message}`); + return []; + } +} + +/** + * Fetches nested catalogs within a catalog + * @async + * @param {Object} catalog - Catalog object with url property + * @returns {Promise} Array of nested catalogs + */ +async function getNestedCatalogs(catalog) { + try { + // Try common STAC nested catalog endpoints + const catalogUrls = [ + `${catalog.url}/catalogs`, + `${catalog.url}/catalogs/`, + `${catalog.url}/api/v1/catalogs` + ]; + + for (const url of catalogUrls) { + try { + const response = await axios.get(url, { timeout: 5000 }); + const catalogsData = Array.isArray(response.data) + ? response.data + : response.data.catalogs || []; + + if (catalogsData.length > 0) { + return catalogsData; + } + } catch (e) { + // Try next URL + continue; + } + } + + return []; + + } catch (error) { + console.error(`Error fetching nested catalogs for ${catalog.id}: ${error.message}`); + return []; + } +} + +/** + * Recursively crawls a catalog, its collections, and nested catalogs + * @async + * @param {Object} catalog - Catalog object to crawl + * @param {number} depth - Current depth level (for indentation) + * @returns {Promise} Stats object with collections count + */ +async function crawlCatalogRecursive(catalog, depth = 0) { + const indent = ' '.repeat(depth); + let stats = { collections: 0 }; + + try { + // First, try to get collections from this catalog + const collections = await getCollections(catalog); + stats.collections = collections.length; + + // Then, try to get nested catalogs + const nestedCatalogs = await getNestedCatalogs(catalog); + + if (nestedCatalogs.length > 0) { + console.log(`${indent} Nested catalogs found: ${nestedCatalogs.length}`); + + // Recursively crawl each nested catalog + for (const nestedCatalog of nestedCatalogs.slice(0, 5)) { // Limit to first 5 nested + const nestedStats = await crawlCatalogRecursive(nestedCatalog, depth + 1); + stats.collections += nestedStats.collections; + } + } + + } catch (error) { + console.error(`Error in recursive crawl for ${catalog.id}: ${error.message}`); + } + + return stats; +} + /** * Derives categories from a catalog object by checking various possible fields * @param {Object} catalog - Catalog object to extract categories from From eaea3e0399601aaf3706329bf4746b4246e12707 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 19 Nov 2025 12:11:56 +0100 Subject: [PATCH 006/134] feat: implement database connection with node pg for catalog management --- crawler/db.js | 111 +++++++++ crawler/index.js | 22 +- crawler/package-lock.json | 479 ++++++++++++++++++++++++++++++++++++++ crawler/package.json | 1 + 4 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 crawler/db.js diff --git a/crawler/db.js b/crawler/db.js new file mode 100644 index 0000000..74bd790 --- /dev/null +++ b/crawler/db.js @@ -0,0 +1,111 @@ +/** + * DB helper for crawler using `pg` Pool + * Exposes: initDb(), insertOrUpdateCatalog(), close() + */ +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + host: process.env.PGHOST || 'localhost', + port: parseInt(process.env.PGPORT || '5432', 10), + user: process.env.PGUSER || 'stac_user', + password: process.env.PGPASSWORD || 'stac_pass', + database: process.env.PGDATABASE || 'stac_db', + max: 10, +}); + +async function initDb() { + const client = await pool.connect(); + try { + // create table if missing and add useful indexes + await client.query(` + CREATE TABLE IF NOT EXISTS catalogs ( + id SERIAL PRIMARY KEY, + catalog_id TEXT UNIQUE, + url TEXT, + title TEXT, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() + ); + `); + + // create GIN index on metadata for JSONB queries + await client.query(` + CREATE INDEX IF NOT EXISTS idx_catalogs_metadata_gin ON catalogs USING GIN (metadata jsonb_path_ops); + `); + + // try enabling PostGIS + try { + await client.query(`CREATE EXTENSION IF NOT EXISTS postgis;`); + } catch (e) { + // ignore extension permission errors + } + + console.log('DB initialized (catalogs table present)'); + } finally { + client.release(); + } +} + +async function insertOrUpdateCatalog(catalog) { + if (!catalog || typeof catalog !== 'object') return null; + const catalogId = catalog.id || catalog.catalog_id || catalog.slug || null; + if (!catalogId) return null; + + const url = catalog.url || null; + const title = catalog.title || catalog.name || null; + const metadata = catalog; + const now = new Date().toISOString(); + + const query = ` + INSERT INTO catalogs (catalog_id, url, title, metadata, updated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (catalog_id) DO UPDATE SET + url = EXCLUDED.url, + title = EXCLUDED.title, + metadata = catalogs.metadata || EXCLUDED.metadata, + updated_at = EXCLUDED.updated_at + RETURNING id; + `; + const values = [catalogId, url, title, metadata, now]; + const res = await pool.query(query, values); + return res.rows[0]; +} + +async function close() { + await pool.end(); +} + +module.exports = { initDb, insertOrUpdateCatalog, close, pool }; + + + +/** + +const createConnectionPool = require('@databases/pg'); +const {sql} = require('@databases/pg'); + +async function run() { + // N.B. you will need to replace this connection + // string with the correct string for your database. + const db = createConnectionPool( + 'postgres://test-user@localhost:5432/test-db', + ); + + const results = await db.query(sql` + SELECT 1 + 1 as result; + `); + + console.log(results); + // => [{result: 2}] + + await db.dispose(); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); + +*/ diff --git a/crawler/index.js b/crawler/index.js index 89a2abf..6bd8b83 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -4,6 +4,7 @@ */ const axios = require('axios'); +const db = require('./db'); /** * URL of the STAC Index API endpoint @@ -19,10 +20,13 @@ const targetUrl = 'https://www.stacindex.org/api/catalogs'; */ const crawler = async () => { try { + await db.initDb(); const response = await axios.get(targetUrl); - splitCatalogs(response.data); + await splitCatalogs(response.data); } catch (error) { console.error(`Error fetching ${targetUrl}: ${error.message}`); + } finally { + // keep DB open for now; close on process exit } }; @@ -35,7 +39,7 @@ const crawler = async () => { * [11] = accessInfo, [12] = categories, ... (followed by any other dynamic properties) * @throws {Error} Throws error if input is not an array */ -function splitCatalogs(catalogs) { +async function splitCatalogs(catalogs) { if (!Array.isArray(catalogs)) { throw new Error('Expected an array'); } @@ -101,6 +105,20 @@ function splitCatalogs(catalogs) { console.log(` elements[0][7] (created): ${elements[0][7]}`); } + // Persist catalogs into DB + try { + await Promise.all(catalogs.map(async (catalog) => { + try { + await db.insertOrUpdateCatalog(catalog); + } catch (err) { + console.error('DB write error for catalog', catalog && (catalog.id || catalog.slug), err && err.message); + } + })); + console.log('Finished writing catalogs to DB'); + } catch (e) { + console.error('Unexpected error while saving catalogs to DB', e && e.message); + } + return elements; } diff --git a/crawler/package-lock.json b/crawler/package-lock.json index 9281009..b668946 100644 --- a/crawler/package-lock.json +++ b/crawler/package-lock.json @@ -9,9 +9,171 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@databases/pg": "^5.5.0", "axios": "^1.13.2" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@databases/connection-pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@databases/connection-pool/-/connection-pool-1.1.0.tgz", + "integrity": "sha512-/12/SNgl0V77mJTo5SX3yGPz4c9XGQwAlCfA0vlfs/0HcaErNpYXpmhj0StET07w6TmTJTnaUgX2EPcQK9ez5A==", + "license": "MIT", + "dependencies": { + "@databases/queue": "^1.0.0", + "is-promise": "^4.0.0" + } + }, + "node_modules/@databases/escape-identifier": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@databases/escape-identifier/-/escape-identifier-1.0.3.tgz", + "integrity": "sha512-Su36iSVzaHxpVdISVMViUX/32sLvzxVgjZpYhzhotxZUuLo11GVWsiHwqkvUZijTLUxcDmUqEwGJO3O/soLuZA==", + "license": "MIT", + "dependencies": { + "@databases/validate-unicode": "^1.0.0" + } + }, + "node_modules/@databases/lock": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@databases/lock/-/lock-2.1.0.tgz", + "integrity": "sha512-ReWnFE5qeCuO2SA5h5fDh/hE/vMolA+Epe6xkAQP1FL2nhnsTCYwN2JACk/kWctR4OQoh0njBjPZ0yfIptclcA==", + "license": "MIT", + "dependencies": { + "@databases/queue": "^1.0.0" + } + }, + "node_modules/@databases/pg": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@databases/pg/-/pg-5.5.0.tgz", + "integrity": "sha512-WIojK9AYIlNi5YRfc5YUOow3PQ82ClmwT9HG3nEsKLUERYieoVmHMYDQLS0ry6FjgJx+2yFs7LCw4kZpWu1TBw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "@databases/escape-identifier": "^1.0.3", + "@databases/pg-config": "^3.2.0", + "@databases/pg-connection-string": "^1.0.0", + "@databases/pg-data-type-id": "^3.0.0", + "@databases/pg-errors": "^1.0.0", + "@databases/push-to-async-iterable": "^3.0.0", + "@databases/shared": "^3.1.0", + "@databases/split-sql-query": "^1.0.4", + "@databases/sql": "^3.3.0", + "assert-never": "^1.2.1", + "pg": "^8.4.2", + "pg-cursor": "^2.4.2" + } + }, + "node_modules/@databases/pg-config": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@databases/pg-config/-/pg-config-3.4.0.tgz", + "integrity": "sha512-4dYiTbHjzyQfEfaIGkh3uCBNBRWPs5Jcws94cFagLAGnjO/TcghC7oexzC81+bIADLDlpCw7DEWJAK/gNSQxkw==", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^8.1.0", + "funtypes": "^4.1.0" + } + }, + "node_modules/@databases/pg-connection-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@databases/pg-connection-string/-/pg-connection-string-1.0.0.tgz", + "integrity": "sha512-8czOF9jlv7PlS7BPjnL82ynpDs1t8cu+C2jvdtMr37e8daPKMS7n1KfNE9xtr2Gq4QYKjynep097eYa5yIwcLA==", + "license": "MIT" + }, + "node_modules/@databases/pg-data-type-id": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@databases/pg-data-type-id/-/pg-data-type-id-3.0.0.tgz", + "integrity": "sha512-VqW1csN8pRsWJxjPsGIC9FQ8wyenfmGv0P//BaeDMAu/giM3IXKxKM8fkScUSQ00uqFK/L1iHS5g6dgodF3XzA==", + "license": "MIT" + }, + "node_modules/@databases/pg-errors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@databases/pg-errors/-/pg-errors-1.0.0.tgz", + "integrity": "sha512-Yz3exbptZwOn4ZD/MSwY6z++XVyOFsMh5DERvSw3awRwJFnfdaqdeiIxxX0MVjM6KPihF0xxp8lPO7vTc5ydpw==", + "license": "MIT" + }, + "node_modules/@databases/push-to-async-iterable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@databases/push-to-async-iterable/-/push-to-async-iterable-3.0.0.tgz", + "integrity": "sha512-xwu/yNgINdMU+fn6UwFsxh+pa6UrVPafY+0qm0RK0/nKyjllfDqSbwK4gSmdmLEwPYxKwch9CAE3P8NxN1hPSg==", + "license": "MIT", + "dependencies": { + "@databases/queue": "^1.0.0" + } + }, + "node_modules/@databases/queue": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@databases/queue/-/queue-1.0.1.tgz", + "integrity": "sha512-dqRU+/aQ4lhFzjPIkIhjB0+UEKMb76FoBgHOJUTcEblgatr/IhdhHliT3VVwcImXh35Mz297PAXE4yFM4eYWUQ==", + "license": "MIT" + }, + "node_modules/@databases/shared": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@databases/shared/-/shared-3.1.0.tgz", + "integrity": "sha512-bO1DIYAYDiWOCqVPvBio1JqZQYh4dph2M1av2w/REeFT6WBd64mTrOFlcxKV0CUAYT0UiJsDfPqEfw0/APRzWg==", + "license": "MIT", + "dependencies": { + "@databases/connection-pool": "^1.1.0", + "@databases/lock": "^2.1.0", + "@databases/queue": "^1.0.1", + "@databases/split-sql-query": "^1.0.4", + "@databases/sql": "^3.3.0" + } + }, + "node_modules/@databases/split-sql-query": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@databases/split-sql-query/-/split-sql-query-1.0.4.tgz", + "integrity": "sha512-lDqDQvH34NNjLs0knaDvL6HKgPtishQlDYHfOkvbAd5VQOEhcDvvmG2zbBuFvS2HQAz5NsyLj5erGaxibkxhvQ==", + "license": "MIT", + "peerDependencies": { + "@databases/sql": "*" + } + }, + "node_modules/@databases/sql": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@databases/sql/-/sql-3.3.0.tgz", + "integrity": "sha512-vj9huEy4mjJ48GS1Z8yvtMm4BYAnFYACUds25ym6Gd/gsnngkJ17fo62a6mmbNNwCBS/8467PmZR01Zs/06TjA==", + "license": "MIT" + }, + "node_modules/@databases/validate-unicode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@databases/validate-unicode/-/validate-unicode-1.0.0.tgz", + "integrity": "sha512-dLKqxGcymeVwEb/6c44KjOnzaAafFf0Wxa8xcfEjx/qOl3rdijsKYBAtIGhtVtOlpPf/PFKfgTuFurSPn/3B/g==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -42,6 +204,15 @@ "node": ">= 0.4" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -54,6 +225,32 @@ "node": ">= 0.8" } }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -77,6 +274,15 @@ "node": ">= 0.4" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -167,6 +373,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/funtypes": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/funtypes/-/funtypes-4.2.0.tgz", + "integrity": "sha512-DvOtjiKvkeuXGV0O8LQh9quUP3bSOTEQPGv537Sao8kDq2rDbg48UsSJ7wlBLPzR2Mn0pV7cyAiq5pYG1oUyCQ==", + "license": "MIT" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -255,6 +467,64 @@ "node": ">= 0.4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -285,11 +555,220 @@ "node": ">= 0.6" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-cursor": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.15.3.tgz", + "integrity": "sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==", + "license": "MIT", + "peerDependencies": { + "pg": "^8" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/crawler/package.json b/crawler/package.json index 9cf3b83..47988ed 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -9,6 +9,7 @@ "author": "", "license": "ISC", "dependencies": { + "@databases/pg": "^5.5.0", "axios": "^1.13.2" } } From dda02f227b67c752883e000e3cf788eaf787ec35 Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 19 Nov 2025 14:47:10 +0100 Subject: [PATCH 007/134] closes #53, set up docker for the crawler component --- crawler/.dockerignore | 6 +++++ crawler/Dockerfile | 17 +++++++++++++ crawler/README.md | 49 ++++++++++++++++++++++++++++++++++++++ crawler/docker-compose.yml | 12 ++++++++++ crawler/package.json | 5 ++++ 5 files changed, 89 insertions(+) create mode 100644 crawler/.dockerignore create mode 100644 crawler/Dockerfile create mode 100644 crawler/docker-compose.yml diff --git a/crawler/.dockerignore b/crawler/.dockerignore new file mode 100644 index 0000000..f94907b --- /dev/null +++ b/crawler/.dockerignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +.git +.gitignore +.env +*.md diff --git a/crawler/Dockerfile b/crawler/Dockerfile new file mode 100644 index 0000000..6c77666 --- /dev/null +++ b/crawler/Dockerfile @@ -0,0 +1,17 @@ +# Use official Node.js LTS image +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --production + +# Copy application files +COPY . . + +# Run the crawler +CMD ["node", "index.js"] diff --git a/crawler/README.md b/crawler/README.md index e69de29..1293303 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -0,0 +1,49 @@ +# STAC Crawler + +A Node.js crawler for STAC Index API that fetches and processes catalog and collection data. + +## Running Locally + +```bash +npm install +npm start +``` + +## Docker + +### Build and run with Docker + +```bash +# Build the image +docker build -t stac-crawler + +# Run the container +docker run --rm stac-crawler +``` + +Or use npm scripts: + +```bash +npm run docker:build +npm run docker:run +``` + +### Using Docker Compose + +```bash +# Start the crawler +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop the crawler +docker-compose down +``` + +Or use npm scripts: + +```bash +npm run docker:compose:up +npm run docker:compose:down +``` diff --git a/crawler/docker-compose.yml b/crawler/docker-compose.yml new file mode 100644 index 0000000..797cb5f --- /dev/null +++ b/crawler/docker-compose.yml @@ -0,0 +1,12 @@ +services: + crawler: + build: + context: . + dockerfile: Dockerfile + container_name: stac-crawler + restart: unless-stopped + environment: + - NODE_ENV=production + # Optional: Add volume to persist data + # volumes: + # - ./data:/app/data diff --git a/crawler/package.json b/crawler/package.json index 9cf3b83..e9ab811 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -4,6 +4,11 @@ "description": "STAC Index crawler", "main": "index.js", "scripts": { + "start": "node index.js", + "docker:build": "docker build -t stac-crawler .", + "docker:run": "docker run --rm stac-crawler", + "docker:compose:up": "docker-compose up -d", + "docker:compose:down": "docker-compose down", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", From b3e289f2731488c93a8588fed8e49f64ff67eae3 Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 19 Nov 2025 15:04:30 +0100 Subject: [PATCH 008/134] fixes #55, restructured the crawler component into catalog and api crawling --- crawler/apis/api.js | 4 + crawler/catalogs/catalog.js | 260 ++++++++++++++++++++++++++++++++++++ crawler/index.js | 251 +--------------------------------- 3 files changed, 266 insertions(+), 249 deletions(-) create mode 100644 crawler/apis/api.js create mode 100644 crawler/catalogs/catalog.js diff --git a/crawler/apis/api.js b/crawler/apis/api.js new file mode 100644 index 0000000..87cdde2 --- /dev/null +++ b/crawler/apis/api.js @@ -0,0 +1,4 @@ +/** + * @fileoverview API crawling functionality for STAC Index + * @module apis/api + */ \ No newline at end of file diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js new file mode 100644 index 0000000..4ff86d2 --- /dev/null +++ b/crawler/catalogs/catalog.js @@ -0,0 +1,260 @@ +/** + * @fileoverview Catalog crawling functionality for STAC Index + * @module catalogs/catalog + */ + +const axios = require('axios'); + +/** + * Splits an array of catalogs into individual elements where each element is an array of its properties + * @param {Array} catalogs - Array of catalog objects from the STAC Index API + * @returns {Array} Array of arrays, where each inner array contains: + * [0] = index, [1] = id, [2] = url, [3] = slug, [4] = title, [5] = summary, + * [6] = access, [7] = created, [8] = updated, [9] = isPrivate, [10] = isApi, + * [11] = accessInfo, [12] = categories, ... (followed by any other dynamic properties) + * @throws {Error} Throws error if input is not an array + */ +function splitCatalogs(catalogs) { + if (!Array.isArray(catalogs)) { + throw new Error('Expected an array'); + } + + // Define the order of standard properties + const standardPropertyOrder = [ + 'index', 'id', 'url', 'slug', 'title', 'summary', + 'access', 'created', 'updated', 'isPrivate', 'isApi', 'accessInfo' + ]; + + // Split each element individually and convert to array format + const elements = catalogs.map((catalog, index) => { + + // Create object with all properties + const elementObj = { + index: index, + ...catalog, + }; + + // Get all property names + const allProperties = Object.keys(elementObj); + + // Separate standard and dynamic properties + const standardProps = standardPropertyOrder.filter(prop => prop in elementObj); + const dynamicProps = allProperties.filter(prop => !standardPropertyOrder.includes(prop)); + + // Create array with standard properties first, then dynamic ones + const elementArray = []; + + // Add standard properties in defined order + standardProps.forEach(prop => { + elementArray.push(elementObj[prop]); + }); + + // Add dynamic properties + dynamicProps.forEach(prop => { + elementArray.push(elementObj[prop]); + }); + return elementArray; + }); + + // Output for demonstration + console.log(`Total: ${elements.length} elements found\n`); + + if (elements.length > 0) { + console.log('Example - First Element (as array):'); + console.log('Array indices and values:'); + const firstElement = elements[0]; + const allProps = [ + 'index', 'id', 'url', 'slug', 'title', 'summary', + 'access', 'created', 'updated', 'isPrivate', 'isApi', 'accessInfo' + ]; + firstElement.forEach((value, idx) => { + const propName = idx < allProps.length ? allProps[idx] : `dynamic_property_${idx}`; + console.log(` [${idx}] ${propName}: ${JSON.stringify(value)}`); + }); + console.log('\nAccess examples:'); + console.log(` elements[0][0] (index): ${elements[0][0]}`); + console.log(` elements[0][1] (id): ${elements[0][1]}`); + console.log(` elements[0][2] (url): ${elements[0][2]}`); + console.log(` elements[0][7] (created): ${elements[0][7]}`); + } + + return elements; +} + +/** + * Fetches and processes collections from a catalog + * @async + * @param {Object} catalog - Catalog object with url property + * @returns {Promise} Array of collections formatted as arrays + */ +async function getCollections(catalog) { + try { + // Try common STAC collection endpoints + const collectionUrls = [ + `${catalog.url}/collections`, + `${catalog.url}/collections/`, + `${catalog.url}/api/v1/collections` + ]; + + for (const url of collectionUrls) { + try { + const response = await axios.get(url, { timeout: 5000 }); + const collectionsData = Array.isArray(response.data) + ? response.data + : response.data.collections || []; + + if (collectionsData.length > 0) { + console.log(`\n Catalog: ${catalog.id}`); + console.log(` Found ${collectionsData.length} collections`); + + // Convert collections to array format similar to catalogs + const collections = collectionsData.map((col, index) => { + return [ + index, + col.id || 'Unknown', + col.url || col.links?.find(l => l.rel === 'self')?.href || 'N/A', + col.title || 'N/A', + col.description || col.summary || 'N/A', + col.extent?.spatial?.bbox?.[0] || 'N/A', + col.extent?.temporal?.interval?.[0] || 'N/A', + col.license || 'N/A', + col.keywords || [] + ]; + }); + + // Display collection details + if (collections.length > 0) { + console.log(' Collection details:'); + collections.forEach((col, idx) => { + console.log(` [${idx}] ${col[1]} - ${col[3]}`); + }); + } + + return collections; + } + } catch (e) { + // Try next URL + continue; + } + } + + console.log(`\n No collections found for catalog: ${catalog.id}`); + return []; + + } catch (error) { + console.error(`Error fetching collections for ${catalog.id}: ${error.message}`); + return []; + } +} + +/** + * Fetches nested catalogs within a catalog + * @async + * @param {Object} catalog - Catalog object with url property + * @returns {Promise} Array of nested catalogs + */ +async function getNestedCatalogs(catalog) { + try { + // Try common STAC nested catalog endpoints + const catalogUrls = [ + `${catalog.url}/catalogs`, + `${catalog.url}/catalogs/`, + `${catalog.url}/api/v1/catalogs` + ]; + + for (const url of catalogUrls) { + try { + const response = await axios.get(url, { timeout: 5000 }); + const catalogsData = Array.isArray(response.data) + ? response.data + : response.data.catalogs || []; + + if (catalogsData.length > 0) { + return catalogsData; + } + } catch (e) { + // Try next URL + continue; + } + } + + return []; + + } catch (error) { + console.error(`Error fetching nested catalogs for ${catalog.id}: ${error.message}`); + return []; + } +} + +/** + * Recursively crawls a catalog, its collections, and nested catalogs + * @async + * @param {Object} catalog - Catalog object to crawl + * @param {number} depth - Current depth level (for indentation) + * @returns {Promise} Stats object with collections count + */ +async function crawlCatalogRecursive(catalog, depth = 0) { + const indent = ' '.repeat(depth); + let stats = { collections: 0 }; + + try { + // First, try to get collections from this catalog + const collections = await getCollections(catalog); + stats.collections = collections.length; + + // Then, try to get nested catalogs + const nestedCatalogs = await getNestedCatalogs(catalog); + + if (nestedCatalogs.length > 0) { + console.log(`${indent} Nested catalogs found: ${nestedCatalogs.length}`); + + // Recursively crawl each nested catalog + for (const nestedCatalog of nestedCatalogs.slice(0, 5)) { // Limit to first 5 nested + const nestedStats = await crawlCatalogRecursive(nestedCatalog, depth + 1); + stats.collections += nestedStats.collections; + } + } + + } catch (error) { + console.error(`Error in recursive crawl for ${catalog.id}: ${error.message}`); + } + + return stats; +} + +/** + * Derives categories from a catalog object by checking various possible fields + * @param {Object} catalog - Catalog object to extract categories from + * @returns {Array} Array of category strings, empty array if none found + */ +function deriveCategories(catalog) { + if (!catalog || typeof catalog !== 'object') { + return []; + } + + if (Array.isArray(catalog.categories)) { + return catalog.categories.filter(Boolean).map(String); + } + + if (Array.isArray(catalog.keywords)) { + return catalog.keywords.filter(Boolean).map(String); + } + + if (Array.isArray(catalog.tags)) { + return catalog.tags.filter(Boolean).map(String); + } + + if (typeof catalog.access === 'string' && catalog.access.trim().length) { + return [catalog.access.trim()]; + } + + return []; +} + +module.exports = { + splitCatalogs, + getCollections, + getNestedCatalogs, + crawlCatalogRecursive, + deriveCategories +}; diff --git a/crawler/index.js b/crawler/index.js index f3aafd0..9f2f260 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -4,6 +4,7 @@ */ const axios = require('axios'); +const { splitCatalogs, crawlCatalogRecursive } = require('./catalogs/catalog'); /** * URL of the STAC Index API endpoint @@ -36,252 +37,4 @@ const crawler = async () => { } }; -/** - * Splits an array of catalogs into individual elements where each element is an array of its properties - * @param {Array} catalogs - Array of catalog objects from the STAC Index API - * @returns {Array} Array of arrays, where each inner array contains: - * [0] = index, [1] = id, [2] = url, [3] = slug, [4] = title, [5] = summary, - * [6] = access, [7] = created, [8] = updated, [9] = isPrivate, [10] = isApi, - * [11] = accessInfo, [12] = categories, ... (followed by any other dynamic properties) - * @throws {Error} Throws error if input is not an array - */ -function splitCatalogs(catalogs) { - if (!Array.isArray(catalogs)) { - throw new Error('Expected an array'); - } - - // Define the order of standard properties - const standardPropertyOrder = [ - 'index', 'id', 'url', 'slug', 'title', 'summary', - 'access', 'created', 'updated', 'isPrivate', 'isApi', 'accessInfo' - ]; - - // Split each element individually and convert to array format - const elements = catalogs.map((catalog, index) => { - - // Create object with all properties - const elementObj = { - index: index, - ...catalog, // All properties of the catalog object are automatically inherited - }; - - // Get all property names - const allProperties = Object.keys(elementObj); - - // Separate standard and dynamic properties - const standardProps = standardPropertyOrder.filter(prop => prop in elementObj); - const dynamicProps = allProperties.filter(prop => !standardPropertyOrder.includes(prop)); - - // Create array with standard properties first, then dynamic ones - const elementArray = []; - - // Add standard properties in defined order - standardProps.forEach(prop => { - elementArray.push(elementObj[prop]); - }); - - // Add dynamic properties - dynamicProps.forEach(prop => { - elementArray.push(elementObj[prop]); - }); - return elementArray; - }); - - // Output for demonstration - console.log(`Total: ${elements.length} elements found\n`); - - // Output first element as example with index mapping (remove later on) - - if (elements.length > 0) { - console.log('Example - First Element (as array):'); - console.log('Array indices and values:'); - const firstElement = elements[0]; - const allProps = [ - 'index', 'id', 'url', 'slug', 'title', 'summary', - 'access', 'created', 'updated', 'isPrivate', 'isApi', 'accessInfo' - ]; - firstElement.forEach((value, idx) => { - const propName = idx < allProps.length ? allProps[idx] : `dynamic_property_${idx}`; - console.log(` [${idx}] ${propName}: ${JSON.stringify(value)}`); - }); - console.log('\nAccess examples:'); - console.log(` elements[0][0] (index): ${elements[0][0]}`); - console.log(` elements[0][1] (id): ${elements[0][1]}`); - console.log(` elements[0][2] (url): ${elements[0][2]}`); - console.log(` elements[0][7] (created): ${elements[0][7]}`); - } - - return elements; -} - -/** - * Fetches and processes collections from a catalog - * @async - * @param {Object} catalog - Catalog object with url property - * @returns {Promise} Array of collections formatted as arrays - */ -async function getCollections(catalog) { - try { - // Try common STAC collection endpoints - const collectionUrls = [ - `${catalog.url}/collections`, - `${catalog.url}/collections/`, - `${catalog.url}/api/v1/collections` - ]; - - for (const url of collectionUrls) { - try { - const response = await axios.get(url, { timeout: 5000 }); - const collectionsData = Array.isArray(response.data) - ? response.data - : response.data.collections || []; - - if (collectionsData.length > 0) { - console.log(`\n Catalog: ${catalog.id}`); - console.log(` Found ${collectionsData.length} collections`); - - // Convert collections to array format similar to catalogs - const collections = collectionsData.map((col, index) => { - return [ - index, - col.id || 'Unknown', - col.url || col.links?.find(l => l.rel === 'self')?.href || 'N/A', - col.title || 'N/A', - col.description || col.summary || 'N/A', - col.extent?.spatial?.bbox?.[0] || 'N/A', - col.extent?.temporal?.interval?.[0] || 'N/A', - col.license || 'N/A', - col.keywords || [] - ]; - }); - - // Display collection details - if (collections.length > 0) { - console.log(' Collection details:'); - collections.forEach((col, idx) => { - console.log(` [${idx}] ${col[1]} - ${col[3]}`); - }); - } - - return collections; - } - } catch (e) { - // Try next URL - continue; - } - } - - console.log(`\n No collections found for catalog: ${catalog.id}`); - return []; - - } catch (error) { - console.error(`Error fetching collections for ${catalog.id}: ${error.message}`); - return []; - } -} - -/** - * Fetches nested catalogs within a catalog - * @async - * @param {Object} catalog - Catalog object with url property - * @returns {Promise} Array of nested catalogs - */ -async function getNestedCatalogs(catalog) { - try { - // Try common STAC nested catalog endpoints - const catalogUrls = [ - `${catalog.url}/catalogs`, - `${catalog.url}/catalogs/`, - `${catalog.url}/api/v1/catalogs` - ]; - - for (const url of catalogUrls) { - try { - const response = await axios.get(url, { timeout: 5000 }); - const catalogsData = Array.isArray(response.data) - ? response.data - : response.data.catalogs || []; - - if (catalogsData.length > 0) { - return catalogsData; - } - } catch (e) { - // Try next URL - continue; - } - } - - return []; - - } catch (error) { - console.error(`Error fetching nested catalogs for ${catalog.id}: ${error.message}`); - return []; - } -} - -/** - * Recursively crawls a catalog, its collections, and nested catalogs - * @async - * @param {Object} catalog - Catalog object to crawl - * @param {number} depth - Current depth level (for indentation) - * @returns {Promise} Stats object with collections count - */ -async function crawlCatalogRecursive(catalog, depth = 0) { - const indent = ' '.repeat(depth); - let stats = { collections: 0 }; - - try { - // First, try to get collections from this catalog - const collections = await getCollections(catalog); - stats.collections = collections.length; - - // Then, try to get nested catalogs - const nestedCatalogs = await getNestedCatalogs(catalog); - - if (nestedCatalogs.length > 0) { - console.log(`${indent} Nested catalogs found: ${nestedCatalogs.length}`); - - // Recursively crawl each nested catalog - for (const nestedCatalog of nestedCatalogs.slice(0, 5)) { // Limit to first 5 nested - const nestedStats = await crawlCatalogRecursive(nestedCatalog, depth + 1); - stats.collections += nestedStats.collections; - } - } - - } catch (error) { - console.error(`Error in recursive crawl for ${catalog.id}: ${error.message}`); - } - - return stats; -} - -/** - * Derives categories from a catalog object by checking various possible fields - * @param {Object} catalog - Catalog object to extract categories from - * @returns {Array} Array of category strings, empty array if none found - */ -function deriveCategories(catalog) { - if (!catalog || typeof catalog !== 'object') { - return []; - } - - if (Array.isArray(catalog.categories)) { - return catalog.categories.filter(Boolean).map(String); - } - - if (Array.isArray(catalog.keywords)) { - return catalog.keywords.filter(Boolean).map(String); - } - - if (Array.isArray(catalog.tags)) { - return catalog.tags.filter(Boolean).map(String); - } - - if (typeof catalog.access === 'string' && catalog.access.trim().length) { - return [catalog.access.trim()]; - } - - return []; -} - -crawler(); \ No newline at end of file +crawler(); From 4be5bc86ee9e488469fed15573ee84cf924317d1 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 19 Nov 2025 15:32:07 +0100 Subject: [PATCH 009/134] feat: add dotenv for environment variable management and improved database connection --- crawler/.env | 6 ++++ crawler/db.js | 58 ++------------------------------------- crawler/package-lock.json | 15 +++++++++- crawler/package.json | 3 +- 4 files changed, 25 insertions(+), 57 deletions(-) create mode 100644 crawler/.env diff --git a/crawler/.env b/crawler/.env new file mode 100644 index 0000000..f5a0661 --- /dev/null +++ b/crawler/.env @@ -0,0 +1,6 @@ +# Database configuration +PGHOST=localhost +PGPORT=5432 +PGUSER=stac_user +PGPASSWORD=stac_pass +PGDATABASE=stac_db \ No newline at end of file diff --git a/crawler/db.js b/crawler/db.js index 74bd790..3a7788b 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -15,34 +15,11 @@ const pool = new Pool({ }); async function initDb() { + // Test database connection const client = await pool.connect(); try { - // create table if missing and add useful indexes - await client.query(` - CREATE TABLE IF NOT EXISTS catalogs ( - id SERIAL PRIMARY KEY, - catalog_id TEXT UNIQUE, - url TEXT, - title TEXT, - metadata JSONB, - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now() - ); - `); - - // create GIN index on metadata for JSONB queries - await client.query(` - CREATE INDEX IF NOT EXISTS idx_catalogs_metadata_gin ON catalogs USING GIN (metadata jsonb_path_ops); - `); - - // try enabling PostGIS - try { - await client.query(`CREATE EXTENSION IF NOT EXISTS postgis;`); - } catch (e) { - // ignore extension permission errors - } - - console.log('DB initialized (catalogs table present)'); + await client.query('SELECT 1'); + console.log('DB connection established successfully'); } finally { client.release(); } @@ -80,32 +57,3 @@ async function close() { module.exports = { initDb, insertOrUpdateCatalog, close, pool }; - -/** - -const createConnectionPool = require('@databases/pg'); -const {sql} = require('@databases/pg'); - -async function run() { - // N.B. you will need to replace this connection - // string with the correct string for your database. - const db = createConnectionPool( - 'postgres://test-user@localhost:5432/test-db', - ); - - const results = await db.query(sql` - SELECT 1 + 1 as result; - `); - - console.log(results); - // => [{result: 2}] - - await db.dispose(); -} - -run().catch((err) => { - console.error(err); - process.exit(1); -}); - -*/ diff --git a/crawler/package-lock.json b/crawler/package-lock.json index b668946..20adfc3 100644 --- a/crawler/package-lock.json +++ b/crawler/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "dependencies": { "@databases/pg": "^5.5.0", - "axios": "^1.13.2" + "axios": "^1.13.2", + "dotenv": "^17.2.3" } }, "node_modules/@babel/code-frame": { @@ -260,6 +261,18 @@ "node": ">=0.4.0" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/crawler/package.json b/crawler/package.json index 47988ed..9077d5f 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@databases/pg": "^5.5.0", - "axios": "^1.13.2" + "axios": "^1.13.2", + "dotenv": "^17.2.3" } } From cc3216274b7b9056d5224375ee1a3e0679f1d3d7 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 19 Nov 2025 16:00:16 +0100 Subject: [PATCH 010/134] deleted .env --- crawler/.env | 6 ------ crawler/.gitignore | 4 +++- 2 files changed, 3 insertions(+), 7 deletions(-) delete mode 100644 crawler/.env diff --git a/crawler/.env b/crawler/.env deleted file mode 100644 index f5a0661..0000000 --- a/crawler/.env +++ /dev/null @@ -1,6 +0,0 @@ -# Database configuration -PGHOST=localhost -PGPORT=5432 -PGUSER=stac_user -PGPASSWORD=stac_pass -PGDATABASE=stac_db \ No newline at end of file diff --git a/crawler/.gitignore b/crawler/.gitignore index b512c09..a2e04e4 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -1 +1,3 @@ -node_modules \ No newline at end of file +node_modules + +.env \ No newline at end of file From 88f4bcc7bb05500f926ee3f8eb7adbd7a872602f Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 19 Nov 2025 21:58:57 +0100 Subject: [PATCH 011/134] updated crawling with use uf stac-js --- crawler/catalogs/catalog.js | 174 +++++++++-- crawler/package-lock.json | 565 +++++++++++++++++++++++++++++++++++- crawler/package.json | 3 +- 3 files changed, 714 insertions(+), 28 deletions(-) diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 4ff86d2..edb8b89 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -4,6 +4,7 @@ */ const axios = require('axios'); +const { default: create } = require('stac-js'); /** * Splits an array of catalogs into individual elements where each element is an array of its properties @@ -29,11 +30,22 @@ function splitCatalogs(catalogs) { const elements = catalogs.map((catalog, index) => { // Create object with all properties - const elementObj = { + let elementObj = { index: index, ...catalog, }; + // Validate and migrate STAC catalog using stac-js if it has STAC properties + if (catalog.url && typeof catalog.url === 'string') { + try { + // For STAC Index catalogs, we don't validate against STAC spec + // as they are metadata about STAC catalogs, not STAC objects themselves + // This is just for potential future enhancement + } catch (error) { + console.warn(`STAC validation skipped for catalog ${catalog.id}: ${error.message}`); + } + } + // Get all property names const allProperties = Object.keys(elementObj); @@ -82,7 +94,7 @@ function splitCatalogs(catalogs) { } /** - * Fetches and processes collections from a catalog + * Fetches and processes collections from a catalog using stac-js * @async * @param {Object} catalog - Catalog object with url property * @returns {Promise} Array of collections formatted as arrays @@ -99,26 +111,76 @@ async function getCollections(catalog) { for (const url of collectionUrls) { try { const response = await axios.get(url, { timeout: 5000 }); - const collectionsData = Array.isArray(response.data) - ? response.data - : response.data.collections || []; + + // Parse response with stac-js for validation and enhanced metadata extraction + let stacObj; + try { + stacObj = create(response.data, url); + } catch (parseError) { + // If stac-js cannot parse, skip this catalog (non-compliant STAC) + console.warn(` Skipping non-compliant STAC at ${url}: ${parseError.message}`); + continue; + } + + let collectionsData = []; + + // Check if this is a CollectionCollection (STAC API response) + if (stacObj && typeof stacObj.getAll === 'function') { + collectionsData = stacObj.getAll(); + } else if (Array.isArray(response.data)) { + // Handle array of collections + collectionsData = response.data.map(col => { + try { + return create(col, url); + } catch { + return null; + } + }).filter(Boolean); + } else if (response.data.collections) { + // Handle nested collections property + collectionsData = response.data.collections.map(col => { + try { + return create(col, url); + } catch { + return null; + } + }).filter(Boolean); + } if (collectionsData.length > 0) { console.log(`\n Catalog: ${catalog.id}`); console.log(` Found ${collectionsData.length} collections`); - // Convert collections to array format similar to catalogs - const collections = collectionsData.map((col, index) => { + // Convert collections using stac-js methods for metadata extraction + const collections = collectionsData.map((colObj, index) => { + // Use stac-js methods for robust metadata extraction + const bbox = typeof colObj.getBoundingBox === 'function' + ? colObj.getBoundingBox() + : (colObj.extent?.spatial?.bbox?.[0] || 'N/A'); + + const temporal = typeof colObj.getTemporalExtent === 'function' + ? colObj.getTemporalExtent() + : (colObj.extent?.temporal?.interval?.[0] || 'N/A'); + + // Get self URL using stac-js link navigation + let selfUrl = 'N/A'; + if (typeof colObj.getAbsoluteUrl === 'function') { + selfUrl = colObj.getAbsoluteUrl(); + } else if (colObj.links) { + const selfLink = colObj.links.find(l => l.rel === 'self'); + selfUrl = selfLink?.href || 'N/A'; + } + return [ index, - col.id || 'Unknown', - col.url || col.links?.find(l => l.rel === 'self')?.href || 'N/A', - col.title || 'N/A', - col.description || col.summary || 'N/A', - col.extent?.spatial?.bbox?.[0] || 'N/A', - col.extent?.temporal?.interval?.[0] || 'N/A', - col.license || 'N/A', - col.keywords || [] + colObj.id || 'Unknown', + selfUrl, + colObj.title || 'N/A', + colObj.description || colObj.summary || 'N/A', + bbox, + temporal, + colObj.license || 'N/A', + colObj.keywords || [] ]; }); @@ -148,14 +210,41 @@ async function getCollections(catalog) { } /** - * Fetches nested catalogs within a catalog + * Fetches nested catalogs within a catalog using stac-js link navigation * @async * @param {Object} catalog - Catalog object with url property + * @param {Object} [stacCatalog] - Optional stac-js catalog object for link extraction * @returns {Promise} Array of nested catalogs */ -async function getNestedCatalogs(catalog) { +async function getNestedCatalogs(catalog, stacCatalog = null) { try { - // Try common STAC nested catalog endpoints + // If we have a stac-js catalog object, use its link navigation methods + if (stacCatalog && typeof stacCatalog.getChildLinks === 'function') { + const childLinks = stacCatalog.getChildLinks(); + + if (childLinks.length > 0) { + console.log(` Found ${childLinks.length} child catalog links via stac-js`); + + // Fetch each child catalog + const nestedCatalogs = []; + for (const link of childLinks) { + try { + const childUrl = typeof link.getAbsoluteUrl === 'function' + ? link.getAbsoluteUrl() + : link.href; + + const response = await axios.get(childUrl, { timeout: 5000 }); + nestedCatalogs.push(response.data); + } catch (e) { + console.warn(` Failed to fetch child catalog: ${e.message}`); + } + } + + return nestedCatalogs; + } + } + + // Fallback: Try common STAC nested catalog endpoints const catalogUrls = [ `${catalog.url}/catalogs`, `${catalog.url}/catalogs/`, @@ -187,36 +276,69 @@ async function getNestedCatalogs(catalog) { } /** - * Recursively crawls a catalog, its collections, and nested catalogs + * Recursively crawls a catalog using stac-js for parsing and navigation * @async * @param {Object} catalog - Catalog object to crawl * @param {number} depth - Current depth level (for indentation) - * @returns {Promise} Stats object with collections count + * @returns {Promise} Stats object with collections count and STAC compliance */ async function crawlCatalogRecursive(catalog, depth = 0) { const indent = ' '.repeat(depth); - let stats = { collections: 0 }; + let stats = { collections: 0, stacCompliant: false }; try { + let stacCatalog = null; + + // Try to fetch and parse the catalog with stac-js + if (catalog.url) { + try { + const response = await axios.get(catalog.url, { timeout: 5000 }); + + try { + stacCatalog = create(response.data, catalog.url); + stats.stacCompliant = true; + + // Log STAC object type + if (typeof stacCatalog.isCatalog === 'function' && stacCatalog.isCatalog()) { + console.log(`${indent} STAC Catalog validated: ${catalog.id}`); + } else if (typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection()) { + console.log(`${indent} STAC Collection validated: ${catalog.id}`); + } + } catch (parseError) { + console.warn(`${indent} Non-compliant STAC catalog ${catalog.id}, skipping: ${parseError.message}`); + return stats; // Skip non-compliant catalogs as per requirement + } + } catch (fetchError) { + console.warn(`${indent} Failed to fetch catalog ${catalog.id}: ${fetchError.message}`); + return stats; + } + } + // First, try to get collections from this catalog const collections = await getCollections(catalog); stats.collections = collections.length; - // Then, try to get nested catalogs - const nestedCatalogs = await getNestedCatalogs(catalog); + // Then, try to get nested catalogs using stac-js if available + const nestedCatalogs = await getNestedCatalogs(catalog, stacCatalog); if (nestedCatalogs.length > 0) { console.log(`${indent} Nested catalogs found: ${nestedCatalogs.length}`); // Recursively crawl each nested catalog for (const nestedCatalog of nestedCatalogs.slice(0, 5)) { // Limit to first 5 nested - const nestedStats = await crawlCatalogRecursive(nestedCatalog, depth + 1); + // Convert nested catalog to expected format + const nestedCatalogObj = { + id: nestedCatalog.id || 'unknown', + url: nestedCatalog.links?.find(l => l.rel === 'self')?.href || nestedCatalog.url + }; + + const nestedStats = await crawlCatalogRecursive(nestedCatalogObj, depth + 1); stats.collections += nestedStats.collections; } } } catch (error) { - console.error(`Error in recursive crawl for ${catalog.id}: ${error.message}`); + console.error(`${indent}Error in recursive crawl for ${catalog.id}: ${error.message}`); } return stats; @@ -257,4 +379,4 @@ module.exports = { getNestedCatalogs, crawlCatalogRecursive, deriveCategories -}; +}; \ No newline at end of file diff --git a/crawler/package-lock.json b/crawler/package-lock.json index 9281009..9b74099 100644 --- a/crawler/package-lock.json +++ b/crawler/package-lock.json @@ -9,7 +9,63 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "axios": "^1.13.2" + "axios": "^1.13.2", + "stac-js": "^0.1.9" + } + }, + "node_modules/@multiformats/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@multiformats/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==", + "license": "MIT" + }, + "node_modules/@radiantearth/stac-migrate": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radiantearth/stac-migrate/-/stac-migrate-2.0.2.tgz", + "integrity": "sha512-1GsszCVwZY42tQCOnhLzOERnXXzJBX5PQ5Qq1puBI19liWzdJZO2+6bbKmQclkYsPJYiQ6JnLbGf5QWIw4S+kQ==", + "license": "Apache-2.0", + "dependencies": { + "compare-versions": "^3.6.0", + "multihashes": "^3.1.2", + "yargs": "^17.6.2" + }, + "bin": { + "stac-migrate": "bin/cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/m-mohr" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/asynckit": { @@ -18,6 +74,21 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -29,6 +100,24 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -42,6 +131,54 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -54,6 +191,29 @@ "node": ">= 0.8" } }, + "node_modules/compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -77,6 +237,12 @@ "node": ">= 0.4" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -122,6 +288,15 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -142,6 +317,21 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -167,6 +357,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -216,6 +424,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -255,6 +475,101 @@ "node": ">= 0.4" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -285,11 +600,259 @@ "node": ">= 0.6" } }, + "node_modules/multibase": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/multibase/-/multibase-3.1.2.tgz", + "integrity": "sha512-bpklWHs70LO3smJUHOjcnzGceJJvn9ui0Vau6Za0B/GBepaXswmW8Ufea0uD9pROf/qCQ4N4lZ3sf3U+SNf0tw==", + "deprecated": "This module has been superseded by the multiformats module", + "license": "MIT", + "dependencies": { + "@multiformats/base-x": "^4.0.1", + "web-encoding": "^1.0.6" + }, + "engines": { + "node": ">=10.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" + }, + "node_modules/multihashes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/multihashes/-/multihashes-3.1.2.tgz", + "integrity": "sha512-AP4IoV/YzkNrfbQKZE3OMPibrmy350OmCd6cJkwyM8oExaXIlOY4UnOOVSQtAEuq/LR01XfXKCESidzZvSwHCQ==", + "license": "MIT", + "dependencies": { + "multibase": "^3.1.0", + "uint8arrays": "^2.0.5", + "varint": "^6.0.0" + }, + "engines": { + "node": ">=10.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stac-js": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/stac-js/-/stac-js-0.1.9.tgz", + "integrity": "sha512-qxb8aWQ1T8jJVLsUszpvj9+KrPTDdf2s0bWBWTP+SaplNxznKXXo5xkEPtrXEYwOTbLLeUVSNQhz9J5uFcz6TA==", + "license": "Apache-2.0", + "dependencies": { + "@radiantearth/stac-migrate": "^2.0.2", + "urijs": "^1.19.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/m-mohr" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8arrays": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-2.1.10.tgz", + "integrity": "sha512-Q9/hhJa2836nQfEJSZTmr+pg9+cDJS9XEAp7N2Vg5MzL3bK/mkMVfjscRGYruP9jNda6MAdf4QD/y78gSzkp6A==", + "license": "MIT", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "license": "MIT" + }, + "node_modules/web-encoding": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", + "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", + "license": "MIT", + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } } } } diff --git a/crawler/package.json b/crawler/package.json index e9ab811..8fb5506 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -14,6 +14,7 @@ "author": "", "license": "ISC", "dependencies": { - "axios": "^1.13.2" + "axios": "^1.13.2", + "stac-js": "^0.1.9" } } From e15fcaed2004d0019ac510db94861d00f762f733 Mon Sep 17 00:00:00 2001 From: mammutor Date: Fri, 21 Nov 2025 00:54:03 +0100 Subject: [PATCH 012/134] feat: basic api crawling --- crawler/apis/api.js | 156 +++++++++++++++++++++++++++++++++++++++++++- crawler/index.js | 25 +++++++ 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 87cdde2..eaaece9 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -1,4 +1,158 @@ /** * @fileoverview API crawling functionality for STAC Index * @module apis/api - */ \ No newline at end of file + */ + +const axios = require('axios'); +const { default: create } = require('stac-js'); + +/** + * Crawls STAC APIs to retrieve collection information without fetching items. + * + * @param {string[]} urls - Array of API URLs to crawl + * @param {boolean} isApi - Boolean flag indicating if the URLs are APIs + * @returns {Promise} Array of STAC Collection objects ordered by URL + */ +async function crawlApis(urls, isApi) { + if (!isApi || !Array.isArray(urls) || urls.length === 0) { + return []; + } + + const allCollections = new Map(); // Use Map to avoid duplicates by URL + + console.log(`Starting to crawl ${urls.length} APIs...`); + + // Process each URL + for (const [index, url] of urls.entries()) { + console.log(`Processing API ${index + 1}/${urls.length}: ${url}`); + try { + await crawlSingleApi(url, allCollections); + } catch (error) { + console.error(`Error crawling API ${url}:`, error.message); + } + } + + console.log(`Finished crawling APIs. Total unique collections found: ${allCollections.size}`); + + // Convert Map to array and sort by URL + const sortedCollections = Array.from(allCollections.values()).sort((a, b) => { + const urlA = getSelfLink(a) || ''; + const urlB = getSelfLink(b) || ''; + return urlA.localeCompare(urlB); + }); + + return sortedCollections; +} + +/** + * Helper function to crawl a single API recursively for collections/catalogs + * + * @param {string} url - URL to crawl + * @param {Map} collectionMap - Map to store found collections + * @param {Set} visited - Set of visited URLs to prevent loops + */ +async function crawlSingleApi(url, collectionMap, visited = new Set()) { + if (!url || visited.has(url)) return; + visited.add(url); + + try { + console.log(` Fetching: ${url}`); + const response = await axios.get(url); + const stacObj = create(response.data); + + // If it's a Collection, add it + if (stacObj.isCollection()) { + const selfUrl = stacObj.getAbsoluteUrl() || url; + console.log(` Found collection: ${stacObj.id} (${selfUrl})`); + collectionMap.set(selfUrl, stacObj.toJSON()); + } + + // If it has collections (API or Catalog), fetch them + // stac-js APICollection or Catalog might have getApiCollectionsLink or similar + // Or we manually check links. + + // Check for /collections endpoint if it's a root catalog/API + if (stacObj.isCatalogLike()) { + // Try to get collections link + const collectionsLink = stacObj.getApiCollectionsLink(); + if (collectionsLink) { + console.log(` Found /collections endpoint link in ${url}`); + await fetchCollectionsFromLink(collectionsLink, collectionMap, visited); + } else { + // Fallback: check standard STAC API structure if not explicitly found + // Many STAC APIs have a /collections endpoint relative to root + // But stac-js might handle this via getApiCollectionsLink if rel="data" exists + + // Also check child links for nested catalogs + const childLinks = stacObj.getChildLinks(); + if (childLinks.length > 0) { + console.log(` Found ${childLinks.length} child links in ${url}, recursing...`); + for (const link of childLinks) { + const childUrl = link.getAbsoluteUrl(); + if (childUrl) { + await crawlSingleApi(childUrl, collectionMap, visited); + } + } + } + } + } + + } catch (error) { + console.warn(`Failed to process ${url}:`, error.message); + } +} + +/** + * Fetches collections from a collections endpoint (e.g., /collections) + * + * @param {Object} link - stac-js Link object + * @param {Map} collectionMap - Map to store collections + * @param {Set} visited - Visited set + */ +async function fetchCollectionsFromLink(link, collectionMap, visited) { + const url = link.getAbsoluteUrl(); + if (!url || visited.has(url)) return; + visited.add(url); + + try { + console.log(` Fetching collections from: ${url}`); + const response = await axios.get(url); + // create() handles CollectionCollection (API Collections response) + const stacObj = create(response.data); + + let collections = []; + + if (stacObj && typeof stacObj.getAll === 'function') { + // Use stac-js to get all collections from the response + collections = stacObj.getAll(); + } else if (response.data.collections && Array.isArray(response.data.collections)) { + // Fallback for manual parsing if stac-js didn't detect CollectionCollection + collections = response.data.collections.map(c => create(c)); + } else if (Array.isArray(response.data)) { + collections = response.data.map(c => create(c)); + } + + console.log(` Retrieved ${collections.length} collections from ${url}`); + + for (const colStac of collections) { + if (colStac && typeof colStac.isCollection === 'function' && colStac.isCollection()) { + const selfUrl = colStac.getAbsoluteUrl() || url; + collectionMap.set(selfUrl, colStac.toJSON()); + } + } + } catch (error) { + console.warn(`Failed to fetch collections from ${url}:`, error.message); + } +} + +/** + * Helper to get self link from a plain JSON object (since we store toJSON results) + */ +function getSelfLink(stacJson) { + const selfLink = stacJson.links?.find(l => l.rel === 'self'); + return selfLink ? selfLink.href : null; +} + +module.exports = { + crawlApis +}; diff --git a/crawler/index.js b/crawler/index.js index 9f2f260..d237961 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -5,6 +5,7 @@ const axios = require('axios'); const { splitCatalogs, crawlCatalogRecursive } = require('./catalogs/catalog'); +const { crawlApis } = require('./apis/api'); /** * URL of the STAC Index API endpoint @@ -32,6 +33,30 @@ const crawler = async () => { } console.log(`\n Total collections found across all catalogs: ${totalCollections}`); + + // Extract API URLs and crawl them + console.log('\n Crawling APIs...'); + const apiUrls = catalogs + .filter(cat => cat[10] === true) // isApi is at index 10 + .map(cat => cat[2]); // url is at index 2 + + if (apiUrls.length > 0) { + console.log(`Found ${apiUrls.length} APIs. Starting crawl...`); + // Limit to first 5 APIs for demonstration/performance + const collections = await crawlApis(apiUrls.slice(0, 5), true); + console.log(`\nFetched ${collections.length} collections from APIs (sorted by URL).`); + + if (collections.length > 0) { + console.log('First 3 collections found:'); + collections.slice(0, 3).forEach(c => { + const selfLink = c.links?.find(l => l.rel === 'self')?.href; + console.log(` - ${c.id}: ${selfLink}`); + }); + } + } else { + console.log('No APIs found to crawl.'); + } + } catch (error) { console.error(`Error fetching ${targetUrl}: ${error.message}`); } From fa5fdaeaf29303ce1922de178b4854b8e9fece99 Mon Sep 17 00:00:00 2001 From: Jakob Date: Mon, 24 Nov 2025 14:40:51 +0100 Subject: [PATCH 013/134] implements #76, stac api validation for catalog crawling --- crawler/catalogs/catalog.js | 119 +++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index edb8b89..f885470 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -6,6 +6,101 @@ const axios = require('axios'); const { default: create } = require('stac-js'); +/** + * Checks if a catalog is STAC API compliant by validating conformance classes + * @async + * @param {string} catalogUrl - The base URL of the catalog + * @returns {Promise} Object with isStacApi flag and conformance classes + */ +async function checkStacApiCompliance(catalogUrl) { + const result = { + isStacApi: false, + conformsTo: [], + features: [] + }; + + try { + // Try to fetch conformance declaration + const conformanceUrls = [ + `${catalogUrl}/conformance`, + `${catalogUrl}/api/conformance`, + catalogUrl // Root might have conformsTo property + ]; + + for (const url of conformanceUrls) { + try { + const response = await axios.get(url, { timeout: 5000 }); + + // Check for conformsTo array (STAC API spec) + if (response.data.conformsTo && Array.isArray(response.data.conformsTo)) { + result.conformsTo = response.data.conformsTo; + + // Check for STAC API specific conformance classes + const stacApiClasses = [ + 'https://api.stacspec.org/v1.0.0/core', + 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', + 'https://api.stacspec.org/v1.0.0-rc.1/core' + ]; + + result.isStacApi = result.conformsTo.some(conformance => + stacApiClasses.some(stacClass => conformance.includes('stacspec.org') || conformance.includes('ogcapi-features')) + ); + + // Identify supported features + if (result.conformsTo.some(c => c.includes('item-search') || c.includes('/search'))) { + result.features.push('search'); + } + if (result.conformsTo.some(c => c.includes('filter'))) { + result.features.push('filter'); + } + if (result.conformsTo.some(c => c.includes('query'))) { + result.features.push('query'); + } + if (result.conformsTo.some(c => c.includes('sort'))) { + result.features.push('sort'); + } + if (result.conformsTo.some(c => c.includes('fields'))) { + result.features.push('fields'); + } + + if (result.isStacApi) { + break; // Found valid STAC API conformance + } + } + } catch (e) { + // Try next URL + continue; + } + } + + // Fallback: Check for /search endpoint existence (practical test) + if (!result.isStacApi) { + try { + const searchResponse = await axios.get(`${catalogUrl}/search`, { + timeout: 3000, + params: { limit: 1 }, + validateStatus: (status) => status < 500 // Accept 4xx as "endpoint exists" + }); + + // If search endpoint exists and returns expected structure + if (searchResponse.status === 200 && + (searchResponse.data.type === 'FeatureCollection' || + searchResponse.data.features)) { + result.isStacApi = true; + result.features.push('search'); + } + } catch (e) { + // Search endpoint doesn't exist - likely static catalog + } + } + + } catch (error) { + // STAC API validation failed - catalog is likely static-only + } + + return result; +} + /** * Splits an array of catalogs into individual elements where each element is an array of its properties * @param {Array} catalogs - Array of catalog objects from the STAC Index API @@ -280,11 +375,17 @@ async function getNestedCatalogs(catalog, stacCatalog = null) { * @async * @param {Object} catalog - Catalog object to crawl * @param {number} depth - Current depth level (for indentation) - * @returns {Promise} Stats object with collections count and STAC compliance + * @returns {Promise} Stats object with collections count, STAC compliance, and API capabilities */ async function crawlCatalogRecursive(catalog, depth = 0) { const indent = ' '.repeat(depth); - let stats = { collections: 0, stacCompliant: false }; + let stats = { + collections: 0, + stacCompliant: false, + stacApiCompliant: false, + apiFeatures: [], + conformsTo: [] + }; try { let stacCatalog = null; @@ -304,6 +405,17 @@ async function crawlCatalogRecursive(catalog, depth = 0) { } else if (typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection()) { console.log(`${indent} STAC Collection validated: ${catalog.id}`); } + + // Check STAC API compliance + const apiCompliance = await checkStacApiCompliance(catalog.url); + stats.stacApiCompliant = apiCompliance.isStacApi; + stats.apiFeatures = apiCompliance.features; + stats.conformsTo = apiCompliance.conformsTo; + + if (apiCompliance.isStacApi) { + console.log(`${indent} STAC API compliant - Features: [${apiCompliance.features.join(', ')}]`); + } + } catch (parseError) { console.warn(`${indent} Non-compliant STAC catalog ${catalog.id}, skipping: ${parseError.message}`); return stats; // Skip non-compliant catalogs as per requirement @@ -378,5 +490,6 @@ module.exports = { getCollections, getNestedCatalogs, crawlCatalogRecursive, - deriveCategories + deriveCategories, + checkStacApiCompliance }; \ No newline at end of file From b611ef49a2ddf938d658336e4d9617fe71f56a2d Mon Sep 17 00:00:00 2001 From: mammutor Date: Mon, 24 Nov 2025 15:58:22 +0100 Subject: [PATCH 014/134] fix: updated crawler project to be ESM compliant I had an issue on the server, where it doesnt want to start, because stac-js is ESM only and the project isnt --- crawler/apis/api.js | 6 +++--- crawler/catalogs/catalog.js | 6 +++--- crawler/index.js | 6 +++--- crawler/package.json | 1 + 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index eaaece9..5eda959 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -3,8 +3,8 @@ * @module apis/api */ -const axios = require('axios'); -const { default: create } = require('stac-js'); +import axios from 'axios'; +import create from 'stac-js'; /** * Crawls STAC APIs to retrieve collection information without fetching items. @@ -153,6 +153,6 @@ function getSelfLink(stacJson) { return selfLink ? selfLink.href : null; } -module.exports = { +export { crawlApis }; diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index f885470..fe2bbb1 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -3,8 +3,8 @@ * @module catalogs/catalog */ -const axios = require('axios'); -const { default: create } = require('stac-js'); +import axios from 'axios'; +import create from 'stac-js'; /** * Checks if a catalog is STAC API compliant by validating conformance classes @@ -485,7 +485,7 @@ function deriveCategories(catalog) { return []; } -module.exports = { +export { splitCatalogs, getCollections, getNestedCatalogs, diff --git a/crawler/index.js b/crawler/index.js index d237961..d949a5c 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -3,9 +3,9 @@ * @module crawler */ -const axios = require('axios'); -const { splitCatalogs, crawlCatalogRecursive } = require('./catalogs/catalog'); -const { crawlApis } = require('./apis/api'); +import axios from 'axios'; +import { splitCatalogs, crawlCatalogRecursive } from './catalogs/catalog.js'; +import { crawlApis } from './apis/api.js'; /** * URL of the STAC Index API endpoint diff --git a/crawler/package.json b/crawler/package.json index 8fb5506..49e9982 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -13,6 +13,7 @@ }, "author": "", "license": "ISC", + "type": "module", "dependencies": { "axios": "^1.13.2", "stac-js": "^0.1.9" From 9fc290000bf204cb8b4428afd39a596afa00f169 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 25 Nov 2025 14:44:46 +0100 Subject: [PATCH 015/134] refactor: improve catalog handling in crawler by restructuring catalog data extraction and ensuring consistent property ordering --- crawler/catalogs/catalog.js | 5 ++--- crawler/index.js | 6 +++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index fe2bbb1..daed1eb 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -145,14 +145,13 @@ function splitCatalogs(catalogs) { const allProperties = Object.keys(elementObj); // Separate standard and dynamic properties - const standardProps = standardPropertyOrder.filter(prop => prop in elementObj); const dynamicProps = allProperties.filter(prop => !standardPropertyOrder.includes(prop)); // Create array with standard properties first, then dynamic ones const elementArray = []; - // Add standard properties in defined order - standardProps.forEach(prop => { + // Add standard properties in defined order (always include all standard fields for consistent indices) + standardPropertyOrder.forEach(prop => { elementArray.push(elementObj[prop]); }); diff --git a/crawler/index.js b/crawler/index.js index d949a5c..bdbb7d9 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -27,7 +27,11 @@ const crawler = async () => { // Crawl collections and nested catalogs for each catalog console.log('\n Crawling collections and nested catalogs...\n'); let totalCollections = 0; - for (const catalog of response.data.slice(0, 10)) { // Limit to first 10 catalogs + for (const catalogData of catalogs.slice(0, 10)) { // Limit to first 10 catalogs + const catalog = { + id: catalogData[1], + url: catalogData[2] + }; const stats = await crawlCatalogRecursive(catalog); totalCollections += stats.collections; } From ad9a4408f89867d3d80d701aec39b6a8c23b0a42 Mon Sep 17 00:00:00 2001 From: Lenn Date: Tue, 25 Nov 2025 17:58:28 +0100 Subject: [PATCH 016/134] feat: enhance database operations: insertOrUpdateCatalog and insertOrUpdateCollection --- crawler/db.js | 215 ++++++++++++++++++++++++++++++++++++++++++----- crawler/index.js | 15 ++++ 2 files changed, 208 insertions(+), 22 deletions(-) diff --git a/crawler/db.js b/crawler/db.js index 3a7788b..4980428 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -25,35 +25,206 @@ async function initDb() { } } +/** + * Insert or update a catalog in the database + * @param {Object} catalog - STAC catalog object + * @returns {Promise} catalog ID + */ async function insertOrUpdateCatalog(catalog) { if (!catalog || typeof catalog !== 'object') return null; - const catalogId = catalog.id || catalog.catalog_id || catalog.slug || null; - if (!catalogId) return null; - - const url = catalog.url || null; - const title = catalog.title || catalog.name || null; - const metadata = catalog; - const now = new Date().toISOString(); - - const query = ` - INSERT INTO catalogs (catalog_id, url, title, metadata, updated_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (catalog_id) DO UPDATE SET - url = EXCLUDED.url, - title = EXCLUDED.title, - metadata = catalogs.metadata || EXCLUDED.metadata, - updated_at = EXCLUDED.updated_at - RETURNING id; - `; - const values = [catalogId, url, title, metadata, now]; - const res = await pool.query(query, values); - return res.rows[0]; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Insert or update catalog + const catalogQuery = ` + INSERT INTO catalog (stac_version, type, title, description, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT ON CONSTRAINT catalog_pkey DO UPDATE SET + stac_version = EXCLUDED.stac_version, + type = EXCLUDED.type, + title = EXCLUDED.title, + description = EXCLUDED.description, + updated_at = now() + RETURNING id; + `; + const catalogResult = await client.query(catalogQuery, [ + catalog.stac_version || null, + catalog.type || 'Catalog', + catalog.title || catalog.id || null, + catalog.description || null + ]); + const catalogId = catalogResult.rows[0].id; + + // Insert links + if (catalog.links && Array.isArray(catalog.links)) { + await client.query('DELETE FROM catalog_links WHERE catalog_id = $1', [catalogId]); + for (const link of catalog.links) { + await client.query( + 'INSERT INTO catalog_links (catalog_id, rel, href, type, title) VALUES ($1, $2, $3, $4, $5)', + [catalogId, link.rel, link.href, link.type || null, link.title || null] + ); + } + } + + // Insert keywords + if (catalog.keywords && Array.isArray(catalog.keywords)) { + await insertKeywords(client, catalogId, catalog.keywords, 'catalog'); + } + + // Insert STAC extensions + if (catalog.stac_extensions && Array.isArray(catalog.stac_extensions)) { + await insertStacExtensions(client, catalogId, catalog.stac_extensions, 'catalog'); + } + + // Update crawl log + await client.query( + `INSERT INTO crawllog_catalog (catalog_id, last_crawled) + VALUES ($1, now()) + ON CONFLICT (catalog_id) DO UPDATE SET last_crawled = now()`, + [catalogId] + ); + + await client.query('COMMIT'); + return catalogId; + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error inserting catalog:', error.message); + throw error; + } finally { + client.release(); + } } +/** + * Insert or update a collection in the database + * @param {Object} collection - STAC collection object + * @returns {Promise} collection ID + */ +async function insertOrUpdateCollection(collection) { + if (!collection || typeof collection !== 'object') return null; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Parse spatial extent (bbox) + let spatialExtend = null; + if (collection.extent?.spatial?.bbox && collection.extent.spatial.bbox[0]) { + const bbox = collection.extent.spatial.bbox[0]; + if (bbox.length === 4) { + // Create polygon from bbox [west, south, east, north] + spatialExtend = `SRID=4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; + } + } + + // Parse temporal extent + let temporalStart = null; + let temporalEnd = null; + if (collection.extent?.temporal?.interval && collection.extent.temporal.interval[0]) { + const interval = collection.extent.temporal.interval[0]; + temporalStart = interval[0] ? new Date(interval[0]) : null; + temporalEnd = interval[1] ? new Date(interval[1]) : null; + } + + // Insert or update collection + const collectionQuery = ` + INSERT INTO collection ( + stac_version, type, title, description, license, + spatial_extend, temporal_extend_start, temporal_extend_end, + is_api, is_active, full_json, updated_at + ) + VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) + ON CONFLICT ON CONSTRAINT collection_pkey DO UPDATE SET + stac_version = EXCLUDED.stac_version, + type = EXCLUDED.type, + title = EXCLUDED.title, + description = EXCLUDED.description, + license = EXCLUDED.license, + spatial_extend = EXCLUDED.spatial_extend, + temporal_extend_start = EXCLUDED.temporal_extend_start, + temporal_extend_end = EXCLUDED.temporal_extend_end, + is_api = EXCLUDED.is_api, + is_active = EXCLUDED.is_active, + full_json = EXCLUDED.full_json, + updated_at = now() + RETURNING id; + `; + const collectionResult = await client.query(collectionQuery, [ + collection.stac_version || null, + collection.type || 'Collection', + collection.title || collection.id || null, + collection.description || null, + collection.license || null, + spatialExtend, + temporalStart, + temporalEnd, + false, // is_api - will be determined by crawler + true, // is_active + JSON.stringify(collection) + ]); + const collectionId = collectionResult.rows[0].id; + + // Insert summaries + if (collection.summaries && typeof collection.summaries === 'object') { + await client.query('DELETE FROM collection_summaries WHERE collection_id = $1', [collectionId]); + for (const [name, value] of Object.entries(collection.summaries)) { + await insertSummary(client, collectionId, name, value); + } + } + + // Insert keywords + if (collection.keywords && Array.isArray(collection.keywords)) { + await insertKeywords(client, collectionId, collection.keywords, 'collection'); + } + + // Insert STAC extensions + if (collection.stac_extensions && Array.isArray(collection.stac_extensions)) { + await insertStacExtensions(client, collectionId, collection.stac_extensions, 'collection'); + } + + // Insert providers + if (collection.providers && Array.isArray(collection.providers)) { + await insertProviders(client, collectionId, collection.providers); + } + + // Insert assets + if (collection.assets && typeof collection.assets === 'object') { + await insertAssets(client, collectionId, collection.assets); + } + + // Update crawl log + await client.query( + `INSERT INTO crawllog_collection (collection_id, last_crawled) + VALUES ($1, now()) + ON CONFLICT (collection_id) DO UPDATE SET last_crawled = now()`, + [collectionId] + ); + + await client.query('COMMIT'); + return collectionId; + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error inserting collection:', error.message); + throw error; + } finally { + client.release(); + } +} + +// TODO: Implement insertKeywords, insertStacExtensions, insertSummary, insertProviders, insertAssets functions + async function close() { await pool.end(); } -module.exports = { initDb, insertOrUpdateCatalog, close, pool }; +module.exports = { + initDb, + insertOrUpdateCatalog, + insertOrUpdateCollection, + close, + pool +}; diff --git a/crawler/index.js b/crawler/index.js index 3af528c..e8dae19 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -35,6 +35,21 @@ const crawler = async () => { } catch (error) { console.error(`Error fetching ${targetUrl}: ${error.message}`); } + + // Persist catalogs into DB + try { + await Promise.all(catalogs.map(async (catalog) => { + try { + await db.insertOrUpdateCatalog(catalog); + } catch (err) { + console.error('DB write error for catalog', catalog && (catalog.id || catalog.slug), err && err.message); + } + })); + console.log('Finished writing catalogs to DB'); + } catch (e) { + console.error('Unexpected error while saving catalogs to DB', e && e.message); + } + }; crawler(); \ No newline at end of file From d0691ba561f8e11952c4d7a077bb3e2d00b762c6 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 25 Nov 2025 18:44:39 +0100 Subject: [PATCH 017/134] fix: adjust API crawling limit to include all found APIs instead of the first five --- crawler/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crawler/index.js b/crawler/index.js index bdbb7d9..7cffb6a 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -47,7 +47,7 @@ const crawler = async () => { if (apiUrls.length > 0) { console.log(`Found ${apiUrls.length} APIs. Starting crawl...`); // Limit to first 5 APIs for demonstration/performance - const collections = await crawlApis(apiUrls.slice(0, 5), true); + const collections = await crawlApis(apiUrls, true); console.log(`\nFetched ${collections.length} collections from APIs (sorted by URL).`); if (collections.length > 0) { From 3c37635adb68f81f6910bee95a09a304b9d5922e Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 26 Nov 2025 12:18:02 +0100 Subject: [PATCH 018/134] updated pghost function to insert keywords --- crawler/db.js | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crawler/db.js b/crawler/db.js index 4980428..23c5db5 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -6,7 +6,7 @@ const { Pool } = require('pg'); require('dotenv').config(); const pool = new Pool({ - host: process.env.PGHOST || 'localhost', + host: process.env.PGHOST || 'atlas.stacindex.org', port: parseInt(process.env.PGPORT || '5432', 10), user: process.env.PGUSER || 'stac_user', password: process.env.PGPASSWORD || 'stac_pass', @@ -215,6 +215,36 @@ async function insertOrUpdateCollection(collection) { // TODO: Implement insertKeywords, insertStacExtensions, insertSummary, insertProviders, insertAssets functions + +/** + * Helper function to insert keywords + */ +async function insertKeywords(client, parentId, keywords, type) { + await client.query( + `DELETE FROM ${type}_keywords WHERE ${type}_id = $1`, + [parentId] + ); + + for (const keyword of keywords) { + if (!keyword) continue; + + // Insert keyword if not exists + const keywordResult = await client.query( + 'INSERT INTO keywords (keyword) VALUES ($1) ON CONFLICT (keyword) DO UPDATE SET keyword = EXCLUDED.keyword RETURNING id', + [keyword] + ); + const keywordId = keywordResult.rows[0].id; + + // Link keyword to parent + await client.query( + `INSERT INTO ${type}_keywords (${type}_id, keyword_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [parentId, keywordId] + ); + } +} + + + async function close() { await pool.end(); } From f1f3e7b057a5bf6172ec2496500225dfa9a2d79b Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 26 Nov 2025 14:57:32 +0100 Subject: [PATCH 019/134] feat: changed database connection configuration and add insertStacExtensions helper function --- crawler/db.js | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crawler/db.js b/crawler/db.js index 23c5db5..c6b813f 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -6,11 +6,11 @@ const { Pool } = require('pg'); require('dotenv').config(); const pool = new Pool({ - host: process.env.PGHOST || 'atlas.stacindex.org', - port: parseInt(process.env.PGPORT || '5432', 10), - user: process.env.PGUSER || 'stac_user', - password: process.env.PGPASSWORD || 'stac_pass', - database: process.env.PGDATABASE || 'stac_db', + host: process.env.PGHOST, + port: parseInt(process.env.PGPORT, 10), + user: process.env.PGUSER , + password: process.env.PGPASSWORD , + database: process.env.PGDATABASE , max: 10, }); @@ -243,6 +243,24 @@ async function insertKeywords(client, parentId, keywords, type) { } } +/** + * Helper function to insert STAC extensions + */ +async function insertStacExtensions(client, parentId, extensions, type) { + await client.query( + `DELETE FROM ${type}_stac_extensions WHERE ${type}_id = $1`, + [parentId] + ); + + for (const extension of extensions) { + await client.query( + `INSERT INTO ${type}_stac_extensions (${type}_id, extension) VALUES ($1, $2)`, + [parentId, extension] + ); + } +} + + async function close() { From 54a231c33fe1c68bde7fcadd5756cff2e0ebef2d Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 26 Nov 2025 15:04:44 +0100 Subject: [PATCH 020/134] removed the api valication #76 --- crawler/catalogs/catalog.js | 115 +----------------------------------- crawler/package-lock.json | 72 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 112 deletions(-) diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index f885470..117e412 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -6,101 +6,6 @@ const axios = require('axios'); const { default: create } = require('stac-js'); -/** - * Checks if a catalog is STAC API compliant by validating conformance classes - * @async - * @param {string} catalogUrl - The base URL of the catalog - * @returns {Promise} Object with isStacApi flag and conformance classes - */ -async function checkStacApiCompliance(catalogUrl) { - const result = { - isStacApi: false, - conformsTo: [], - features: [] - }; - - try { - // Try to fetch conformance declaration - const conformanceUrls = [ - `${catalogUrl}/conformance`, - `${catalogUrl}/api/conformance`, - catalogUrl // Root might have conformsTo property - ]; - - for (const url of conformanceUrls) { - try { - const response = await axios.get(url, { timeout: 5000 }); - - // Check for conformsTo array (STAC API spec) - if (response.data.conformsTo && Array.isArray(response.data.conformsTo)) { - result.conformsTo = response.data.conformsTo; - - // Check for STAC API specific conformance classes - const stacApiClasses = [ - 'https://api.stacspec.org/v1.0.0/core', - 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', - 'https://api.stacspec.org/v1.0.0-rc.1/core' - ]; - - result.isStacApi = result.conformsTo.some(conformance => - stacApiClasses.some(stacClass => conformance.includes('stacspec.org') || conformance.includes('ogcapi-features')) - ); - - // Identify supported features - if (result.conformsTo.some(c => c.includes('item-search') || c.includes('/search'))) { - result.features.push('search'); - } - if (result.conformsTo.some(c => c.includes('filter'))) { - result.features.push('filter'); - } - if (result.conformsTo.some(c => c.includes('query'))) { - result.features.push('query'); - } - if (result.conformsTo.some(c => c.includes('sort'))) { - result.features.push('sort'); - } - if (result.conformsTo.some(c => c.includes('fields'))) { - result.features.push('fields'); - } - - if (result.isStacApi) { - break; // Found valid STAC API conformance - } - } - } catch (e) { - // Try next URL - continue; - } - } - - // Fallback: Check for /search endpoint existence (practical test) - if (!result.isStacApi) { - try { - const searchResponse = await axios.get(`${catalogUrl}/search`, { - timeout: 3000, - params: { limit: 1 }, - validateStatus: (status) => status < 500 // Accept 4xx as "endpoint exists" - }); - - // If search endpoint exists and returns expected structure - if (searchResponse.status === 200 && - (searchResponse.data.type === 'FeatureCollection' || - searchResponse.data.features)) { - result.isStacApi = true; - result.features.push('search'); - } - } catch (e) { - // Search endpoint doesn't exist - likely static catalog - } - } - - } catch (error) { - // STAC API validation failed - catalog is likely static-only - } - - return result; -} - /** * Splits an array of catalogs into individual elements where each element is an array of its properties * @param {Array} catalogs - Array of catalog objects from the STAC Index API @@ -375,16 +280,13 @@ async function getNestedCatalogs(catalog, stacCatalog = null) { * @async * @param {Object} catalog - Catalog object to crawl * @param {number} depth - Current depth level (for indentation) - * @returns {Promise} Stats object with collections count, STAC compliance, and API capabilities + * @returns {Promise} Stats object with collections count and STAC compliance */ async function crawlCatalogRecursive(catalog, depth = 0) { const indent = ' '.repeat(depth); let stats = { collections: 0, - stacCompliant: false, - stacApiCompliant: false, - apiFeatures: [], - conformsTo: [] + stacCompliant: false }; try { @@ -406,16 +308,6 @@ async function crawlCatalogRecursive(catalog, depth = 0) { console.log(`${indent} STAC Collection validated: ${catalog.id}`); } - // Check STAC API compliance - const apiCompliance = await checkStacApiCompliance(catalog.url); - stats.stacApiCompliant = apiCompliance.isStacApi; - stats.apiFeatures = apiCompliance.features; - stats.conformsTo = apiCompliance.conformsTo; - - if (apiCompliance.isStacApi) { - console.log(`${indent} STAC API compliant - Features: [${apiCompliance.features.join(', ')}]`); - } - } catch (parseError) { console.warn(`${indent} Non-compliant STAC catalog ${catalog.id}, skipping: ${parseError.message}`); return stats; // Skip non-compliant catalogs as per requirement @@ -490,6 +382,5 @@ module.exports = { getCollections, getNestedCatalogs, crawlCatalogRecursive, - deriveCategories, - checkStacApiCompliance + deriveCategories }; \ No newline at end of file diff --git a/crawler/package-lock.json b/crawler/package-lock.json index 9b74099..447b591 100644 --- a/crawler/package-lock.json +++ b/crawler/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", "axios": "^1.13.2", "stac-js": "^0.1.9" } @@ -44,6 +46,39 @@ "license": "(Unlicense OR Apache-2.0)", "optional": true }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -297,6 +332,28 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -570,6 +627,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -660,6 +723,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", From c9852fe498c79de85356d9d718863d15837ae6f1 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 26 Nov 2025 16:00:35 +0100 Subject: [PATCH 021/134] feat: implement helper functions for inserting STAC extensions, summaries, providers, and assets --- crawler/db.js | 109 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/crawler/db.js b/crawler/db.js index c6b813f..1668eca 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -115,7 +115,7 @@ async function insertOrUpdateCollection(collection) { const bbox = collection.extent.spatial.bbox[0]; if (bbox.length === 4) { // Create polygon from bbox [west, south, east, north] - spatialExtend = `SRID=4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; + spatialExtend = `EPSG:4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; } } @@ -213,7 +213,6 @@ async function insertOrUpdateCollection(collection) { } } -// TODO: Implement insertKeywords, insertStacExtensions, insertSummary, insertProviders, insertAssets functions /** @@ -252,10 +251,110 @@ async function insertStacExtensions(client, parentId, extensions, type) { [parentId] ); - for (const extension of extensions) { + for (const extension of extensions) { + if (!extension) continue; + + // Insert extension if not exists + const extResult = await client.query( + 'INSERT INTO stac_extensions (stac_extension) VALUES ($1) ON CONFLICT (stac_extension) DO UPDATE SET stac_extension = EXCLUDED.stac_extension RETURNING id', + [extension] + ); + const extId = extResult.rows[0].id; + + // Link extension to parent + await client.query( + `INSERT INTO ${type}_stac_extension (${type}_id, stac_extension_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [parentId, extId] + ); + } +} + + +/** + * Helper function to insert collection summaries + */ +async function insertSummary(client, collectionId, name, value) { + let kind = 'unknown'; + let rangeMin = null; + let rangeMax = null; + let setValue = null; + let jsonSchema = null; + + if (Array.isArray(value)) { + if (value.length === 2 && typeof value[0] === 'number' && typeof value[1] === 'number') { + kind = 'range'; + rangeMin = value[0]; + rangeMax = value[1]; + } else { + kind = 'set'; + setValue = JSON.stringify(value); + } + } else if (typeof value === 'object') { + kind = 'schema'; + jsonSchema = JSON.stringify(value); + } else { + kind = 'value'; + setValue = String(value); + } + + await client.query( + 'INSERT INTO collection_summaries (collection_id, name, kind, range_min, range_max, set_value, json_schema) VALUES ($1, $2, $3, $4, $5, $6, $7)', + [collectionId, name, kind, rangeMin, rangeMax, setValue, jsonSchema] + ); +} + +/** + * Helper function to insert providers + */ +async function insertProviders(client, collectionId, providers) { + await client.query('DELETE FROM collection_providers WHERE collection_id = $1', [collectionId]); + + for (const provider of providers) { + if (!provider.name) continue; + + // Insert provider if not exists + const providerResult = await client.query( + 'INSERT INTO providers (provider) VALUES ($1) ON CONFLICT (provider) DO UPDATE SET provider = EXCLUDED.provider RETURNING id', + [provider.name] + ); + const providerId = providerResult.rows[0].id; + + // Link provider to collection + const roles = provider.roles ? provider.roles.join(',') : null; + await client.query( + 'INSERT INTO collection_providers (collection_id, provider_id, collection_provider_roles) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', + [collectionId, providerId, roles] + ); + } +} + +/** + * Helper function to insert assets + */ +async function insertAssets(client, collectionId, assets) { + await client.query('DELETE FROM collection_assets WHERE collection_id = $1', [collectionId]); + + for (const [assetName, assetData] of Object.entries(assets)) { + if (!assetData) continue; + + // Insert asset + const assetResult = await client.query( + 'INSERT INTO assets (name, href, type, roles, metadata) VALUES ($1, $2, $3, $4, $5) RETURNING id', + [ + assetName, + assetData.href || null, + assetData.type || null, + assetData.roles || null, + JSON.stringify(assetData) + ] + ); + const assetId = assetResult.rows[0].id; + + // Link asset to collection + const roles = assetData.roles ? assetData.roles.join(',') : null; await client.query( - `INSERT INTO ${type}_stac_extensions (${type}_id, extension) VALUES ($1, $2)`, - [parentId, extension] + 'INSERT INTO collection_assets (collection_id, asset_id, collection_asset_roles) VALUES ($1, $2, $3)', + [collectionId, assetId, roles] ); } } From 239b5aa0a26ab365568f6fe26b57ec8f17c6d13b Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 26 Nov 2025 16:09:46 +0100 Subject: [PATCH 022/134] feat: enhance crawler configuration with CLI and environment variable support --- crawler/.env.example | 17 ++++ crawler/README.md | 113 +++++++++++++++++++++++++- crawler/apis/api.js | 34 ++++++-- crawler/catalogs/catalog.js | 55 ++++++++++--- crawler/index.js | 97 +++++++++++++++------- crawler/package-lock.json | 85 +++----------------- crawler/package.json | 1 + crawler/utils/config.js | 156 ++++++++++++++++++++++++++++++++++++ 8 files changed, 434 insertions(+), 124 deletions(-) create mode 100644 crawler/.env.example create mode 100644 crawler/utils/config.js diff --git a/crawler/.env.example b/crawler/.env.example new file mode 100644 index 0000000..013b893 --- /dev/null +++ b/crawler/.env.example @@ -0,0 +1,17 @@ +# STAC Crawler Configuration +# Copy this file to .env and adjust values as needed + +# Crawl mode: 'catalogs', 'apis', or 'both' +CRAWL_MODE=both + +# Maximum number of catalogs to crawl +MAX_CATALOGS=10 + +# Maximum number of APIs to crawl +MAX_APIS=5 + +# Timeout for each crawl operation in milliseconds (30 seconds = 30000ms) +TIMEOUT_MS=30000 + +# Maximum recursion depth for nested catalogs +MAX_DEPTH=3 diff --git a/crawler/README.md b/crawler/README.md index 1293303..86026dd 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -1,12 +1,76 @@ # STAC Crawler -A Node.js crawler for STAC Index API that fetches and processes catalog and collection data. +A Node.js crawler for STAC Index API that fetches and processes catalog and collection data with configurable options. + +## Configuration + +The crawler can be configured using environment variables, CLI arguments, or a combination of both. CLI arguments take precedence over environment variables. + +### Configuration Options + +| Option | CLI Flag | Environment Variable | Default | Description | +|--------|----------|---------------------|---------|-------------| +| Mode | `-m, --mode` | `CRAWL_MODE` | `both` | Crawl mode: `catalogs`, `apis`, or `both` | +| Max Catalogs | `-c, --max-catalogs` | `MAX_CATALOGS` | `10` | Maximum number of catalogs to process | +| Max APIs | `-a, --max-apis` | `MAX_APIS` | `5` | Maximum number of APIs to process | +| Timeout | `-t, --timeout` | `TIMEOUT_MS` | `30000` | Timeout per operation in milliseconds | +| Max Depth | `-d, --max-depth` | `MAX_DEPTH` | `3` | Maximum recursion depth for nested catalogs | + +### Using Environment Variables + +1. Copy the example environment file: +```bash +cp .env.example .env +``` + +2. Edit `.env` to customize settings: +```bash +CRAWL_MODE=both +MAX_CATALOGS=20 +MAX_APIS=10 +TIMEOUT_MS=60000 +MAX_DEPTH=5 +``` + +3. Run the crawler: +```bash +npm start +``` + +### Using CLI Arguments + +Run the crawler with command-line arguments to override defaults or environment variables: + +```bash +# Crawl only catalogs with custom limits +node index.js --mode catalogs --max-catalogs 20 + +# Crawl only APIs with extended timeout +node index.js -m apis -a 10 -t 60000 + +# Crawl both with all custom settings +node index.js -m both -c 50 -a 20 -t 45000 -d 5 +``` + +### Show Help + +Display all available options: + +```bash +node index.js --help +``` ## Running Locally ```bash +# Install dependencies npm install + +# Run with default configuration npm start + +# Run with custom configuration +node index.js --mode catalogs --max-catalogs 15 ``` ## Docker @@ -15,10 +79,16 @@ npm start ```bash # Build the image -docker build -t stac-crawler +docker build -t stac-crawler . -# Run the container +# Run with default configuration docker run --rm stac-crawler + +# Run with environment variables +docker run --rm -e CRAWL_MODE=apis -e MAX_APIS=10 stac-crawler + +# Run with CLI arguments +docker run --rm stac-crawler --mode catalogs --max-catalogs 20 ``` Or use npm scripts: @@ -30,6 +100,8 @@ npm run docker:run ### Using Docker Compose +Create a `.env` file or modify `docker-compose.yml` to set environment variables: + ```bash # Start the crawler docker-compose up -d @@ -47,3 +119,38 @@ Or use npm scripts: npm run docker:compose:up npm run docker:compose:down ``` + +## Examples + +### Example 1: Quick API Test +Crawl only the first 3 APIs with a short timeout: +```bash +node index.js -m apis -a 3 -t 15000 +``` + +### Example 2: Deep Catalog Exploration +Crawl 100 catalogs with maximum depth and extended timeout: +```bash +node index.js -m catalogs -c 100 -d 10 -t 120000 +``` + +### Example 3: Balanced Crawl +Crawl both catalogs and APIs with moderate settings: +```bash +node index.js -m both -c 25 -a 15 -t 45000 -d 4 +``` + +### Example 4: Production Environment +Set up `.env` for production: +```bash +CRAWL_MODE=both +MAX_CATALOGS=1000 +MAX_APIS=500 +TIMEOUT_MS=60000 +MAX_DEPTH=5 +``` + +Then run: +```bash +npm start +``` diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 5eda959..c414889 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -5,15 +5,17 @@ import axios from 'axios'; import create from 'stac-js'; +import { withTimeout } from '../utils/config.js'; /** * Crawls STAC APIs to retrieve collection information without fetching items. * * @param {string[]} urls - Array of API URLs to crawl * @param {boolean} isApi - Boolean flag indicating if the URLs are APIs + * @param {Object} config - Configuration object with timeout settings * @returns {Promise} Array of STAC Collection objects ordered by URL */ -async function crawlApis(urls, isApi) { +async function crawlApis(urls, isApi, config = {}) { if (!isApi || !Array.isArray(urls) || urls.length === 0) { return []; } @@ -26,7 +28,7 @@ async function crawlApis(urls, isApi) { for (const [index, url] of urls.entries()) { console.log(`Processing API ${index + 1}/${urls.length}: ${url}`); try { - await crawlSingleApi(url, allCollections); + await crawlSingleApi(url, allCollections, new Set(), config); } catch (error) { console.error(`Error crawling API ${url}:`, error.message); } @@ -50,14 +52,22 @@ async function crawlApis(urls, isApi) { * @param {string} url - URL to crawl * @param {Map} collectionMap - Map to store found collections * @param {Set} visited - Set of visited URLs to prevent loops + * @param {Object} config - Configuration object with timeout settings */ -async function crawlSingleApi(url, collectionMap, visited = new Set()) { +async function crawlSingleApi(url, collectionMap, visited = new Set(), config = {}) { if (!url || visited.has(url)) return; visited.add(url); + const timeoutMs = config.timeout || 30000; + const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(10000, timeoutMs); + try { console.log(` Fetching: ${url}`); - const response = await axios.get(url); + const response = await withTimeout( + axios.get(url, { timeout: axiosTimeout }), + timeoutMs, + `Crawling API ${url}` + ); const stacObj = create(response.data); // If it's a Collection, add it @@ -77,7 +87,7 @@ async function crawlSingleApi(url, collectionMap, visited = new Set()) { const collectionsLink = stacObj.getApiCollectionsLink(); if (collectionsLink) { console.log(` Found /collections endpoint link in ${url}`); - await fetchCollectionsFromLink(collectionsLink, collectionMap, visited); + await fetchCollectionsFromLink(collectionsLink, collectionMap, visited, config); } else { // Fallback: check standard STAC API structure if not explicitly found // Many STAC APIs have a /collections endpoint relative to root @@ -90,7 +100,7 @@ async function crawlSingleApi(url, collectionMap, visited = new Set()) { for (const link of childLinks) { const childUrl = link.getAbsoluteUrl(); if (childUrl) { - await crawlSingleApi(childUrl, collectionMap, visited); + await crawlSingleApi(childUrl, collectionMap, visited, config); } } } @@ -108,15 +118,23 @@ async function crawlSingleApi(url, collectionMap, visited = new Set()) { * @param {Object} link - stac-js Link object * @param {Map} collectionMap - Map to store collections * @param {Set} visited - Visited set + * @param {Object} config - Configuration object with timeout settings */ -async function fetchCollectionsFromLink(link, collectionMap, visited) { +async function fetchCollectionsFromLink(link, collectionMap, visited, config = {}) { const url = link.getAbsoluteUrl(); if (!url || visited.has(url)) return; visited.add(url); + const timeoutMs = config.timeout || 30000; + const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(10000, timeoutMs); + try { console.log(` Fetching collections from: ${url}`); - const response = await axios.get(url); + const response = await withTimeout( + axios.get(url, { timeout: axiosTimeout }), + timeoutMs, + `Fetching collections from ${url}` + ); // create() handles CollectionCollection (API Collections response) const stacObj = create(response.data); diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 1a2df1b..4527acb 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -5,6 +5,7 @@ import axios from 'axios'; import create from 'stac-js'; +import { withTimeout } from '../utils/config.js'; /** * Splits an array of catalogs into individual elements where each element is an array of its properties @@ -96,10 +97,14 @@ function splitCatalogs(catalogs) { * Fetches and processes collections from a catalog using stac-js * @async * @param {Object} catalog - Catalog object with url property + * @param {Object} config - Configuration object with timeout settings * @returns {Promise} Array of collections formatted as arrays */ -async function getCollections(catalog) { +async function getCollections(catalog, config = {}) { try { + const timeoutMs = config.timeout || 30000; + const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(5000, timeoutMs); + // Try common STAC collection endpoints const collectionUrls = [ `${catalog.url}/collections`, @@ -109,7 +114,11 @@ async function getCollections(catalog) { for (const url of collectionUrls) { try { - const response = await axios.get(url, { timeout: 5000 }); + const response = await withTimeout( + axios.get(url, { timeout: axiosTimeout }), + timeoutMs, + `Fetching collections from ${url}` + ); // Parse response with stac-js for validation and enhanced metadata extraction let stacObj; @@ -213,10 +222,14 @@ async function getCollections(catalog) { * @async * @param {Object} catalog - Catalog object with url property * @param {Object} [stacCatalog] - Optional stac-js catalog object for link extraction + * @param {Object} config - Configuration object with timeout settings * @returns {Promise} Array of nested catalogs */ -async function getNestedCatalogs(catalog, stacCatalog = null) { +async function getNestedCatalogs(catalog, stacCatalog = null, config = {}) { try { + const timeoutMs = config.timeout || 30000; + const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(5000, timeoutMs); + // If we have a stac-js catalog object, use its link navigation methods if (stacCatalog && typeof stacCatalog.getChildLinks === 'function') { const childLinks = stacCatalog.getChildLinks(); @@ -232,7 +245,11 @@ async function getNestedCatalogs(catalog, stacCatalog = null) { ? link.getAbsoluteUrl() : link.href; - const response = await axios.get(childUrl, { timeout: 5000 }); + const response = await withTimeout( + axios.get(childUrl, { timeout: axiosTimeout }), + timeoutMs, + `Fetching child catalog from ${childUrl}` + ); nestedCatalogs.push(response.data); } catch (e) { console.warn(` Failed to fetch child catalog: ${e.message}`); @@ -252,7 +269,11 @@ async function getNestedCatalogs(catalog, stacCatalog = null) { for (const url of catalogUrls) { try { - const response = await axios.get(url, { timeout: 5000 }); + const response = await withTimeout( + axios.get(url, { timeout: axiosTimeout }), + timeoutMs, + `Fetching nested catalogs from ${url}` + ); const catalogsData = Array.isArray(response.data) ? response.data : response.data.catalogs || []; @@ -279,22 +300,36 @@ async function getNestedCatalogs(catalog, stacCatalog = null) { * @async * @param {Object} catalog - Catalog object to crawl * @param {number} depth - Current depth level (for indentation) + * @param {Object} config - Configuration object with timeout and depth settings * @returns {Promise} Stats object with collections count and STAC compliance */ -async function crawlCatalogRecursive(catalog, depth = 0) { +async function crawlCatalogRecursive(catalog, depth = 0, config = {}) { const indent = ' '.repeat(depth); let stats = { collections: 0, stacCompliant: false }; + // Check max depth + const maxDepth = config.maxDepth || 3; + if (depth >= maxDepth) { + console.log(`${indent} Max depth ${maxDepth} reached, stopping recursion`); + return stats; + } + try { + const timeoutMs = config.timeout || 30000; + const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(5000, timeoutMs); let stacCatalog = null; // Try to fetch and parse the catalog with stac-js if (catalog.url) { try { - const response = await axios.get(catalog.url, { timeout: 5000 }); + const response = await withTimeout( + axios.get(catalog.url, { timeout: axiosTimeout }), + timeoutMs, + `Fetching catalog ${catalog.id}` + ); try { stacCatalog = create(response.data, catalog.url); @@ -318,11 +353,11 @@ async function crawlCatalogRecursive(catalog, depth = 0) { } // First, try to get collections from this catalog - const collections = await getCollections(catalog); + const collections = await getCollections(catalog, config); stats.collections = collections.length; // Then, try to get nested catalogs using stac-js if available - const nestedCatalogs = await getNestedCatalogs(catalog, stacCatalog); + const nestedCatalogs = await getNestedCatalogs(catalog, stacCatalog, config); if (nestedCatalogs.length > 0) { console.log(`${indent} Nested catalogs found: ${nestedCatalogs.length}`); @@ -335,7 +370,7 @@ async function crawlCatalogRecursive(catalog, depth = 0) { url: nestedCatalog.links?.find(l => l.rel === 'self')?.href || nestedCatalog.url }; - const nestedStats = await crawlCatalogRecursive(nestedCatalogObj, depth + 1); + const nestedStats = await crawlCatalogRecursive(nestedCatalogObj, depth + 1, config); stats.collections += nestedStats.collections; } } diff --git a/crawler/index.js b/crawler/index.js index 7cffb6a..11d9bb3 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -6,6 +6,7 @@ import axios from 'axios'; import { splitCatalogs, crawlCatalogRecursive } from './catalogs/catalog.js'; import { crawlApis } from './apis/api.js'; +import { getConfig } from './utils/config.js'; /** * URL of the STAC Index API endpoint @@ -21,44 +22,78 @@ const targetUrl = 'https://www.stacindex.org/api/catalogs'; */ const crawler = async () => { try { + // Load configuration + const config = getConfig(); + + // Display configuration + console.log('\n=== STAC Crawler Configuration ==='); + console.log(`Mode: ${config.mode}`); + console.log(`Max Catalogs: ${config.maxCatalogs === Infinity ? 'unlimited' : config.maxCatalogs}`); + console.log(`Max APIs: ${config.maxApis === Infinity ? 'unlimited' : config.maxApis}`); + console.log(`Timeout: ${config.timeout === Infinity ? 'unlimited' : config.timeout + 'ms'}`); + console.log(`Max Depth: ${config.maxDepth === Infinity ? 'unlimited' : config.maxDepth}`); + console.log('==================================\n'); + const response = await axios.get(targetUrl); const catalogs = splitCatalogs(response.data); - // Crawl collections and nested catalogs for each catalog - console.log('\n Crawling collections and nested catalogs...\n'); - let totalCollections = 0; - for (const catalogData of catalogs.slice(0, 10)) { // Limit to first 10 catalogs - const catalog = { - id: catalogData[1], - url: catalogData[2] - }; - const stats = await crawlCatalogRecursive(catalog); - totalCollections += stats.collections; - } - - console.log(`\n Total collections found across all catalogs: ${totalCollections}`); - - // Extract API URLs and crawl them - console.log('\n Crawling APIs...'); - const apiUrls = catalogs - .filter(cat => cat[10] === true) // isApi is at index 10 - .map(cat => cat[2]); // url is at index 2 + // Crawl catalogs if mode is 'catalogs' or 'both' + if (config.mode === 'catalogs' || config.mode === 'both') { + console.log('\n Crawling collections and nested catalogs...\n'); + let totalCollections = 0; - if (apiUrls.length > 0) { - console.log(`Found ${apiUrls.length} APIs. Starting crawl...`); - // Limit to first 5 APIs for demonstration/performance - const collections = await crawlApis(apiUrls, true); - console.log(`\nFetched ${collections.length} collections from APIs (sorted by URL).`); + const catalogsToProcess = config.maxCatalogs === Infinity ? catalogs : catalogs.slice(0, config.maxCatalogs); + console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === Infinity ? 'unlimited' : config.maxCatalogs})\n`); - if (collections.length > 0) { - console.log('First 3 collections found:'); - collections.slice(0, 3).forEach(c => { - const selfLink = c.links?.find(l => l.rel === 'self')?.href; - console.log(` - ${c.id}: ${selfLink}`); - }); + for (const catalogData of catalogsToProcess) { + const catalog = { + id: catalogData[1], + url: catalogData[2] + }; + + try { + const stats = await crawlCatalogRecursive(catalog, 0, config); + totalCollections += stats.collections; + } catch (error) { + console.error(`Failed to crawl catalog ${catalog.id}: ${error.message}`); + } + } + + console.log(`\n Total collections found across all catalogs: ${totalCollections}`); + } else { + console.log('\n Skipping catalog crawling (mode: apis)\n'); + } + + // Crawl APIs if mode is 'apis' or 'both' + if (config.mode === 'apis' || config.mode === 'both') { + console.log('\n Crawling APIs...'); + const apiUrls = catalogs + .filter(cat => cat[10] === true) // isApi is at index 10 + .map(cat => cat[2]); // url is at index 2 + + if (apiUrls.length > 0) { + const apisToProcess = config.maxApis === Infinity ? apiUrls : apiUrls.slice(0, config.maxApis); + console.log(`Found ${apiUrls.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === Infinity ? 'unlimited' : config.maxApis})...`); + + try { + const collections = await crawlApis(apisToProcess, true, config); + console.log(`\nFetched ${collections.length} collections from APIs (sorted by URL).`); + + if (collections.length > 0) { + console.log('First 3 collections found:'); + collections.slice(0, 3).forEach(c => { + const selfLink = c.links?.find(l => l.rel === 'self')?.href; + console.log(` - ${c.id}: ${selfLink}`); + }); + } + } catch (error) { + console.error(`Failed to crawl APIs: ${error.message}`); + } + } else { + console.log('No APIs found to crawl.'); } } else { - console.log('No APIs found to crawl.'); + console.log('\n Skipping API crawling (mode: catalogs)\n'); } } catch (error) { diff --git a/crawler/package-lock.json b/crawler/package-lock.json index 447b591..3ab52ae 100644 --- a/crawler/package-lock.json +++ b/crawler/package-lock.json @@ -9,9 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", "axios": "^1.13.2", + "dotenv": "^17.2.3", "stac-js": "^0.1.9" } }, @@ -46,39 +45,6 @@ "license": "(Unlicense OR Apache-2.0)", "optional": true }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -258,6 +224,18 @@ "node": ">=0.4.0" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -332,28 +310,6 @@ "node": ">=6" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -627,12 +583,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -723,15 +673,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", diff --git a/crawler/package.json b/crawler/package.json index 49e9982..81f72fa 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -16,6 +16,7 @@ "type": "module", "dependencies": { "axios": "^1.13.2", + "dotenv": "^17.2.3", "stac-js": "^0.1.9" } } diff --git a/crawler/utils/config.js b/crawler/utils/config.js new file mode 100644 index 0000000..2b006c1 --- /dev/null +++ b/crawler/utils/config.js @@ -0,0 +1,156 @@ +/** + * @fileoverview Configuration management for STAC crawler + * @module utils/config + */ + +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); + +/** + * Parse command line arguments + * @returns {Object} Parsed CLI arguments + */ +function parseCliArgs() { + const args = process.argv.slice(2); + const config = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--mode' || arg === '-m') { + config.mode = args[++i]; + } else if (arg === '--max-catalogs' || arg === '-c') { + config.maxCatalogs = parseInt(args[++i], 10); + } else if (arg === '--max-apis' || arg === '-a') { + config.maxApis = parseInt(args[++i], 10); + } else if (arg === '--timeout' || arg === '-t') { + config.timeout = parseInt(args[++i], 10); + } else if (arg === '--max-depth' || arg === '-d') { + config.maxDepth = parseInt(args[++i], 10); + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + } + + return config; +} + +/** + * Print help message + */ +function printHelp() { + console.log(` +STAC Crawler Configuration Options: + + -m, --mode Crawl mode: 'catalogs', 'apis', or 'both' (default: 'both') + -c, --max-catalogs Maximum number of catalogs to crawl (default: 10) + -a, --max-apis Maximum number of APIs to crawl (default: 5) + -t, --timeout Timeout for each crawl operation in ms (default: 30000) + -d, --max-depth Maximum recursion depth for nested catalogs (default: 3) + -h, --help Show this help message + +Environment Variables: + CRAWL_MODE Same as --mode + MAX_CATALOGS Same as --max-catalogs + MAX_APIS Same as --max-apis + TIMEOUT_MS Same as --timeout + MAX_DEPTH Same as --max-depth + +Examples: + node index.js --mode catalogs --max-catalogs 20 + node index.js -m apis -a 10 -t 60000 + CRAWL_MODE=both MAX_CATALOGS=50 node index.js + `); +} + +/** + * Get configuration from environment variables, CLI args, and defaults + * CLI args take precedence over env vars, which take precedence over defaults + * @returns {Object} Configuration object + */ +function getConfig() { + const cliArgs = parseCliArgs(); + + // Default configuration + const defaults = { + mode: 'both', // 'catalogs', 'apis', or 'both' + maxCatalogs: 10, // Maximum number of catalogs to crawl + maxApis: 5, // Maximum number of APIs to crawl + timeout: 30000, // Timeout in milliseconds (30 seconds) + maxDepth: 3 // Maximum recursion depth for nested catalogs + }; + + // Build configuration with precedence: CLI > ENV > Defaults + const config = { + mode: cliArgs.mode || process.env.CRAWL_MODE || defaults.mode, + maxCatalogs: cliArgs.maxCatalogs || parseInt(process.env.MAX_CATALOGS, 10) || defaults.maxCatalogs, + maxApis: cliArgs.maxApis || parseInt(process.env.MAX_APIS, 10) || defaults.maxApis, + timeout: cliArgs.timeout || parseInt(process.env.TIMEOUT_MS, 10) || defaults.timeout, + maxDepth: cliArgs.maxDepth || parseInt(process.env.MAX_DEPTH, 10) || defaults.maxDepth + }; + + // Validate mode + const validModes = ['catalogs', 'apis', 'both']; + if (!validModes.includes(config.mode)) { + console.error(`Invalid mode: ${config.mode}. Must be one of: ${validModes.join(', ')}`); + process.exit(1); + } + + // When mode is 'catalogs', remove limits for comprehensive crawling + if (config.mode === 'catalogs') { + config.maxCatalogs = Infinity; + config.maxDepth = Infinity; + config.timeout = Infinity; + } + + // Validate numeric values + if (config.maxCatalogs < 0 || config.maxApis < 0 || config.timeout < 0 || config.maxDepth < 0) { + console.error('Numeric configuration values must be non-negative'); + process.exit(1); + } + + return config; +} + +/** + * Create a timeout promise that rejects after the specified time + * @param {number} ms - Timeout in milliseconds + * @param {string} operation - Description of the operation for error message + * @returns {Promise} Promise that rejects after timeout + */ +function createTimeout(ms, operation = 'Operation') { + return new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`${operation} timed out after ${ms}ms`)); + }, ms); + }); +} + +/** + * Wrap a promise with a timeout + * @param {Promise} promise - Promise to wrap + * @param {number} ms - Timeout in milliseconds (Infinity for no timeout) + * @param {string} operation - Description of the operation + * @returns {Promise} Promise that races against timeout + */ +async function withTimeout(promise, ms, operation = 'Operation') { + // If timeout is Infinity, just return the promise without racing + if (ms === Infinity) { + return promise; + } + return Promise.race([ + promise, + createTimeout(ms, operation) + ]); +} + +export { + getConfig, + parseCliArgs, + printHelp, + withTimeout, + createTimeout +}; From 4b818c7f66aa886f505e41d73e1775b54278f4e2 Mon Sep 17 00:00:00 2001 From: Lenn Date: Thu, 27 Nov 2025 17:25:06 +0100 Subject: [PATCH 023/134] feat: functions to save crawled catalogs, collections and apis in the database --- crawler/db.js | 8 ++++-- crawler/index.js | 72 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/crawler/db.js b/crawler/db.js index 1668eca..8587c11 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -2,8 +2,10 @@ * DB helper for crawler using `pg` Pool * Exposes: initDb(), insertOrUpdateCatalog(), close() */ -const { Pool } = require('pg'); -require('dotenv').config(); +import pkg from 'pg'; +const { Pool } = pkg; +import dotenv from 'dotenv'; +dotenv.config(); const pool = new Pool({ host: process.env.PGHOST, @@ -366,7 +368,7 @@ async function close() { await pool.end(); } -module.exports = { +export default { initDb, insertOrUpdateCatalog, insertOrUpdateCollection, diff --git a/crawler/index.js b/crawler/index.js index 1a96a56..d532154 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -7,6 +7,7 @@ import axios from 'axios'; import { splitCatalogs, crawlCatalogRecursive } from './catalogs/catalog.js'; import { crawlApis } from './apis/api.js'; import { getConfig } from './utils/config.js'; +import db from './db.js'; /** * URL of the STAC Index API endpoint @@ -34,9 +35,31 @@ const crawler = async () => { console.log(`Max Depth: ${config.maxDepth === Infinity ? 'unlimited' : config.maxDepth}`); console.log('==================================\n'); + // Initialize database connection + console.log('Initializing database connection...'); + await db.initDb(); + const response = await axios.get(targetUrl); const catalogs = splitCatalogs(response.data); + // Save catalogs from STAC Index to database + console.log('\n=== Saving catalogs to database ==='); + let catalogsSaved = 0; + let catalogsFailed = 0; + + for (const catalog of response.data) { + try { + const catalogId = await db.insertOrUpdateCatalog(catalog); + console.log(`✓ Saved catalog: ${catalog.title || catalog.id} (DB ID: ${catalogId})`); + catalogsSaved++; + } catch (err) { + console.error(`✗ Failed catalog: ${catalog.title || catalog.id} - ${err.message}`); + catalogsFailed++; + } + } + + console.log(`\nCatalogs: ${catalogsSaved} saved, ${catalogsFailed} failed\n`); + // Crawl catalogs if mode is 'catalogs' or 'both' if (config.mode === 'catalogs' || config.mode === 'both') { console.log('\n Crawling collections and nested catalogs...\n'); @@ -54,6 +77,18 @@ const crawler = async () => { try { const stats = await crawlCatalogRecursive(catalog, 0, config); totalCollections += stats.collections; + + // Save collections to database if returned + if (stats.collectionsData && Array.isArray(stats.collectionsData)) { + for (const collection of stats.collectionsData) { + try { + const collectionId = await db.insertOrUpdateCollection(collection); + console.log(` ✓ Saved collection: ${collection.title || collection.id} (DB ID: ${collectionId})`); + } catch (err) { + console.error(` ✗ Failed collection: ${collection.title || collection.id} - ${err.message}`); + } + } + } } catch (error) { console.error(`Failed to crawl catalog ${catalog.id}: ${error.message}`); } @@ -85,6 +120,24 @@ const crawler = async () => { const selfLink = c.links?.find(l => l.rel === 'self')?.href; console.log(` - ${c.id}: ${selfLink}`); }); + + // Save API collections to database + console.log('\n=== Saving API collections to database ==='); + let apiCollectionsSaved = 0; + let apiCollectionsFailed = 0; + + for (const collection of collections) { + try { + const collectionId = await db.insertOrUpdateCollection(collection); + console.log(` ✓ Saved: ${collection.title || collection.id} (DB ID: ${collectionId})`); + apiCollectionsSaved++; + } catch (err) { + console.error(` ✗ Failed: ${collection.title || collection.id} - ${err.message}`); + apiCollectionsFailed++; + } + } + + console.log(`\nAPI Collections: ${apiCollectionsSaved} saved, ${apiCollectionsFailed} failed`); } } catch (error) { console.error(`Failed to crawl APIs: ${error.message}`); @@ -98,22 +151,11 @@ const crawler = async () => { } catch (error) { console.error(`Error fetching ${targetUrl}: ${error.message}`); + } finally { + // Close database connection + await db.close(); + console.log('\nDatabase connection closed.'); } - - // Persist catalogs into DB - try { - await Promise.all(catalogs.map(async (catalog) => { - try { - await db.insertOrUpdateCatalog(catalog); - } catch (err) { - console.error('DB write error for catalog', catalog && (catalog.id || catalog.slug), err && err.message); - } - })); - console.log('Finished writing catalogs to DB'); - } catch (e) { - console.error('Unexpected error while saving catalogs to DB', e && e.message); - } - }; crawler(); \ No newline at end of file From 6c47638ba9d32e8e97f170e938ed497de2f18f2b Mon Sep 17 00:00:00 2001 From: Jakob Date: Fri, 28 Nov 2025 14:19:32 +0100 Subject: [PATCH 024/134] #154, refactored the crawler to use crawlee Framework, improved performance and structure --- crawler/catalogs/catalog.js | 472 +--- crawler/index.js | 124 +- crawler/package-lock.json | 4553 ++++++++++++++++++++++++++++---- crawler/package.json | 1 + crawler/utils/config.js | 31 +- crawler/utils/endpoints.js | 36 + crawler/utils/handlers.js | 205 ++ crawler/utils/normalization.js | 130 + 8 files changed, 4662 insertions(+), 890 deletions(-) create mode 100644 crawler/utils/endpoints.js create mode 100644 crawler/utils/handlers.js create mode 100644 crawler/utils/normalization.js diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 4527acb..2ba561a 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -1,420 +1,102 @@ /** - * @fileoverview Catalog crawling functionality for STAC Index + * @fileoverview Catalog crawling functionality for STAC Index using Crawlee * @module catalogs/catalog */ -import axios from 'axios'; -import create from 'stac-js'; -import { withTimeout } from '../utils/config.js'; +import { HttpCrawler } from 'crawlee'; +import { handleCatalog, handleCollections } from '../utils/handlers.js'; /** - * Splits an array of catalogs into individual elements where each element is an array of its properties - * @param {Array} catalogs - Array of catalog objects from the STAC Index API - * @returns {Array} Array of arrays, where each inner array contains: - * [0] = index, [1] = id, [2] = url, [3] = slug, [4] = title, [5] = summary, - * [6] = access, [7] = created, [8] = updated, [9] = isPrivate, [10] = isApi, - * [11] = accessInfo, [12] = categories, ... (followed by any other dynamic properties) - * @throws {Error} Throws error if input is not an array + * Creates and runs a Crawlee HttpCrawler to crawl STAC catalogs + * @async + * @param {Array} initialCatalogs - Array of catalog objects to start crawling from + * @param {Object} config - Configuration object with timeout and depth settings + * @returns {Promise} Crawl results with collections and statistics */ -function splitCatalogs(catalogs) { - if (!Array.isArray(catalogs)) { - throw new Error('Expected an array'); - } - - // Define the order of standard properties - const standardPropertyOrder = [ - 'index', 'id', 'url', 'slug', 'title', 'summary', - 'access', 'created', 'updated', 'isPrivate', 'isApi', 'accessInfo' - ]; - - // Split each element individually and convert to array format - const elements = catalogs.map((catalog, index) => { - - // Create object with all properties - let elementObj = { - index: index, - ...catalog, - }; - - // Validate and migrate STAC catalog using stac-js if it has STAC properties - if (catalog.url && typeof catalog.url === 'string') { - try { - // For STAC Index catalogs, we don't validate against STAC spec - // as they are metadata about STAC catalogs, not STAC objects themselves - // This is just for potential future enhancement - } catch (error) { - console.warn(`STAC validation skipped for catalog ${catalog.id}: ${error.message}`); - } +async function crawlCatalogs(initialCatalogs, config = {}) { + const timeoutSecs = config.timeout && config.timeout !== Infinity + ? Math.ceil(config.timeout / 1000) + : 60; + + // Store results + const results = { + collections: [], + catalogs: [], + stats: { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + collectionsFound: 0, + catalogsProcessed: 0, + stacCompliant: 0, + nonCompliant: 0 } + }; - // Get all property names - const allProperties = Object.keys(elementObj); + const crawler = new HttpCrawler({ + requestHandlerTimeoutSecs: timeoutSecs, - // Separate standard and dynamic properties - const dynamicProps = allProperties.filter(prop => !standardPropertyOrder.includes(prop)); - - // Create array with standard properties first, then dynamic ones - const elementArray = []; - - // Add standard properties in defined order (always include all standard fields for consistent indices) - standardPropertyOrder.forEach(prop => { - elementArray.push(elementObj[prop]); - }); - - // Add dynamic properties - dynamicProps.forEach(prop => { - elementArray.push(elementObj[prop]); - }); - return elementArray; - }); - - // Output for demonstration - console.log(`Total: ${elements.length} elements found\n`); - - if (elements.length > 0) { - console.log('Example - First Element (as array):'); - console.log('Array indices and values:'); - const firstElement = elements[0]; - const allProps = [ - 'index', 'id', 'url', 'slug', 'title', 'summary', - 'access', 'created', 'updated', 'isPrivate', 'isApi', 'accessInfo' - ]; - firstElement.forEach((value, idx) => { - const propName = idx < allProps.length ? allProps[idx] : `dynamic_property_${idx}`; - console.log(` [${idx}] ${propName}: ${JSON.stringify(value)}`); - }); - console.log('\nAccess examples:'); - console.log(` elements[0][0] (index): ${elements[0][0]}`); - console.log(` elements[0][1] (id): ${elements[0][1]}`); - console.log(` elements[0][2] (url): ${elements[0][2]}`); - console.log(` elements[0][7] (created): ${elements[0][7]}`); - } - - return elements; -} - -/** - * Fetches and processes collections from a catalog using stac-js - * @async - * @param {Object} catalog - Catalog object with url property - * @param {Object} config - Configuration object with timeout settings - * @returns {Promise} Array of collections formatted as arrays - */ -async function getCollections(catalog, config = {}) { - try { - const timeoutMs = config.timeout || 30000; - const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(5000, timeoutMs); - - // Try common STAC collection endpoints - const collectionUrls = [ - `${catalog.url}/collections`, - `${catalog.url}/collections/`, - `${catalog.url}/api/v1/collections` - ]; - - for (const url of collectionUrls) { + async requestHandler({ request, json, crawler, log }) { + results.stats.totalRequests++; + const depth = request.userData?.depth || 0; + const indent = ' '.repeat(depth); + try { - const response = await withTimeout( - axios.get(url, { timeout: axiosTimeout }), - timeoutMs, - `Fetching collections from ${url}` - ); - - // Parse response with stac-js for validation and enhanced metadata extraction - let stacObj; - try { - stacObj = create(response.data, url); - } catch (parseError) { - // If stac-js cannot parse, skip this catalog (non-compliant STAC) - console.warn(` Skipping non-compliant STAC at ${url}: ${parseError.message}`); - continue; + // Route based on request label + if (request.label === 'CATALOG') { + await handleCatalog({ request, json, crawler, log, indent, results }); + } else if (request.label === 'COLLECTIONS') { + await handleCollections({ request, json, crawler, log, indent, results }); } - let collectionsData = []; - - // Check if this is a CollectionCollection (STAC API response) - if (stacObj && typeof stacObj.getAll === 'function') { - collectionsData = stacObj.getAll(); - } else if (Array.isArray(response.data)) { - // Handle array of collections - collectionsData = response.data.map(col => { - try { - return create(col, url); - } catch { - return null; - } - }).filter(Boolean); - } else if (response.data.collections) { - // Handle nested collections property - collectionsData = response.data.collections.map(col => { - try { - return create(col, url); - } catch { - return null; - } - }).filter(Boolean); - } - - if (collectionsData.length > 0) { - console.log(`\n Catalog: ${catalog.id}`); - console.log(` Found ${collectionsData.length} collections`); - - // Convert collections using stac-js methods for metadata extraction - const collections = collectionsData.map((colObj, index) => { - // Use stac-js methods for robust metadata extraction - const bbox = typeof colObj.getBoundingBox === 'function' - ? colObj.getBoundingBox() - : (colObj.extent?.spatial?.bbox?.[0] || 'N/A'); - - const temporal = typeof colObj.getTemporalExtent === 'function' - ? colObj.getTemporalExtent() - : (colObj.extent?.temporal?.interval?.[0] || 'N/A'); - - // Get self URL using stac-js link navigation - let selfUrl = 'N/A'; - if (typeof colObj.getAbsoluteUrl === 'function') { - selfUrl = colObj.getAbsoluteUrl(); - } else if (colObj.links) { - const selfLink = colObj.links.find(l => l.rel === 'self'); - selfUrl = selfLink?.href || 'N/A'; - } - - return [ - index, - colObj.id || 'Unknown', - selfUrl, - colObj.title || 'N/A', - colObj.description || colObj.summary || 'N/A', - bbox, - temporal, - colObj.license || 'N/A', - colObj.keywords || [] - ]; - }); - - // Display collection details - if (collections.length > 0) { - console.log(' Collection details:'); - collections.forEach((col, idx) => { - console.log(` [${idx}] ${col[1]} - ${col[3]}`); - }); - } - - return collections; - } - } catch (e) { - // Try next URL - continue; + results.stats.successfulRequests++; + } catch (error) { + log.error(`${indent}Error handling ${request.label} at ${request.url}: ${error.message}`); + throw error; // Re-throw to trigger failedRequestHandler } - } - - console.log(`\n No collections found for catalog: ${catalog.id}`); - return []; - - } catch (error) { - console.error(`Error fetching collections for ${catalog.id}: ${error.message}`); - return []; - } -} - -/** - * Fetches nested catalogs within a catalog using stac-js link navigation - * @async - * @param {Object} catalog - Catalog object with url property - * @param {Object} [stacCatalog] - Optional stac-js catalog object for link extraction - * @param {Object} config - Configuration object with timeout settings - * @returns {Promise} Array of nested catalogs - */ -async function getNestedCatalogs(catalog, stacCatalog = null, config = {}) { - try { - const timeoutMs = config.timeout || 30000; - const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(5000, timeoutMs); + }, - // If we have a stac-js catalog object, use its link navigation methods - if (stacCatalog && typeof stacCatalog.getChildLinks === 'function') { - const childLinks = stacCatalog.getChildLinks(); + async failedRequestHandler({ request, error, log }) { + results.stats.failedRequests++; + const depth = request.userData?.depth || 0; + const indent = ' '.repeat(depth); - if (childLinks.length > 0) { - console.log(` Found ${childLinks.length} child catalog links via stac-js`); - - // Fetch each child catalog - const nestedCatalogs = []; - for (const link of childLinks) { - try { - const childUrl = typeof link.getAbsoluteUrl === 'function' - ? link.getAbsoluteUrl() - : link.href; - - const response = await withTimeout( - axios.get(childUrl, { timeout: axiosTimeout }), - timeoutMs, - `Fetching child catalog from ${childUrl}` - ); - nestedCatalogs.push(response.data); - } catch (e) { - console.warn(` Failed to fetch child catalog: ${e.message}`); - } - } - - return nestedCatalogs; + // Log non-compliant STAC or failed requests + if (error.message.includes('STAC') || error.message.includes('validation')) { + log.info(`${indent}Skipping non-compliant STAC: ${request.url}`); + results.stats.nonCompliant++; + } else { + log.warning(`${indent}Failed request: ${request.url} - ${error.message}`); } } - - // Fallback: Try common STAC nested catalog endpoints - const catalogUrls = [ - `${catalog.url}/catalogs`, - `${catalog.url}/catalogs/`, - `${catalog.url}/api/v1/catalogs` - ]; + }); - for (const url of catalogUrls) { - try { - const response = await withTimeout( - axios.get(url, { timeout: axiosTimeout }), - timeoutMs, - `Fetching nested catalogs from ${url}` - ); - const catalogsData = Array.isArray(response.data) - ? response.data - : response.data.catalogs || []; - - if (catalogsData.length > 0) { - return catalogsData; - } - } catch (e) { - // Try next URL - continue; - } + // Seed the crawler with initial catalog requests + const initialRequests = initialCatalogs.map(catalog => ({ + url: catalog.url, + label: 'CATALOG', + userData: { + depth: 0, + catalogId: catalog.id, + catalogTitle: catalog.title } - - return []; - - } catch (error) { - console.error(`Error fetching nested catalogs for ${catalog.id}: ${error.message}`); - return []; - } -} + })); -/** - * Recursively crawls a catalog using stac-js for parsing and navigation - * @async - * @param {Object} catalog - Catalog object to crawl - * @param {number} depth - Current depth level (for indentation) - * @param {Object} config - Configuration object with timeout and depth settings - * @returns {Promise} Stats object with collections count and STAC compliance - */ -async function crawlCatalogRecursive(catalog, depth = 0, config = {}) { - const indent = ' '.repeat(depth); - let stats = { - collections: 0, - stacCompliant: false - }; + await crawler.addRequests(initialRequests); - // Check max depth - const maxDepth = config.maxDepth || 3; - if (depth >= maxDepth) { - console.log(`${indent} Max depth ${maxDepth} reached, stopping recursion`); - return stats; - } + console.log(`\nStarting Crawlee crawler with ${initialRequests.length} initial catalogs...\n`); + await crawler.run(); - try { - const timeoutMs = config.timeout || 30000; - const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(5000, timeoutMs); - let stacCatalog = null; - - // Try to fetch and parse the catalog with stac-js - if (catalog.url) { - try { - const response = await withTimeout( - axios.get(catalog.url, { timeout: axiosTimeout }), - timeoutMs, - `Fetching catalog ${catalog.id}` - ); - - try { - stacCatalog = create(response.data, catalog.url); - stats.stacCompliant = true; - - // Log STAC object type - if (typeof stacCatalog.isCatalog === 'function' && stacCatalog.isCatalog()) { - console.log(`${indent} STAC Catalog validated: ${catalog.id}`); - } else if (typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection()) { - console.log(`${indent} STAC Collection validated: ${catalog.id}`); - } - - } catch (parseError) { - console.warn(`${indent} Non-compliant STAC catalog ${catalog.id}, skipping: ${parseError.message}`); - return stats; // Skip non-compliant catalogs as per requirement - } - } catch (fetchError) { - console.warn(`${indent} Failed to fetch catalog ${catalog.id}: ${fetchError.message}`); - return stats; - } - } - - // First, try to get collections from this catalog - const collections = await getCollections(catalog, config); - stats.collections = collections.length; - - // Then, try to get nested catalogs using stac-js if available - const nestedCatalogs = await getNestedCatalogs(catalog, stacCatalog, config); - - if (nestedCatalogs.length > 0) { - console.log(`${indent} Nested catalogs found: ${nestedCatalogs.length}`); - - // Recursively crawl each nested catalog - for (const nestedCatalog of nestedCatalogs.slice(0, 5)) { // Limit to first 5 nested - // Convert nested catalog to expected format - const nestedCatalogObj = { - id: nestedCatalog.id || 'unknown', - url: nestedCatalog.links?.find(l => l.rel === 'self')?.href || nestedCatalog.url - }; - - const nestedStats = await crawlCatalogRecursive(nestedCatalogObj, depth + 1, config); - stats.collections += nestedStats.collections; - } - } - - } catch (error) { - console.error(`${indent}Error in recursive crawl for ${catalog.id}: ${error.message}`); - } - - return stats; -} - -/** - * Derives categories from a catalog object by checking various possible fields - * @param {Object} catalog - Catalog object to extract categories from - * @returns {Array} Array of category strings, empty array if none found - */ -function deriveCategories(catalog) { - if (!catalog || typeof catalog !== 'object') { - return []; - } - - if (Array.isArray(catalog.categories)) { - return catalog.categories.filter(Boolean).map(String); - } - - if (Array.isArray(catalog.keywords)) { - return catalog.keywords.filter(Boolean).map(String); - } - - if (Array.isArray(catalog.tags)) { - return catalog.tags.filter(Boolean).map(String); - } - - if (typeof catalog.access === 'string' && catalog.access.trim().length) { - return [catalog.access.trim()]; - } - - return []; + console.log('\nCrawl Statistics:'); + console.log(` Total Requests: ${results.stats.totalRequests}`); + console.log(` Successful: ${results.stats.successfulRequests}`); + console.log(` Failed: ${results.stats.failedRequests}`); + console.log(` STAC Compliant: ${results.stats.stacCompliant}`); + console.log(` Non-Compliant: ${results.stats.nonCompliant}`); + console.log(` Catalogs Processed: ${results.stats.catalogsProcessed}`); + console.log(` Collections Found: ${results.stats.collectionsFound}\n`); + + return results; } -export { - splitCatalogs, - getCollections, - getNestedCatalogs, - crawlCatalogRecursive, - deriveCategories -}; \ No newline at end of file +export { crawlCatalogs }; \ No newline at end of file diff --git a/crawler/index.js b/crawler/index.js index 1a96a56..e3f7c0a 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -4,9 +4,15 @@ */ import axios from 'axios'; -import { splitCatalogs, crawlCatalogRecursive } from './catalogs/catalog.js'; +import { processCatalogs } from './utils/normalization.js'; +import { crawlCatalogs } from './catalogs/catalog.js'; import { crawlApis } from './apis/api.js'; import { getConfig } from './utils/config.js'; +import { createRequire } from 'module'; + +// Import CommonJS db module +const require = createRequire(import.meta.url); +const db = require('./db.js'); /** * URL of the STAC Index API endpoint @@ -22,6 +28,9 @@ const targetUrl = 'https://www.stacindex.org/api/catalogs'; */ const crawler = async () => { try { + // Initialize database connection + await db.initDb(); + // Load configuration const config = getConfig(); @@ -31,45 +40,60 @@ const crawler = async () => { console.log(`Max Catalogs: ${config.maxCatalogs === Infinity ? 'unlimited' : config.maxCatalogs}`); console.log(`Max APIs: ${config.maxApis === Infinity ? 'unlimited' : config.maxApis}`); console.log(`Timeout: ${config.timeout === Infinity ? 'unlimited' : config.timeout + 'ms'}`); - console.log(`Max Depth: ${config.maxDepth === Infinity ? 'unlimited' : config.maxDepth}`); + console.log(`Crawl Depth: unlimited`); console.log('==================================\n'); const response = await axios.get(targetUrl); - const catalogs = splitCatalogs(response.data); + const catalogs = processCatalogs(response.data); // Crawl catalogs if mode is 'catalogs' or 'both' if (config.mode === 'catalogs' || config.mode === 'both') { - console.log('\n Crawling collections and nested catalogs...\n'); - let totalCollections = 0; + console.log('\nCrawling collections and nested catalogs with Crawlee...\n'); + + const catalogsToProcess = config.maxCatalogs === Infinity + ? catalogs + : catalogs.slice(0, config.maxCatalogs); - const catalogsToProcess = config.maxCatalogs === Infinity ? catalogs : catalogs.slice(0, config.maxCatalogs); console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === Infinity ? 'unlimited' : config.maxCatalogs})\n`); - for (const catalogData of catalogsToProcess) { - const catalog = { - id: catalogData[1], - url: catalogData[2] - }; + try { + const results = await crawlCatalogs(catalogsToProcess, config); + console.log(`\nTotal collections found across all catalogs: ${results.stats.collectionsFound}`); - try { - const stats = await crawlCatalogRecursive(catalog, 0, config); - totalCollections += stats.collections; - } catch (error) { - console.error(`Failed to crawl catalog ${catalog.id}: ${error.message}`); + // Persist collections to database + if (results.collections.length > 0) { + console.log(`\nSaving ${results.collections.length} collections to database...`); + let savedCount = 0; + let errorCount = 0; + + for (const collection of results.collections) { + try { + await db.insertOrUpdateCollection(collection); + savedCount++; + } catch (err) { + errorCount++; + console.error(`DB write error for collection ${collection.id}:`, err.message); + } + } + + console.log(`Successfully saved ${savedCount} collections to database`); + if (errorCount > 0) { + console.log(`Failed to save ${errorCount} collections`); + } } + } catch (error) { + console.error(`Failed to crawl catalogs: ${error.message}`); } - - console.log(`\n Total collections found across all catalogs: ${totalCollections}`); } else { - console.log('\n Skipping catalog crawling (mode: apis)\n'); + console.log('\nSkipping catalog crawling (mode: apis)\n'); } // Crawl APIs if mode is 'apis' or 'both' if (config.mode === 'apis' || config.mode === 'both') { - console.log('\n Crawling APIs...'); + console.log('\nCrawling APIs...'); const apiUrls = catalogs - .filter(cat => cat[10] === true) // isApi is at index 10 - .map(cat => cat[2]); // url is at index 2 + .filter(cat => cat.isApi === true) + .map(cat => cat.url); if (apiUrls.length > 0) { const apisToProcess = config.maxApis === Infinity ? apiUrls : apiUrls.slice(0, config.maxApis); @@ -85,6 +109,26 @@ const crawler = async () => { const selfLink = c.links?.find(l => l.rel === 'self')?.href; console.log(` - ${c.id}: ${selfLink}`); }); + + // Persist API collections to database + console.log(`\nSaving ${collections.length} API collections to database...`); + let savedCount = 0; + let errorCount = 0; + + for (const collection of collections) { + try { + await db.insertOrUpdateCollection(collection); + savedCount++; + } catch (err) { + errorCount++; + console.error(`DB write error for collection ${collection.id}:`, err.message); + } + } + + console.log(`Successfully saved ${savedCount} API collections to database`); + if (errorCount > 0) { + console.log(`Failed to save ${errorCount} collections`); + } } } catch (error) { console.error(`Failed to crawl APIs: ${error.message}`); @@ -93,25 +137,35 @@ const crawler = async () => { console.log('No APIs found to crawl.'); } } else { - console.log('\n Skipping API crawling (mode: catalogs)\n'); + console.log('\nSkipping API crawling (mode: catalogs)\n'); } - } catch (error) { - console.error(`Error fetching ${targetUrl}: ${error.message}`); - } - - // Persist catalogs into DB - try { - await Promise.all(catalogs.map(async (catalog) => { + // Persist catalogs metadata to database + console.log(`\nSaving ${catalogs.length} catalog metadata entries to database...`); + let savedCatalogCount = 0; + let errorCatalogCount = 0; + + for (const catalog of catalogs) { try { await db.insertOrUpdateCatalog(catalog); + savedCatalogCount++; } catch (err) { - console.error('DB write error for catalog', catalog && (catalog.id || catalog.slug), err && err.message); + errorCatalogCount++; + console.error(`DB write error for catalog ${catalog.id || catalog.slug}:`, err.message); } - })); - console.log('Finished writing catalogs to DB'); - } catch (e) { - console.error('Unexpected error while saving catalogs to DB', e && e.message); + } + + console.log(`Successfully saved ${savedCatalogCount} catalog metadata entries`); + if (errorCatalogCount > 0) { + console.log(`Failed to save ${errorCatalogCount} catalog entries`); + } + + } catch (error) { + console.error(`Error fetching ${targetUrl}: ${error.message}`); + } finally { + // Close database connection + await db.close(); + console.log('\nDatabase connection closed.'); } }; diff --git a/crawler/package-lock.json b/crawler/package-lock.json index 40ee2dd..a91cada 100644 --- a/crawler/package-lock.json +++ b/crawler/package-lock.json @@ -9,10 +9,86 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@databases/pg": "^5.5.0", "axios": "^1.13.2", + "crawlee": "^3.15.3", "dotenv": "^17.2.3", "stac-js": "^0.1.9" - "@databases/pg": "^5.5.0", + } + }, + "node_modules/@apify/consts": { + "version": "2.48.0", + "resolved": "https://registry.npmjs.org/@apify/consts/-/consts-2.48.0.tgz", + "integrity": "sha512-a0HeYDxAbbkRxc9z2N6beMFAmAJSgBw8WuKUwV+KmCuPyGUVLp54fYzjQ63p9Gv5IVFC88/HMXpAzI29ARgO5w==", + "license": "Apache-2.0" + }, + "node_modules/@apify/datastructures": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@apify/datastructures/-/datastructures-2.0.3.tgz", + "integrity": "sha512-E6yQyc/XZDqJopbaGmhzZXMJqwGf96ELtDANZa0t68jcOAJZS+pF7YUfQOLszXq6JQAdnRvTH2caotL6urX7HA==", + "license": "Apache-2.0" + }, + "node_modules/@apify/log": { + "version": "2.5.28", + "resolved": "https://registry.npmjs.org/@apify/log/-/log-2.5.28.tgz", + "integrity": "sha512-jU8qIvU+Crek8glBjFl3INjJQWWDR9n2z9Dr0WvUI8KJi0LG9fMdTvV+Aprf9z1b37CbHXgiZkA1iPlNYxKOEQ==", + "license": "Apache-2.0", + "dependencies": { + "@apify/consts": "^2.48.0", + "ansi-colors": "^4.1.1" + } + }, + "node_modules/@apify/ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@apify/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-VHIswI7rD/R4bToeIDuJ9WJXt+qr5SdhfoZ9RzdjmCs9mgy7l0P4RugQEUCcU+WB4sfImbd4CKwzXcn0uYx1yw==", + "license": "MIT", + "dependencies": { + "event-stream": "3.3.4" + }, + "bin": { + "ps-tree": "bin/ps-tree.js" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@apify/pseudo_url": { + "version": "2.0.69", + "resolved": "https://registry.npmjs.org/@apify/pseudo_url/-/pseudo_url-2.0.69.tgz", + "integrity": "sha512-p/jZpaITBbFX8uVqz5MeY0uvOsMSV0SKbxrkTd8ZkmF8L7+LU93aOb/G/AnAEozgzjPV8Tf1ihkHnP2aY09y6Q==", + "license": "Apache-2.0", + "dependencies": { + "@apify/log": "^2.5.28" + } + }, + "node_modules/@apify/timeout": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@apify/timeout/-/timeout-0.3.2.tgz", + "integrity": "sha512-JnOLIOpqfm366q7opKrA6HrL0iYRpYYDn8Mi77sMR2GZ1fPbwMWCVzN23LJWfJV7izetZbCMrqRUXsR1etZ7dA==", + "license": "Apache-2.0" + }, + "node_modules/@apify/utilities": { + "version": "2.23.4", + "resolved": "https://registry.npmjs.org/@apify/utilities/-/utilities-2.23.4.tgz", + "integrity": "sha512-1tLXOJBJR1SUSp/iEj6kcvV+9B5dn1mvIWDtRYwevJXXURyJdPwzJApi0F0DZz/Vk2HeCC381gnSqASzXN8MLA==", + "license": "Apache-2.0", + "dependencies": { + "@apify/consts": "^2.48.0", + "@apify/log": "^2.5.28" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" } }, "node_modules/@babel/code-frame": { @@ -38,6 +114,556 @@ "node": ">=6.9.0" } }, + "node_modules/@borewit/text-codec": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", + "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@crawlee/basic": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/basic/-/basic-3.15.3.tgz", + "integrity": "sha512-+j0rhP16Gx84eFFXnG2t0YxmwkIwz5cWFnJ6CFyj1F7ElQ5JmVkzxyIWoyKBWCmLpPlafySht1tKq7L/4EZ1AQ==", + "license": "Apache-2.0", + "dependencies": { + "@apify/log": "^2.4.0", + "@apify/timeout": "^0.3.0", + "@apify/utilities": "^2.7.10", + "@crawlee/core": "3.15.3", + "@crawlee/types": "3.15.3", + "@crawlee/utils": "3.15.3", + "csv-stringify": "^6.2.0", + "fs-extra": "^11.0.0", + "got-scraping": "^4.0.0", + "ow": "^0.28.1", + "tldts": "^7.0.0", + "tslib": "^2.4.0", + "type-fest": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/browser": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/browser/-/browser-3.15.3.tgz", + "integrity": "sha512-PtRzsurFO/A+puXg9oFUcP5LmEYNXkGyyQ2RQUJdg9exN1kbRwaaSrx4IUVi55waC1Z1Vkr5Ycq6nkVGTE6OcQ==", + "license": "Apache-2.0", + "dependencies": { + "@apify/timeout": "^0.3.0", + "@crawlee/basic": "3.15.3", + "@crawlee/browser-pool": "3.15.3", + "@crawlee/types": "3.15.3", + "@crawlee/utils": "3.15.3", + "ow": "^0.28.1", + "tslib": "^2.4.0", + "type-fest": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "playwright": "*", + "puppeteer": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "puppeteer": { + "optional": true + } + } + }, + "node_modules/@crawlee/browser-pool": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/browser-pool/-/browser-pool-3.15.3.tgz", + "integrity": "sha512-a+QPQyHhLOO2cVzqjA8c9nuu++omzS9PWXRq248z46F+CnmAyYbClhuKiuBwhaNSTDKv7mgD8u1HikpzSN1duA==", + "license": "Apache-2.0", + "dependencies": { + "@apify/log": "^2.4.0", + "@apify/timeout": "^0.3.0", + "@crawlee/core": "3.15.3", + "@crawlee/types": "3.15.3", + "fingerprint-generator": "^2.1.68", + "fingerprint-injector": "^2.1.68", + "lodash.merge": "^4.6.2", + "nanoid": "^3.3.4", + "ow": "^0.28.1", + "p-limit": "^3.1.0", + "proxy-chain": "^2.0.1", + "quick-lru": "^5.1.1", + "tiny-typed-emitter": "^2.1.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "playwright": "*", + "puppeteer": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "puppeteer": { + "optional": true + } + } + }, + "node_modules/@crawlee/cheerio": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/cheerio/-/cheerio-3.15.3.tgz", + "integrity": "sha512-yYbaUkV7meXtHLN9AW/Loo6BfZonp8ma2GvTZAlWXmQbiq3nmZ/npVWvR4UHWVDj0VsRe/IWsl8jgUzJFxD2/Q==", + "license": "Apache-2.0", + "dependencies": { + "@crawlee/http": "3.15.3", + "@crawlee/types": "3.15.3", + "@crawlee/utils": "3.15.3", + "cheerio": "1.0.0-rc.12", + "htmlparser2": "^9.0.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/cli": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/cli/-/cli-3.15.3.tgz", + "integrity": "sha512-cWo0NeF96WGO9sl5Q6BDFthvtqLky0CaCK2NtUvPJ7/EoXaiKm5D8o//lo1coMQmy72JEEgCGnSc/Xo8SDNUhA==", + "license": "Apache-2.0", + "dependencies": { + "@crawlee/templates": "3.15.3", + "ansi-colors": "^4.1.3", + "fs-extra": "^11.0.0", + "inquirer": "^8.2.4", + "tslib": "^2.4.0", + "yargonaut": "^1.1.4", + "yargs": "^17.5.1" + }, + "bin": { + "crawlee": "index.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/core": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/core/-/core-3.15.3.tgz", + "integrity": "sha512-cBglpY4KVlKnozeO8K4lw6/TDWajtJjPj4aNfckxUeEzapJgvTaxg6ZI4zxir6vic8sTeK9Olp3qG3wLnlrtXw==", + "license": "Apache-2.0", + "dependencies": { + "@apify/consts": "^2.20.0", + "@apify/datastructures": "^2.0.0", + "@apify/log": "^2.4.0", + "@apify/pseudo_url": "^2.0.30", + "@apify/timeout": "^0.3.0", + "@apify/utilities": "^2.7.10", + "@crawlee/memory-storage": "3.15.3", + "@crawlee/types": "3.15.3", + "@crawlee/utils": "3.15.3", + "@sapphire/async-queue": "^1.5.1", + "@vladfrangu/async_event_emitter": "^2.2.2", + "csv-stringify": "^6.2.0", + "fs-extra": "^11.0.0", + "got-scraping": "^4.0.0", + "json5": "^2.2.3", + "minimatch": "^9.0.0", + "ow": "^0.28.1", + "stream-json": "^1.8.0", + "tldts": "^7.0.0", + "tough-cookie": "^6.0.0", + "tslib": "^2.4.0", + "type-fest": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/http": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/http/-/http-3.15.3.tgz", + "integrity": "sha512-NvD9khVsji6gX/t1YNBgTSlVCMRgzVhkL/oygyXPjfihfX42adAWJGi/ztK5/drA+7nNZlGY304W9Kheo5SqCQ==", + "license": "Apache-2.0", + "dependencies": { + "@apify/timeout": "^0.3.0", + "@apify/utilities": "^2.7.10", + "@crawlee/basic": "3.15.3", + "@crawlee/types": "3.15.3", + "@crawlee/utils": "3.15.3", + "@types/content-type": "^1.1.5", + "cheerio": "1.0.0-rc.12", + "content-type": "^1.0.4", + "got-scraping": "^4.0.0", + "iconv-lite": "^0.7.0", + "mime-types": "^2.1.35", + "ow": "^0.28.1", + "tslib": "^2.4.0", + "type-fest": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/jsdom": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/jsdom/-/jsdom-3.15.3.tgz", + "integrity": "sha512-SmsJcaLW12C35Myy2e0jdZpG1HhbAA/QwF+P1Op0AB0lerDYT8sGQJXARczLJceu+zhZeM/90g1oRuH8N3wB7g==", + "license": "Apache-2.0", + "dependencies": { + "@apify/timeout": "^0.3.0", + "@apify/utilities": "^2.7.10", + "@crawlee/http": "3.15.3", + "@crawlee/types": "3.15.3", + "@crawlee/utils": "3.15.3", + "@types/jsdom": "^21.0.0", + "cheerio": "1.0.0-rc.12", + "jsdom": "^26.0.0", + "ow": "^0.28.2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/linkedom": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/linkedom/-/linkedom-3.15.3.tgz", + "integrity": "sha512-pCmfjMuRDAdDqJiHL/Ph9rOZC9I4tFxXhveNUS0X3suOj/5y67m5t9CsV6Bv3XuwAEVXDc8MNOCz0FUN9dl3jw==", + "license": "Apache-2.0", + "dependencies": { + "@apify/timeout": "^0.3.0", + "@apify/utilities": "^2.7.10", + "@crawlee/http": "3.15.3", + "@crawlee/types": "3.15.3", + "linkedom": "^0.18.0", + "ow": "^0.28.2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/memory-storage": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/memory-storage/-/memory-storage-3.15.3.tgz", + "integrity": "sha512-iOUOGBTZNyl2srDsrAJwhYu4+leOxQSlx9uAtGt88kC7srlrn2B/OgXjhzTv0Vo7+kkAiTkxUMAfZ2eNW+UbSw==", + "license": "Apache-2.0", + "dependencies": { + "@apify/log": "^2.4.0", + "@crawlee/types": "3.15.3", + "@sapphire/async-queue": "^1.5.0", + "@sapphire/shapeshift": "^3.0.0", + "content-type": "^1.0.4", + "fs-extra": "^11.0.0", + "json5": "^2.2.3", + "mime-types": "^2.1.35", + "proper-lockfile": "^4.1.2", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/@crawlee/playwright": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/playwright/-/playwright-3.15.3.tgz", + "integrity": "sha512-PTIqiE0gTdIBJIJ9GC7VZYSOjyMNjg156nhsLKoJJK3dT/M0dKhoM36zgchghagcjuP+fLZYmx+TMDgpAfWfxQ==", + "license": "Apache-2.0", + "dependencies": { + "@apify/datastructures": "^2.0.0", + "@apify/log": "^2.4.0", + "@apify/timeout": "^0.3.1", + "@crawlee/browser": "3.15.3", + "@crawlee/browser-pool": "3.15.3", + "@crawlee/core": "3.15.3", + "@crawlee/types": "3.15.3", + "@crawlee/utils": "3.15.3", + "cheerio": "1.0.0-rc.12", + "idcac-playwright": "^0.1.2", + "jquery": "^3.6.0", + "lodash.isequal": "^4.5.0", + "ml-logistic-regression": "^2.0.0", + "ml-matrix": "^6.11.0", + "ow": "^0.28.1", + "string-comparison": "^1.3.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "playwright": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + } + } + }, + "node_modules/@crawlee/puppeteer": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/puppeteer/-/puppeteer-3.15.3.tgz", + "integrity": "sha512-hiNrXwCPLaEEqejlXPWf567KnArwhZx4HHs16YqiB6wElf2eptvPO6jdeAnQX7BXyV3NWP4QPVKyieOXa/d51A==", + "license": "Apache-2.0", + "dependencies": { + "@apify/datastructures": "^2.0.0", + "@apify/log": "^2.4.0", + "@crawlee/browser": "3.15.3", + "@crawlee/browser-pool": "3.15.3", + "@crawlee/types": "3.15.3", + "@crawlee/utils": "3.15.3", + "cheerio": "1.0.0-rc.12", + "devtools-protocol": "*", + "idcac-playwright": "^0.1.2", + "jquery": "^3.6.0", + "ow": "^0.28.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "puppeteer": "*" + }, + "peerDependenciesMeta": { + "puppeteer": { + "optional": true + } + } + }, + "node_modules/@crawlee/templates": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/templates/-/templates-3.15.3.tgz", + "integrity": "sha512-7VKwdKYFf8yF4uaZU626cdZDpVQs5jv9bK//q94JK5IzpRdkwRedD2N93fYBrGVYyGqNhlEJz1nEIdAe+d6Knw==", + "license": "Apache-2.0", + "dependencies": { + "ansi-colors": "^4.1.3", + "inquirer": "^9.0.0", + "tslib": "^2.4.0", + "yargonaut": "^1.1.4", + "yargs": "^17.5.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/templates/node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@crawlee/templates/node_modules/inquirer": { + "version": "9.3.8", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.8.tgz", + "integrity": "sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.2", + "@inquirer/figures": "^1.0.3", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "1.0.0", + "ora": "^5.4.1", + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@crawlee/templates/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@crawlee/templates/node_modules/run-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", + "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@crawlee/templates/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@crawlee/types": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/types/-/types-3.15.3.tgz", + "integrity": "sha512-RvgVPXrsQw4GQIUXrC1z1aNOedUPJnZ/U/8n+jZ0fu1Iw9moJVMuiuIxSI8q1P6BA84aWZdalyfDWBZ3FMjsiw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@crawlee/utils": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/@crawlee/utils/-/utils-3.15.3.tgz", + "integrity": "sha512-guldTIfG+No6zoNmi5CKwABJDnrN8NqgwB9PFMR8kD+5r//TPFENfU9I3w4tQXx/pefnSZ99JrVZMUL3zenpJA==", + "license": "Apache-2.0", + "dependencies": { + "@apify/log": "^2.4.0", + "@apify/ps-tree": "^1.2.0", + "@crawlee/types": "3.15.3", + "@types/sax": "^1.2.7", + "cheerio": "1.0.0-rc.12", + "file-type": "^20.0.0", + "got-scraping": "^4.0.3", + "ow": "^0.28.1", + "robots-parser": "^3.0.1", + "sax": "^1.4.1", + "tslib": "^2.4.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@databases/connection-pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@databases/connection-pool/-/connection-pool-1.1.0.tgz", @@ -164,60 +790,3037 @@ "integrity": "sha512-dLKqxGcymeVwEb/6c44KjOnzaAafFf0Wxa8xcfEjx/qOl3rdijsKYBAtIGhtVtOlpPf/PFKfgTuFurSPn/3B/g==", "license": "MIT" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } }, - "node_modules/assert-never": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", - "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "node_modules/@multiformats/base-x": { + "version": "4.0.1", "license": "MIT" }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", + "node_modules/@radiantearth/stac-migrate": { + "version": "2.0.2", + "license": "Apache-2.0", "dependencies": { - "possible-typed-array-names": "^1.0.0" + "compare-versions": "^3.6.0", + "multihashes": "^3.1.2", + "yargs": "^17.6.2" }, - "engines": { - "node": ">= 0.4" + "bin": { + "stac-migrate": "bin/cli.js" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/m-mohr" } }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.7.tgz", + "integrity": "sha512-4It2mxPSr4OGn4HSQWGmhFMsNFGfFVhWeRPCRwbH972Ek2pzfGRZtb0pJ4Ze6oIzcyh2jw7nUDa6qGlWofgd9g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.1.tgz", + "integrity": "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/content-type": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@types/content-type/-/content-type-1.1.9.tgz", + "integrity": "sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A==", + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/byte-counter": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz", + "integrity": "sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "13.0.15", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.15.tgz", + "integrity": "sha512-NjiSrjv37X73FmGGU5ec/M83vWQ6q1Ae3BFe+ABfdeeMy4LOMKYTpfEjrBnLedu43clKZtsYbKrHTIQE7vKq+A==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.4", + "get-stream": "^9.0.1", + "http-cache-semantics": "^4.2.0", + "keyv": "^5.5.4", + "mimic-response": "^4.0.0", + "normalize-url": "^8.1.0", + "responselike": "^4.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/compare-versions": { + "version": "3.6.0", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/crawlee": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/crawlee/-/crawlee-3.15.3.tgz", + "integrity": "sha512-l+l1Fs4fEKUqKn9Vuw+tHiraWIVbRpSXFa09JeTdZID/xUlPHVLkKrqGLNa0cvZc7dqX2s9+1xLXid+pRn851w==", + "license": "Apache-2.0", + "dependencies": { + "@crawlee/basic": "3.15.3", + "@crawlee/browser": "3.15.3", + "@crawlee/browser-pool": "3.15.3", + "@crawlee/cheerio": "3.15.3", + "@crawlee/cli": "3.15.3", + "@crawlee/core": "3.15.3", + "@crawlee/http": "3.15.3", + "@crawlee/jsdom": "3.15.3", + "@crawlee/linkedom": "3.15.3", + "@crawlee/playwright": "3.15.3", + "@crawlee/puppeteer": "3.15.3", + "@crawlee/utils": "3.15.3", + "import-local": "^3.1.0", + "tslib": "^2.4.0" + }, + "bin": { + "crawlee": "cli.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "playwright": "*", + "puppeteer": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "puppeteer": { + "optional": true + } + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csv-stringify": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.6.0.tgz", + "integrity": "sha512-YW32lKOmIBgbxtu3g5SaiqWNwa/9ISQt2EcgOq0+RAIFufFp9is6tqNnKahqE5kuKvrnYAzs28r+s6pXJR8Vcw==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz", + "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==", + "license": "MIT", + "dependencies": { + "mimic-response": "^4.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1551306", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", + "integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==", + "license": "BSD-3-Clause" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/figlet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.9.4.tgz", + "integrity": "sha512-uN6QE+TrzTAHC1IWTyrc4FfGo2KH/82J8Jl1tyKB7+z5DBit/m3D++Iu5lg91qJMnQQ3vpJrj5gxcK/pk4R9tQ==", + "license": "MIT", + "dependencies": { + "commander": "^14.0.0" + }, + "bin": { + "figlet": "bin/index.js" + }, + "engines": { + "node": ">= 17.0.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-type": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.5.0.tgz", + "integrity": "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fingerprint-generator": { + "version": "2.1.77", + "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.77.tgz", + "integrity": "sha512-wR15VUEZnwozFiSDRV+40zxlEt3ZV3JNYvLx0CSF9D9smov4pUC6MJZJnlxtDr+Ir4oppU8vn1JXApLk/Qr5Uw==", + "license": "Apache-2.0", + "dependencies": { + "generative-bayesian-network": "^2.1.77", + "header-generator": "^2.1.77", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fingerprint-injector": { + "version": "2.1.77", + "resolved": "https://registry.npmjs.org/fingerprint-injector/-/fingerprint-injector-2.1.77.tgz", + "integrity": "sha512-R778SIyrqgWO0P+UWKzIFWUWZz13EGu6UmV7CX3vuFDbsYIL1xiH+s+/nzPSOqFdhXyLo7d8aTOjbGbRLULoQQ==", + "license": "Apache-2.0", + "dependencies": { + "fingerprint-generator": "^2.1.77", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "playwright": "^1.22.2", + "puppeteer": ">= 9.x" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "puppeteer": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz", + "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/funtypes": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/funtypes/-/funtypes-4.2.0.tgz", + "integrity": "sha512-DvOtjiKvkeuXGV0O8LQh9quUP3bSOTEQPGv537Sao8kDq2rDbg48UsSJ7wlBLPzR2Mn0pV7cyAiq5pYG1oUyCQ==", + "license": "MIT" + }, + "node_modules/generative-bayesian-network": { + "version": "2.1.77", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.77.tgz", + "integrity": "sha512-viU4CRPsmgiklR94LhvdMndaY73BkCH1pGjmOjWbLR/ZwcUd06gKF3TCcsS3npRl74o33YSInSixxm16wIukcA==", + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.9", + "tslib": "^2.4.0" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/got/-/got-14.6.5.tgz", + "integrity": "sha512-Su87c0NNeg97de1sO02gy9I8EmE7DCJ1gzcFLcgGpYeq2PnLg4xz73MWrp6HjqbSsjb6Glf4UBDW6JNyZA6uSg==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^7.0.1", + "byte-counter": "^0.1.0", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^13.0.12", + "decompress-response": "^10.0.0", + "form-data-encoder": "^4.0.2", + "http2-wrapper": "^2.2.1", + "keyv": "^5.5.3", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^4.0.1", + "responselike": "^4.0.2", + "type-fest": "^4.26.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got-scraping": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/got-scraping/-/got-scraping-4.1.2.tgz", + "integrity": "sha512-LtVwPM5YLnNY7HVT/AK/yDBUg/4yOZSlAjjug2ovrHQseS43QCmO1XosKKXcXrfc6OMX8OnDbAWIauFMcaJ5TQ==", + "license": "Apache-2.0", + "dependencies": { + "got": "^14.2.1", + "header-generator": "^2.1.41", + "http2-wrapper": "^2.2.0", + "mimic-response": "^4.0.0", + "ow": "^1.1.1", + "quick-lru": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/got-scraping/node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/got-scraping/node_modules/callsites": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-4.2.0.tgz", + "integrity": "sha512-kfzR4zzQtAE9PC7CzZsjl3aBNbXWuXiSeOCdLcPpBfGW8YuCqQHcRPFDbr/BPVmd3EEPVpuFzLyuT/cUhPr4OQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got-scraping/node_modules/dot-prop": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-7.2.0.tgz", + "integrity": "sha512-Ol/IPXUARn9CSbkrdV4VJo7uCy1I3VuSiWCaFSg+8BdUOzF9n3jefIpcgAydvUZbTdEBZs2vEiTiS9m61ssiDA==", + "license": "MIT", + "dependencies": { + "type-fest": "^2.11.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got-scraping/node_modules/ow": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ow/-/ow-1.1.1.tgz", + "integrity": "sha512-sJBRCbS5vh1Jp9EOgwp1Ws3c16lJrUkJYlvWTYC03oyiYVwS/ns7lKRWow4w4XjDyTrA2pplQv4B2naWSR6yDA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.3.0", + "callsites": "^4.0.0", + "dot-prop": "^7.2.0", + "lodash.isequal": "^4.5.0", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got-scraping/node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got-scraping/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/header-generator": { + "version": "2.1.77", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.77.tgz", + "integrity": "sha512-ggSG/mfkFMu8CO7xP591G8kp1IJCBvgXu7M8oxTjC9u914JsIzE6zIfoFsXzA+pf0utWJhUsdqU0oV/DtQ4DFQ==", + "license": "Apache-2.0", + "dependencies": { + "browserslist": "^4.21.1", + "generative-bayesian-network": "^2.1.77", + "ow": "^0.28.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/idcac-playwright": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/idcac-playwright/-/idcac-playwright-0.1.3.tgz", + "integrity": "sha512-VVYQ4sv6OrUJKVzYaIP1hq0qAHd1O22HW5LnL1Wf6zkrLStQ/QEg4iJ0rllIOEpd+Rmm+635AJD59A+Vw+2PgQ==", + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-any-array": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", + "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", + "license": "MIT" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/jsdom/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz", + "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/linkedom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom/node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ml-array-max": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz", + "integrity": "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ==", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0" + } + }, + "node_modules/ml-array-min": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-1.2.3.tgz", + "integrity": "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q==", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0" + } + }, + "node_modules/ml-array-rescale": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-1.3.7.tgz", + "integrity": "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ==", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0", + "ml-array-max": "^1.2.4", + "ml-array-min": "^1.2.3" + } + }, + "node_modules/ml-logistic-regression": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ml-logistic-regression/-/ml-logistic-regression-2.0.0.tgz", + "integrity": "sha512-xHhB91ut8GRRbJyB1ZQfKsl1MHmE1PqMeRjxhks96M5BGvCbC9eEojf4KgRMKM2LxFblhVUcVzweAoPB48Nt0A==", + "license": "MIT", + "dependencies": { + "ml-matrix": "^6.5.0" + } + }, + "node_modules/ml-matrix": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.12.1.tgz", + "integrity": "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw==", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.1", + "ml-array-rescale": "^1.3.7" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multibase": { + "version": "3.1.2", + "license": "MIT", + "dependencies": { + "@multiformats/base-x": "^4.0.1", + "web-encoding": "^1.0.6" + }, + "engines": { + "node": ">=10.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/multiformats": { + "version": "9.9.0", + "license": "(Apache-2.0 AND MIT)" + }, + "node_modules/multihashes": { + "version": "3.1.2", + "license": "MIT", + "dependencies": { + "multibase": "^3.1.0", + "uint8arrays": "^2.0.5", + "varint": "^6.0.0" + }, + "engines": { + "node": ">=10.0.0", + "npm": ">=6.0.0" + } + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.0.tgz", + "integrity": "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "license": "MIT" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ow": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz", + "integrity": "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.2.0", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ow/node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/p-cancelable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", + "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parent-require/-/parent-require-1.0.0.tgz", + "integrity": "sha512-2MXDNZC4aXdkkap+rBBMv0lUsfJqvX5/2FiYYnfCnorZt3Pk06/IOR5KeaoghgS2w07MLWgjbsnyaq6PdHn2LQ==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-cursor": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.15.3.tgz", + "integrity": "sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==", + "license": "MIT", + "peerDependencies": { + "pg": "^8" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proxy-chain": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/proxy-chain/-/proxy-chain-2.6.0.tgz", + "integrity": "sha512-+NpVKSk68j8sQJG2tBbFuJxMzKTlqeCXXFbqvlyiFhnmxdcYJSv4XZzUSIfwIUwR3D0T8fEJqrA4C7yykU40Pw==", + "license": "Apache-2.0", + "dependencies": { + "socks": "^2.8.3", + "socks-proxy-agent": "^8.0.3", + "tslib": "^2.3.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", + "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/robots-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz", + "integrity": "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -226,631 +3829,697 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", "license": "MIT", "dependencies": { + "define-data-property": "^1.1.4", "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 6.0.0", + "npm": ">= 3.0.0" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 10.0.0", + "npm": ">= 3.0.0" } }, - "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "license": "MIT", "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">= 14" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", "license": "MIT", + "dependencies": { + "through": "2" + }, "engines": { - "node": ">=0.4.0" + "node": "*" } }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", "engines": { - "node": ">=12" + "node": ">= 10.x" + } + }, + "node_modules/stac-js": { + "version": "0.1.9", + "license": "Apache-2.0", + "dependencies": { + "@radiantearth/stac-migrate": "^2.0.2", + "urijs": "^1.19.11" }, "funding": { - "url": "https://dotenvx.com" + "type": "github", + "url": "https://github.com/sponsors/m-mohr" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" + "duplexer": "~0.1.1" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", "dependencies": { - "is-arrayish": "^0.2.1" + "stream-chain": "^2.2.5" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "safe-buffer": "~5.2.0" } }, - "node_modules/es-errors": { + "node_modules/string-comparison": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "resolved": "https://registry.npmjs.org/string-comparison/-/string-comparison-1.3.0.tgz", + "integrity": "sha512-46aD+slEwybxAMPRII83ATbgMgTiz5P8mVd7Z6VJsCzSHFjdt1hkAVLeFxPIyEb11tc6ihpJTlIqoO0MCF6NPw==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": "^16.0.0 || >=18.0.0" } }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/string-width": { + "version": "4.2.3", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/strip-ansi": { + "version": "6.0.1", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "node": ">=8" } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" + "@tokenizer/token": "^0.3.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">=8" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" } }, - "node_modules/funtypes": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/funtypes/-/funtypes-4.2.0.tgz", - "integrity": "sha512-DvOtjiKvkeuXGV0O8LQh9quUP3bSOTEQPGv537Sao8kDq2rDbg48UsSJ7wlBLPzR2Mn0pV7cyAiq5pYG1oUyCQ==", + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", "license": "MIT" }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/token-types": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", + "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "@borewit/text-codec": "^0.1.0", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" }, "engines": { - "node": ">= 0.4" + "node": ">=14.16" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "tldts": "^7.0.5" }, "engines": { - "node": ">= 0.4" + "node": ">=16" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "punycode": "^2.3.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=18" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "license": "ISC" + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/uint8arrays": { + "version": "2.1.10", "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "multiformats": "^9.4.2" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, "engines": { - "node": ">= 0.4" + "node": ">= 10.0.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, - "engines": { - "node": ">=6" + "bin": { + "update-browserslist-db": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "node_modules/urijs": { + "version": "1.19.11", "license": "MIT" }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "node_modules/util": { + "version": "0.12.5", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/varint": { + "version": "6.0.0", "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "xml-name-validator": "^5.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=18" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT" - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/web-encoding": { + "version": "1.1.5", "license": "MIT", + "dependencies": { + "util": "^0.12.3" + }, + "optionalDependencies": { + "@zxing/text-encoding": "0.9.0" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "license": "MIT", "dependencies": { - "callsites": "^3.0.0" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "node_modules/which-typed-array": { + "version": "1.1.19", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "node_modules/wrap-ansi": { + "version": "7.0.0", "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/pg": { - "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "dependencies": { - "pg-connection-string": "^2.9.1", - "pg-pool": "^3.10.1", - "pg-protocol": "^1.10.3", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.2.7" + "node": ">=10.0.0" }, "peerDependencies": { - "pg-native": ">=3.0.1" + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { - "pg-native": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { "optional": true } } }, - "node_modules/pg-cloudflare": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", - "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", - "license": "MIT", - "optional": true + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } }, - "node_modules/pg-connection-string": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", - "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, - "node_modules/pg-cursor": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.15.3.tgz", - "integrity": "sha512-eHw63TsiGtFEfAd7tOTZ+TLy+i/2ePKS20H84qCQ+aQ60pve05Okon9tKMC+YN3j6XyeFoHnaim7Lt9WVafQsA==", + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", - "peerDependencies": { - "pg": "^8" + "engines": { + "node": ">=0.4" } }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "node_modules/y18n": { + "version": "5.0.8", "license": "ISC", "engines": { - "node": ">=4.0.0" + "node": ">=10" } }, - "node_modules/pg-pool": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", - "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" + "node_modules/yargonaut": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/yargonaut/-/yargonaut-1.1.4.tgz", + "integrity": "sha512-rHgFmbgXAAzl+1nngqOcwEljqHGG9uUZoPjsdZEs1w5JW9RXYzrSvH/u70C1JE5qFi0qjsdhnUX/dJRpWqitSA==", + "license": "Apache-2.0", + "dependencies": { + "chalk": "^1.1.1", + "figlet": "^1.1.1", + "parent-require": "^1.0.0" } }, - "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "node_modules/yargonaut/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "node_modules/yargonaut/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", "license": "MIT", - "dependencies": { - "split2": "^4.1.0" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "node_modules/yargonaut/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "node_modules/yargonaut/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, "engines": { "node": ">=0.10.0" } }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "node_modules/yargonaut/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=0.8.0" } }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "node_modules/yargs": { + "version": "17.7.2", "license": "MIT", "dependencies": { - "xtend": "^4.0.0" + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", + "node_modules/yargs-parser": { + "version": "21.1.1", + "license": "ISC", "engines": { - "node": ">=4" + "node": ">=12" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", "engines": { - "node": ">= 10.x" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", "license": "MIT", "engines": { - "node": ">=0.4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } } } diff --git a/crawler/package.json b/crawler/package.json index df349cc..175957d 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -17,6 +17,7 @@ "dependencies": { "@databases/pg": "^5.5.0", "axios": "^1.13.2", + "crawlee": "^3.15.3", "dotenv": "^17.2.3", "stac-js": "^0.1.9" } diff --git a/crawler/utils/config.js b/crawler/utils/config.js index 2b006c1..3424a7b 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -27,8 +27,6 @@ function parseCliArgs() { config.maxApis = parseInt(args[++i], 10); } else if (arg === '--timeout' || arg === '-t') { config.timeout = parseInt(args[++i], 10); - } else if (arg === '--max-depth' || arg === '-d') { - config.maxDepth = parseInt(args[++i], 10); } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); @@ -49,7 +47,6 @@ STAC Crawler Configuration Options: -c, --max-catalogs Maximum number of catalogs to crawl (default: 10) -a, --max-apis Maximum number of APIs to crawl (default: 5) -t, --timeout Timeout for each crawl operation in ms (default: 30000) - -d, --max-depth Maximum recursion depth for nested catalogs (default: 3) -h, --help Show this help message Environment Variables: @@ -57,7 +54,6 @@ Environment Variables: MAX_CATALOGS Same as --max-catalogs MAX_APIS Same as --max-apis TIMEOUT_MS Same as --timeout - MAX_DEPTH Same as --max-depth Examples: node index.js --mode catalogs --max-catalogs 20 @@ -79,17 +75,18 @@ function getConfig() { mode: 'both', // 'catalogs', 'apis', or 'both' maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl - timeout: 30000, // Timeout in milliseconds (30 seconds) - maxDepth: 3 // Maximum recursion depth for nested catalogs + timeout: 30000 // Timeout in milliseconds (30 seconds) }; // Build configuration with precedence: CLI > ENV > Defaults const config = { mode: cliArgs.mode || process.env.CRAWL_MODE || defaults.mode, - maxCatalogs: cliArgs.maxCatalogs || parseInt(process.env.MAX_CATALOGS, 10) || defaults.maxCatalogs, - maxApis: cliArgs.maxApis || parseInt(process.env.MAX_APIS, 10) || defaults.maxApis, - timeout: cliArgs.timeout || parseInt(process.env.TIMEOUT_MS, 10) || defaults.timeout, - maxDepth: cliArgs.maxDepth || parseInt(process.env.MAX_DEPTH, 10) || defaults.maxDepth + maxCatalogs: cliArgs.maxCatalogs !== undefined ? cliArgs.maxCatalogs : + (process.env.MAX_CATALOGS ? parseInt(process.env.MAX_CATALOGS, 10) : defaults.maxCatalogs), + maxApis: cliArgs.maxApis !== undefined ? cliArgs.maxApis : + (process.env.MAX_APIS ? parseInt(process.env.MAX_APIS, 10) : defaults.maxApis), + timeout: cliArgs.timeout !== undefined ? cliArgs.timeout : + (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout) }; // Validate mode @@ -99,15 +96,13 @@ function getConfig() { process.exit(1); } - // When mode is 'catalogs', remove limits for comprehensive crawling - if (config.mode === 'catalogs') { - config.maxCatalogs = Infinity; - config.maxDepth = Infinity; - config.timeout = Infinity; - } + // NOTE: Removed automatic Infinity override for 'catalogs' mode + // Users can now control limits via CLI args even when mode is 'catalogs' - // Validate numeric values - if (config.maxCatalogs < 0 || config.maxApis < 0 || config.timeout < 0 || config.maxDepth < 0) { + // Validate numeric values (allow Infinity for unlimited mode) + if ((config.maxCatalogs !== Infinity && config.maxCatalogs < 0) || + (config.maxApis !== Infinity && config.maxApis < 0) || + (config.timeout !== Infinity && config.timeout < 0)) { console.error('Numeric configuration values must be non-negative'); process.exit(1); } diff --git a/crawler/utils/endpoints.js b/crawler/utils/endpoints.js new file mode 100644 index 0000000..09ebe6f --- /dev/null +++ b/crawler/utils/endpoints.js @@ -0,0 +1,36 @@ +/** + * @fileoverview Endpoint utilities for STAC collections + * @module utils/endpoints + */ + +/** + * Tries common STAC collection endpoints and enqueues successful ones + * @async + * @param {string} baseUrl - Base catalog URL + * @param {string} catalogId - Catalog ID for logging + * @param {number} depth - Current depth + * @param {Object} crawler - Crawlee crawler instance + * @param {Object} log - Logger + * @param {string} indent - Indentation for logging + */ +export async function tryCollectionEndpoints(baseUrl, catalogId, depth, crawler, log, indent) { + const collectionEndpoints = [ + '/collections', + '/collections/', + '/api/v1/collections' + ]; + + const requests = collectionEndpoints.map(endpoint => ({ + url: `${baseUrl}${endpoint}`, + label: 'COLLECTIONS', + userData: { + catalogUrl: baseUrl, + catalogId, + depth + }, + // Don't retry failed endpoint attempts - just try the next one + maxRetries: 0 + })); + + await crawler.addRequests(requests); +} diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js new file mode 100644 index 0000000..5c021fe --- /dev/null +++ b/crawler/utils/handlers.js @@ -0,0 +1,205 @@ +/** + * @fileoverview Request handlers for catalog and collection crawling + * @module utils/handlers + */ + +import create from 'stac-js'; +import { normalizeCollection } from './normalization.js'; +import { tryCollectionEndpoints } from './endpoints.js'; + +/** + * Handles catalog requests - validates STAC, extracts child catalogs and collections + * @async + * @param {Object} context - Request handler context + * @param {Object} context.request - Crawlee request object + * @param {Object} context.json - Parsed JSON response + * @param {Object} context.crawler - Crawlee crawler instance + * @param {Object} context.log - Logger instance + * @param {string} context.indent - Indentation for logging + * @param {Object} context.results - Results object to store data + */ +export async function handleCatalog({ request, json, crawler, log, indent, results }) { + const depth = request.userData?.depth || 0; + const catalogId = request.userData?.catalogId || 'unknown'; + + log.info(`${indent}Processing catalog: ${catalogId} (depth: ${depth})`); + + // Validate with stac-js + let stacCatalog; + try { + stacCatalog = create(json, request.url); + results.stats.stacCompliant++; + + // Log STAC object type + if (typeof stacCatalog.isCatalog === 'function' && stacCatalog.isCatalog()) { + log.info(`${indent}STAC Catalog validated: ${catalogId}`); + } else if (typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection()) { + log.info(`${indent}STAC Collection validated: ${catalogId}`); + } + } catch (parseError) { + log.warning(`${indent}Non-compliant STAC catalog ${catalogId}: ${parseError.message}`); + throw new Error(`STAC validation failed: ${parseError.message}`); + } + + results.stats.catalogsProcessed++; + results.catalogs.push({ + id: catalogId, + url: request.url, + depth, + stacType: stacCatalog.isCatalog() ? 'catalog' : 'collection' + }); + + // If this is a STAC Collection (not a catalog), extract and store it + if (typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection()) { + const collection = normalizeCollection(stacCatalog, results.collections.length); + results.collections.push(collection); + results.stats.collectionsFound++; + log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); + } + + // Try to get collections from this catalog + await tryCollectionEndpoints(request.url, catalogId, depth, crawler, log, indent); + + // Extract and enqueue child catalog links using stac-js + if (stacCatalog && typeof stacCatalog.getChildLinks === 'function') { + const childLinks = stacCatalog.getChildLinks(); + + if (childLinks.length > 0) { + log.info(`${indent}Found ${childLinks.length} child catalog links`); + + // Log first child link structure for debugging + if (childLinks[0]) { + log.debug(`${indent}Sample child link structure:`, { + hasGetAbsoluteUrl: typeof childLinks[0].getAbsoluteUrl === 'function', + href: childLinks[0].href, + title: childLinks[0].title, + rel: childLinks[0].rel + }); + } + + const childRequests = childLinks + .map((link, idx) => { + let childUrl; + try { + childUrl = typeof link.getAbsoluteUrl === 'function' + ? link.getAbsoluteUrl() + : link.href; + } catch (err) { + log.warning(`${indent}Error getting URL for link ${idx}: ${err.message}`); + return null; + } + + // If URL is relative, make it absolute using the catalog URL + if (childUrl && typeof childUrl === 'string' && !childUrl.startsWith('http')) { + const baseUrl = request.url.endsWith('/') ? request.url.slice(0, -1) : request.url; + const basePath = baseUrl.substring(0, baseUrl.lastIndexOf('/')); + childUrl = `${basePath}/${childUrl}`; + } + + // Validate URL is a string and looks like a URL + if (!childUrl || typeof childUrl !== 'string' || !childUrl.startsWith('http')) { + log.warning(`${indent}Skipping invalid URL at index ${idx}: ${childUrl}`); + return null; + } + + // Get title as string + const linkTitle = typeof link.title === 'string' && link.title.length > 0 + ? link.title + : `child-${idx}`; + + return { + url: childUrl, + label: 'CATALOG', + userData: { + depth: depth + 1, + catalogId: linkTitle, + parentId: catalogId + } + }; + }) + .filter(Boolean); // Remove null entries + + log.info(`${indent}Valid child requests: ${childRequests.length}/${childLinks.length}`); + + if (childRequests.length > 0) { + try { + await crawler.addRequests(childRequests); + log.info(`${indent}Successfully enqueued ${childRequests.length} child catalogs`); + } catch (error) { + log.error(`${indent}Failed to add child requests: ${error.message}`); + // Log the actual requests that failed + childRequests.forEach((req, idx) => { + log.error(`${indent} [${idx}] url="${req.url}" label="${req.label}" userData=${JSON.stringify(req.userData)}`); + }); + throw error; + } + } + } + } +} + +/** + * Handles collection endpoint requests + * @async + * @param {Object} context - Request handler context + * @param {Object} context.request - Crawlee request object + * @param {Object} context.json - Parsed JSON response + * @param {Object} context.crawler - Crawlee crawler instance + * @param {Object} context.log - Logger instance + * @param {string} context.indent - Indentation for logging + * @param {Object} context.results - Results object to store data + */ +export async function handleCollections({ request, json, crawler, log, indent, results }) { + const catalogId = request.userData?.catalogId || 'unknown'; + + // Parse response with stac-js + let stacObj; + try { + stacObj = create(json, request.url); + } catch (parseError) { + log.warning(`${indent}Skipping non-compliant STAC collections at ${request.url}`); + return; + } + + let collectionsData = []; + + // Check if this is a CollectionCollection (STAC API response) + if (stacObj && typeof stacObj.getAll === 'function') { + collectionsData = stacObj.getAll(); + } else if (Array.isArray(json)) { + // Handle array of collections + collectionsData = json.map(col => { + try { + return create(col, request.url); + } catch { + return null; + } + }).filter(Boolean); + } else if (json.collections) { + // Handle nested collections property + collectionsData = json.collections.map(col => { + try { + return create(col, request.url); + } catch { + return null; + } + }).filter(Boolean); + } + + if (collectionsData.length > 0) { + log.info(`${indent}Found ${collectionsData.length} collections for catalog ${catalogId}`); + + // Normalize and store collections + const collections = collectionsData.map((colObj, index) => + normalizeCollection(colObj, index) + ); + + results.collections.push(...collections); + results.stats.collectionsFound += collections.length; + + // Display sample + if (collections.length > 0) { + log.info(`${indent} Sample: ${collections[0].id} - ${collections[0].title}`); + } + } +} diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js new file mode 100644 index 0000000..b58f5b0 --- /dev/null +++ b/crawler/utils/normalization.js @@ -0,0 +1,130 @@ +/** + * @fileoverview Normalization utilities for STAC catalogs and collections + * @module utils/normalization + */ + +/** + * Derives categories from a catalog object by checking various possible fields + * @param {Object} catalog - Catalog object to extract categories from + * @returns {Array} Array of category strings, empty array if none found + */ +export function deriveCategories(catalog) { + if (!catalog || typeof catalog !== 'object') { + return []; + } + + if (Array.isArray(catalog.categories)) { + return catalog.categories.filter(Boolean).map(String); + } + + if (Array.isArray(catalog.keywords)) { + return catalog.keywords.filter(Boolean).map(String); + } + + if (Array.isArray(catalog.tags)) { + return catalog.tags.filter(Boolean).map(String); + } + + if (typeof catalog.access === 'string' && catalog.access.trim().length) { + return [catalog.access.trim()]; + } + + return []; +} + +/** + * Normalizes a catalog object from STAC Index API format + * @param {Object} catalog - Catalog object from the STAC Index API + * @param {number} index - Index position in the original array + * @returns {Object} Normalized catalog object with standard properties + */ +export function normalizeCatalog(catalog, index) { + return { + index, + id: catalog.id, + url: catalog.url, + slug: catalog.slug, + title: catalog.title, + summary: catalog.summary, + access: catalog.access, + created: catalog.created, + updated: catalog.updated, + isPrivate: catalog.isPrivate, + isApi: catalog.isApi, + accessInfo: catalog.accessInfo, + categories: deriveCategories(catalog), + // Preserve any additional dynamic properties + ...Object.fromEntries( + Object.entries(catalog).filter(([key]) => + !['id', 'url', 'slug', 'title', 'summary', 'access', 'created', + 'updated', 'isPrivate', 'isApi', 'accessInfo'].includes(key) + ) + ) + }; +} + +/** + * Normalizes a collection object using stac-js methods for metadata extraction + * @param {Object} colObj - Collection object (stac-js or plain object) + * @param {number} index - Index position + * @returns {Object} Normalized collection object + */ +export function normalizeCollection(colObj, index) { + // Use stac-js methods for robust metadata extraction + const bbox = typeof colObj.getBoundingBox === 'function' + ? colObj.getBoundingBox() + : (colObj.extent?.spatial?.bbox?.[0] || null); + + const temporal = typeof colObj.getTemporalExtent === 'function' + ? colObj.getTemporalExtent() + : (colObj.extent?.temporal?.interval?.[0] || null); + + // Get self URL using stac-js link navigation + let selfUrl = null; + if (typeof colObj.getAbsoluteUrl === 'function') { + selfUrl = colObj.getAbsoluteUrl(); + } else if (colObj.links) { + const selfLink = colObj.links.find(l => l.rel === 'self'); + selfUrl = selfLink?.href || null; + } + + return { + index, + id: colObj.id || 'Unknown', + url: selfUrl, + title: colObj.title || null, + description: colObj.description || colObj.summary || null, + bbox, + temporal, + license: colObj.license || null, + keywords: colObj.keywords || [] + }; +} + +/** + * Processes an array of catalogs from the STAC Index API + * @param {Array} catalogs - Array of catalog objects from the STAC Index API + * @returns {Array} Array of normalized catalog objects + * @throws {Error} Throws error if input is not an array + */ +export function processCatalogs(catalogs) { + if (!Array.isArray(catalogs)) { + throw new Error('Expected an array'); + } + + const normalized = catalogs.map((catalog, index) => normalizeCatalog(catalog, index)); + + console.log(`Total: ${normalized.length} catalogs found\n`); + + if (normalized.length > 0) { + console.log('Example - First Catalog:'); + const first = normalized[0]; + console.log(` ID: ${first.id}`); + console.log(` URL: ${first.url}`); + console.log(` Title: ${first.title}`); + console.log(` Is API: ${first.isApi}`); + console.log(` Categories: ${JSON.stringify(first.categories)}`); + } + + return normalized; +} From fdb0cada7ceafbf8541d58384d996d809abecc09 Mon Sep 17 00:00:00 2001 From: Jakob Date: Fri, 28 Nov 2025 14:26:50 +0100 Subject: [PATCH 025/134] #53,example environment configuration file for database and crawler settings --- .env.example | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3db8e63 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Database Configuration +# PostgreSQL connection parameters +PGHOST=atlas.stacindex.org +PGPORT=5432 +PGUSER= +PGPASSWORD= +PGDATABASE=stac_db + +# Crawler Configuration +# Crawl mode: 'catalogs', 'apis', or 'both' +CRAWL_MODE=both + +# Maximum number of catalogs to crawl (default: 10) +MAX_CATALOGS=10 + +# Maximum number of APIs to crawl (default: 5) +MAX_APIS=5 + +# Timeout for each crawl operation in milliseconds (default: 30000) +TIMEOUT_MS=30000 + +# Node Environment +NODE_ENV=development From 243c351e0eca2eefabf5069b7200410b5974e1b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Tue, 2 Dec 2025 07:42:54 +0100 Subject: [PATCH 026/134] added `.env` --- db/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/db/.gitignore b/db/.gitignore index e69de29..2eea525 100644 --- a/db/.gitignore +++ b/db/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file From 40422c957f746ae80659d8248398321301b13443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Tue, 2 Dec 2025 07:54:00 +0100 Subject: [PATCH 027/134] added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details --- db/docker-compose.yml | 10 ++++++---- db/example.env | 7 +++++++ 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 db/example.env diff --git a/db/docker-compose.yml b/db/docker-compose.yml index 4c74f91..12c20f8 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -3,14 +3,16 @@ services: image: postgis/postgis:16-3.4 container_name: stac_db restart: always + env_file: + - .env environment: - POSTGRES_DB: stac_db - POSTGRES_USER: stac_user - POSTGRES_PASSWORD: stac_password + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - "5432:5432" + - "${DB_PORT}:5432" volumes: - stac_data:/var/lib/postgresql/data diff --git a/db/example.env b/db/example.env new file mode 100644 index 0000000..e175b0b --- /dev/null +++ b/db/example.env @@ -0,0 +1,7 @@ +# PostgreSQL Database Configuration +POSTGRES_DB= # stac_db is the database we are running on +POSTGRES_USER= # add postgres_user here +POSTGRES_PASSWORD= # add postgres_password here + +# Database Port (host:container) +DB_PORT= # 5432 / 5433 (at the moment both are available) \ No newline at end of file From b02ad17ca68d168ea940ea978e81e2286cf7a810 Mon Sep 17 00:00:00 2001 From: SonkeHoffmann Date: Tue, 2 Dec 2025 13:04:49 +0100 Subject: [PATCH 028/134] added description of how to use the `.env` and `example.env` in the `README.md` --- db/README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/db/README.md b/db/README.md index da1c603..435aea0 100644 --- a/db/README.md +++ b/db/README.md @@ -59,11 +59,12 @@ Comprehensive indexing for optimal query performance: ```bash cd ./db/ docker-compose up +``` ### Connection Details - **Host**: `atlas.stacindex.org` -- **Port**: `5432` +- **Port**: `5432` and `5433` ## Port Configuration @@ -71,15 +72,18 @@ This project exposes the database service on a port that can be changed. Update The database uses port mapping in the format `HOST:CONTAINER`: - **`5432:5432`** means: - - Left side (`5432`): Port on your local machine (host) + - Left side (`5432`): Port on your local machine (host) (must be changed in the `.env`) - Right side (`5432`): Port inside the Docker container -What to change in the Docker Compose file +What to change the environment parameters in the Docker Compose file - Open the `docker-compose.yml`. -- Locate the `ports:` and change the host side: +- Locate e.g. `ports:` and change the host side: - Format: `":"` - Example: change `5432:5432` to `15432:5432` to expose the container's 5432 on host port 15432. -- TODO: If the compose file references environment variables (e.g. `${DB_PORT}`), change the value in the corresponding `.env` file. +- If the compose file references environment variables (e.g. `${DB_PORT}`), change the value in the corresponding `.env` file. +**Important**: Do not modify the `docker-compose.yml` file directly. Instead, update the port configuration in the `.env` file by changing the `DB_PORT`, `POSTGRES_DB`, `POSTGRES_USER` and `POSTGRES_PASSWORD` variable, then restart the service with `docker-compose up`. +- The change in the `.env` does not count for the , you can change that directly in the `docker-compose.yml` if needed. +- There is an `example.env` provided that can be renamed into `.env` and then modified. ## Initialization Scripts From 9005ccc49b9d2fdc3c9d4edca6ee173cfccdd726 Mon Sep 17 00:00:00 2001 From: SonkeHoffmann Date: Tue, 2 Dec 2025 13:15:30 +0100 Subject: [PATCH 029/134] changed a few things e.g. DB_PORT --> ${DB_PORT} --- db/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/README.md b/db/README.md index 435aea0..c9e4f69 100644 --- a/db/README.md +++ b/db/README.md @@ -79,9 +79,9 @@ What to change the environment parameters in the Docker Compose file - Open the `docker-compose.yml`. - Locate e.g. `ports:` and change the host side: - Format: `":"` -- Example: change `5432:5432` to `15432:5432` to expose the container's 5432 on host port 15432. +- Example: change `5432:5432` to `5433:5432` to expose the container's 5432 on host port 5433. - If the compose file references environment variables (e.g. `${DB_PORT}`), change the value in the corresponding `.env` file. -**Important**: Do not modify the `docker-compose.yml` file directly. Instead, update the port configuration in the `.env` file by changing the `DB_PORT`, `POSTGRES_DB`, `POSTGRES_USER` and `POSTGRES_PASSWORD` variable, then restart the service with `docker-compose up`. +**Important**: Do not modify the `docker-compose.yml` file directly. Instead, update the port configuration in the `.env` file by changing the `${DB_PORT}`, `${POSTGRES_DB}`, `${POSTGRES_USER}` and `${POSTGRES_PASSWORD}` variable, then restart the service with `docker-compose up`. - The change in the `.env` does not count for the , you can change that directly in the `docker-compose.yml` if needed. - There is an `example.env` provided that can be renamed into `.env` and then modified. From eee44db375229e10651b25aed9a53072994ad195 Mon Sep 17 00:00:00 2001 From: SonkeHoffmann Date: Tue, 2 Dec 2025 13:17:35 +0100 Subject: [PATCH 030/134] now, everthing should be done. my god, help. sorry --- db/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/README.md b/db/README.md index c9e4f69..6ddad08 100644 --- a/db/README.md +++ b/db/README.md @@ -82,7 +82,7 @@ What to change the environment parameters in the Docker Compose file - Example: change `5432:5432` to `5433:5432` to expose the container's 5432 on host port 5433. - If the compose file references environment variables (e.g. `${DB_PORT}`), change the value in the corresponding `.env` file. **Important**: Do not modify the `docker-compose.yml` file directly. Instead, update the port configuration in the `.env` file by changing the `${DB_PORT}`, `${POSTGRES_DB}`, `${POSTGRES_USER}` and `${POSTGRES_PASSWORD}` variable, then restart the service with `docker-compose up`. -- The change in the `.env` does not count for the , you can change that directly in the `docker-compose.yml` if needed. +- The change in the `.env` does not count for the ``, you can change that directly in the `docker-compose.yml` if needed. - There is an `example.env` provided that can be renamed into `.env` and then modified. ## Initialization Scripts From aecce7e06f5f86920c7890ce315bb6c5bdd10db5 Mon Sep 17 00:00:00 2001 From: SonkeHoffmann Date: Tue, 2 Dec 2025 13:20:57 +0100 Subject: [PATCH 031/134] layout issues fixed --- db/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/db/README.md b/db/README.md index 6ddad08..0e83708 100644 --- a/db/README.md +++ b/db/README.md @@ -81,6 +81,7 @@ What to change the environment parameters in the Docker Compose file - Format: `":"` - Example: change `5432:5432` to `5433:5432` to expose the container's 5432 on host port 5433. - If the compose file references environment variables (e.g. `${DB_PORT}`), change the value in the corresponding `.env` file. + **Important**: Do not modify the `docker-compose.yml` file directly. Instead, update the port configuration in the `.env` file by changing the `${DB_PORT}`, `${POSTGRES_DB}`, `${POSTGRES_USER}` and `${POSTGRES_PASSWORD}` variable, then restart the service with `docker-compose up`. - The change in the `.env` does not count for the ``, you can change that directly in the `docker-compose.yml` if needed. - There is an `example.env` provided that can be renamed into `.env` and then modified. From 7a885ce0e85cc1dcf8a9cce20f272069ab0e426d Mon Sep 17 00:00:00 2001 From: Robin Tammo Gummels Date: Tue, 2 Dec 2025 14:16:36 +0100 Subject: [PATCH 032/134] Fixed Typo/incomplete Sentence in README.md --- db/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/README.md b/db/README.md index 0e83708..05e1542 100644 --- a/db/README.md +++ b/db/README.md @@ -75,7 +75,7 @@ The database uses port mapping in the format `HOST:CONTAINER`: - Left side (`5432`): Port on your local machine (host) (must be changed in the `.env`) - Right side (`5432`): Port inside the Docker container -What to change the environment parameters in the Docker Compose file +How to change the environment parameters in the Docker Compose file - Open the `docker-compose.yml`. - Locate e.g. `ports:` and change the host side: - Format: `":"` From 14a60136f16674424c0a25c28cac7accb9cefed1 Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 3 Dec 2025 11:54:24 +0100 Subject: [PATCH 033/134] converted to es modules, added --no-db mode for debugging and moved cli parsing to own file --- crawler/catalogs/catalog.js | 17 ++++-- crawler/db.js | 8 +-- crawler/index.js | 101 ++++++++++++++++++++---------------- crawler/utils/cli.js | 62 ++++++++++++++++++++++ crawler/utils/config.js | 64 +++-------------------- crawler/utils/handlers.js | 4 +- 6 files changed, 145 insertions(+), 111 deletions(-) create mode 100644 crawler/utils/cli.js diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 2ba561a..cbb825a 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -60,13 +60,22 @@ async function crawlCatalogs(initialCatalogs, config = {}) { results.stats.failedRequests++; const depth = request.userData?.depth || 0; const indent = ' '.repeat(depth); + const catalogId = request.userData?.catalogId || 'unknown'; - // Log non-compliant STAC or failed requests - if (error.message.includes('STAC') || error.message.includes('validation')) { - log.info(`${indent}Skipping non-compliant STAC: ${request.url}`); + // Log detailed failure information + if (error.message.includes('STAC validation')) { + log.info(`${indent}[STAC VALIDATION FAILED] ${catalogId} at ${request.url}`); + log.info(`${indent} Reason: ${error.message}`); results.stats.nonCompliant++; + } else if (error.message.includes('timeout')) { + log.warning(`${indent}[TIMEOUT] ${catalogId} at ${request.url}`); + } else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { + log.warning(`${indent}[CONNECTION FAILED] ${catalogId} at ${request.url}`); + } else if (error.code === 'ERR_NON_2XX_3XX_RESPONSE') { + log.warning(`${indent}[HTTP ERROR] ${catalogId} at ${request.url} - Status: ${error.statusCode}`); } else { - log.warning(`${indent}Failed request: ${request.url} - ${error.message}`); + log.warning(`${indent}[FAILED] ${catalogId} at ${request.url}`); + log.warning(`${indent} Error: ${error.message}`); } } }); diff --git a/crawler/db.js b/crawler/db.js index 1668eca..c1e9d6b 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -2,8 +2,10 @@ * DB helper for crawler using `pg` Pool * Exposes: initDb(), insertOrUpdateCatalog(), close() */ -const { Pool } = require('pg'); -require('dotenv').config(); +import pkg from 'pg'; +const { Pool } = pkg; +import dotenv from 'dotenv'; +dotenv.config(); const pool = new Pool({ host: process.env.PGHOST, @@ -366,7 +368,7 @@ async function close() { await pool.end(); } -module.exports = { +export { initDb, insertOrUpdateCatalog, insertOrUpdateCollection, diff --git a/crawler/index.js b/crawler/index.js index e3f7c0a..e563b86 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -8,11 +8,7 @@ import { processCatalogs } from './utils/normalization.js'; import { crawlCatalogs } from './catalogs/catalog.js'; import { crawlApis } from './apis/api.js'; import { getConfig } from './utils/config.js'; -import { createRequire } from 'module'; - -// Import CommonJS db module -const require = createRequire(import.meta.url); -const db = require('./db.js'); +import * as db from './db.js'; /** * URL of the STAC Index API endpoint @@ -28,12 +24,14 @@ const targetUrl = 'https://www.stacindex.org/api/catalogs'; */ const crawler = async () => { try { - // Initialize database connection - await db.initDb(); - // Load configuration const config = getConfig(); + // Initialize database connection (unless --no-db flag is set) + if (!config.noDb) { + await db.initDb(); + } + // Display configuration console.log('\n=== STAC Crawler Configuration ==='); console.log(`Mode: ${config.mode}`); @@ -41,6 +39,7 @@ const crawler = async () => { console.log(`Max APIs: ${config.maxApis === Infinity ? 'unlimited' : config.maxApis}`); console.log(`Timeout: ${config.timeout === Infinity ? 'unlimited' : config.timeout + 'ms'}`); console.log(`Crawl Depth: unlimited`); + console.log(`Database: ${config.noDb ? 'disabled (terminal output only)' : 'enabled'}`); console.log('==================================\n'); const response = await axios.get(targetUrl); @@ -61,7 +60,7 @@ const crawler = async () => { console.log(`\nTotal collections found across all catalogs: ${results.stats.collectionsFound}`); // Persist collections to database - if (results.collections.length > 0) { + if (!config.noDb && results.collections.length > 0) { console.log(`\nSaving ${results.collections.length} collections to database...`); let savedCount = 0; let errorCount = 0; @@ -80,6 +79,8 @@ const crawler = async () => { if (errorCount > 0) { console.log(`Failed to save ${errorCount} collections`); } + } else if (config.noDb && results.collections.length > 0) { + console.log(`\n[--no-db mode] Found ${results.collections.length} collections (not saved to database)`); } } catch (error) { console.error(`Failed to crawl catalogs: ${error.message}`); @@ -111,23 +112,27 @@ const crawler = async () => { }); // Persist API collections to database - console.log(`\nSaving ${collections.length} API collections to database...`); - let savedCount = 0; - let errorCount = 0; - - for (const collection of collections) { - try { - await db.insertOrUpdateCollection(collection); - savedCount++; - } catch (err) { - errorCount++; - console.error(`DB write error for collection ${collection.id}:`, err.message); + if (!config.noDb) { + console.log(`\nSaving ${collections.length} API collections to database...`); + let savedCount = 0; + let errorCount = 0; + + for (const collection of collections) { + try { + await db.insertOrUpdateCollection(collection); + savedCount++; + } catch (err) { + errorCount++; + console.error(`DB write error for collection ${collection.id}:`, err.message); + } } - } - - console.log(`Successfully saved ${savedCount} API collections to database`); - if (errorCount > 0) { - console.log(`Failed to save ${errorCount} collections`); + + console.log(`Successfully saved ${savedCount} API collections to database`); + if (errorCount > 0) { + console.log(`Failed to save ${errorCount} collections`); + } + } else { + console.log(`\n[--no-db mode] Found ${collections.length} API collections (not saved to database)`); } } } catch (error) { @@ -141,31 +146,37 @@ const crawler = async () => { } // Persist catalogs metadata to database - console.log(`\nSaving ${catalogs.length} catalog metadata entries to database...`); - let savedCatalogCount = 0; - let errorCatalogCount = 0; - - for (const catalog of catalogs) { - try { - await db.insertOrUpdateCatalog(catalog); - savedCatalogCount++; - } catch (err) { - errorCatalogCount++; - console.error(`DB write error for catalog ${catalog.id || catalog.slug}:`, err.message); + if (!config.noDb) { + console.log(`\nSaving ${catalogs.length} catalog metadata entries to database...`); + let savedCatalogCount = 0; + let errorCatalogCount = 0; + + for (const catalog of catalogs) { + try { + await db.insertOrUpdateCatalog(catalog); + savedCatalogCount++; + } catch (err) { + errorCatalogCount++; + console.error(`DB write error for catalog ${catalog.id || catalog.slug}:`, err.message); + } } - } - - console.log(`Successfully saved ${savedCatalogCount} catalog metadata entries`); - if (errorCatalogCount > 0) { - console.log(`Failed to save ${errorCatalogCount} catalog entries`); + + console.log(`Successfully saved ${savedCatalogCount} catalog metadata entries`); + if (errorCatalogCount > 0) { + console.log(`Failed to save ${errorCatalogCount} catalog entries`); + } + } else { + console.log(`\n[--no-db mode] Skipping catalog metadata persistence`); } } catch (error) { - console.error(`Error fetching ${targetUrl}: ${error.message}`); + console.error(`Error fetching ${targetUrl}:`, error); } finally { - // Close database connection - await db.close(); - console.log('\nDatabase connection closed.'); + // Close database connection (unless --no-db flag is set) + if (!getConfig().noDb) { + await db.close(); + console.log('\nDatabase connection closed.'); + } } }; diff --git a/crawler/utils/cli.js b/crawler/utils/cli.js new file mode 100644 index 0000000..b5b6118 --- /dev/null +++ b/crawler/utils/cli.js @@ -0,0 +1,62 @@ +/** + * @fileoverview CLI argument parsing for STAC crawler (temporary debugging file) + * @module utils/cli + * @note This file can be easily removed after debugging is complete + */ + +/** + * Parse command line arguments + * @returns {Object} Parsed CLI arguments + */ +export function parseCliArgs() { + const args = process.argv.slice(2); + const config = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--mode' || arg === '-m') { + config.mode = args[++i]; + } else if (arg === '--max-catalogs' || arg === '-c') { + config.maxCatalogs = parseInt(args[++i], 10); + } else if (arg === '--max-apis' || arg === '-a') { + config.maxApis = parseInt(args[++i], 10); + } else if (arg === '--timeout' || arg === '-t') { + config.timeout = parseInt(args[++i], 10); + } else if (arg === '--no-db') { + config.noDb = true; + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + } + + return config; +} + +/** + * Print help message + */ +export function printHelp() { + console.log(` +STAC Crawler Configuration Options: + + -m, --mode Crawl mode: 'catalogs', 'apis', or 'both' (default: 'both') + -c, --max-catalogs Maximum number of catalogs to crawl (default: 10) + -a, --max-apis Maximum number of APIs to crawl (default: 5) + -t, --timeout Timeout for each crawl operation in ms (default: 30000) + --no-db Skip database operations (terminal output only) + -h, --help Show this help message + +Environment Variables: + CRAWL_MODE Same as --mode + MAX_CATALOGS Same as --max-catalogs + MAX_APIS Same as --max-apis + TIMEOUT_MS Same as --timeout + +Examples: + node index.js --mode catalogs --max-catalogs 20 + node index.js -m apis -a 10 -t 60000 + CRAWL_MODE=both MAX_CATALOGS=50 node index.js + `); +} diff --git a/crawler/utils/config.js b/crawler/utils/config.js index 3424a7b..f69b745 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -4,64 +4,11 @@ */ import dotenv from 'dotenv'; +import { parseCliArgs } from './cli.js'; // Load environment variables from .env file dotenv.config(); -/** - * Parse command line arguments - * @returns {Object} Parsed CLI arguments - */ -function parseCliArgs() { - const args = process.argv.slice(2); - const config = {}; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === '--mode' || arg === '-m') { - config.mode = args[++i]; - } else if (arg === '--max-catalogs' || arg === '-c') { - config.maxCatalogs = parseInt(args[++i], 10); - } else if (arg === '--max-apis' || arg === '-a') { - config.maxApis = parseInt(args[++i], 10); - } else if (arg === '--timeout' || arg === '-t') { - config.timeout = parseInt(args[++i], 10); - } else if (arg === '--help' || arg === '-h') { - printHelp(); - process.exit(0); - } - } - - return config; -} - -/** - * Print help message - */ -function printHelp() { - console.log(` -STAC Crawler Configuration Options: - - -m, --mode Crawl mode: 'catalogs', 'apis', or 'both' (default: 'both') - -c, --max-catalogs Maximum number of catalogs to crawl (default: 10) - -a, --max-apis Maximum number of APIs to crawl (default: 5) - -t, --timeout Timeout for each crawl operation in ms (default: 30000) - -h, --help Show this help message - -Environment Variables: - CRAWL_MODE Same as --mode - MAX_CATALOGS Same as --max-catalogs - MAX_APIS Same as --max-apis - TIMEOUT_MS Same as --timeout - -Examples: - node index.js --mode catalogs --max-catalogs 20 - node index.js -m apis -a 10 -t 60000 - CRAWL_MODE=both MAX_CATALOGS=50 node index.js - `); -} - /** * Get configuration from environment variables, CLI args, and defaults * CLI args take precedence over env vars, which take precedence over defaults @@ -75,7 +22,8 @@ function getConfig() { mode: 'both', // 'catalogs', 'apis', or 'both' maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl - timeout: 30000 // Timeout in milliseconds (30 seconds) + timeout: 30000, // Timeout in milliseconds (30 seconds) + noDb: false // Skip database operations }; // Build configuration with precedence: CLI > ENV > Defaults @@ -86,7 +34,9 @@ function getConfig() { maxApis: cliArgs.maxApis !== undefined ? cliArgs.maxApis : (process.env.MAX_APIS ? parseInt(process.env.MAX_APIS, 10) : defaults.maxApis), timeout: cliArgs.timeout !== undefined ? cliArgs.timeout : - (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout) + (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout), + noDb: cliArgs.noDb !== undefined ? cliArgs.noDb : + (process.env.NO_DB === 'true' ? true : defaults.noDb) }; // Validate mode @@ -144,8 +94,6 @@ async function withTimeout(promise, ms, operation = 'Operation') { export { getConfig, - parseCliArgs, - printHelp, withTimeout, createTimeout }; diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 5c021fe..f60facc 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -37,7 +37,9 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul log.info(`${indent}STAC Collection validated: ${catalogId}`); } } catch (parseError) { - log.warning(`${indent}Non-compliant STAC catalog ${catalogId}: ${parseError.message}`); + log.warning(`${indent}Non-compliant STAC catalog ${catalogId} at ${request.url}`); + log.warning(`${indent}Error details: ${parseError.message}`); + log.debug(`${indent}Response preview: ${JSON.stringify(json).substring(0, 200)}...`); throw new Error(`STAC validation failed: ${parseError.message}`); } From 7b7e96990b96db24bcd15a63ac10f22ce7de1eb2 Mon Sep 17 00:00:00 2001 From: Jakob <70800454+vertrox78@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:21:43 +0100 Subject: [PATCH 034/134] Update .env.example Co-authored-by: Humam <44206081+Mammutor@users.noreply.github.com> --- .env.example | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 3db8e63..e0d6ba7 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ # Database Configuration # PostgreSQL connection parameters -PGHOST=atlas.stacindex.org +PGHOST=atlas.example.org PGPORT=5432 -PGUSER= -PGPASSWORD= -PGDATABASE=stac_db +PGUSER=example_user +PGPASSWORD=example_password +PGDATABASE=example_db # Crawler Configuration # Crawl mode: 'catalogs', 'apis', or 'both' From 86aaacd74f0cb108371635baa0a6055aa2e5f2f9 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 3 Dec 2025 15:37:13 +0100 Subject: [PATCH 035/134] Refactor database interactions by moving db helper to utils and implementing insert/update functions for catalogs and collections --- crawler/index.js | 2 +- crawler/{ => utils}/db.js | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename crawler/{ => utils}/db.js (100%) diff --git a/crawler/index.js b/crawler/index.js index d33d400..05cdf7a 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -8,7 +8,7 @@ import { processCatalogs } from './utils/normalization.js'; import { crawlCatalogs } from './catalogs/catalog.js'; import { crawlApis } from './apis/api.js'; import { getConfig } from './utils/config.js'; -import db from './db.js'; +import db from './utils/db.js'; /** * URL of the STAC Index API endpoint diff --git a/crawler/db.js b/crawler/utils/db.js similarity index 100% rename from crawler/db.js rename to crawler/utils/db.js From 166ff8b99f3df250a6d6d2e4842ab2103b526137 Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 3 Dec 2025 16:11:04 +0100 Subject: [PATCH 036/134] #176, fixed logic for max apis, fixed double db init --- .env.example | 6 ++--- crawler/.gitignore | 3 ++- crawler/index.js | 54 ++++++++++++----------------------------- crawler/utils/cli.js | 14 +++++------ crawler/utils/config.js | 16 ++++++------ 5 files changed, 35 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index e0d6ba7..c4d6686 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,9 @@ # PostgreSQL connection parameters PGHOST=atlas.example.org PGPORT=5432 -PGUSER=example_user -PGPASSWORD=example_password -PGDATABASE=example_db +PGUSER=stac_user +PGPASSWORD=stac_password +PGDATABASE=stac_db # Crawler Configuration # Crawl mode: 'catalogs', 'apis', or 'both' diff --git a/crawler/.gitignore b/crawler/.gitignore index a2e04e4..873a820 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -1,3 +1,4 @@ node_modules -.env \ No newline at end of file +.env +storage diff --git a/crawler/index.js b/crawler/index.js index d33d400..703e016 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -27,25 +27,18 @@ const crawler = async () => { // Load configuration const config = getConfig(); - // Initialize database connection (unless --no-db flag is set) - if (!config.noDb) { - await db.initDb(); - } + // Initialize database connection + await db.initDb(); // Display configuration console.log('\n=== STAC Crawler Configuration ==='); console.log(`Mode: ${config.mode}`); - console.log(`Max Catalogs: ${config.maxCatalogs === Infinity ? 'unlimited' : config.maxCatalogs}`); - console.log(`Max APIs: ${config.maxApis === Infinity ? 'unlimited' : config.maxApis}`); + console.log(`Max Catalogs: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs} (debugging limit)`); + console.log(`Max APIs: ${config.maxApis === 0 ? 'unlimited' : config.maxApis} (debugging limit)`); console.log(`Timeout: ${config.timeout === Infinity ? 'unlimited' : config.timeout + 'ms'}`); console.log(`Crawl Depth: unlimited`); - console.log(`Database: ${config.noDb ? 'disabled (terminal output only)' : 'enabled'}`); console.log('==================================\n'); - // Initialize database connection - console.log('Initializing database connection...'); - await db.initDb(); - const response = await axios.get(targetUrl); const catalogs = processCatalogs(response.data); @@ -57,10 +50,10 @@ const crawler = async () => { for (const catalog of response.data) { try { const catalogId = await db.insertOrUpdateCatalog(catalog); - console.log(`✓ Saved catalog: ${catalog.title || catalog.id} (DB ID: ${catalogId})`); + console.log(`Saved catalog: ${catalog.title || catalog.id} (DB ID: ${catalogId})`); catalogsSaved++; } catch (err) { - console.error(`✗ Failed catalog: ${catalog.title || catalog.id} - ${err.message}`); + console.error(`Failed catalog: ${catalog.title || catalog.id} - ${err.message}`); catalogsFailed++; } } @@ -71,34 +64,17 @@ const crawler = async () => { if (config.mode === 'catalogs' || config.mode === 'both') { console.log('\nCrawling collections and nested catalogs with Crawlee...\n'); - const catalogsToProcess = config.maxCatalogs === Infinity + // Note: MAX_CATALOGS limit is for debugging purposes only + // Set maxCatalogs to 0 or use --max-catalogs 0 for unlimited catalog crawling + const catalogsToProcess = config.maxCatalogs === 0 ? catalogs : catalogs.slice(0, config.maxCatalogs); - console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === Infinity ? 'unlimited' : config.maxCatalogs})\n`); + console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs})\n`); try { const results = await crawlCatalogs(catalogsToProcess, config); console.log(`\nTotal collections found across all catalogs: ${results.stats.collectionsFound}`); - - try { - const stats = await crawlCatalogRecursive(catalog, 0, config); - totalCollections += stats.collections; - - // Save collections to database if returned - if (stats.collectionsData && Array.isArray(stats.collectionsData)) { - for (const collection of stats.collectionsData) { - try { - const collectionId = await db.insertOrUpdateCollection(collection); - console.log(` ✓ Saved collection: ${collection.title || collection.id} (DB ID: ${collectionId})`); - } catch (err) { - console.error(` ✗ Failed collection: ${collection.title || collection.id} - ${err.message}`); - } - } - } - } catch (error) { - console.error(`Failed to crawl catalog ${catalog.id}: ${error.message}`); - } } catch (error) { console.error(`Failed to crawl catalogs: ${error.message}`); } @@ -114,8 +90,10 @@ const crawler = async () => { .map(cat => cat.url); if (apiUrls.length > 0) { - const apisToProcess = config.maxApis === Infinity ? apiUrls : apiUrls.slice(0, config.maxApis); - console.log(`Found ${apiUrls.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === Infinity ? 'unlimited' : config.maxApis})...`); + // Note: MAX_APIS limit is for debugging purposes only + // Set maxApis to 0 or use --max-apis 0 for unlimited API crawling + const apisToProcess = config.maxApis === 0 ? apiUrls : apiUrls.slice(0, config.maxApis); + console.log(`Found ${apiUrls.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); try { const collections = await crawlApis(apisToProcess, true, config); @@ -136,10 +114,10 @@ const crawler = async () => { for (const collection of collections) { try { const collectionId = await db.insertOrUpdateCollection(collection); - console.log(` ✓ Saved: ${collection.title || collection.id} (DB ID: ${collectionId})`); + console.log(` Saved: ${collection.title || collection.id} (DB ID: ${collectionId})`); apiCollectionsSaved++; } catch (err) { - console.error(` ✗ Failed: ${collection.title || collection.id} - ${err.message}`); + console.error(` Failed: ${collection.title || collection.id} - ${err.message}`); apiCollectionsFailed++; } } diff --git a/crawler/utils/cli.js b/crawler/utils/cli.js index b5b6118..035a46f 100644 --- a/crawler/utils/cli.js +++ b/crawler/utils/cli.js @@ -23,8 +23,6 @@ export function parseCliArgs() { config.maxApis = parseInt(args[++i], 10); } else if (arg === '--timeout' || arg === '-t') { config.timeout = parseInt(args[++i], 10); - } else if (arg === '--no-db') { - config.noDb = true; } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); @@ -42,21 +40,23 @@ export function printHelp() { STAC Crawler Configuration Options: -m, --mode Crawl mode: 'catalogs', 'apis', or 'both' (default: 'both') - -c, --max-catalogs Maximum number of catalogs to crawl (default: 10) - -a, --max-apis Maximum number of APIs to crawl (default: 5) + -c, --max-catalogs Maximum number of catalogs to crawl (default: 10, use 0 for unlimited) + Note: Limits are for debugging purposes only + -a, --max-apis Maximum number of APIs to crawl (default: 5, use 0 for unlimited) + Note: Limits are for debugging purposes only -t, --timeout Timeout for each crawl operation in ms (default: 30000) - --no-db Skip database operations (terminal output only) -h, --help Show this help message Environment Variables: CRAWL_MODE Same as --mode - MAX_CATALOGS Same as --max-catalogs - MAX_APIS Same as --max-apis + MAX_CATALOGS Same as --max-catalogs (use 0 for unlimited) + MAX_APIS Same as --max-apis (use 0 for unlimited) TIMEOUT_MS Same as --timeout Examples: node index.js --mode catalogs --max-catalogs 20 node index.js -m apis -a 10 -t 60000 + node index.js -m both -c 0 -a 0 # Unlimited mode (no debugging limits) CRAWL_MODE=both MAX_CATALOGS=50 node index.js `); } diff --git a/crawler/utils/config.js b/crawler/utils/config.js index f69b745..6fa883b 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -22,8 +22,7 @@ function getConfig() { mode: 'both', // 'catalogs', 'apis', or 'both' maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl - timeout: 30000, // Timeout in milliseconds (30 seconds) - noDb: false // Skip database operations + timeout: 30000 // Timeout in milliseconds (30 seconds) }; // Build configuration with precedence: CLI > ENV > Defaults @@ -34,9 +33,7 @@ function getConfig() { maxApis: cliArgs.maxApis !== undefined ? cliArgs.maxApis : (process.env.MAX_APIS ? parseInt(process.env.MAX_APIS, 10) : defaults.maxApis), timeout: cliArgs.timeout !== undefined ? cliArgs.timeout : - (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout), - noDb: cliArgs.noDb !== undefined ? cliArgs.noDb : - (process.env.NO_DB === 'true' ? true : defaults.noDb) + (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout) }; // Validate mode @@ -48,12 +45,13 @@ function getConfig() { // NOTE: Removed automatic Infinity override for 'catalogs' mode // Users can now control limits via CLI args even when mode is 'catalogs' + // Set maxCatalogs or maxApis to 0 for unlimited crawling (for debugging purposes) - // Validate numeric values (allow Infinity for unlimited mode) - if ((config.maxCatalogs !== Infinity && config.maxCatalogs < 0) || - (config.maxApis !== Infinity && config.maxApis < 0) || + // Validate numeric values (0 means unlimited) + if ((config.maxCatalogs < 0) || + (config.maxApis < 0) || (config.timeout !== Infinity && config.timeout < 0)) { - console.error('Numeric configuration values must be non-negative'); + console.error('Numeric configuration values must be non-negative (use 0 for unlimited)'); process.exit(1); } From a7411ffea0ff7008929d63f68567d134ff104090 Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 3 Dec 2025 16:11:04 +0100 Subject: [PATCH 037/134] #167, fixed logic for max apis, fixed double db init --- .env.example | 6 ++--- crawler/.gitignore | 3 ++- crawler/index.js | 54 ++++++++++++----------------------------- crawler/utils/cli.js | 14 +++++------ crawler/utils/config.js | 16 ++++++------ 5 files changed, 35 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index e0d6ba7..c4d6686 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,9 @@ # PostgreSQL connection parameters PGHOST=atlas.example.org PGPORT=5432 -PGUSER=example_user -PGPASSWORD=example_password -PGDATABASE=example_db +PGUSER=stac_user +PGPASSWORD=stac_password +PGDATABASE=stac_db # Crawler Configuration # Crawl mode: 'catalogs', 'apis', or 'both' diff --git a/crawler/.gitignore b/crawler/.gitignore index a2e04e4..873a820 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -1,3 +1,4 @@ node_modules -.env \ No newline at end of file +.env +storage diff --git a/crawler/index.js b/crawler/index.js index d33d400..703e016 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -27,25 +27,18 @@ const crawler = async () => { // Load configuration const config = getConfig(); - // Initialize database connection (unless --no-db flag is set) - if (!config.noDb) { - await db.initDb(); - } + // Initialize database connection + await db.initDb(); // Display configuration console.log('\n=== STAC Crawler Configuration ==='); console.log(`Mode: ${config.mode}`); - console.log(`Max Catalogs: ${config.maxCatalogs === Infinity ? 'unlimited' : config.maxCatalogs}`); - console.log(`Max APIs: ${config.maxApis === Infinity ? 'unlimited' : config.maxApis}`); + console.log(`Max Catalogs: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs} (debugging limit)`); + console.log(`Max APIs: ${config.maxApis === 0 ? 'unlimited' : config.maxApis} (debugging limit)`); console.log(`Timeout: ${config.timeout === Infinity ? 'unlimited' : config.timeout + 'ms'}`); console.log(`Crawl Depth: unlimited`); - console.log(`Database: ${config.noDb ? 'disabled (terminal output only)' : 'enabled'}`); console.log('==================================\n'); - // Initialize database connection - console.log('Initializing database connection...'); - await db.initDb(); - const response = await axios.get(targetUrl); const catalogs = processCatalogs(response.data); @@ -57,10 +50,10 @@ const crawler = async () => { for (const catalog of response.data) { try { const catalogId = await db.insertOrUpdateCatalog(catalog); - console.log(`✓ Saved catalog: ${catalog.title || catalog.id} (DB ID: ${catalogId})`); + console.log(`Saved catalog: ${catalog.title || catalog.id} (DB ID: ${catalogId})`); catalogsSaved++; } catch (err) { - console.error(`✗ Failed catalog: ${catalog.title || catalog.id} - ${err.message}`); + console.error(`Failed catalog: ${catalog.title || catalog.id} - ${err.message}`); catalogsFailed++; } } @@ -71,34 +64,17 @@ const crawler = async () => { if (config.mode === 'catalogs' || config.mode === 'both') { console.log('\nCrawling collections and nested catalogs with Crawlee...\n'); - const catalogsToProcess = config.maxCatalogs === Infinity + // Note: MAX_CATALOGS limit is for debugging purposes only + // Set maxCatalogs to 0 or use --max-catalogs 0 for unlimited catalog crawling + const catalogsToProcess = config.maxCatalogs === 0 ? catalogs : catalogs.slice(0, config.maxCatalogs); - console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === Infinity ? 'unlimited' : config.maxCatalogs})\n`); + console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs})\n`); try { const results = await crawlCatalogs(catalogsToProcess, config); console.log(`\nTotal collections found across all catalogs: ${results.stats.collectionsFound}`); - - try { - const stats = await crawlCatalogRecursive(catalog, 0, config); - totalCollections += stats.collections; - - // Save collections to database if returned - if (stats.collectionsData && Array.isArray(stats.collectionsData)) { - for (const collection of stats.collectionsData) { - try { - const collectionId = await db.insertOrUpdateCollection(collection); - console.log(` ✓ Saved collection: ${collection.title || collection.id} (DB ID: ${collectionId})`); - } catch (err) { - console.error(` ✗ Failed collection: ${collection.title || collection.id} - ${err.message}`); - } - } - } - } catch (error) { - console.error(`Failed to crawl catalog ${catalog.id}: ${error.message}`); - } } catch (error) { console.error(`Failed to crawl catalogs: ${error.message}`); } @@ -114,8 +90,10 @@ const crawler = async () => { .map(cat => cat.url); if (apiUrls.length > 0) { - const apisToProcess = config.maxApis === Infinity ? apiUrls : apiUrls.slice(0, config.maxApis); - console.log(`Found ${apiUrls.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === Infinity ? 'unlimited' : config.maxApis})...`); + // Note: MAX_APIS limit is for debugging purposes only + // Set maxApis to 0 or use --max-apis 0 for unlimited API crawling + const apisToProcess = config.maxApis === 0 ? apiUrls : apiUrls.slice(0, config.maxApis); + console.log(`Found ${apiUrls.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); try { const collections = await crawlApis(apisToProcess, true, config); @@ -136,10 +114,10 @@ const crawler = async () => { for (const collection of collections) { try { const collectionId = await db.insertOrUpdateCollection(collection); - console.log(` ✓ Saved: ${collection.title || collection.id} (DB ID: ${collectionId})`); + console.log(` Saved: ${collection.title || collection.id} (DB ID: ${collectionId})`); apiCollectionsSaved++; } catch (err) { - console.error(` ✗ Failed: ${collection.title || collection.id} - ${err.message}`); + console.error(` Failed: ${collection.title || collection.id} - ${err.message}`); apiCollectionsFailed++; } } diff --git a/crawler/utils/cli.js b/crawler/utils/cli.js index b5b6118..035a46f 100644 --- a/crawler/utils/cli.js +++ b/crawler/utils/cli.js @@ -23,8 +23,6 @@ export function parseCliArgs() { config.maxApis = parseInt(args[++i], 10); } else if (arg === '--timeout' || arg === '-t') { config.timeout = parseInt(args[++i], 10); - } else if (arg === '--no-db') { - config.noDb = true; } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); @@ -42,21 +40,23 @@ export function printHelp() { STAC Crawler Configuration Options: -m, --mode Crawl mode: 'catalogs', 'apis', or 'both' (default: 'both') - -c, --max-catalogs Maximum number of catalogs to crawl (default: 10) - -a, --max-apis Maximum number of APIs to crawl (default: 5) + -c, --max-catalogs Maximum number of catalogs to crawl (default: 10, use 0 for unlimited) + Note: Limits are for debugging purposes only + -a, --max-apis Maximum number of APIs to crawl (default: 5, use 0 for unlimited) + Note: Limits are for debugging purposes only -t, --timeout Timeout for each crawl operation in ms (default: 30000) - --no-db Skip database operations (terminal output only) -h, --help Show this help message Environment Variables: CRAWL_MODE Same as --mode - MAX_CATALOGS Same as --max-catalogs - MAX_APIS Same as --max-apis + MAX_CATALOGS Same as --max-catalogs (use 0 for unlimited) + MAX_APIS Same as --max-apis (use 0 for unlimited) TIMEOUT_MS Same as --timeout Examples: node index.js --mode catalogs --max-catalogs 20 node index.js -m apis -a 10 -t 60000 + node index.js -m both -c 0 -a 0 # Unlimited mode (no debugging limits) CRAWL_MODE=both MAX_CATALOGS=50 node index.js `); } diff --git a/crawler/utils/config.js b/crawler/utils/config.js index f69b745..6fa883b 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -22,8 +22,7 @@ function getConfig() { mode: 'both', // 'catalogs', 'apis', or 'both' maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl - timeout: 30000, // Timeout in milliseconds (30 seconds) - noDb: false // Skip database operations + timeout: 30000 // Timeout in milliseconds (30 seconds) }; // Build configuration with precedence: CLI > ENV > Defaults @@ -34,9 +33,7 @@ function getConfig() { maxApis: cliArgs.maxApis !== undefined ? cliArgs.maxApis : (process.env.MAX_APIS ? parseInt(process.env.MAX_APIS, 10) : defaults.maxApis), timeout: cliArgs.timeout !== undefined ? cliArgs.timeout : - (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout), - noDb: cliArgs.noDb !== undefined ? cliArgs.noDb : - (process.env.NO_DB === 'true' ? true : defaults.noDb) + (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout) }; // Validate mode @@ -48,12 +45,13 @@ function getConfig() { // NOTE: Removed automatic Infinity override for 'catalogs' mode // Users can now control limits via CLI args even when mode is 'catalogs' + // Set maxCatalogs or maxApis to 0 for unlimited crawling (for debugging purposes) - // Validate numeric values (allow Infinity for unlimited mode) - if ((config.maxCatalogs !== Infinity && config.maxCatalogs < 0) || - (config.maxApis !== Infinity && config.maxApis < 0) || + // Validate numeric values (0 means unlimited) + if ((config.maxCatalogs < 0) || + (config.maxApis < 0) || (config.timeout !== Infinity && config.timeout < 0)) { - console.error('Numeric configuration values must be non-negative'); + console.error('Numeric configuration values must be non-negative (use 0 for unlimited)'); process.exit(1); } From c997896b90fcbddebee2fb3b5436eb9ac0c88bac Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 3 Dec 2025 16:39:55 +0100 Subject: [PATCH 038/134] #153, refactor: update API crawling logic to use Crawlee and enhance result structure --- crawler/apis/api.js | 392 +++++++++++++++++++++++++++++--------------- crawler/index.js | 8 +- 2 files changed, 267 insertions(+), 133 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index c414889..ff02ca5 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -1,11 +1,12 @@ /** - * @fileoverview API crawling functionality for STAC Index + * @fileoverview API crawling functionality for STAC Index using Crawlee * @module apis/api */ -import axios from 'axios'; +import { HttpCrawler } from 'crawlee'; import create from 'stac-js'; -import { withTimeout } from '../utils/config.js'; +import { normalizeCollection } from '../utils/normalization.js'; +import { handleCollections } from '../utils/handlers.js'; /** * Crawls STAC APIs to retrieve collection information without fetching items. @@ -13,162 +14,295 @@ import { withTimeout } from '../utils/config.js'; * @param {string[]} urls - Array of API URLs to crawl * @param {boolean} isApi - Boolean flag indicating if the URLs are APIs * @param {Object} config - Configuration object with timeout settings - * @returns {Promise} Array of STAC Collection objects ordered by URL + * @returns {Promise} Results object with collections array and statistics */ async function crawlApis(urls, isApi, config = {}) { if (!isApi || !Array.isArray(urls) || urls.length === 0) { - return []; + return { + collections: [], + stats: { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + collectionsFound: 0, + apisProcessed: 0, + stacCompliant: 0, + nonCompliant: 0 + } + }; } - const allCollections = new Map(); // Use Map to avoid duplicates by URL + const timeoutSecs = config.timeout && config.timeout !== Infinity + ? Math.ceil(config.timeout / 1000) + : 60; - console.log(`Starting to crawl ${urls.length} APIs...`); + // Store results + const results = { + collections: [], + apis: [], + stats: { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + collectionsFound: 0, + apisProcessed: 0, + stacCompliant: 0, + nonCompliant: 0 + } + }; - // Process each URL - for (const [index, url] of urls.entries()) { - console.log(`Processing API ${index + 1}/${urls.length}: ${url}`); - try { - await crawlSingleApi(url, allCollections, new Set(), config); - } catch (error) { - console.error(`Error crawling API ${url}:`, error.message); + const crawler = new HttpCrawler({ + requestHandlerTimeoutSecs: timeoutSecs, + + async requestHandler({ request, json, crawler, log }) { + results.stats.totalRequests++; + const indent = ' '; + + try { + // Route based on request label + if (request.label === 'API_ROOT') { + await handleApiRoot({ request, json, crawler, log, indent, results }); + } else if (request.label === 'API_COLLECTIONS') { + await handleCollections({ request, json, crawler, log, indent, results }); + } else if (request.label === 'API_COLLECTION') { + await handleApiCollection({ request, json, crawler, log, indent, results }); + } + + results.stats.successfulRequests++; + } catch (error) { + log.error(`${indent}Error handling ${request.label} at ${request.url}: ${error.message}`); + throw error; // Re-throw to trigger failedRequestHandler + } + }, + + async failedRequestHandler({ request, error, log }) { + results.stats.failedRequests++; + const indent = ' '; + const apiId = request.userData?.apiId || 'unknown'; + + // Log detailed failure information + if (error.message.includes('STAC validation')) { + log.info(`${indent}[STAC VALIDATION FAILED] ${apiId} at ${request.url}`); + log.info(`${indent} Reason: ${error.message}`); + results.stats.nonCompliant++; + } else if (error.message.includes('timeout')) { + log.warning(`${indent}[TIMEOUT] ${apiId} at ${request.url}`); + } else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { + log.warning(`${indent}[CONNECTION FAILED] ${apiId} at ${request.url}`); + } else if (error.code === 'ERR_NON_2XX_3XX_RESPONSE') { + log.warning(`${indent}[HTTP ERROR] ${apiId} at ${request.url} - Status: ${error.statusCode}`); + } else { + log.warning(`${indent}[FAILED] ${apiId} at ${request.url}`); + log.warning(`${indent} Error: ${error.message}`); + } } - } + }); - console.log(`Finished crawling APIs. Total unique collections found: ${allCollections.size}`); + // Seed the crawler with initial API requests + const initialRequests = urls.map((url, index) => ({ + url: url, + label: 'API_ROOT', + userData: { + apiId: `api-${index}`, + apiUrl: url + } + })); - // Convert Map to array and sort by URL - const sortedCollections = Array.from(allCollections.values()).sort((a, b) => { - const urlA = getSelfLink(a) || ''; - const urlB = getSelfLink(b) || ''; - return urlA.localeCompare(urlB); - }); + await crawler.addRequests(initialRequests); + + console.log(`\nStarting Crawlee crawler with ${initialRequests.length} APIs...\n`); + await crawler.run(); + + console.log('\nAPI Crawl Statistics:'); + console.log(` Total Requests: ${results.stats.totalRequests}`); + console.log(` Successful: ${results.stats.successfulRequests}`); + console.log(` Failed: ${results.stats.failedRequests}`); + console.log(` STAC Compliant: ${results.stats.stacCompliant}`); + console.log(` Non-Compliant: ${results.stats.nonCompliant}`); + console.log(` APIs Processed: ${results.stats.apisProcessed}`); + console.log(` Collections Found: ${results.stats.collectionsFound}\n`); - return sortedCollections; + return results; } + /** - * Helper function to crawl a single API recursively for collections/catalogs - * - * @param {string} url - URL to crawl - * @param {Map} collectionMap - Map to store found collections - * @param {Set} visited - Set of visited URLs to prevent loops - * @param {Object} config - Configuration object with timeout settings + * Handles API root endpoint - validates STAC, discovers collections endpoints + * @async + * @param {Object} context - Request handler context + * @param {Object} context.request - Crawlee request object + * @param {Object} context.json - Parsed JSON response + * @param {Object} context.crawler - Crawlee crawler instance + * @param {Object} context.log - Logger instance + * @param {string} context.indent - Indentation for logging + * @param {Object} context.results - Results object to store data */ -async function crawlSingleApi(url, collectionMap, visited = new Set(), config = {}) { - if (!url || visited.has(url)) return; - visited.add(url); - - const timeoutMs = config.timeout || 30000; - const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(10000, timeoutMs); - +async function handleApiRoot({ request, json, crawler, log, indent, results }) { + const apiId = request.userData?.apiId || 'unknown'; + const apiUrl = request.userData?.apiUrl || request.url; + + log.info(`${indent}Processing API: ${apiId} at ${apiUrl}`); + + // Validate with stac-js + let stacObj; try { - console.log(` Fetching: ${url}`); - const response = await withTimeout( - axios.get(url, { timeout: axiosTimeout }), - timeoutMs, - `Crawling API ${url}` - ); - const stacObj = create(response.data); - - // If it's a Collection, add it - if (stacObj.isCollection()) { - const selfUrl = stacObj.getAbsoluteUrl() || url; - console.log(` Found collection: ${stacObj.id} (${selfUrl})`); - collectionMap.set(selfUrl, stacObj.toJSON()); - } - - // If it has collections (API or Catalog), fetch them - // stac-js APICollection or Catalog might have getApiCollectionsLink or similar - // Or we manually check links. + stacObj = create(json, request.url); + results.stats.stacCompliant++; - // Check for /collections endpoint if it's a root catalog/API - if (stacObj.isCatalogLike()) { - // Try to get collections link - const collectionsLink = stacObj.getApiCollectionsLink(); - if (collectionsLink) { - console.log(` Found /collections endpoint link in ${url}`); - await fetchCollectionsFromLink(collectionsLink, collectionMap, visited, config); - } else { - // Fallback: check standard STAC API structure if not explicitly found - // Many STAC APIs have a /collections endpoint relative to root - // But stac-js might handle this via getApiCollectionsLink if rel="data" exists - - // Also check child links for nested catalogs - const childLinks = stacObj.getChildLinks(); - if (childLinks.length > 0) { - console.log(` Found ${childLinks.length} child links in ${url}, recursing...`); - for (const link of childLinks) { - const childUrl = link.getAbsoluteUrl(); - if (childUrl) { - await crawlSingleApi(childUrl, collectionMap, visited, config); - } - } - } + if (typeof stacObj.isCatalog === 'function' && stacObj.isCatalog()) { + log.info(`${indent}STAC Catalog/API validated: ${apiId}`); + } else if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { + log.info(`${indent}STAC Collection validated: ${apiId}`); + } + } catch (parseError) { + log.warning(`${indent}Non-compliant STAC API ${apiId} at ${request.url}`); + log.warning(`${indent}Error details: ${parseError.message}`); + throw new Error(`STAC validation failed: ${parseError.message}`); + } + + results.stats.apisProcessed++; + results.apis.push({ + id: apiId, + url: request.url, + stacType: stacObj.isCollection() ? 'collection' : 'catalog' + }); + + // If this is a STAC Collection directly, extract and store it + if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { + const collection = normalizeCollection(stacObj, results.collections.length); + results.collections.push(collection); + results.stats.collectionsFound++; + log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); + return; // Single collection, no need to look for more + } + + // Try to find collections endpoint using stac-js + let collectionsEndpoint = null; + + if (typeof stacObj.getApiCollectionsLink === 'function') { + const collectionsLink = stacObj.getApiCollectionsLink(); + if (collectionsLink) { + const linkUrl = typeof collectionsLink.getAbsoluteUrl === 'function' + ? collectionsLink.getAbsoluteUrl() + : collectionsLink.href; + if (linkUrl) { + collectionsEndpoint = linkUrl; + log.info(`${indent}Found collections link via stac-js: ${collectionsEndpoint}`); } } - - } catch (error) { - console.warn(`Failed to process ${url}:`, error.message); } -} - -/** - * Fetches collections from a collections endpoint (e.g., /collections) - * - * @param {Object} link - stac-js Link object - * @param {Map} collectionMap - Map to store collections - * @param {Set} visited - Visited set - * @param {Object} config - Configuration object with timeout settings - */ -async function fetchCollectionsFromLink(link, collectionMap, visited, config = {}) { - const url = link.getAbsoluteUrl(); - if (!url || visited.has(url)) return; - visited.add(url); - - const timeoutMs = config.timeout || 30000; - const axiosTimeout = timeoutMs === Infinity ? 0 : Math.min(10000, timeoutMs); - - try { - console.log(` Fetching collections from: ${url}`); - const response = await withTimeout( - axios.get(url, { timeout: axiosTimeout }), - timeoutMs, - `Fetching collections from ${url}` - ); - // create() handles CollectionCollection (API Collections response) - const stacObj = create(response.data); + + // Fallback: try common STAC API endpoints + if (!collectionsEndpoint) { + const baseUrl = request.url.endsWith('/') ? request.url.slice(0, -1) : request.url; + const endpoints = [ + `${baseUrl}/collections`, + `${apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl}/collections` + ]; - let collections = []; - - if (stacObj && typeof stacObj.getAll === 'function') { - // Use stac-js to get all collections from the response - collections = stacObj.getAll(); - } else if (response.data.collections && Array.isArray(response.data.collections)) { - // Fallback for manual parsing if stac-js didn't detect CollectionCollection - collections = response.data.collections.map(c => create(c)); - } else if (Array.isArray(response.data)) { - collections = response.data.map(c => create(c)); + for (const endpoint of endpoints) { + if (endpoint && endpoint !== collectionsEndpoint) { + log.info(`${indent}Trying collections endpoint: ${endpoint}`); + await crawler.addRequests([{ + url: endpoint, + label: 'API_COLLECTIONS', + userData: { + apiId: apiId, + apiUrl: apiUrl + } + }]); + } } + } else { + // Use the discovered collections endpoint + await crawler.addRequests([{ + url: collectionsEndpoint, + label: 'API_COLLECTIONS', + userData: { + apiId: apiId, + apiUrl: apiUrl + } + }]); + } + + // Also check for child links (nested catalogs) + if (typeof stacObj.getChildLinks === 'function') { + const childLinks = stacObj.getChildLinks(); - console.log(` Retrieved ${collections.length} collections from ${url}`); - - for (const colStac of collections) { - if (colStac && typeof colStac.isCollection === 'function' && colStac.isCollection()) { - const selfUrl = colStac.getAbsoluteUrl() || url; - collectionMap.set(selfUrl, colStac.toJSON()); + if (childLinks.length > 0) { + log.info(`${indent}Found ${childLinks.length} child catalog links`); + + const childRequests = childLinks + .map((link, idx) => { + let childUrl; + try { + childUrl = typeof link.getAbsoluteUrl === 'function' + ? link.getAbsoluteUrl() + : link.href; + } catch (err) { + log.warning(`${indent}Error getting URL for link ${idx}: ${err.message}`); + return null; + } + + // Validate URL + if (!childUrl || typeof childUrl !== 'string' || !childUrl.startsWith('http')) { + return null; + } + + // Check if this is a collection link or child catalog + const rel = link.rel || ''; + const label = rel === 'child' || rel === 'item' ? 'API_ROOT' : 'API_COLLECTION'; + + return { + url: childUrl, + label: label, + userData: { + apiId: `${apiId}-child-${idx}`, + apiUrl: apiUrl, + parentId: apiId + } + }; + }) + .filter(Boolean); + + if (childRequests.length > 0) { + await crawler.addRequests(childRequests); + log.info(`${indent}Enqueued ${childRequests.length} child catalogs/collections`); } } - } catch (error) { - console.warn(`Failed to fetch collections from ${url}:`, error.message); } } /** - * Helper to get self link from a plain JSON object (since we store toJSON results) + * Handles individual API collection endpoint + * @async + * @param {Object} context - Request handler context + * @param {Object} context.request - Crawlee request object + * @param {Object} context.json - Parsed JSON response + * @param {Object} context.crawler - Crawlee crawler instance + * @param {Object} context.log - Logger instance + * @param {string} context.indent - Indentation for logging + * @param {Object} context.results - Results object to store data */ -function getSelfLink(stacJson) { - const selfLink = stacJson.links?.find(l => l.rel === 'self'); - return selfLink ? selfLink.href : null; +async function handleApiCollection({ request, json, crawler, log, indent, results }) { + const apiId = request.userData?.apiId || 'unknown'; + + // Validate with stac-js + let stacObj; + try { + stacObj = create(json, request.url); + + if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { + const collection = normalizeCollection(stacObj, results.collections.length); + results.collections.push(collection); + results.stats.collectionsFound++; + log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); + } else { + log.warning(`${indent}Expected collection but got: ${json.type || 'unknown type'}`); + } + } catch (parseError) { + log.warning(`${indent}Skipping non-compliant STAC collection at ${request.url}`); + } } export { diff --git a/crawler/index.js b/crawler/index.js index 703e016..a2cf14e 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -96,14 +96,14 @@ const crawler = async () => { console.log(`Found ${apiUrls.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); try { - const collections = await crawlApis(apisToProcess, true, config); - console.log(`\nFetched ${collections.length} collections from APIs (sorted by URL).`); + const apiResults = await crawlApis(apisToProcess, true, config); + const collections = apiResults.collections || []; + console.log(`\nFetched ${collections.length} collections from APIs.`); if (collections.length > 0) { console.log('First 3 collections found:'); collections.slice(0, 3).forEach(c => { - const selfLink = c.links?.find(l => l.rel === 'self')?.href; - console.log(` - ${c.id}: ${selfLink}`); + console.log(` - ${c.id}: ${c.title}`); }); // Save API collections to database From 410339401230c7189e3c413cfef1e0725ea3ec8d Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 3 Dec 2025 16:44:07 +0100 Subject: [PATCH 039/134] Update .gitignore and enhance catalog/collection database handling --- crawler/.gitignore | 2 +- crawler/db.js | 194 ++++++++++++++++++++++++++++++--------------- 2 files changed, 130 insertions(+), 66 deletions(-) diff --git a/crawler/.gitignore b/crawler/.gitignore index a2e04e4..5daf102 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -1,3 +1,3 @@ node_modules - +storage .env \ No newline at end of file diff --git a/crawler/db.js b/crawler/db.js index 8587c11..cd7b99e 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -40,24 +40,51 @@ async function insertOrUpdateCatalog(catalog) { await client.query('BEGIN'); // Insert or update catalog - const catalogQuery = ` - INSERT INTO catalog (stac_version, type, title, description, updated_at) - VALUES ($1, $2, $3, $4, now()) - ON CONFLICT ON CONSTRAINT catalog_pkey DO UPDATE SET - stac_version = EXCLUDED.stac_version, - type = EXCLUDED.type, - title = EXCLUDED.title, - description = EXCLUDED.description, - updated_at = now() - RETURNING id; - `; - const catalogResult = await client.query(catalogQuery, [ - catalog.stac_version || null, - catalog.type || 'Catalog', - catalog.title || catalog.id || null, - catalog.description || null - ]); - const catalogId = catalogResult.rows[0].id; + const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; + + // Handle both STAC format and STAC Index API format + const description = catalog.description || null; + const stacVersion = catalog.stac_version || catalog.stacVersion || null; + + // Check if catalog with this title already exists + const existingCatalog = await client.query( + 'SELECT id FROM catalog WHERE title = $1', + [catalogTitle] + ); + + let catalogId; + if (existingCatalog.rows.length > 0) { + // Update existing catalog + catalogId = existingCatalog.rows[0].id; + await client.query( + `UPDATE catalog SET + stac_version = $1, + type = $2, + description = $3, + updated_at = now() + WHERE id = $4`, + [ + stacVersion, + catalog.type || 'Catalog', + description, + catalogId + ] + ); + } else { + // Insert new catalog + const catalogResult = await client.query( + `INSERT INTO catalog (stac_version, type, title, description, updated_at) + VALUES ($1, $2, $3, $4, now()) + RETURNING id`, + [ + stacVersion, + catalog.type || 'Catalog', + catalogTitle, + description + ] + ); + catalogId = catalogResult.rows[0].id; + } // Insert links if (catalog.links && Array.isArray(catalog.links)) { @@ -71,20 +98,25 @@ async function insertOrUpdateCatalog(catalog) { } // Insert keywords - if (catalog.keywords && Array.isArray(catalog.keywords)) { - await insertKeywords(client, catalogId, catalog.keywords, 'catalog'); + // STAC Index API uses 'categories' instead of 'keywords' + const keywords = catalog.keywords || catalog.categories || []; + if (Array.isArray(keywords) && keywords.length > 0) { + await insertKeywords(client, catalogId, keywords, 'catalog'); } // Insert STAC extensions - if (catalog.stac_extensions && Array.isArray(catalog.stac_extensions)) { - await insertStacExtensions(client, catalogId, catalog.stac_extensions, 'catalog'); + const extensions = catalog.stac_extensions || catalog.stacExtensions || []; + if (Array.isArray(extensions) && extensions.length > 0) { + await insertStacExtensions(client, catalogId, extensions, 'catalog'); } // Update crawl log await client.query( - `INSERT INTO crawllog_catalog (catalog_id, last_crawled) - VALUES ($1, now()) - ON CONFLICT (catalog_id) DO UPDATE SET last_crawled = now()`, + 'DELETE FROM crawllog_catalog WHERE catalog_id = $1', + [catalogId] + ); + await client.query( + 'INSERT INTO crawllog_catalog (catalog_id, last_crawled) VALUES ($1, now())', [catalogId] ); @@ -131,42 +163,72 @@ async function insertOrUpdateCollection(collection) { } // Insert or update collection - const collectionQuery = ` - INSERT INTO collection ( - stac_version, type, title, description, license, - spatial_extend, temporal_extend_start, temporal_extend_end, - is_api, is_active, full_json, updated_at - ) - VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) - ON CONFLICT ON CONSTRAINT collection_pkey DO UPDATE SET - stac_version = EXCLUDED.stac_version, - type = EXCLUDED.type, - title = EXCLUDED.title, - description = EXCLUDED.description, - license = EXCLUDED.license, - spatial_extend = EXCLUDED.spatial_extend, - temporal_extend_start = EXCLUDED.temporal_extend_start, - temporal_extend_end = EXCLUDED.temporal_extend_end, - is_api = EXCLUDED.is_api, - is_active = EXCLUDED.is_active, - full_json = EXCLUDED.full_json, - updated_at = now() - RETURNING id; - `; - const collectionResult = await client.query(collectionQuery, [ - collection.stac_version || null, - collection.type || 'Collection', - collection.title || collection.id || null, - collection.description || null, - collection.license || null, - spatialExtend, - temporalStart, - temporalEnd, - false, // is_api - will be determined by crawler - true, // is_active - JSON.stringify(collection) - ]); - const collectionId = collectionResult.rows[0].id; + const collectionTitle = collection.title || collection.id || 'Unnamed Collection'; + + // Check if collection with this title already exists + const existingCollection = await client.query( + 'SELECT id FROM collection WHERE title = $1', + [collectionTitle] + ); + + let collectionId; + if (existingCollection.rows.length > 0) { + // Update existing collection + collectionId = existingCollection.rows[0].id; + await client.query( + `UPDATE collection SET + stac_version = $1, + type = $2, + description = $3, + license = $4, + spatial_extend = ST_GeomFromEWKT($5), + temporal_extend_start = $6, + temporal_extend_end = $7, + is_api = $8, + is_active = $9, + full_json = $10, + updated_at = now() + WHERE id = $11`, + [ + collection.stac_version || null, + collection.type || 'Collection', + collection.description || null, + collection.license || null, + spatialExtend, + temporalStart, + temporalEnd, + false, // is_api - will be determined by crawler + true, // is_active + JSON.stringify(collection), + collectionId + ] + ); + } else { + // Insert new collection + const collectionResult = await client.query( + `INSERT INTO collection ( + stac_version, type, title, description, license, + spatial_extend, temporal_extend_start, temporal_extend_end, + is_api, is_active, full_json, updated_at + ) + VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) + RETURNING id`, + [ + collection.stac_version || null, + collection.type || 'Collection', + collectionTitle, + collection.description || null, + collection.license || null, + spatialExtend, + temporalStart, + temporalEnd, + false, // is_api - will be determined by crawler + true, // is_active + JSON.stringify(collection) + ] + ); + collectionId = collectionResult.rows[0].id; + } // Insert summaries if (collection.summaries && typeof collection.summaries === 'object') { @@ -198,9 +260,11 @@ async function insertOrUpdateCollection(collection) { // Update crawl log await client.query( - `INSERT INTO crawllog_collection (collection_id, last_crawled) - VALUES ($1, now()) - ON CONFLICT (collection_id) DO UPDATE SET last_crawled = now()`, + 'DELETE FROM crawllog_collection WHERE collection_id = $1', + [collectionId] + ); + await client.query( + 'INSERT INTO crawllog_collection (collection_id, last_crawled) VALUES ($1, now())', [collectionId] ); @@ -249,7 +313,7 @@ async function insertKeywords(client, parentId, keywords, type) { */ async function insertStacExtensions(client, parentId, extensions, type) { await client.query( - `DELETE FROM ${type}_stac_extensions WHERE ${type}_id = $1`, + `DELETE FROM ${type}_stac_extension WHERE ${type}_id = $1`, [parentId] ); From 6429285e3dadc08eee8b1f99fe429205a6fb64b6 Mon Sep 17 00:00:00 2001 From: Jakob Date: Wed, 3 Dec 2025 16:47:54 +0100 Subject: [PATCH 040/134] psst --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index c4d6686..e0d6ba7 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,9 @@ # PostgreSQL connection parameters PGHOST=atlas.example.org PGPORT=5432 -PGUSER=stac_user -PGPASSWORD=stac_password -PGDATABASE=stac_db +PGUSER=example_user +PGPASSWORD=example_password +PGDATABASE=example_db # Crawler Configuration # Crawl mode: 'catalogs', 'apis', or 'both' From b82b1807097dbf573613d0a4a1e5cd95fdaff343 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 3 Dec 2025 16:44:07 +0100 Subject: [PATCH 041/134] Update .gitignore and enhance catalog/collection database handling --- crawler/.gitignore | 3 +- crawler/utils/db.js | 194 +++++++++++++++++++++++++++++--------------- 2 files changed, 130 insertions(+), 67 deletions(-) diff --git a/crawler/.gitignore b/crawler/.gitignore index 873a820..02f3661 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -1,4 +1,3 @@ node_modules - -.env storage +.env diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 8587c11..cd7b99e 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -40,24 +40,51 @@ async function insertOrUpdateCatalog(catalog) { await client.query('BEGIN'); // Insert or update catalog - const catalogQuery = ` - INSERT INTO catalog (stac_version, type, title, description, updated_at) - VALUES ($1, $2, $3, $4, now()) - ON CONFLICT ON CONSTRAINT catalog_pkey DO UPDATE SET - stac_version = EXCLUDED.stac_version, - type = EXCLUDED.type, - title = EXCLUDED.title, - description = EXCLUDED.description, - updated_at = now() - RETURNING id; - `; - const catalogResult = await client.query(catalogQuery, [ - catalog.stac_version || null, - catalog.type || 'Catalog', - catalog.title || catalog.id || null, - catalog.description || null - ]); - const catalogId = catalogResult.rows[0].id; + const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; + + // Handle both STAC format and STAC Index API format + const description = catalog.description || null; + const stacVersion = catalog.stac_version || catalog.stacVersion || null; + + // Check if catalog with this title already exists + const existingCatalog = await client.query( + 'SELECT id FROM catalog WHERE title = $1', + [catalogTitle] + ); + + let catalogId; + if (existingCatalog.rows.length > 0) { + // Update existing catalog + catalogId = existingCatalog.rows[0].id; + await client.query( + `UPDATE catalog SET + stac_version = $1, + type = $2, + description = $3, + updated_at = now() + WHERE id = $4`, + [ + stacVersion, + catalog.type || 'Catalog', + description, + catalogId + ] + ); + } else { + // Insert new catalog + const catalogResult = await client.query( + `INSERT INTO catalog (stac_version, type, title, description, updated_at) + VALUES ($1, $2, $3, $4, now()) + RETURNING id`, + [ + stacVersion, + catalog.type || 'Catalog', + catalogTitle, + description + ] + ); + catalogId = catalogResult.rows[0].id; + } // Insert links if (catalog.links && Array.isArray(catalog.links)) { @@ -71,20 +98,25 @@ async function insertOrUpdateCatalog(catalog) { } // Insert keywords - if (catalog.keywords && Array.isArray(catalog.keywords)) { - await insertKeywords(client, catalogId, catalog.keywords, 'catalog'); + // STAC Index API uses 'categories' instead of 'keywords' + const keywords = catalog.keywords || catalog.categories || []; + if (Array.isArray(keywords) && keywords.length > 0) { + await insertKeywords(client, catalogId, keywords, 'catalog'); } // Insert STAC extensions - if (catalog.stac_extensions && Array.isArray(catalog.stac_extensions)) { - await insertStacExtensions(client, catalogId, catalog.stac_extensions, 'catalog'); + const extensions = catalog.stac_extensions || catalog.stacExtensions || []; + if (Array.isArray(extensions) && extensions.length > 0) { + await insertStacExtensions(client, catalogId, extensions, 'catalog'); } // Update crawl log await client.query( - `INSERT INTO crawllog_catalog (catalog_id, last_crawled) - VALUES ($1, now()) - ON CONFLICT (catalog_id) DO UPDATE SET last_crawled = now()`, + 'DELETE FROM crawllog_catalog WHERE catalog_id = $1', + [catalogId] + ); + await client.query( + 'INSERT INTO crawllog_catalog (catalog_id, last_crawled) VALUES ($1, now())', [catalogId] ); @@ -131,42 +163,72 @@ async function insertOrUpdateCollection(collection) { } // Insert or update collection - const collectionQuery = ` - INSERT INTO collection ( - stac_version, type, title, description, license, - spatial_extend, temporal_extend_start, temporal_extend_end, - is_api, is_active, full_json, updated_at - ) - VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) - ON CONFLICT ON CONSTRAINT collection_pkey DO UPDATE SET - stac_version = EXCLUDED.stac_version, - type = EXCLUDED.type, - title = EXCLUDED.title, - description = EXCLUDED.description, - license = EXCLUDED.license, - spatial_extend = EXCLUDED.spatial_extend, - temporal_extend_start = EXCLUDED.temporal_extend_start, - temporal_extend_end = EXCLUDED.temporal_extend_end, - is_api = EXCLUDED.is_api, - is_active = EXCLUDED.is_active, - full_json = EXCLUDED.full_json, - updated_at = now() - RETURNING id; - `; - const collectionResult = await client.query(collectionQuery, [ - collection.stac_version || null, - collection.type || 'Collection', - collection.title || collection.id || null, - collection.description || null, - collection.license || null, - spatialExtend, - temporalStart, - temporalEnd, - false, // is_api - will be determined by crawler - true, // is_active - JSON.stringify(collection) - ]); - const collectionId = collectionResult.rows[0].id; + const collectionTitle = collection.title || collection.id || 'Unnamed Collection'; + + // Check if collection with this title already exists + const existingCollection = await client.query( + 'SELECT id FROM collection WHERE title = $1', + [collectionTitle] + ); + + let collectionId; + if (existingCollection.rows.length > 0) { + // Update existing collection + collectionId = existingCollection.rows[0].id; + await client.query( + `UPDATE collection SET + stac_version = $1, + type = $2, + description = $3, + license = $4, + spatial_extend = ST_GeomFromEWKT($5), + temporal_extend_start = $6, + temporal_extend_end = $7, + is_api = $8, + is_active = $9, + full_json = $10, + updated_at = now() + WHERE id = $11`, + [ + collection.stac_version || null, + collection.type || 'Collection', + collection.description || null, + collection.license || null, + spatialExtend, + temporalStart, + temporalEnd, + false, // is_api - will be determined by crawler + true, // is_active + JSON.stringify(collection), + collectionId + ] + ); + } else { + // Insert new collection + const collectionResult = await client.query( + `INSERT INTO collection ( + stac_version, type, title, description, license, + spatial_extend, temporal_extend_start, temporal_extend_end, + is_api, is_active, full_json, updated_at + ) + VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) + RETURNING id`, + [ + collection.stac_version || null, + collection.type || 'Collection', + collectionTitle, + collection.description || null, + collection.license || null, + spatialExtend, + temporalStart, + temporalEnd, + false, // is_api - will be determined by crawler + true, // is_active + JSON.stringify(collection) + ] + ); + collectionId = collectionResult.rows[0].id; + } // Insert summaries if (collection.summaries && typeof collection.summaries === 'object') { @@ -198,9 +260,11 @@ async function insertOrUpdateCollection(collection) { // Update crawl log await client.query( - `INSERT INTO crawllog_collection (collection_id, last_crawled) - VALUES ($1, now()) - ON CONFLICT (collection_id) DO UPDATE SET last_crawled = now()`, + 'DELETE FROM crawllog_collection WHERE collection_id = $1', + [collectionId] + ); + await client.query( + 'INSERT INTO crawllog_collection (collection_id, last_crawled) VALUES ($1, now())', [collectionId] ); @@ -249,7 +313,7 @@ async function insertKeywords(client, parentId, keywords, type) { */ async function insertStacExtensions(client, parentId, extensions, type) { await client.query( - `DELETE FROM ${type}_stac_extensions WHERE ${type}_id = $1`, + `DELETE FROM ${type}_stac_extension WHERE ${type}_id = $1`, [parentId] ); From a4ed54f7973d1e9bcecb29571e9d663a7aa9f30d Mon Sep 17 00:00:00 2001 From: SonkeHoffmann Date: Thu, 4 Dec 2025 13:16:40 +0100 Subject: [PATCH 042/134] added `stac_id` for collections --- db/init/03_tables_collections.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 0fd6197..e5e2c0f 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -6,6 +6,7 @@ CREATE TABLE collection ( id SERIAL PRIMARY KEY, stac_version TEXT, + stac_id INTEGER, type TEXT, title TEXT, description TEXT, From 0bd589ed2efc168ab5f0d02a20c892b3db796ee1 Mon Sep 17 00:00:00 2001 From: SonkeHoffmann Date: Thu, 4 Dec 2025 13:20:38 +0100 Subject: [PATCH 043/134] all IDs are now written in the newer PostgrSQL standart: ```SQL id SERIAL PRIMARY KEY, ``` changed to ```SQL id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ``` --- db/init/02_tables_catalog.sql | 10 +++++----- db/init/03_tables_collections.sql | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index 9078d24..a44355f 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -3,7 +3,7 @@ -- Main catalog table: Stores STAC catalog metadata including version, type, title, and description -- Each catalog represents a STAC catalog endpoint that has been discovered and indexed CREATE TABLE catalog ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, type TEXT, title TEXT, @@ -15,7 +15,7 @@ CREATE TABLE catalog ( -- Catalog links table: Stores related links for catalogs (e.g., self, root, child, item links) -- Links define the navigation structure between STAC resources CREATE TABLE catalog_links ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, rel TEXT, href TEXT, @@ -26,21 +26,21 @@ CREATE TABLE catalog_links ( -- Keywords lookup table: Stores unique searchable keywords -- Used by both catalogs and collections for categorization and search CREATE TABLE keywords ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, keyword TEXT UNIQUE ); -- STAC extensions lookup table: Stores unique STAC extension identifiers -- Extensions provide additional standardized fields beyond core STAC spec CREATE TABLE stac_extensions ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_extension TEXT UNIQUE ); -- Crawl log for catalogs: Tracks when each catalog was last crawled for updates -- Used to schedule re-crawling and maintain freshness of catalog data CREATE TABLE crawllog_catalog ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, last_crawled TIMESTAMP ); diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index e5e2c0f..3d0fff0 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -4,7 +4,7 @@ -- Collections group related STAC items and define their common properties -- full_json: Complete JSONB representation the whole collection CREATE TABLE collection ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, stac_id INTEGER, type TEXT, @@ -28,7 +28,7 @@ CREATE TABLE collection ( -- represent ranges (min/max), sets of values, or JSON schemas -- Used to describe the range of values found in collection items CREATE TABLE collection_summaries ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, name TEXT, kind TEXT, @@ -41,14 +41,14 @@ CREATE TABLE collection_summaries ( -- Providers lookup table: Stores unique data provider names -- Providers are organizations or entities that produce, host, or process the data CREATE TABLE providers ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, provider TEXT UNIQUE ); -- Assets table: Stores downloadable assets (data files, thumbnails, metadata files, etc.) -- Assets are the actual data products or resources associated with collections CREATE TABLE assets ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT, href TEXT, type TEXT, @@ -60,7 +60,7 @@ CREATE TABLE assets ( -- Used to schedule re-crawling and maintain freshness of collection data -- (same usecase as the crawllog for catalogs) CREATE TABLE crawllog_collection ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, last_crawled TIMESTAMP ); From 49dd72001b2edb88f6fc89d94fc615212967edb7 Mon Sep 17 00:00:00 2001 From: SonkeHoffmann Date: Thu, 4 Dec 2025 13:32:36 +0100 Subject: [PATCH 044/134] changed `extend` to `extent`. --- db/init/03_tables_collections.sql | 6 +++--- db/init/05_indexes.sql | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 3d0fff0..733bb08 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -14,9 +14,9 @@ CREATE TABLE collection ( created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now(), - spatial_extend GEOMETRY(POLYGON, 4326), - temporal_extend_start TIMESTAMP, - temporal_extend_end TIMESTAMP, + spatial_extent GEOMETRY(POLYGON, 4326), + temporal_extent_start TIMESTAMP, + temporal_extent_end TIMESTAMP, is_api BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, diff --git a/db/init/05_indexes.sql b/db/init/05_indexes.sql index 76a750f..9349b88 100644 --- a/db/init/05_indexes.sql +++ b/db/init/05_indexes.sql @@ -25,10 +25,10 @@ CREATE INDEX idx_crawllog_catalog_last ON crawllog_catalog (last_crawled); -- Basic collection lookups CREATE INDEX idx_collection_title ON collection (title); -CREATE INDEX idx_collection_temp ON collection (temporal_extend_start, temporal_extend_end); +CREATE INDEX idx_collection_temp ON collection (temporal_extent_start, temporal_extent_end); CREATE INDEX idx_collection_active ON collection (is_active); -CREATE INDEX idx_collection_spatial ON collection USING GIST (spatial_extend); +CREATE INDEX idx_collection_spatial ON collection USING GIST (spatial_extent); CREATE INDEX idx_collection_fulltext ON collection USING GIN (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))); From 5f017a92ca44d78e35aef81eddcfb4bf67bf750b Mon Sep 17 00:00:00 2001 From: Jakob <70800454+vertrox78@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:29:08 +0100 Subject: [PATCH 045/134] Scheduling and time logic for recrawling (#190) * added crawling time statistics * #189, added 7 day scheduling, created time util for easier managment/readability --- crawler/index.js | 55 ++++++++++++++-- crawler/scheduler.js | 144 ++++++++++++++++++++++++++++++++++++++++++ crawler/utils/time.js | 47 ++++++++++++++ 3 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 crawler/scheduler.js create mode 100644 crawler/utils/time.js diff --git a/crawler/index.js b/crawler/index.js index c2e60de..ada7ed5 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -20,15 +20,26 @@ const targetUrl = 'https://www.stacindex.org/api/catalogs'; * Fetches catalog data from the STAC Index API and processes it * @async * @function crawler - * @returns {Promise} + * @returns {Promise} Returns statistics about the crawl including success status and runtime */ -const crawler = async () => { +export const crawler = async () => { + // Start the timer + const startTime = Date.now(); + let dbError = false; + let crawlError = false; + try { // Load configuration const config = getConfig(); // Initialize database connection - await db.initDb(); + try { + await db.initDb(); + } catch (err) { + console.error(`\nDatabase initialization failed: ${err.message}`); + dbError = true; + throw err; + } // Display configuration console.log('\n=== STAC Crawler Configuration ==='); @@ -136,11 +147,43 @@ const crawler = async () => { } catch (error) { console.error(`Error fetching ${targetUrl}: ${error.message}`); + if (!dbError) { + crawlError = true; + } } finally { // Close database connection - await db.close(); - console.log('\nDatabase connection closed.'); + if (!dbError) { + try { + await db.close(); + console.log('\nDatabase connection closed.'); + } catch (err) { + console.error(`Error closing database: ${err.message}`); + } + } + + // Display total running time + const endTime = Date.now(); + const elapsedTime = endTime - startTime; + + console.log('\n=== Crawler Time Statistics ==='); + console.log(`Total Running Time: ${formatDuration(elapsedTime)}`); + console.log(`Total Running Time (ms): ${elapsedTime}ms`); + console.log(`Status: ${dbError ? 'Database Error' : crawlError ? 'Crawl Error' : 'Success'}`); + console.log('================================\n'); + + // Return statistics + return { + success: !dbError && !crawlError, + dbError, + crawlError, + elapsedTime, + startTime, + endTime + }; } }; -crawler(); \ No newline at end of file +// Run crawler if this file is executed directly +if (import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`) { + crawler(); +} \ No newline at end of file diff --git a/crawler/scheduler.js b/crawler/scheduler.js new file mode 100644 index 0000000..8e7a92f --- /dev/null +++ b/crawler/scheduler.js @@ -0,0 +1,144 @@ +/** + * @fileoverview Scheduler for running STAC crawler every 7 days + * Waits for the actual crawl time to complete before scheduling next run + * Skips scheduling if database errors occur + * @module scheduler + */ + +import { crawler } from './index.js'; +import { formatDuration } from './utils/time.js'; + +/** + * Configuration + */ +const DAYS_INTERVAL = 7; // Run every 7 days +const RUN_ON_STARTUP = true; // Set to false to wait 7 days before first run +const RETRY_ON_CRAWL_ERROR = true; // Retry if crawl fails but DB is ok +const RETRY_DELAY_HOURS = 2; // Hours to wait before retry on crawl error + +/** + * Runs the crawler and returns statistics + * @async + * @function runCrawler + * @returns {Promise} Crawler statistics + */ +const runCrawler = async () => { + const timestamp = new Date().toISOString(); + console.log(`\n${'='.repeat(60)}`); + console.log(`[${timestamp}] Starting crawler run...`); + console.log('='.repeat(60)); + + try { + const stats = await crawler(); + console.log(`\n[${new Date().toISOString()}] Crawler finished`); + return stats; + } catch (error) { + console.error(`\n[${new Date().toISOString()}] Crawler encountered an error:`, error.message); + return { + success: false, + dbError: true, + crawlError: true, + elapsedTime: 0, + error: error.message + }; + } +}; + +/** + * Schedule next run based on the crawl duration + * @param {number} crawlDuration - Duration of the last crawl in milliseconds + */ +const scheduleNextRun = (crawlDuration = 0, isRetry = false) => { + let delayMs; + let intervalDescription; + + if (isRetry) { + delayMs = RETRY_DELAY_HOURS * 60 * 60 * 1000; + intervalDescription = `${RETRY_DELAY_HOURS} hour(s) (retry)`; + } else { + // Schedule next run: 7 days minus the time the crawl took + const intervalMs = DAYS_INTERVAL * 24 * 60 * 60 * 1000; + delayMs = Math.max(intervalMs - crawlDuration, 0); + intervalDescription = `${DAYS_INTERVAL} days`; + } + + const nextRun = new Date(Date.now() + delayMs); + + console.log(`\nNext crawl scheduled for: ${nextRun.toLocaleString()}`); + console.log(` Interval: ${intervalDescription}`); + if (!isRetry && crawlDuration > 0) { + console.log(` Adjusted for crawl time: -${formatDuration(crawlDuration)}`); + } + console.log(` Wait time: ${formatDuration(delayMs)}\n`); + + setTimeout(async () => { + const stats = await runCrawler(); + + if (stats.dbError) { + console.error('\nDATABASE ERROR DETECTED - Scheduler stopped to prevent data issues.'); + console.error(' Please fix the database connection and restart the scheduler.\n'); + process.exit(1); + } else if (stats.crawlError && RETRY_ON_CRAWL_ERROR) { + console.warn('\nCrawl error detected but database is OK - scheduling retry...'); + scheduleNextRun(0, true); // Retry without adjusting for crawl time + } else if (stats.success) { + console.log('\nCrawl completed successfully - scheduling next run...'); + scheduleNextRun(stats.elapsedTime, false); // Schedule next run with time adjustment + } else { + console.error('\nCrawl failed - scheduler stopped.\n'); + process.exit(1); + } + }, delayMs); +}; + +/** + * Start the scheduler + */ +const startScheduler = async () => { + console.log('\n╔═══════════════════════════════════════════════════════════╗'); + console.log('║ STAC Crawler Scheduler Started ║'); + console.log('╚═══════════════════════════════════════════════════════════╝\n'); + console.log(`Interval: Every ${DAYS_INTERVAL} days`); + console.log(`Run on startup: ${RUN_ON_STARTUP}`); + console.log(`Retry on crawl error: ${RETRY_ON_CRAWL_ERROR}`); + if (RETRY_ON_CRAWL_ERROR) { + console.log(` Retry delay: ${RETRY_DELAY_HOURS} hour(s)`); + } + console.log(`Current time: ${new Date().toLocaleString()}\n`); + + // Run immediately if configured + if (RUN_ON_STARTUP) { + console.log('Running initial crawl on startup...'); + const stats = await runCrawler(); + + if (stats.dbError) { + console.error('\nDATABASE ERROR - Cannot start scheduler.'); + console.error(' Please fix the database connection and try again.\n'); + process.exit(1); + } else if (stats.crawlError && RETRY_ON_CRAWL_ERROR) { + console.warn('\nInitial crawl had errors but database is OK - scheduling retry...'); + scheduleNextRun(0, true); + } else if (stats.success) { + console.log('\nInitial crawl completed - scheduling next run...'); + scheduleNextRun(stats.elapsedTime, false); + } else { + console.error('\nInitial crawl failed - exiting.\n'); + process.exit(1); + } + } else { + // Schedule first run without running now + scheduleNextRun(0, false); + } + + console.log('Scheduler is running. Press Ctrl+C to stop.\n'); + + // Graceful shutdown + process.on('SIGINT', () => { + console.log('\n\nStopping scheduler...'); + console.log('Scheduler stopped. See ya later Aligator!\n'); + process.exit(0); + }); +}; + +// Start the scheduler +startScheduler(); diff --git a/crawler/utils/time.js b/crawler/utils/time.js new file mode 100644 index 0000000..b1fe747 --- /dev/null +++ b/crawler/utils/time.js @@ -0,0 +1,47 @@ +/** + * @fileoverview Time formatting utilities for STAC crawler + * @module utils/time + */ + +/** + * Format milliseconds into a human-readable duration string + * @param {number} ms - Duration in milliseconds + * @returns {string} Formatted duration string (e.g., "2h 30m 15s" or "45s") + */ +export function formatDuration(ms) { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + const displaySeconds = seconds % 60; + const displayMinutes = minutes % 60; + const displayHours = hours % 24; + + if (days > 0) { + return `${days}d ${displayHours}h ${displayMinutes}m`; + } else if (hours > 0) { + return `${displayHours}h ${displayMinutes}m ${displaySeconds}s`; + } else if (minutes > 0) { + return `${displayMinutes}m ${displaySeconds}s`; + } else { + return `${displaySeconds}s`; + } +} + +/** + * Get formatted timestamp for logging + * @returns {string} ISO formatted timestamp + */ +export function getTimestamp() { + return new Date().toISOString(); +} + +/** + * Get localized date/time string for display + * @param {Date} date - Date object (defaults to now) + * @returns {string} Localized date/time string + */ +export function getLocalizedTime(date = new Date()) { + return date.toLocaleString(); +} From 0b396e3138d8ee4390d967f6916ce2d940207443 Mon Sep 17 00:00:00 2001 From: RobinGummels Date: Sun, 14 Dec 2025 11:33:40 +0100 Subject: [PATCH 046/134] Changed language used in `./api/README.md` from german to english. I wanted to thsi anyway at some point, but this is now more like a Test-commit to see if the CI/CD Pipeline triggers... --- api/README.md | 138 +++++++++++++++++++++++++------------------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/api/README.md b/api/README.md index aff0ae1..96d89e2 100644 --- a/api/README.md +++ b/api/README.md @@ -1,88 +1,88 @@ # STAC Atlas API -STAC-konforme API für die Verwaltung und Bereitstellung von STAC Collection Metadaten. +STAC-compliant API for managing and serving STAC Collection metadata. -## 🚀 Schnellstart +## 🚀 Quick Start -### Voraussetzungen +### Prerequisites - Node.js >= 22.0.0 -- PostgreSQL mit PostGIS Extension -- npm oder yarn +- PostgreSQL with PostGIS extension +- npm or yarn ### Installation ```bash -# Dependencies installieren +# Install dependencies npm install -# Umgebungsvariablen konfigurieren +# Configure environment variables cp .env.example .env -# .env bearbeiten und DATABASE_URL etc. anpassen +# Edit .env and set DATABASE_URL etc. ``` -### Entwicklung +### Development ```bash -# Development Server mit Auto-Reload starten +# Start development server with auto-reload npm run dev -# Oder Production Server +# Or start production server npm start ``` -Die API läuft dann auf `http://localhost:3000` +The API will be available at `http://localhost:3000`. ### Tests ```bash -# Alle Tests ausführen +# Run all tests npm test -# Tests im Watch-Mode +# Run tests in watch mode npm run test:watch ``` -### Code-Qualität +### Code Quality ```bash # Linting npm run lint -# Automatisches Fixing +# Automatic fixing npm run lint:fix -# Code formatieren +# Code formatting npm run format ``` ## CI/CD Pipeline -This Project uses GitHub Actions for Continous Integration: +This project uses GitHub Actions for Continuous Integration: -- **Automatic Tests** at every push and pull request -- **Branch Protection** prevent merges if tests failed -- **Code Quality Checks** (ESLint, Tests, Build-Validation) -- **Test Coverage Reports** as artifacts +- **Automated tests** on every push and pull request +- **Branch protection** prevents merges if tests fail +- **Code quality checks** (ESLint, tests, build validation) +- **Test coverage reports** as artifacts **Status:** ![CI Status](https://github.com/SpatioCore/STAC-Atlas/workflows/API%20CI%2FCD%20Pipeline/badge.svg?branch=dev-api) -## 📋 API Endpunkte +## 📋 API Endpoints ### Core Endpoints -| Methode | Endpoint | Beschreibung | +| Method | Endpoint | Description | |---------|----------|--------------| -| GET | `/` | Landing Page (STAC Catalog Root) | -| GET | `/conformance` | Conformance Classes | -| GET | `/collections` | Liste aller Collections (mit Filterung) | -| POST | `/collections` | Collection Search mit CQL2 | -| GET | `/collections/:id` | Einzelne Collection abrufen | -| GET | `/collections-queryables` | Queryable Properties Schema | +| GET | `/` | Landing page (STAC catalog root) | +| GET | `/conformance` | Conformance classes | +| GET | `/collections` | List all collections (with filtering) | +| POST | `/collections` | Collection search with CQL2 | +| GET | `/collections/:id` | Retrieve a single collection | +| GET | `/collections-queryables` | Queryable properties schema | ### Query Parameters (GET /collections) -Die Collection Search API unterstützt folgende Query-Parameter: +The collection search API supports the following query parameters: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -93,7 +93,7 @@ Die Collection Search API unterstützt folgende Query-Parameter: | `sortby` | String | No | Sort by field: `+/-field` (title, id, license, created, updated) | | `token` | Integer | No | Pagination token (offset, default: 0) | -**Beispiele:** +**Examples:** ```bash # Free-text search GET /collections?q=sentinel @@ -105,45 +105,45 @@ GET /collections?bbox=-10,40,10,50&datetime=2020-01-01/2021-12-31 GET /collections?limit=20&sortby=-created&token=2 ``` -📖 **Detaillierte Dokumentation:** Siehe [docs/collection-search-parameters.md](docs/collection-search-parameters.md) +📖 **Detailed documentation:** See [docs/collection-search-parameters.md](docs/collection-search-parameters.md) -### API Dokumentation +### API Documentation -- **Swagger UI**: `http://localhost:3000/api-docs` (wenn `docs/openapi.yaml` existiert) +- **Swagger UI**: `http://localhost:3000/api-docs` (if `docs/openapi.yaml` exists) - **OpenAPI Spec**: `docs/openapi.yaml` -## 🏗️ Projektstruktur +## 🏗️ Project Structure ``` api/ ├── bin/ -│ └── www # Server-Startskript +│ └── www # Server start script ├── config/ -│ └── conformanceURIS.js # STAC Conformance URIs +│ └── conformanceURIS.js # STAC conformance URIs ├── data/ │ └── collections.js # Test collections ├── docs/ -│ └── collection-search-parameters.md # Query Parameter Dokumentation +│ └── collection-search-parameters.md # Query parameter documentation ├── middleware/ -│ └── validateCollectionSearch.js # Query Parameter Validation +│ └── validateCollectionSearch.js # Query parameter validation ├── routes/ -│ ├── index.js # Landing Page (/) -│ ├── conformance.js # Conformance Classes -│ ├── collections.js # Collections Endpoints -│ └── queryables.js # Queryables Schema +│ ├── index.js # Landing page (/) +│ ├── conformance.js # Conformance classes +│ ├── collections.js # Collections endpoints +│ └── queryables.js # Queryables schema ├── validators/ -│ └── collectionSearchParams.js # Parameter Validators +│ └── collectionSearchParams.js # Parameter validators ├── __tests__/ -│ └── api.test.js # API Tests +│ └── api.test.js # API tests ├── app.js # Express App Setup ├── package.json -├── .env.example # Beispiel-Umgebungsvariablen +├── .env.example # Example environment variables └── README.md ``` -## 🔧 Konfiguration +## 🔧 Configuration -Alle Konfigurationen erfolgen über Umgebungsvariablen (`.env`): +All configuration is managed via environment variables (`.env`): ```env PORT=3000 @@ -154,46 +154,46 @@ CORS_ORIGIN=* ## 🧪 STAC Conformance -Diese API implementiert: +This API implements: - ✅ STAC API Core (v1.0.0) - ✅ OGC API Features Core - ✅ STAC Collections - ✅ Collection Search Extension -- 🚧 CQL2 Basic Filtering (in Entwicklung) -- 🚧 CQL2 Advanced Operators (in Entwicklung) +- 🚧 CQL2 Basic Filtering (in development) +- 🚧 CQL2 Advanced Operators (in development) -## 📦 Nächste Schritte +## 📦 Next Steps ### TODO -- [ ] Datenbank-Integration (PostgreSQL + PostGIS) +- [ ] Database integration (PostgreSQL + PostGIS) - [ ] Implement q (full-text search with TSVector) - [ ] Implement bbox (PostGIS spatial queries) - [ ] Implement datetime (temporal overlap queries) - [ ] Implement sortby (ORDER BY in SQL) -- [ ] CQL2-Parser Integration (cql2-rs via WASM) -- [ ] Controller-Layer implementieren -- [ ] Service-Layer für Business Logic -- [ ] OpenAPI Dokumentation vervollständigen -- [ ] Erweiterte Tests (Integration, E2E) +- [ ] CQL2 parser integration (cql2-rs via WASM) +- [ ] Implement controller layer +- [ ] Service layer for business logic +- [ ] Complete OpenAPI documentation +- [ ] Advanced tests (integration, E2E) - [ ] Unit tests for validators - [ ] Integration tests for filtered queries -- [ ] Docker Setup -- [ ] CI/CD Pipeline +- [ ] Docker setup +- [ ] CI/CD pipeline -### Implementierungsplan (siehe bid.md) +### Implementation Plan (see bid.md) -1. ✅ **AP-01**: Projekt-Skeleton & Infrastruktur -2. ✅ **AP-02**: Query Parameter Validation (q, bbox, datetime, limit, sortby, token) -3. 🚧 **AP-03**: STAC-Core Endpunkte (Basis vorhanden) -4. 🚧 **AP-04**: Collection Search – Filter-Implementierung (DB-Integration pending) -5. ⏳ **AP-05**: CQL2-Filtering Integration +1. ✅ **AP-01**: Project skeleton & infrastructure +2. ✅ **AP-02**: Query parameter validation (q, bbox, datetime, limit, sortby, token) +3. 🚧 **AP-03**: STAC core endpoints (baseline implemented) +4. 🚧 **AP-04**: Collection search – filter implementation (DB integration pending) +5. ⏳ **AP-05**: CQL2 filtering integration -## 📄 Lizenz +## 📄 License Apache-2.0 ## 👥 Team -STAC Atlas API Team - Robin (Teamleiter), Jonas, George, Vincent +STAC Atlas API Team — Robin (Team lead), Jonas, George, Vincent From eb6d088bd0a9d5725c25e57735430a67147ad6c6 Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:14:36 +0100 Subject: [PATCH 047/134] fix: time utility was missing and improved main module check logic (#193) * fix: time utility was missing and improved main module check logic * chore: add stac-network to crawler and db services in docker-compose files * chore: update .gitignore and .env.example for crawler configuration --- .gitignore | 1 + crawler/.dockerignore | 1 - crawler/.env.example | 19 +++++-------------- crawler/.gitignore | 2 +- crawler/docker-compose.yml | 6 ++++++ crawler/index.js | 4 +++- db/docker-compose.yml | 8 +++++++- 7 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62c8935 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ \ No newline at end of file diff --git a/crawler/.dockerignore b/crawler/.dockerignore index f94907b..5bf1e41 100644 --- a/crawler/.dockerignore +++ b/crawler/.dockerignore @@ -2,5 +2,4 @@ node_modules npm-debug.log .git .gitignore -.env *.md diff --git a/crawler/.env.example b/crawler/.env.example index 013b893..7c52159 100644 --- a/crawler/.env.example +++ b/crawler/.env.example @@ -1,17 +1,8 @@ # STAC Crawler Configuration # Copy this file to .env and adjust values as needed -# Crawl mode: 'catalogs', 'apis', or 'both' -CRAWL_MODE=both - -# Maximum number of catalogs to crawl -MAX_CATALOGS=10 - -# Maximum number of APIs to crawl -MAX_APIS=5 - -# Timeout for each crawl operation in milliseconds (30 seconds = 30000ms) -TIMEOUT_MS=30000 - -# Maximum recursion depth for nested catalogs -MAX_DEPTH=3 +PGHOST=example_db_URL +PGPORT=5432 +PGUSER=example_user +PGPASSWORD=example_password +PGDATABASE=example_db diff --git a/crawler/.gitignore b/crawler/.gitignore index 02f3661..5daf102 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -1,3 +1,3 @@ node_modules storage -.env +.env \ No newline at end of file diff --git a/crawler/docker-compose.yml b/crawler/docker-compose.yml index 797cb5f..37ffa14 100644 --- a/crawler/docker-compose.yml +++ b/crawler/docker-compose.yml @@ -7,6 +7,12 @@ services: restart: unless-stopped environment: - NODE_ENV=production + networks: + - stac-network # Optional: Add volume to persist data # volumes: # - ./data:/app/data + +networks: + stac-network: + external: true diff --git a/crawler/index.js b/crawler/index.js index ada7ed5..32cbabd 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -9,6 +9,7 @@ import { crawlCatalogs } from './catalogs/catalog.js'; import { crawlApis } from './apis/api.js'; import { getConfig } from './utils/config.js'; import db from './utils/db.js'; +import { formatDuration } from './utils/time.js'; /** * URL of the STAC Index API endpoint @@ -184,6 +185,7 @@ export const crawler = async () => { }; // Run crawler if this file is executed directly -if (import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`) { +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule || import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`) { crawler(); } \ No newline at end of file diff --git a/db/docker-compose.yml b/db/docker-compose.yml index 4c74f91..68a3f35 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -15,6 +15,12 @@ services: volumes: - stac_data:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d + networks: + - stac-network volumes: - stac_data: \ No newline at end of file + stac_data: + +networks: + stac-network: + name: stac-network \ No newline at end of file From 9e9bc3090c3b1fe51fdd002809f5846872a11157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 3 Jan 2026 19:30:07 +0100 Subject: [PATCH 048/134] added triggering function for an auto-update search_vector, both for collections and catalogs. The search_vector includes title, description and keywords --- db/init/02_tables_catalog.sql | 64 ++++++++++++++++++++++++++++++- db/init/03_tables_collections.sql | 64 ++++++++++++++++++++++++++++++- db/init/05_indexes.sql | 8 ++-- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index a44355f..3098cd3 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -9,7 +9,8 @@ CREATE TABLE catalog ( title TEXT, description TEXT, created_at TIMESTAMP DEFAULT now(), - updated_at TIMESTAMP DEFAULT now() + updated_at TIMESTAMP DEFAULT now(), + search_vector tsvector ); -- Catalog links table: Stores related links for catalogs (e.g., self, root, child, item links) @@ -44,3 +45,64 @@ CREATE TABLE crawllog_catalog ( catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, last_crawled TIMESTAMP ); + +-- ======================================== +-- FULL-TEXT SEARCH TRIGGERS +-- ======================================== + +-- Trigger function to auto-update search_vector when catalog is inserted or updated +-- Includes title, description, and all associated keywords for comprehensive search +CREATE OR REPLACE FUNCTION update_catalog_search_vector() +RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := to_tsvector('simple', + coalesce(NEW.title, '') || ' ' || + coalesce(NEW.description, '') || ' ' || + coalesce( + ( + SELECT string_agg(k.keyword, ' ') + FROM catalog_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.catalog_id = NEW.id + ), + '' + ) + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER catalog_search_vector_update +BEFORE INSERT OR UPDATE ON catalog +FOR EACH ROW +EXECUTE FUNCTION update_catalog_search_vector(); + +-- Trigger function to update search_vector when keywords are added/removed +-- Ensures search index stays in sync with keyword changes +CREATE OR REPLACE FUNCTION update_catalog_search_vector_on_keyword_change() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE catalog + SET search_vector = to_tsvector('simple', + coalesce(title, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce( + ( + SELECT string_agg(k.keyword, ' ') + FROM catalog_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.catalog_id = catalog.id + ), + '' + ) + ) + WHERE id = COALESCE(NEW.catalog_id, OLD.catalog_id); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER catalog_keywords_update_vector +AFTER INSERT OR DELETE ON catalog_keywords +FOR EACH ROW +EXECUTE FUNCTION update_catalog_search_vector_on_keyword_change(); diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 733bb08..1f72ab7 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -21,7 +21,8 @@ CREATE TABLE collection ( is_api BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, - full_json JSONB + full_json JSONB, + search_vector tsvector ); -- Collection summaries: Stores summaries for collection properties @@ -64,3 +65,64 @@ CREATE TABLE crawllog_collection ( collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, last_crawled TIMESTAMP ); + +-- ======================================== +-- FULL-TEXT SEARCH TRIGGERS +-- ======================================== + +-- Trigger function to auto-update search_vector when collection is inserted or updated +-- Includes title, description, and all associated keywords for comprehensive search +CREATE OR REPLACE FUNCTION update_collection_search_vector() +RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := to_tsvector('simple', + coalesce(NEW.title, '') || ' ' || + coalesce(NEW.description, '') || ' ' || + coalesce( + ( + SELECT string_agg(k.keyword, ' ') + FROM collection_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.collection_id = NEW.id + ), + '' + ) + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER collection_search_vector_update +BEFORE INSERT OR UPDATE ON collection +FOR EACH ROW +EXECUTE FUNCTION update_collection_search_vector(); + +-- Trigger function to update search_vector when keywords are added/removed +-- Ensures search index stays in sync with keyword changes +CREATE OR REPLACE FUNCTION update_collection_search_vector_on_keyword_change() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE collection + SET search_vector = to_tsvector('simple', + coalesce(title, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce( + ( + SELECT string_agg(k.keyword, ' ') + FROM collection_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.collection_id = collection.id + ), + '' + ) + ) + WHERE id = COALESCE(NEW.collection_id, OLD.collection_id); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER collection_keywords_update_vector +AFTER INSERT OR DELETE ON collection_keywords +FOR EACH ROW +EXECUTE FUNCTION update_collection_search_vector_on_keyword_change(); diff --git a/db/init/05_indexes.sql b/db/init/05_indexes.sql index 9349b88..7c0d74a 100644 --- a/db/init/05_indexes.sql +++ b/db/init/05_indexes.sql @@ -9,8 +9,8 @@ CREATE INDEX idx_catalog_title ON catalog (title); CREATE INDEX idx_catalog_updated_at ON catalog (updated_at); -CREATE INDEX idx_catalog_fulltext ON catalog -USING GIN (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))); +-- Full-text search index on computed search_vector column (includes title, description, and keywords) +CREATE INDEX idx_catalog_search_vector ON catalog USING GIN (search_vector); CREATE INDEX idx_catalog_links_catalog_id ON catalog_links (catalog_id); CREATE INDEX idx_catalog_keywords_catalog ON catalog_keywords (catalog_id); @@ -30,8 +30,8 @@ CREATE INDEX idx_collection_active ON collection (is_active); CREATE INDEX idx_collection_spatial ON collection USING GIST (spatial_extent); -CREATE INDEX idx_collection_fulltext ON collection -USING GIN (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))); +-- Full-text search index on computed search_vector column (includes title, description, and keywords) +CREATE INDEX idx_collection_search_vector ON collection USING GIN (search_vector); CREATE INDEX idx_collection_jsonb ON collection USING GIN (full_json); From b7d51a26d0df836df4333148ddf2e3f9535fa71b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 3 Jan 2026 19:41:50 +0100 Subject: [PATCH 049/134] changed the CI-Pipeline. Now also Changes in the /db will be acceped by the Pipeline --- .github/workflows/api-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index 5394698..1b49ad6 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -9,6 +9,7 @@ on: - main paths: - 'api/**' + - 'db/**' - '.github/workflows/api-ci.yml' pull_request: branches: @@ -17,6 +18,7 @@ on: - main paths: - 'api/**' + - 'db/**' - '.github/workflows/api-ci.yml' jobs: From 9cca397885d5bf15d77f132fc250bb1d20bd3422 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 7 Jan 2026 14:44:09 +0100 Subject: [PATCH 050/134] bug fix: extent was written wrong --- crawler/db.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crawler/db.js b/crawler/db.js index cd7b99e..86eaee8 100644 --- a/crawler/db.js +++ b/crawler/db.js @@ -144,12 +144,12 @@ async function insertOrUpdateCollection(collection) { await client.query('BEGIN'); // Parse spatial extent (bbox) - let spatialExtend = null; + let spatialExtent = null; if (collection.extent?.spatial?.bbox && collection.extent.spatial.bbox[0]) { const bbox = collection.extent.spatial.bbox[0]; if (bbox.length === 4) { // Create polygon from bbox [west, south, east, north] - spatialExtend = `EPSG:4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; + spatialExtent = `EPSG:4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; } } @@ -181,9 +181,9 @@ async function insertOrUpdateCollection(collection) { type = $2, description = $3, license = $4, - spatial_extend = ST_GeomFromEWKT($5), - temporal_extend_start = $6, - temporal_extend_end = $7, + spatial_extent = ST_GeomFromEWKT($5), + temporal_extent_start = $6, + temporal_extent_end = $7, is_api = $8, is_active = $9, full_json = $10, @@ -194,7 +194,7 @@ async function insertOrUpdateCollection(collection) { collection.type || 'Collection', collection.description || null, collection.license || null, - spatialExtend, + spatialExtent, temporalStart, temporalEnd, false, // is_api - will be determined by crawler @@ -208,7 +208,7 @@ async function insertOrUpdateCollection(collection) { const collectionResult = await client.query( `INSERT INTO collection ( stac_version, type, title, description, license, - spatial_extend, temporal_extend_start, temporal_extend_end, + spatial_extent, temporal_extent_start, temporal_extent_end, is_api, is_active, full_json, updated_at ) VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) @@ -219,7 +219,7 @@ async function insertOrUpdateCollection(collection) { collectionTitle, collection.description || null, collection.license || null, - spatialExtend, + spatialExtent, temporalStart, temporalEnd, false, // is_api - will be determined by crawler From 1e335dbaa1109a75d3354904ca2b66177bd86088 Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Wed, 7 Jan 2026 14:54:08 +0100 Subject: [PATCH 051/134] A quick cleanup and fix for the crawler to work for docker (#210) * fix: time utility was missing and improved main module check logic * chore: add stac-network to crawler and db services in docker-compose files * chore: update .gitignore and .env.example for crawler configuration * fix: enhance database connection error handling and logging in initDb function --- crawler/utils/db.js | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index cd7b99e..46c1139 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -17,13 +17,36 @@ const pool = new Pool({ }); async function initDb() { + const host = process.env.PGHOST; + const port = parseInt(process.env.PGPORT, 10); + const database = process.env.PGDATABASE; + const user = process.env.PGUSER; + // Test database connection - const client = await pool.connect(); + let client; try { + client = await pool.connect(); await client.query('SELECT 1'); - console.log('DB connection established successfully'); + console.log(`DB connection established successfully to ${host}:${port}/${database}`); + } catch (error) { + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error('DATABASE CONNECTION FAILED'); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error(` Host: ${host}`); + console.error(` Port: ${port}`); + console.error(` Database: ${database}`); + console.error(` User: ${user}`); + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.error(` Error: ${error.message}`); + if (error.code) { + console.error(` Code: ${error.code}`); + } + console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + throw error; } finally { - client.release(); + if (client) { + client.release(); + } } } From 24f6cb591c5326524eda44bb1f8df0937d29ecde Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 7 Jan 2026 15:10:11 +0100 Subject: [PATCH 052/134] bug fix: extent was written wrong (#212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): initialize STAC Atlas API with collections, conformance, and queryables routes - Added package.json for project dependencies and scripts. - Implemented GET endpoint for collections. - Created conformance endpoint to list supported conformance classes. - Developed landing page for the API with links to collections and documentation. - Added queryables endpoint to return queryable properties for collections. (If i'm correct this can be removed) * Added all Remarks to the bid and finished it (#74) * changed Texts 1,2 and 8 according to the remarks of the customer * Did my fixes to 4. and 10.3 * added remark why we want to save every catalog * deleted keywords for catalog * changed everything related to the database component * Updated 3.1, 3.3, 3,4 (References to Lastenheft/small other changes) * Added small Graph to 3. Produktumgebung * Update bid.md 7.3STAC-Validator added the handling of collections that cannot be validated automatically. * Update bid.md 7.3STAC-API-Validator changed the way we validate the collection search extension. * Update bid.md 9.3.2Endpunkte small fix collection search extension. * added Skizze for 3, and updated 6.1, 10.1 * Updated 3. Produktumgebung * Update bid.md 7.Qualitätsanforderungen minor fixes * Update 6.4.2 added remark about loading feedback * Minor change to Table in "11 Zeitplan". A collum was missing in the head, therefore the table wasn't rendering correctly.. --------- Co-authored-by: Jakob Co-authored-by: Sönke Hoffmann Co-authored-by: jklaer Co-authored-by: VincentKuehn Co-authored-by: mammutor Co-authored-by: Humam <44206081+Mammutor@users.noreply.github.com> Co-authored-by: Justin K * fix(api): enhance STAC API landing page and conformance links - Overhaul of first idea landing page - Added some more tests for the required elements in the landingpage-Catalog * feat(api): implement shared conformance URIs and add tests for conformance endpoint - implemented condormance endpoint * Implemented 2.3 and 2.4 (#111) * Temporary mock data for testing and frontend development * Added API middleware layer for error handling and validation * TODOs ready? pls review * Added API utilities for query parsing, validation, and response formatting * Added swagger and openapi.yaml * Update queryables.js Refactor queryables endpoint into /collections/queryables * Renamed the collections.js file to mocks-collections.js to better reflect its purpose and improve project clarity * changed README "Projektstruktur" * restart from dev-api 22.11..2025 * API: 2.3 Implement Collections List Endpoint done (added explanations as comments in the code) * API: 2.4 Implement Single Collection Endpoint (added explanations as comments in the code) * Update api/routes/collections.js Co-authored-by: Robin Tammo Gummels * Changed some of the code with the comments on Github (i will finish it tomorrow morning) * Implement most of the feedback and comments (need to talk about some other changes) * Update api/routes/index.js Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/README.md Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/README.md Removed missing folder * Update api/routes/collections.js Removed TODOs from wrong lines * Update api/routes/collections.js Added TODOs * Update api/routes/queryables.js Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/routes/queryables.js Changed wording from `/collections/queryables` to `/collections-queryables` --------- Co-authored-by: VincentKuehn Co-authored-by: Robin Tammo Gummels * feat(api): add collection search parameters and validation middleware (#159) * feat(api): add collection search parameters and validation middleware * Added unit-test for validator-functions and integration-tests for `GET /collections`-Querys. - Also minor bugfix, because the validator accepted deecimals as tokens. * API: 3 Database Integration first version (#161) * database connection in implementated. The parameters for the connection have to added in the .env-file. Also there is test-file for testing and console messages (installed `pg`) * support for spatial queries via postgis + error handling for datatbase operations changed language to english * error handling * added DATABASE_URL There is an issue with the distance query. Changed the error handling and testing, the console messages are now way better structured * found the Problem with the distance query. The layer are so big, that they reach over the 180° long (PostgGIS can't handel that). Now the calc is done by degree and not meters. * The two files `test-data-retrieval.js` and `verify-schema.js` have been added. `test-data-retrieval` (theoretical, checks against the spezification): ``` Discovers all tables and columns and validates against expected schema. ``` The second files `verify-schema.js` (practical, checks against the real data): ``` Discovers all tables and columns, validates against expected schema ``` * pooling error hanling and log imporoved. renamed tests files to actual test-files * standalone node tests were convertad into JEST * write file `validateRequest.js`. Validates every incoming API request, whether the request is valid and logical. * commented `stac_id` from the tests, it is not in both databases, so the tests for `stac_id` will always fail Added explanation to the `.env.example`, which port is which database * added example pattern for API - database connection. * deleted `validateRequest` cause it's already implemented by @robinGummels --------- Co-authored-by: Sönke Hoffmann * Added environment variables and a `.env` for `docker-compose.yml` (#164) * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels * Added a CI/CD Pipeline to prevent pull-requests without functioning tests and proper linting. * fixed errors suggested by the linter. - Some lines used tab and spaces... * Implemented Collection Search extension including a DB-Connection (#165) * added SQLQuery-builder with these parameters: q,bbox,datetime,sortby,limit and token * finalised bbox and datetime * adapted to DB, QueryBuilder and added helperfunction runQuery * added question-TODOs * added bbox+datetime to the Query-Builder from Jonas * added tests for Query-Builder from Jonas * added tests from George * added falsely deleted TODOs again * fixed collumn names to match our DB and adjusted full text search to match 05_indexes.sql correctly * Used a formatter and linter on `buildCollectionSearchQuery.js * Did some major and minor fixes to the collection search. - Updated `buildCollectionSearchQuery` to support pagination and improved text search with English language settings. - Modified tests in `buildCollectionsSearchQuery.basic.test.js`, `collections-pagination.test.js`, and `collections-sort.test.js` to reflect new query behavior and validation logic. - Enhanced sort validation in `validators.test.js` and `collectionSearchParams.js` to map API fields to database column names. - Implemented total count retrieval for matched results in `collections.js`. * Added a internal .env creation in the CI/CD Pipeline. It utilzes GitHub Repository Secrets to not publish any private Logins and stuff. * Forgot that the second Job of the CI/CD pipeline runs seperatly and needs a internal .env file too. * Enhance documentation for buildCollectionSearchQuery Updated the documentation for: - the buildCollectionSearchQuery function - the fulltextsearch * Refactor buildCollectionSearchQuery and updated SELECT part Changed the SELECT part to match our bid and the database shema. Updated comments and for clarity. Changed full-text search to use 'simple' configuration instead of 'english'. * Update api/routes/collections.js small typo Co-authored-by: Robin Tammo Gummels * Remove sorting TODO from collections route Removed TODO comment about sorting based on sortby parameter. * Explicitly return undefined for normalized in validateSortby Update validateSortby function to explicitly return undefined for normalized when sortby is not provided. * small fix in buildCollectionSearch.fulltext.test.js Change plainto_tsquery language from 'english' to 'simple' * Fix duplicate SELECT keyword in query Remove duplicate 'SELECT' keyword in SQL query. * Fix missing newline at end of collectionSearchParams.js * Fixed missing bracket in collectionSearchParams.js * Refactor validateSortby for optional parameter handling Refactor validateSortby function to handle optional sortby parameter and improve validation logic. * Stabilize API test pipeline by running Jest in-band with extended timeout Run Jest in CI with --runInBand and a higher default --testTimeout to stabilize database-backed integration tests. Multiple Jest workers were competing for the same PostgreSQL connection pool and some long-running /collections queries exceeded the default 5s timeout, causing failures in existing test suites (e.g. collectionSearch and DBconnection). * Fixed leaking tests that blocked CI/CD-Pipeline. - Added a global Teardown for jest and force-exited the tests to prevent leaking. - Made a change to db_APIconnection to only log the pool-(dis)connection if it isn't run in a test enviroment. * Did a minimum amount of Formatting to the discription * Used `npm audit fix --force` to fix all vulnerabilties in our used packages. * Fixed curious doublechecking for empty Strings for the sortby-Parameter. - Now we only check once for a empty sortby - And added a test which distinguish between `sortby=""` and `sortby="+"` * Update api/routes/collections.js Removed the TODO about switching from mock-data to the real db * Removed globalTeardown as i brought up some problems corresponding to long db-queries (for example BBOX). Instead i increased the maximal testTimeout. --------- Co-authored-by: Robin Tammo Gummels * Update api/.env.example * latest database Version (#187) with `stac_id` and changed definition of `primary Keys` * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md * added `stac_id` for collections * all IDs are now written in the newer PostgrSQL standart: ```SQL id SERIAL PRIMARY KEY, ``` changed to ```SQL id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ``` * changed `extend` to `extent`. * Changed language used in `./api/README.md` from german to english. I wanted to thsi anyway at some point, but this is now more like a Test-commit to see if the CI/CD Pipeline triggers... --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels * bug fix: extent was written wrong --------- Co-authored-by: RobinGummels Co-authored-by: Jakob Co-authored-by: Sönke Hoffmann Co-authored-by: jklaer Co-authored-by: VincentKuehn Co-authored-by: mammutor Co-authored-by: Humam <44206081+Mammutor@users.noreply.github.com> Co-authored-by: Justin K Co-authored-by: Georgios Voulgaris Co-authored-by: Sönke Hoffmann --- .github/workflows/api-ci.yml | 220 + api/.env.example | 31 + api/.eslintrc.js | 18 + api/.gitignore | 61 + api/.prettierrc.json | 8 + api/README.md | 199 + api/__tests__/DBconnection.test.js | 166 + api/__tests__/api.test.js | 132 + ...uildCollectionSearchQuery.fulltext.test.js | 43 + .../buildCollectionsSearchQuery.basic.test.js | 41 + api/__tests__/collectionSearch.test.js | 400 ++ api/__tests__/collections-pagination.test.js | 127 + api/__tests__/collections-sort.test.js | 169 + api/__tests__/data-retrieval.test.js | 265 + api/__tests__/validators.test.js | 407 ++ api/__tests__/verify-schema.test.js | 365 + api/app.js | 69 + api/bin/www | 90 + api/config/conformanceURIS.js | 25 + api/data/collections.js | 105 + api/db/buildCollectionSearchQuery.js | 220 + api/db/db_APIconnection.js | 268 + api/docs/collection-search-parameters.md | 262 + api/examples/README.md | 62 + api/jest.config.js | 18 + api/jest.teardown.js | 14 + api/middleware/validateCollectionSearch.js | 100 + api/package-lock.json | 6372 +++++++++++++++++ api/package.json | 43 + api/routes/collections.js | 174 + api/routes/conformance.js | 15 + api/routes/index.js | 68 + api/routes/queryables.js | 92 + api/validators/collectionSearchParams.js | 274 + bid.md | 169 +- crawler/utils/db.js | 16 +- db/.gitignore | 1 + db/README.md | 17 +- db/docker-compose.yml | 10 +- db/example.env | 7 + db/init/02_tables_catalog.sql | 10 +- db/init/03_tables_collections.sql | 17 +- db/init/05_indexes.sql | 4 +- 43 files changed, 11061 insertions(+), 113 deletions(-) create mode 100644 .github/workflows/api-ci.yml create mode 100644 api/.env.example create mode 100644 api/.eslintrc.js create mode 100644 api/.prettierrc.json create mode 100644 api/__tests__/DBconnection.test.js create mode 100644 api/__tests__/api.test.js create mode 100644 api/__tests__/buildCollectionSearchQuery.fulltext.test.js create mode 100644 api/__tests__/buildCollectionsSearchQuery.basic.test.js create mode 100644 api/__tests__/collectionSearch.test.js create mode 100644 api/__tests__/collections-pagination.test.js create mode 100644 api/__tests__/collections-sort.test.js create mode 100644 api/__tests__/data-retrieval.test.js create mode 100644 api/__tests__/validators.test.js create mode 100644 api/__tests__/verify-schema.test.js create mode 100644 api/app.js create mode 100644 api/bin/www create mode 100644 api/config/conformanceURIS.js create mode 100644 api/data/collections.js create mode 100644 api/db/buildCollectionSearchQuery.js create mode 100644 api/db/db_APIconnection.js create mode 100644 api/docs/collection-search-parameters.md create mode 100644 api/examples/README.md create mode 100644 api/jest.config.js create mode 100644 api/jest.teardown.js create mode 100644 api/middleware/validateCollectionSearch.js create mode 100644 api/package-lock.json create mode 100644 api/package.json create mode 100644 api/routes/collections.js create mode 100644 api/routes/conformance.js create mode 100644 api/routes/index.js create mode 100644 api/routes/queryables.js create mode 100644 api/validators/collectionSearchParams.js create mode 100644 db/example.env diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml new file mode 100644 index 0000000..68c87c0 --- /dev/null +++ b/.github/workflows/api-ci.yml @@ -0,0 +1,220 @@ +name: API CI/CD Pipeline + +# Trigger: At every push or pull request to dev, dev-api or main branches affecting the api/ directory or this workflow file +on: + push: + branches: + - dev-api + - dev + - main + paths: + - 'api/**' + - '.github/workflows/api-ci.yml' + pull_request: + branches: + - dev-api + - dev + - main + paths: + - 'api/**' + - '.github/workflows/api-ci.yml' + +jobs: + # Job 1: Build and Test + test: + name: Build & Test + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [22.x] + + steps: + # Step 1: Checkout Repository + - name: Checkout code + uses: actions/checkout@v4 + + # Step 2: Setup Node.js + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: api/package-lock.json + + # Step 3: Create .env file from secrets + - name: Create .env file + working-directory: api + run: | + cat > .env << EOF + # Server Configuration + PORT=3000 + NODE_ENV=test + + # Database Configuration + DB_HOST=${{ secrets.DB_HOST }} + DB_PORT=${{ secrets.DB_PORT }} + DB_NAME=${{ secrets.DB_NAME }} + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_SSL=false + + # Connection Pool Configuration + DB_POOL_MAX=20 + DB_POOL_MIN=2 + DB_IDLE_TIMEOUT=30000 + DB_CONNECTION_TIMEOUT=10000 + + # CORS Configuration + CORS_ORIGIN=* + + # API Configuration + API_TITLE=STAC Atlas + API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata + API_VERSION=1.0.0 + EOF + + # Step 4: Install dependencies + - name: Install dependencies + run: | + cd api + npm ci + + # Step 5: Linting (ESLint) + - name: Run ESLint + run: | + cd api + npm run lint --if-present + continue-on-error: true + + # Step 6: Run tests + - name: Run tests + run: | + cd api + # Run Jest in-band (single process) with a higher default test timeout + npm test -- --runInBand --testTimeout=30000 + + # Step 7: Generate coverage report + - name: Generate coverage report + run: | + cd api + npm test -- --runInBand --testTimeout=30000 --coverage --coverageReporters=text --coverageReporters=lcov + continue-on-error: true + + # Step 8: Upload coverage as artifact + - name: Upload coverage reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: api/coverage/ + retention-days: 30 + + # Step 9: Upload test results as artifact + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: api/test-results/ + retention-days: 30 + + # Job 2: Validate build + build: + name: Validate Build + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: 'npm' + cache-dependency-path: api/package-lock.json + + - name: Create .env file + working-directory: api + run: | + cat > .env << EOF + # Server Configuration + PORT=3000 + NODE_ENV=test + + # Database Configuration + DB_HOST=${{ secrets.DB_HOST }} + DB_PORT=${{ secrets.DB_PORT }} + DB_NAME=${{ secrets.DB_NAME }} + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_SSL=false + + # Connection Pool Configuration + DB_POOL_MAX=20 + DB_POOL_MIN=2 + DB_IDLE_TIMEOUT=30000 + DB_CONNECTION_TIMEOUT=10000 + + # CORS Configuration + CORS_ORIGIN=* + + # API Configuration + API_TITLE=STAC Atlas + API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata + API_VERSION=1.0.0 + EOF + + - name: Install dependencies + run: | + cd api + npm ci + + - name: Validate application starts + run: | + cd api + timeout 10s npm start || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi + continue-on-error: false + + # Job 3: Security Audit + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: 'npm' + cache-dependency-path: api/package-lock.json + + - name: Run npm audit + run: | + cd api + npm audit --audit-level=moderate + continue-on-error: true + + # Job 4: Status-Check for Branch Protection + ci-success: + name: CI Success + runs-on: ubuntu-latest + needs: [test, build] + if: always() + + steps: + - name: Check all jobs succeeded + run: | + if [ "${{ needs.test.result }}" != "success" ] || [ "${{ needs.build.result }}" != "success" ]; then + echo "CI Pipeline failed!" + echo "Test status: ${{ needs.test.result }}" + echo "Build status: ${{ needs.build.result }}" + exit 1 + else + echo "All CI checks passed successfully!" + fi diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..039ac70 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,31 @@ +# Server Configuration +PORT=3000 +NODE_ENV=development + + +# Database Configuration (Debian Server) +# Option 1: Use DATABASE_URL (PostgreSQL connection string) +# add DB_USER and DB_PASSWORD values +DATABASE_URL= postgresql://[**DB_USER**]:[**DB_PASSWORD**]@atlas.stacindex.org:5432/stac_db + +# Option 2: Use individual variables (currently active) +DB_HOST=atlas.stacindex.org +DB_PORT=5433 # 5432 for production +DB_NAME=stac_db +DB_USER= +DB_PASSWORD= +DB_SSL=false + +# Connection Pool Configuration +DB_POOL_MAX=20 +DB_POOL_MIN=2 +DB_IDLE_TIMEOUT=30000 +DB_CONNECTION_TIMEOUT=10000 + +# CORS Configuration +CORS_ORIGIN=* + +# API Configuration +API_TITLE=STAC Atlas +API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata +API_VERSION=1.0.0 diff --git a/api/.eslintrc.js b/api/.eslintrc.js new file mode 100644 index 0000000..e59adff --- /dev/null +++ b/api/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + env: { + node: true, + es2022: true, + jest: true + }, + extends: ['eslint:recommended'], + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module' + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-console': ['warn', { allow: ['warn', 'error'] }], + 'prefer-const': 'warn', + 'no-var': 'error' + } +}; diff --git a/api/.gitignore b/api/.gitignore index e69de29..d1bed12 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -0,0 +1,61 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# 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 + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://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 +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/api/.prettierrc.json b/api/.prettierrc.json new file mode 100644 index 0000000..d507638 --- /dev/null +++ b/api/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "arrowParens": "avoid" +} diff --git a/api/README.md b/api/README.md index e69de29..96d89e2 100644 --- a/api/README.md +++ b/api/README.md @@ -0,0 +1,199 @@ +# STAC Atlas API + +STAC-compliant API for managing and serving STAC Collection metadata. + +## 🚀 Quick Start + +### Prerequisites + +- Node.js >= 22.0.0 +- PostgreSQL with PostGIS extension +- npm or yarn + +### Installation + +```bash +# Install dependencies +npm install + +# Configure environment variables +cp .env.example .env +# Edit .env and set DATABASE_URL etc. +``` + +### Development + +```bash +# Start development server with auto-reload +npm run dev + +# Or start production server +npm start +``` + +The API will be available at `http://localhost:3000`. + +### Tests + +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm run test:watch +``` + +### Code Quality + +```bash +# Linting +npm run lint + +# Automatic fixing +npm run lint:fix + +# Code formatting +npm run format +``` + +## CI/CD Pipeline + +This project uses GitHub Actions for Continuous Integration: + +- **Automated tests** on every push and pull request +- **Branch protection** prevents merges if tests fail +- **Code quality checks** (ESLint, tests, build validation) +- **Test coverage reports** as artifacts + +**Status:** ![CI Status](https://github.com/SpatioCore/STAC-Atlas/workflows/API%20CI%2FCD%20Pipeline/badge.svg?branch=dev-api) + +## 📋 API Endpoints + +### Core Endpoints + +| Method | Endpoint | Description | +|---------|----------|--------------| +| GET | `/` | Landing page (STAC catalog root) | +| GET | `/conformance` | Conformance classes | +| GET | `/collections` | List all collections (with filtering) | +| POST | `/collections` | Collection search with CQL2 | +| GET | `/collections/:id` | Retrieve a single collection | +| GET | `/collections-queryables` | Queryable properties schema | + +### Query Parameters (GET /collections) + +The collection search API supports the following query parameters: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `q` | String | No | Free-text search (max 500 chars) | +| `bbox` | String | No | Bounding box: `minX,minY,maxX,maxY` | +| `datetime` | String | No | ISO8601 datetime or interval | +| `limit` | Integer | No | Result limit (default: 10, max: 10000) | +| `sortby` | String | No | Sort by field: `+/-field` (title, id, license, created, updated) | +| `token` | Integer | No | Pagination token (offset, default: 0) | + +**Examples:** +```bash +# Free-text search +GET /collections?q=sentinel + +# Spatial + temporal filter +GET /collections?bbox=-10,40,10,50&datetime=2020-01-01/2021-12-31 + +# Pagination with sorting +GET /collections?limit=20&sortby=-created&token=2 +``` + +📖 **Detailed documentation:** See [docs/collection-search-parameters.md](docs/collection-search-parameters.md) + +### API Documentation + +- **Swagger UI**: `http://localhost:3000/api-docs` (if `docs/openapi.yaml` exists) +- **OpenAPI Spec**: `docs/openapi.yaml` + +## 🏗️ Project Structure + +``` +api/ +├── bin/ +│ └── www # Server start script +├── config/ +│ └── conformanceURIS.js # STAC conformance URIs +├── data/ +│ └── collections.js # Test collections +├── docs/ +│ └── collection-search-parameters.md # Query parameter documentation +├── middleware/ +│ └── validateCollectionSearch.js # Query parameter validation +├── routes/ +│ ├── index.js # Landing page (/) +│ ├── conformance.js # Conformance classes +│ ├── collections.js # Collections endpoints +│ └── queryables.js # Queryables schema +├── validators/ +│ └── collectionSearchParams.js # Parameter validators +├── __tests__/ +│ └── api.test.js # API tests +├── app.js # Express App Setup +├── package.json +├── .env.example # Example environment variables +└── README.md +``` + +## 🔧 Configuration + +All configuration is managed via environment variables (`.env`): + +```env +PORT=3000 +NODE_ENV=development +DATABASE_URL=postgresql://user:password@localhost:5432/stac_atlas +CORS_ORIGIN=* +``` + +## 🧪 STAC Conformance + +This API implements: + +- ✅ STAC API Core (v1.0.0) +- ✅ OGC API Features Core +- ✅ STAC Collections +- ✅ Collection Search Extension +- 🚧 CQL2 Basic Filtering (in development) +- 🚧 CQL2 Advanced Operators (in development) + +## 📦 Next Steps + +### TODO + +- [ ] Database integration (PostgreSQL + PostGIS) + - [ ] Implement q (full-text search with TSVector) + - [ ] Implement bbox (PostGIS spatial queries) + - [ ] Implement datetime (temporal overlap queries) + - [ ] Implement sortby (ORDER BY in SQL) +- [ ] CQL2 parser integration (cql2-rs via WASM) +- [ ] Implement controller layer +- [ ] Service layer for business logic +- [ ] Complete OpenAPI documentation +- [ ] Advanced tests (integration, E2E) + - [ ] Unit tests for validators + - [ ] Integration tests for filtered queries +- [ ] Docker setup +- [ ] CI/CD pipeline + +### Implementation Plan (see bid.md) + +1. ✅ **AP-01**: Project skeleton & infrastructure +2. ✅ **AP-02**: Query parameter validation (q, bbox, datetime, limit, sortby, token) +3. 🚧 **AP-03**: STAC core endpoints (baseline implemented) +4. 🚧 **AP-04**: Collection search – filter implementation (DB integration pending) +5. ⏳ **AP-05**: CQL2 filtering integration + +## 📄 License + +Apache-2.0 + +## 👥 Team + +STAC Atlas API Team — Robin (Team lead), Jonas, George, Vincent diff --git a/api/__tests__/DBconnection.test.js b/api/__tests__/DBconnection.test.js new file mode 100644 index 0000000..24cac4a --- /dev/null +++ b/api/__tests__/DBconnection.test.js @@ -0,0 +1,166 @@ +const { testConnection, queryByBBox, queryByGeometry, queryByDistance, closePool } = require('../db/db_APIconnection'); + +/** + * Jest Test Suite: Database Connection & PostGIS Tests + */ + +describe('Database Connection', () => { + + // Note: Pool cleanup is handled by Jest's forceExit option + // No need for explicit afterAll here + + describe('Connection Test', () => { + test('should connect to database successfully', async () => { + const connected = await testConnection(); + expect(connected).toBe(true); + }); + + test('should verify PostgreSQL version', async () => { + const connected = await testConnection(); + expect(connected).toBe(true); + }); + }); + + describe('PostGIS - BBox Query', () => { + test('should execute BBox query', async () => { + const result = await queryByBBox('collection', [-180, -90, 180, 90]); + + expect(result).toBeDefined(); + expect(result.rows).toBeDefined(); + }); + + test('should return collections within bbox', async () => { + const result = await queryByBBox('collection', [-180, -90, 180, 90]); + + if (result.rowCount > 0) { + expect(result.rows[0]).toHaveProperty('spatial_extend'); + expect(result.rowCount).toBeGreaterThan(0); + } + }); + + test('should reject invalid longitude', async () => { + await expect( + queryByBBox('collection', [-200, 0, 10, 10]) + ).rejects.toThrow('Longitude must be between -180 and 180'); + }); + + test('should reject invalid latitude', async () => { + await expect( + queryByBBox('collection', [0, -100, 10, 10]) + ).rejects.toThrow('Latitude must be between -90 and 90'); + }); + + test('should reject west >= east', async () => { + await expect( + queryByBBox('collection', [10, 0, 5, 10]) + ).rejects.toThrow('West coordinate must be less than east'); + }); + + test('should reject south >= north', async () => { + await expect( + queryByBBox('collection', [0, 10, 10, 5]) + ).rejects.toThrow('South coordinate must be less than north'); + }); + }); + + describe('PostGIS - Geometry Query', () => { + test('should execute geometry query with Point', async () => { + const point = { + type: 'Point', + coordinates: [0, 0] + }; + + const result = await queryByGeometry('collection', point, 'intersects'); + + expect(result).toBeDefined(); + expect(result.rows).toBeDefined(); + }); + + test('should return spatial_extend column', async () => { + const point = { + type: 'Point', + coordinates: [0, 0] + }; + + const result = await queryByGeometry('collection', point, 'intersects'); + + if (result.rowCount > 0) { + expect(result.rows[0]).toHaveProperty('spatial_extend'); + } + }); + + test('should reject invalid GeoJSON', async () => { + await expect( + queryByGeometry('collection', { invalid: 'json' }) + ).rejects.toThrow('GeoJSON must have type and coordinates'); + }); + + test('should reject empty table name', async () => { + await expect( + queryByGeometry('', { type: 'Point', coordinates: [0, 0] }) + ).rejects.toThrow('Table name must be a non-empty string'); + }); + + test('should reject invalid predicate', async () => { + await expect( + queryByGeometry('collection', { type: 'Point', coordinates: [0, 0] }, 'invalid') + ).rejects.toThrow('Invalid predicate'); + }); + + test('should support different predicates', async () => { + const point = { type: 'Point', coordinates: [0, 0] }; + + const predicates = ['intersects', 'contains', 'within']; + for (const predicate of predicates) { + const result = await queryByGeometry('collection', point, predicate); + expect(result).toBeDefined(); + } + }, 45000); // Increase timeout for slow queries (especially in CI with 3 sequential queries) + }); + + describe('PostGIS - Distance Query', () => { + test('should execute distance query', async () => { + const result = await queryByDistance('collection', [0, 0], 100000); + + expect(result).toBeDefined(); + expect(result.rows).toBeDefined(); + }); + + test('should return distance column', async () => { + const result = await queryByDistance('collection', [0, 0], 100000); + + if (result.rowCount > 0) { + expect(result.rows[0]).toHaveProperty('distance'); + expect(result.rows[0]).toHaveProperty('spatial_extend'); + } + }); + + test('should order results by distance', async () => { + const result = await queryByDistance('collection', [0, 0], 500000); + + if (result.rowCount > 1) { + for (let i = 1; i < result.rows.length; i++) { + expect(result.rows[i].distance).toBeGreaterThanOrEqual(result.rows[i - 1].distance); + } + } + }); + + test('should reject invalid distance', async () => { + // queryByDistance doesn't validate negative distance in current implementation + // It returns empty result set instead of throwing + const result = await queryByDistance('collection', [0, 0], 1000); + expect(result).toBeDefined(); + expect(result.rows).toBeDefined(); + }); + + test('should work with different coordinates', async () => { + // Münster, Germany + const result1 = await queryByDistance('collection', [7.6, 51.9], 50000); + expect(result1).toBeDefined(); + + // New York, USA + const result2 = await queryByDistance('collection', [-74.0, 40.7], 50000); + expect(result2).toBeDefined(); + }); + }); +}); diff --git a/api/__tests__/api.test.js b/api/__tests__/api.test.js new file mode 100644 index 0000000..6c77a1f --- /dev/null +++ b/api/__tests__/api.test.js @@ -0,0 +1,132 @@ +const request = require('supertest'); +const app = require('../app'); + +describe('STAC API Core Endpoints', () => { + describe('GET /', () => { + it('should return the landing page with STAC catalog structure', async () => { + const response = await request(app).get('/').expect(200); + + // Make sure the response has the correct structure of a STAC Catalog + expect(response.body).toHaveProperty('type', 'Catalog'); + expect(response.body).toHaveProperty('id'); + expect(response.body).toHaveProperty('title'); + expect(response.body).toHaveProperty('description'); + expect(response.body).toHaveProperty('stac_version'); + expect(response.body).toHaveProperty('conformsTo'); + expect(response.body).toHaveProperty('links'); + expect(Array.isArray(response.body.links)).toBe(true); + }); + + it('should include required links in landing page', async () => { + const response = await request(app).get('/').expect(200); + + const links = response.body.links; + const linkRels = links.map(link => link.rel); + + expect(linkRels).toContain('self'); + expect(linkRels).toContain('root'); + expect(linkRels).toContain('service-doc'); + expect(linkRels).toContain('service-desc'); + expect(linkRels).toContain('conformance'); + expect(linkRels).toContain('data'); + }); + + + it('should expose the same conformance classes as the /conformance endpoint', async () => { + const [landingRes, confRes] = await Promise.all([ + request(app).get('/').expect(200), + request(app).get('/conformance').expect(200) + ]); + + const landingConformance = landingRes.body.conformsTo; + const endpointConformance = confRes.body.conformsTo; + + // both must be arrays + expect(Array.isArray(landingConformance)).toBe(true); + expect(Array.isArray(endpointConformance)).toBe(true); + + // support function: sort, so that the order doesn't matter + const sortStrings = arr => [...arr].sort(); + + expect(sortStrings(landingConformance)).toEqual( + sortStrings(endpointConformance) + ); + }); + }); + + describe('GET /conformance', () => { + it('should return conformance classes', async () => { + const response = await request(app).get('/conformance').expect(200); + + expect(response.body).toHaveProperty('conformsTo'); + expect(Array.isArray(response.body.conformsTo)).toBe(true); + expect(response.body.conformsTo.length).toBeGreaterThan(0); + }); + + it('should include STAC API Core conformance', async () => { + const response = await request(app).get('/conformance').expect(200); + + expect(response.body.conformsTo).toContain('https://api.stacspec.org/v1.0.0/core'); + }); + }); + + describe('GET /collections', () => { + it('should return a FeatureCollection structure', async () => { + const response = await request(app).get('/collections').expect(200); + + expect(response.body).toHaveProperty('type', 'FeatureCollection'); + expect(response.body).toHaveProperty('collections'); + expect(response.body).toHaveProperty('links'); + expect(response.body).toHaveProperty('context'); + expect(Array.isArray(response.body.collections)).toBe(true); + }); + + it('should include pagination context', async () => { + const response = await request(app).get('/collections').expect(200); + + expect(response.body.context).toHaveProperty('returned'); + expect(response.body.context).toHaveProperty('limit'); + expect(response.body.context).toHaveProperty('matched'); + }); + }); + + describe('GET /queryables', () => { + it('should return queryables schema', async () => { + const response = await request(app).get('/queryables').expect(200); + + expect(response.body).toHaveProperty('$schema'); + expect(response.body).toHaveProperty('type', 'object'); + expect(response.body).toHaveProperty('properties'); + }); + + it('should include standard STAC queryable fields', async () => { + const response = await request(app).get('/queryables').expect(200); + + const properties = response.body.properties; + expect(properties).toHaveProperty('id'); + expect(properties).toHaveProperty('title'); + expect(properties).toHaveProperty('description'); + expect(properties).toHaveProperty('keywords'); + expect(properties).toHaveProperty('license'); + }); + }); + + describe('404 Handling', () => { + it('should return 404 for non-existent routes', async () => { + const response = await request(app).get('/non-existent-route').expect(404); + + expect(response.body).toHaveProperty('code'); + expect(response.body).toHaveProperty('description'); + }); + }); + + describe('GET /collections/:id', () => { + it('should return 404 for non-existent collection', async () => { + const response = await request(app).get('/collections/non-existent-id').expect(404); + + expect(response.body).toHaveProperty('code', 'NotFound'); + expect(response.body).toHaveProperty('description'); + expect(response.body).toHaveProperty('id', 'non-existent-id'); + }); + }); +}); diff --git a/api/__tests__/buildCollectionSearchQuery.fulltext.test.js b/api/__tests__/buildCollectionSearchQuery.fulltext.test.js new file mode 100644 index 0000000..5c8100d --- /dev/null +++ b/api/__tests__/buildCollectionSearchQuery.fulltext.test.js @@ -0,0 +1,43 @@ +const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); + +describe('buildCollectionSearchQuery - full-text search and ranking', () => { + test('q parameter adds plainto_tsquery condition and rank in SELECT', () => { + const { sql, values } = buildCollectionSearchQuery({ q: 'forest', limit: 20, token: 0 }); + + // should contain plainto_tsquery and @@ operator + expect(sql).toMatch(/plainto_tsquery\('simple', \$1\)/); + expect(sql).toMatch(/@@/); + + // rank should be part of the SELECT list + expect(sql).toMatch(/ts_rank_cd\(/); + expect(sql).toMatch(/AS rank/); + + // Ordering defaults to rank DESC when q present and no sortby + expect(sql).toMatch(/ORDER BY rank DESC, id ASC/); + + // values: [q, limit, token] + expect(values[0]).toBe('forest'); + expect(values[1]).toBe(20); + expect(values[2]).toBe(0); + }); + + test('explicit sortby overrides rank ordering', () => { + const { sql } = buildCollectionSearchQuery({ q: 'lake', sortby: { field: 'title', direction: 'ASC' }, limit: 5, token: 0 }); + + expect(sql).toMatch(/ORDER BY title ASC/); + // rank still present in select + expect(sql).toMatch(/AS rank/); + }); + + test('parameter indexes remain correct when q + bbox combined', () => { + const bbox = [0,0,1,1]; + const { sql, values } = buildCollectionSearchQuery({ q: 'river', bbox, limit: 2, token: 0 }); + + // q uses $1, bbox uses $2..$5, then limit/token + expect(sql).toMatch(/plainto_tsquery\('simple', \$1\)/); + expect(sql).toMatch(/ST_MakeEnvelope\(\$2, \$3, \$4, \$5, 4326\)/); + + expect(values[0]).toBe('river'); + expect(values.slice(1,5)).toEqual(bbox); + }); +}); diff --git a/api/__tests__/buildCollectionsSearchQuery.basic.test.js b/api/__tests__/buildCollectionsSearchQuery.basic.test.js new file mode 100644 index 0000000..8756a5f --- /dev/null +++ b/api/__tests__/buildCollectionsSearchQuery.basic.test.js @@ -0,0 +1,41 @@ +const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); + +describe('buildCollectionSearchQuery - basic cases', () => { + test('no params returns base SQL with LIMIT/OFFSET placeholders', () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/FROM collection/); + expect(sql).toMatch(/ORDER BY id ASC/); + // there should be LIMIT and OFFSET placeholders + expect(sql).toMatch(/LIMIT \$1 OFFSET \$2/); + expect(Array.isArray(values)).toBe(true); + // values should contain limit and token + expect(values.length).toBe(2); + expect(values).toEqual([10, 0]); + }); + + test('bbox adds ST_MakeEnvelope parameters in order', () => { + const bbox = [-10, 40, 10, 50]; + const { sql, values } = buildCollectionSearchQuery({ bbox, limit: 5, token: 0 }); + + // Ensure ST_MakeEnvelope uses $1..$4 when bbox is first + expect(sql).toMatch(/ST_MakeEnvelope\(\$1, \$2, \$3, \$4, 4326\)/); + // After bbox, limit and token are appended + expect(values.slice(0,4)).toEqual(bbox); + expect(values[4]).toBe(5); + expect(values[5]).toBe(0); + }); + + test('datetime closed interval produces start/end conditions', () => { + const datetime = '2020-01-01/2021-12-31'; + const { sql, values } = buildCollectionSearchQuery({ datetime, limit: 10, token: 0 }); + + expect(sql).toMatch(/temporal_extend_end >= \$1/); // TODO: Adjust naming to temporal_extent_end, when DB names are updated + expect(sql).toMatch(/temporal_extend_start <= \$2/); // TODO: Adjust naming to temporal_extent_start, when DB names are updated + // values order: start, end, limit, token + expect(values[0]).toBe('2020-01-01'); + expect(values[1]).toBe('2021-12-31'); + expect(values[2]).toBe(10); + expect(values[3]).toBe(0); + }); +}); \ No newline at end of file diff --git a/api/__tests__/collectionSearch.test.js b/api/__tests__/collectionSearch.test.js new file mode 100644 index 0000000..25c16c5 --- /dev/null +++ b/api/__tests__/collectionSearch.test.js @@ -0,0 +1,400 @@ +// __tests__/collectionSearch.test.js + +const request = require('supertest'); +const app = require('../app'); + +describe('Collection Search API - Query Parameters', () => { + + describe('GET /collections - Parameter Validation', () => { + + // ========== Successful Requests ========== + + it('should accept request without any parameters', async () => { + const response = await request(app) + .get('/collections') + .expect(200); + + expect(response.body).toHaveProperty('type', 'FeatureCollection'); + expect(response.body).toHaveProperty('collections'); + expect(response.body).toHaveProperty('context'); + expect(response.body.context.limit).toBe(10); // default limit + }); + + it('should accept valid limit parameter', async () => { + const response = await request(app) + .get('/collections?limit=5') + .expect(200); + + expect(response.body.context.limit).toBe(5); + expect(response.body.collections.length).toBeLessThanOrEqual(5); + }); + + it('should accept valid token parameter', async () => { + const response = await request(app) + .get('/collections?token=10') + .expect(200); + + expect(response.body).toHaveProperty('collections'); + }); + + it('should accept limit and token together', async () => { + const response = await request(app) + .get('/collections?limit=3&token=0') + .expect(200); + + expect(response.body.context.limit).toBe(3); + expect(response.body.collections.length).toBeLessThanOrEqual(3); + }); + + it('should accept valid q parameter', async () => { + const response = await request(app) + .get('/collections?q=test') + .expect(200); + + expect(response.body).toHaveProperty('collections'); + }); + + it('should accept valid bbox parameter', async () => { + const response = await request(app) + .get('/collections?bbox=-10,40,10,50') + .expect(200); + + expect(response.body).toHaveProperty('collections'); + }); + + it('should accept valid datetime parameter', async () => { + const response = await request(app) + .get('/collections?datetime=2020-01-01/2021-12-31') + .expect(200); + + expect(response.body).toHaveProperty('collections'); + }); + + it('should accept valid sortby parameter', async () => { + const response = await request(app) + .get('/collections?sortby=-created') + .expect(200); + + expect(response.body).toHaveProperty('collections'); + }); + + it('should accept multiple parameters combined', async () => { + const response = await request(app) + .get('/collections?q=test&limit=5&sortby=%2Btitle') + .expect(200); + + expect(response.body.context.limit).toBe(5); + expect(response.body).toHaveProperty('collections'); + }); + + // ========== Limit Parameter Validation ========== + + it('should reject limit less than 1', async () => { + const response = await request(app) + .get('/collections?limit=0') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('limit'); + expect(response.body.description).toContain('at least 1'); + }); + + it('should reject negative limit', async () => { + const response = await request(app) + .get('/collections?limit=-5') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('limit'); + }); + + it('should reject limit exceeding maximum', async () => { + const response = await request(app) + .get('/collections?limit=10001') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('limit'); + expect(response.body.description).toContain('10000'); + }); + + it('should reject non-numeric limit', async () => { + const response = await request(app) + .get('/collections?limit=abc') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('limit'); + expect(response.body.description).toContain('integer'); + }); + + // ========== Token Parameter Validation ========== + + it('should reject negative token', async () => { + const response = await request(app) + .get('/collections?token=-10') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('token'); + expect(response.body.description).toContain('non-negative'); + }); + + it('should reject non-numeric token', async () => { + const response = await request(app) + .get('/collections?token=abc') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('token'); + expect(response.body.description).toContain('integer'); + }); + + // ========== Q Parameter Validation ========== + + it('should reject q exceeding max length', async () => { + const longString = 'a'.repeat(501); + const response = await request(app) + .get(`/collections?q=${longString}`) + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('q'); + expect(response.body.description).toContain('500'); + }); + + // ========== Bbox Parameter Validation ========== + + it('should reject bbox with wrong number of coordinates', async () => { + const response = await request(app) + .get('/collections?bbox=1,2,3') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('bbox'); + expect(response.body.description).toContain('4 coordinates'); + }); + + it('should reject bbox with invalid numeric values', async () => { + const response = await request(app) + .get('/collections?bbox=a,b,c,d') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('bbox'); + expect(response.body.description).toContain('numeric'); + }); + + it('should reject bbox where minX >= maxX', async () => { + const response = await request(app) + .get('/collections?bbox=10,40,10,50') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('bbox'); + expect(response.body.description).toContain('minX must be less than maxX'); + }); + + it('should reject bbox where minY >= maxY', async () => { + const response = await request(app) + .get('/collections?bbox=-10,50,10,50') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('bbox'); + expect(response.body.description).toContain('minY must be less than maxY'); + }); + + it('should reject bbox with longitude out of range', async () => { + const response = await request(app) + .get('/collections?bbox=-181,40,10,50') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('bbox'); + expect(response.body.description).toContain('longitude'); + }); + + it('should reject bbox with latitude out of range', async () => { + const response = await request(app) + .get('/collections?bbox=-10,91,10,50') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('bbox'); + expect(response.body.description).toContain('latitude'); + }); + + // ========== Datetime Parameter Validation ========== + + it('should reject invalid datetime format', async () => { + const response = await request(app) + .get('/collections?datetime=not-a-date') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('datetime'); + expect(response.body.description).toContain('ISO8601'); + }); + + it('should reject datetime interval with multiple separators', async () => { + const response = await request(app) + .get('/collections?datetime=2019/2020/2021') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('datetime'); + expect(response.body.description).toContain('separator'); + }); + + it('should reject fully unbounded datetime interval', async () => { + const response = await request(app) + .get('/collections?datetime=../..') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('datetime'); + expect(response.body.description).toContain('unbounded'); + }); + + // ========== Sortby Parameter Validation ========== + + it('should reject unsupported sortby field', async () => { + const response = await request(app) + .get('/collections?sortby=invalid_field') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('sortby'); + expect(response.body.description).toContain('not supported'); + }); + + // ========== Multiple Error Handling ========== + + it('should return all validation errors combined', async () => { + const response = await request(app) + .get('/collections?limit=0&token=-5') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + expect(response.body.description).toContain('limit'); + expect(response.body.description).toContain('token'); + // Errors should be separated by semicolon + expect(response.body.description).toContain(';'); + }); + }); + + describe('GET /collections - Pagination Behavior', () => { + + it('should return correct number of items with limit', async () => { + const response = await request(app) + .get('/collections?limit=2') + .expect(200); + + expect(response.body.collections.length).toBeLessThanOrEqual(2); + expect(response.body.context.limit).toBe(2); + }); + + it('should include next link when more results available', async () => { + const response = await request(app) + .get('/collections?limit=2') + .expect(200); + + const links = response.body.links; + const nextLink = links.find(link => link.rel === 'next'); + + // Only check for next link if there are more items than limit + if (response.body.context.matched > response.body.context.limit) { + expect(nextLink).toBeDefined(); + expect(nextLink.href).toContain('token='); + } + }); + + it('should include prev link when not on first page', async () => { + const response = await request(app) + .get('/collections?limit=2&token=2') + .expect(200); + + const links = response.body.links; + const prevLink = links.find(link => link.rel === 'prev'); + + expect(prevLink).toBeDefined(); + expect(prevLink.href).toContain('token='); + }); + + it('should include self link with current parameters', async () => { + const response = await request(app) + .get('/collections?limit=5&token=10') + .expect(200); + + const links = response.body.links; + const selfLink = links.find(link => link.rel === 'self'); + + expect(selfLink).toBeDefined(); + expect(selfLink.href).toContain('limit=5'); + expect(selfLink.href).toContain('token=10'); + }); + + it('should return context with correct counts', async () => { + const response = await request(app) + .get('/collections?limit=3') + .expect(200); + + const context = response.body.context; + expect(context).toHaveProperty('returned'); + expect(context).toHaveProperty('limit', 3); + expect(context).toHaveProperty('matched'); + expect(context.returned).toBeLessThanOrEqual(context.limit); + expect(context.returned).toBeLessThanOrEqual(context.matched); + }); + + it('should handle token beyond available results', async () => { + const response = await request(app) + .get('/collections?limit=10&token=999999') + .expect(200); + + expect(response.body.collections).toHaveLength(0); + expect(response.body.context.returned).toBe(0); + }); + }); + + describe('GET /collections - Response Format', () => { + + it('should return valid FeatureCollection structure', async () => { + const response = await request(app) + .get('/collections') + .expect(200); + + expect(response.body).toMatchObject({ + type: 'FeatureCollection', + collections: expect.any(Array), + links: expect.any(Array), + context: { + returned: expect.any(Number), + limit: expect.any(Number), + matched: expect.any(Number) + } + }); + }); + + it('should include required link relations', async () => { + const response = await request(app) + .get('/collections') + .expect(200); + + const links = response.body.links; + const linkRels = links.map(link => link.rel); + + expect(linkRels).toContain('self'); + expect(linkRels).toContain('root'); + }); + + it('should return collections as array', async () => { + const response = await request(app) + .get('/collections') + .expect(200); + + expect(Array.isArray(response.body.collections)).toBe(true); + }); + }); +}); diff --git a/api/__tests__/collections-pagination.test.js b/api/__tests__/collections-pagination.test.js new file mode 100644 index 0000000..4133a97 --- /dev/null +++ b/api/__tests__/collections-pagination.test.js @@ -0,0 +1,127 @@ +// __tests__/collections-pagination.test.js + +const request = require('supertest'); +const app = require('../app'); + +/** + * Tests for API 4.5: Implement Pagination + * + * Verifies that: + * - limit correctly restricts number of returned results + * - token acts as offset for pagination + * - matched = total filtered collections BEFORE pagination + * - returned = number of results in this page + * - pagination handles boundaries correctly + */ +describe('Collection Search - Pagination behavior (4.5 Implement Pagination)', () => { + + /** + * Test 1: limit=2 should return exactly 2 collections + */ + it('should return exactly 2 collections with limit=2', async () => { + const response = await request(app) + .get('/collections?limit=2&token=0') + .expect(200); + + expect(response.body.collections.length).toBe(2); + expect(response.body.context.returned).toBe(2); + expect(response.body.context.matched).toBeGreaterThanOrEqual(2); + }); + + /** + * Test 2: token=0 and token=2 should return different slices + * Page 1: first 2 collections + * Page 2: next 2 collections + */ + it('should return different items for token=0 and token=2', async () => { + const page1 = await request(app) + .get('/collections?limit=2&token=0') + .expect(200); + + const page2 = await request(app) + .get('/collections?limit=2&token=2') + .expect(200); + + // Compare IDs to ensure pages differ + const ids1 = page1.body.collections.map(c => c.id); + const ids2 = page2.body.collections.map(c => c.id); + + expect(ids1).not.toEqual(ids2); + }); + + /** + * Test 3: Using token should correctly skip collections + * token = offset + */ + it('should skip the correct number of items based on token', async () => { + const all = await request(app) + .get('/collections') + .expect(200); + + const first = all.body.collections[0]; + const third = all.body.collections[2]; + + const response = await request(app) + .get('/collections?limit=1&token=2') + .expect(200); + + expect(response.body.collections[0].id).toBe(third.id); + expect(response.body.collections[0].id).not.toBe(first.id); + }); + + /** + * Test 4: matched remains constant regardless of limit/token + */ + it('matched should reflect total results, not paginated results', async () => { + const full = await request(app) + .get('/collections') + .expect(200); + + const paginated = await request(app) + .get('/collections?limit=1&token=0') + .expect(200); + + expect(paginated.body.context.matched).toBe(full.body.context.matched); + expect(paginated.body.context.returned).toBe(1); + }); + + /** + * Test 5: If token is out of bounds, should return an empty array + */ + it('should return empty list when token is beyond result count', async () => { + const full = await request(app) + .get('/collections') + .expect(200); + + const tooHighToken = full.body.context.matched + 50; + + const response = await request(app) + .get(`/collections?limit=5&token=${tooHighToken}`) + .expect(200); + + // Should return empty or very few results + expect(response.body.collections.length).toBeLessThanOrEqual(5); + expect(response.body.context.returned).toBe(response.body.collections.length); + }); + + /** + * Test 6: Pagination should not duplicate items across pages + */ + it('should not duplicate items across paginated pages', async () => { + const p1 = await request(app) + .get('/collections?limit=3&token=0') + .expect(200); + + const p2 = await request(app) + .get('/collections?limit=3&token=3') + .expect(200); + + const ids1 = p1.body.collections.map(c => c.id); + const ids2 = p2.body.collections.map(c => c.id); + + ids1.forEach(id => { + expect(ids2).not.toContain(id); + }); + }); + +}); \ No newline at end of file diff --git a/api/__tests__/collections-sort.test.js b/api/__tests__/collections-sort.test.js new file mode 100644 index 0000000..2e257f7 --- /dev/null +++ b/api/__tests__/collections-sort.test.js @@ -0,0 +1,169 @@ +// __tests__/collections-sort.test.js + +const request = require('supertest'); +const app = require('../app'); + +/** + * Tests for API 4.4: Implement Sorting + * + * Verifies that the collection search endpoint correctly: + * - Sorts results by specified field (title, id, license, created, updated) + * - Handles ascending (+field) and descending (-field) order + * - Defaults to ascending when no prefix specified + */ +describe('Collection Search - Sorting behavior (4.4 Implement Sorting)', () => { + + /** + * Test 1: Ascending sort by title with explicit + prefix + * Ensures +title correctly sorts titles A-Z + */ + it('should sort ascending by title with +title', async () => { + const response = await request(app) + .get('/collections?sortby=%2Btitle') + .expect(200); + + const titles = response.body.collections.map(c => c.title); + + // PostgreSQL's collation may differ from JavaScript's localeCompare. + // Instead, verify that: + // 1. Results are returned + // 2. First title alphabetically comes before last title + // 3. At least 80% of consecutive pairs are correctly ordered + expect(titles.length).toBeGreaterThan(0); + + // Check first vs last (should be alphabetically before or equal) + const firstTitle = titles[0].toLowerCase(); + const lastTitle = titles[titles.length - 1].toLowerCase(); + expect(firstTitle.localeCompare(lastTitle, 'en', { sensitivity: 'base' })).toBeLessThanOrEqual(0); + + // Count how many consecutive pairs are correctly ordered + let correctPairs = 0; + for (let i = 0; i < titles.length - 1; i++) { + if (titles[i].toLowerCase().localeCompare(titles[i + 1].toLowerCase(), 'en', { sensitivity: 'base' }) <= 0) { + correctPairs++; + } + } + + // At least 80% of pairs should be correctly ordered + // (allows for some PostgreSQL collation differences) + const pairRatio = correctPairs / (titles.length - 1); + expect(pairRatio).toBeGreaterThanOrEqual(0.8); + }); + + /** + * Test 2: Descending sort by title with - prefix + * Ensures -title correctly sorts titles Z-A + */ + it('should sort descending by title with -title', async () => { + const response = await request(app) + .get('/collections?sortby=-title') + .expect(200); + + const titles = response.body.collections.map(c => c.title); + + expect(titles.length).toBeGreaterThan(0); + + // Check first vs last (should be alphabetically after or equal in descending order) + const firstTitle = titles[0].toLowerCase(); + const lastTitle = titles[titles.length - 1].toLowerCase(); + expect(firstTitle.localeCompare(lastTitle, 'en', { sensitivity: 'base' })).toBeGreaterThanOrEqual(0); + + // Count correctly ordered descending pairs + let correctPairs = 0; + for (let i = 0; i < titles.length - 1; i++) { + if (titles[i].toLowerCase().localeCompare(titles[i + 1].toLowerCase(), 'en', { sensitivity: 'base' }) >= 0) { + correctPairs++; + } + } + + // At least 80% of pairs should be correctly ordered descending + const pairRatio = correctPairs / (titles.length - 1); + expect(pairRatio).toBeGreaterThanOrEqual(0.8); + }); + + /** + * Test 3: Ascending sort by id with explicit + prefix + * Verifies that +id sorts collection IDs in ascending order + */ + it('should sort ascending by id with +id', async () => { + const response = await request(app) + .get('/collections?sortby=%2Bid') + .expect(200); + + const ids = response.body.collections.map(c => c.id); + const sorted = ids.slice().sort((a, b) => a - b); + expect(ids).toEqual(sorted); + }); + + /** + * Test 4: Descending sort by id with - prefix + * Verifies that -id sorts collection IDs in descending order + */ + it('should sort descending by id with -id', async () => { + const response = await request(app) + .get('/collections?sortby=-id') + .expect(200); + + const ids = response.body.collections.map(c => c.id); + const sortedDesc = ids.slice().sort((a, b) => b - a); + expect(ids).toEqual(sortedDesc); + }); + + /** + * Test 5: Ascending sort by license with explicit + prefix + * Ensures +license correctly sorts licenses from A-Z + */ + it('should sort ascending by license with +license', async () => { + const response = await request(app) + .get('/collections?sortby=%2Blicense') + .expect(200); + + const licenses = response.body.collections.map(c => c.license); + const sorted = licenses.slice().sort((a, b) => a.localeCompare(b)); + expect(licenses).toEqual(sorted); + }); + + /** + * Test 6: Descending sort by license with - prefix + * Ensures -license correctly sorts licenses from Z-A + */ + it('should sort descending by license with -license', async () => { + const response = await request(app) + .get('/collections?sortby=-license') + .expect(200); + + const licenses = response.body.collections.map(c => c.license); + const sortedDesc = licenses.slice().sort((a, b) => b.localeCompare(a)); + expect(licenses).toEqual(sortedDesc); + }); + + /** + * Test 7: Default to ascending when no prefix provided + * Ensures that sortby=title (without +/-) defaults to ascending order + */ + it('should default to ascending when no prefix provided', async () => { + const response = await request(app) + .get('/collections?sortby=title') + .expect(200); + + const titles = response.body.collections.map(c => c.title); + + expect(titles.length).toBeGreaterThan(0); + + // Verify ascending order (first <= last) + const firstTitle = titles[0].toLowerCase(); + const lastTitle = titles[titles.length - 1].toLowerCase(); + expect(firstTitle.localeCompare(lastTitle, 'en', { sensitivity: 'base' })).toBeLessThanOrEqual(0); + + // At least 80% of pairs should be ascending + let correctPairs = 0; + for (let i = 0; i < titles.length - 1; i++) { + if (titles[i].toLowerCase().localeCompare(titles[i + 1].toLowerCase(), 'en', { sensitivity: 'base' }) <= 0) { + correctPairs++; + } + } + + const pairRatio = correctPairs / (titles.length - 1); + expect(pairRatio).toBeGreaterThanOrEqual(0.8); + }); +}); \ No newline at end of file diff --git a/api/__tests__/data-retrieval.test.js b/api/__tests__/data-retrieval.test.js new file mode 100644 index 0000000..701f032 --- /dev/null +++ b/api/__tests__/data-retrieval.test.js @@ -0,0 +1,265 @@ +const { query, closePool } = require('../db/db_APIconnection'); + +/** + * Jest Test Suite: Data Retrieval and Schema Validation + * Discovers all tables and columns, validates against expected schema + */ + +// Expected schema definitions +const EXPECTED_SCHEMAS = { + collection: { + id: { type: 'integer', required: true }, + // stac_id: { type: 'text', required: true }, // Column does not exist in both databases + stac_version: { type: 'text', required: true }, + type: { type: 'text', required: true }, + title: { type: 'text', required: true }, + description: { type: 'text', required: true }, + license: { type: 'text', required: true }, + spatial_extend: { type: 'geometry', required: true }, + temporal_extend_start: { type: 'timestamp without time zone', required: true }, + temporal_extend_end: { type: 'timestamp without time zone', required: true }, + full_json: { type: 'jsonb', required: false }, + created_at: { type: 'timestamp without time zone', required: true }, + updated_at: { type: 'timestamp without time zone', required: true }, + is_api: { type: 'boolean', required: true }, + is_active: { type: 'boolean', required: true } + }, + catalog: { + id: { type: 'integer', required: true }, + // stac_id: { type: 'text', required: true }, // Column does not exist in database + stac_version: { type: 'text', required: true }, + type: { type: 'text', required: true }, + title: { type: 'text', required: false }, + description: { type: 'text', required: true }, + created_at: { type: 'timestamp without time zone', required: true }, + updated_at: { type: 'timestamp without time zone', required: true } + } +}; + +describe('Database Schema Validation', () => { + let discoveredTables = []; + + afterAll(async () => { + await closePool(); + }); + + describe('Table Discovery', () => { + test('should discover STAC-related tables', async () => { + const tablesResult = await query(` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename IN ('collection', 'catalog') + ORDER BY tablename + `); + + discoveredTables = tablesResult.rows.map(r => r.tablename); + + expect(discoveredTables).toContain('collection'); + expect(discoveredTables).toContain('catalog'); + expect(discoveredTables.length).toBeGreaterThan(0); + }); + }); + + describe('Schema Validation - Collection Table', () => { + const actualColumns = {}; + + beforeAll(async () => { + const columnsResult = await query(` + SELECT + column_name, + data_type, + udt_name, + is_nullable + FROM information_schema.columns + WHERE table_name = 'collection' + ORDER BY ordinal_position + `); + + columnsResult.rows.forEach(col => { + actualColumns[col.column_name] = { + type: col.data_type === 'USER-DEFINED' ? col.udt_name : col.data_type, + nullable: col.is_nullable === 'YES' + }; + }); + }); + + test('should have all required columns', () => { + const expectedSchema = EXPECTED_SCHEMAS.collection; + + for (const [colName, expected] of Object.entries(expectedSchema)) { + expect(actualColumns).toHaveProperty(colName); + } + }); + + test('should have correct data types', () => { + const expectedSchema = EXPECTED_SCHEMAS.collection; + + for (const [colName, expected] of Object.entries(expectedSchema)) { + const actual = actualColumns[colName]; + if (!actual) continue; + + const actualType = actual.type.toLowerCase(); + const expectedType = expected.type.toLowerCase(); + + const typeMatch = actualType === expectedType || + actualType.includes(expectedType) || + expectedType.includes(actualType) || + (expectedType === 'geometry' && actualType === 'geometry'); + + expect(typeMatch).toBe(true); + } + }); + + test('should have geometry column', () => { + expect(actualColumns.spatial_extend).toBeDefined(); + expect(actualColumns.spatial_extend.type).toBe('geometry'); + }); + + test('should have jsonb column', () => { + expect(actualColumns.full_json).toBeDefined(); + expect(actualColumns.full_json.type).toBe('jsonb'); + }); + }); + + describe('Schema Validation - Catalog Table', () => { + const actualColumns = {}; + + beforeAll(async () => { + const columnsResult = await query(` + SELECT + column_name, + data_type, + udt_name, + is_nullable + FROM information_schema.columns + WHERE table_name = 'catalog' + ORDER BY ordinal_position + `); + + columnsResult.rows.forEach(col => { + actualColumns[col.column_name] = { + type: col.data_type === 'USER-DEFINED' ? col.udt_name : col.data_type, + nullable: col.is_nullable === 'YES' + }; + }); + }); + + test('should have all required columns', () => { + const expectedSchema = EXPECTED_SCHEMAS.catalog; + + for (const [colName, expected] of Object.entries(expectedSchema)) { + expect(actualColumns).toHaveProperty(colName); + } + }); + + test('should have correct data types', () => { + const expectedSchema = EXPECTED_SCHEMAS.catalog; + + for (const [colName, expected] of Object.entries(expectedSchema)) { + const actual = actualColumns[colName]; + if (!actual) continue; + + const actualType = actual.type.toLowerCase(); + const expectedType = expected.type.toLowerCase(); + + const typeMatch = actualType === expectedType || + actualType.includes(expectedType) || + expectedType.includes(actualType); + + expect(typeMatch).toBe(true); + } + }); + }); + + describe('Data Retrieval - Collection Table', () => { + test('should have data in collection table', async () => { + const countResult = await query(`SELECT COUNT(*) as count FROM collection`); + const rowCount = parseInt(countResult.rows[0].count); + + expect(rowCount).toBeGreaterThan(0); + }); + + test('should retrieve sample collection data', async () => { + const sampleResult = await query(`SELECT * FROM collection LIMIT 1`); + + expect(sampleResult.rows).toHaveLength(1); + + const sample = sampleResult.rows[0]; + expect(sample).toHaveProperty('id'); + // expect(sample).toHaveProperty('stac_id'); // Column does not exist in database + expect(sample).toHaveProperty('title'); + }); + + test('should have valid required fields', async () => { + const sampleResult = await query(`SELECT * FROM collection LIMIT 1`); + const sample = sampleResult.rows[0]; + const expectedSchema = EXPECTED_SCHEMAS.collection; + + for (const [colName, expected] of Object.entries(expectedSchema)) { + if (expected.required) { + expect(sample[colName]).not.toBeNull(); + expect(sample[colName]).not.toBeUndefined(); + } + } + }); + + test('should have valid geometry data', async () => { + const geomResult = await query(` + SELECT ST_GeometryType(spatial_extend) as geom_type + FROM collection + WHERE spatial_extend IS NOT NULL + LIMIT 1 + `); + + expect(geomResult.rows).toHaveLength(1); + expect(geomResult.rows[0].geom_type).toBeDefined(); + }); + + test('should have valid JSONB data', async () => { + const jsonResult = await query(` + SELECT full_json + FROM collection + WHERE full_json IS NOT NULL + LIMIT 1 + `); + + expect(jsonResult.rows).toHaveLength(1); + expect(typeof jsonResult.rows[0].full_json).toBe('object'); + expect(Object.keys(jsonResult.rows[0].full_json).length).toBeGreaterThan(0); + }); + }); + + describe('Data Retrieval - Catalog Table', () => { + test('should have data in catalog table', async () => { + const countResult = await query(`SELECT COUNT(*) as count FROM catalog`); + const rowCount = parseInt(countResult.rows[0].count); + + expect(rowCount).toBeGreaterThan(0); + }); + + test('should retrieve sample catalog data', async () => { + const sampleResult = await query(`SELECT * FROM catalog LIMIT 1`); + + expect(sampleResult.rows).toHaveLength(1); + + const sample = sampleResult.rows[0]; + expect(sample).toHaveProperty('id'); + // expect(sample).toHaveProperty('stac_id'); // Column does not exist in database + expect(sample).toHaveProperty('description'); + }); + + test('should have valid required fields', async () => { + const sampleResult = await query(`SELECT * FROM catalog LIMIT 1`); + const sample = sampleResult.rows[0]; + const expectedSchema = EXPECTED_SCHEMAS.catalog; + + for (const [colName, expected] of Object.entries(expectedSchema)) { + if (expected.required) { + expect(sample[colName]).not.toBeNull(); + expect(sample[colName]).not.toBeUndefined(); + } + } + }); + }); +}); diff --git a/api/__tests__/validators.test.js b/api/__tests__/validators.test.js new file mode 100644 index 0000000..c0e50cd --- /dev/null +++ b/api/__tests__/validators.test.js @@ -0,0 +1,407 @@ +// __tests__/validators.test.js + +const { + validateQ, + validateBbox, + validateDatetime, + validateLimit, + validateSortby, + validateToken +} = require('../validators/collectionSearchParams'); + +describe('Collection Search Parameter Validators', () => { + + describe('validateQ - Free-text search', () => { + it('should accept valid q parameter', () => { + const result = validateQ('sentinel'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('sentinel'); + }); + + it('should trim whitespace from q parameter', () => { + const result = validateQ(' landsat california '); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('landsat california'); + }); + + it('should accept undefined q (optional parameter)', () => { + const result = validateQ(undefined); + expect(result.valid).toBe(true); + expect(result.normalized).toBeUndefined(); + }); + + it('should accept empty string', () => { + const result = validateQ(''); + expect(result.valid).toBe(true); + }); + + it('should reject non-string q', () => { + const result = validateQ(123); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a string'); + }); + + it('should reject q exceeding max length', () => { + const longString = 'a'.repeat(501); + const result = validateQ(longString); + expect(result.valid).toBe(false); + expect(result.error).toContain('exceeds maximum length'); + }); + + it('should accept q at max length boundary', () => { + const maxString = 'a'.repeat(500); + const result = validateQ(maxString); + expect(result.valid).toBe(true); + }); + }); + + describe('validateBbox - Bounding box', () => { + it('should accept valid bbox as comma-separated string', () => { + const result = validateBbox('-10,40,10,50'); + expect(result.valid).toBe(true); + expect(result.normalized).toEqual([-10, 40, 10, 50]); + }); + + it('should accept valid bbox as array', () => { + const result = validateBbox([-10, 40, 10, 50]); + expect(result.valid).toBe(true); + expect(result.normalized).toEqual([-10, 40, 10, 50]); + }); + + it('should accept bbox with decimal coordinates', () => { + const result = validateBbox('-122.5,37.7,-122.3,37.9'); + expect(result.valid).toBe(true); + expect(result.normalized).toEqual([-122.5, 37.7, -122.3, 37.9]); + }); + + it('should accept undefined bbox (optional)', () => { + const result = validateBbox(undefined); + expect(result.valid).toBe(true); + }); + + it('should reject bbox with wrong number of coordinates', () => { + const result = validateBbox('1,2,3'); + expect(result.valid).toBe(false); + expect(result.error).toContain('exactly 4 coordinates'); + }); + + it('should reject bbox with non-numeric values', () => { + const result = validateBbox('a,b,c,d'); + expect(result.valid).toBe(false); + expect(result.error).toContain('invalid numeric values'); + }); + + it('should reject bbox where minX >= maxX', () => { + const result = validateBbox('10,40,10,50'); + expect(result.valid).toBe(false); + expect(result.error).toContain('minX must be less than maxX'); + }); + + it('should reject bbox where minX > maxX', () => { + const result = validateBbox('10,40,-10,50'); + expect(result.valid).toBe(false); + expect(result.error).toContain('minX must be less than maxX'); + }); + + it('should reject bbox where minY >= maxY', () => { + const result = validateBbox('-10,50,10,50'); + expect(result.valid).toBe(false); + expect(result.error).toContain('minY must be less than maxY'); + }); + + it('should reject bbox with longitude out of range', () => { + const result = validateBbox('-181,40,10,50'); + expect(result.valid).toBe(false); + expect(result.error).toContain('longitude values must be between -180 and 180'); + }); + + it('should reject bbox with latitude out of range', () => { + const result = validateBbox('-10,91,10,50'); + expect(result.valid).toBe(false); + expect(result.error).toContain('latitude values must be between -90 and 90'); + }); + + it('should accept bbox at coordinate boundaries', () => { + const result = validateBbox('-180,-90,180,90'); + expect(result.valid).toBe(true); + expect(result.normalized).toEqual([-180, -90, 180, 90]); + }); + + it('should reject invalid type for bbox', () => { + const result = validateBbox(123); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be an array or comma-separated string'); + }); + }); + + describe('validateDatetime - Temporal filter', () => { + it('should accept single ISO8601 datetime', () => { + const result = validateDatetime('2020-01-01T00:00:00Z'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('2020-01-01T00:00:00Z'); + }); + + it('should accept date without time', () => { + const result = validateDatetime('2020-01-01'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('2020-01-01'); + }); + + it('should accept closed interval', () => { + const result = validateDatetime('2019-01-01/2021-12-31'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('2019-01-01/2021-12-31'); + }); + + it('should accept open-ended start interval', () => { + const result = validateDatetime('../2021-12-31'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('../2021-12-31'); + }); + + it('should accept open-ended end interval', () => { + const result = validateDatetime('2019-01-01/..'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('2019-01-01/..'); + }); + + it('should accept datetime with timezone offset', () => { + const result = validateDatetime('2020-01-01T00:00:00+02:00'); + expect(result.valid).toBe(true); + }); + + it('should accept datetime with milliseconds', () => { + const result = validateDatetime('2020-01-01T00:00:00.123Z'); + expect(result.valid).toBe(true); + }); + + it('should accept undefined datetime (optional)', () => { + const result = validateDatetime(undefined); + expect(result.valid).toBe(true); + }); + + it('should reject non-string datetime', () => { + const result = validateDatetime(123); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a string'); + }); + + it('should reject invalid ISO8601 format', () => { + const result = validateDatetime('not-a-date'); + expect(result.valid).toBe(false); + expect(result.error).toContain('not valid ISO8601'); + }); + + it('should reject invalid date values', () => { + const result = validateDatetime('2020-13-45'); + expect(result.valid).toBe(false); + expect(result.error).toContain('not valid ISO8601'); + }); + + it('should reject interval with multiple separators', () => { + const result = validateDatetime('2019/2020/2021'); + expect(result.valid).toBe(false); + expect(result.error).toContain('exactly one "/" separator'); + }); + + it('should reject fully unbounded interval', () => { + const result = validateDatetime('../..'); + expect(result.valid).toBe(false); + expect(result.error).toContain('cannot be unbounded on both sides'); + }); + + it('should reject interval with invalid start', () => { + const result = validateDatetime('invalid/2021-12-31'); + expect(result.valid).toBe(false); + expect(result.error).toContain('start value'); + }); + + it('should reject interval with invalid end', () => { + const result = validateDatetime('2019-01-01/invalid'); + expect(result.valid).toBe(false); + expect(result.error).toContain('end value'); + }); + }); + + describe('validateLimit - Result limit', () => { + it('should accept valid limit', () => { + const result = validateLimit('50'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(50); + }); + + it('should accept limit as number', () => { + const result = validateLimit(25); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(25); + }); + + it('should return default when undefined', () => { + const result = validateLimit(undefined); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(10); + }); + + it('should accept limit at minimum boundary', () => { + const result = validateLimit('1'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(1); + }); + + it('should accept limit at maximum boundary', () => { + const result = validateLimit('10000'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(10000); + }); + + it('should reject limit less than 1', () => { + const result = validateLimit('0'); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be at least 1'); + }); + + it('should reject negative limit', () => { + const result = validateLimit('-5'); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be at least 1'); + }); + + it('should reject limit exceeding maximum', () => { + const result = validateLimit('10001'); + expect(result.valid).toBe(false); + expect(result.error).toContain('must not exceed 10000'); + }); + + it('should reject non-numeric limit', () => { + const result = validateLimit('abc'); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a valid integer'); + }); + + it('should reject decimal limit', () => { + const result = validateLimit('10.5'); + expect(result.valid).toBe(false); + expect(result.error).toContain('integer'); + }); + }); + + describe('validateSortby - Sort specification', () => { + it('should accept ascending sort with + prefix', () => { + const result = validateSortby('+title'); + expect(result.valid).toBe(true); + expect(result.normalized).toEqual({ field: 'title', direction: 'ASC' }); + }); + + it('should accept descending sort with - prefix', () => { + const result = validateSortby('-created'); + expect(result.valid).toBe(true); + // Field is mapped to database column name + expect(result.normalized).toEqual({ field: 'created_at', direction: 'DESC' }); + }); + + it('should default to ascending without prefix', () => { + const result = validateSortby('id'); + expect(result.valid).toBe(true); + expect(result.normalized).toEqual({ field: 'id', direction: 'ASC' }); + }); + + it('should accept all allowed fields', () => { + const fieldMapping = { + 'title': 'title', + 'id': 'id', + 'license': 'license', + 'created': 'created_at', + 'updated': 'updated_at' + }; + + const fields = ['title', 'id', 'license', 'created', 'updated']; + fields.forEach(field => { + const result = validateSortby(field); + expect(result.valid).toBe(true); + // Should be mapped to database column name + expect(result.normalized.field).toBe(fieldMapping[field]); + }); + }); + + it('should accept undefined sortby (optional)', () => { + const result = validateSortby(undefined); + expect(result.valid).toBe(true); + expect(result.normalized).toBeUndefined(); + }); + + it('should reject unsupported field', () => { + const result = validateSortby('unsupported_field'); + expect(result.valid).toBe(false); + expect(result.error).toContain('not supported'); + expect(result.error).toContain('Allowed fields:'); + }); + + it('should reject non-string sortby', () => { + const result = validateSortby(123); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a string'); + }); + + it('should reject empty field name (with + prefix)', () => { + const result = validateSortby('+'); + expect(result.valid).toBe(false); + expect(result.error).toContain('must specify a field'); + }); + + it('should reject empty field name (without prefix)', () => { + const result = validateSortby(""); + expect(result.valid).toBe(false); + expect(result.error).toContain('must specify a field'); + }); + }); + + describe('validateToken - Pagination token', () => { + it('should accept valid token', () => { + const result = validateToken('50'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(50); + }); + + it('should accept token as number', () => { + const result = validateToken(100); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(100); + }); + + it('should return default when undefined', () => { + const result = validateToken(undefined); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(0); + }); + + it('should accept zero token', () => { + const result = validateToken('0'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(0); + }); + + it('should accept large token values', () => { + const result = validateToken('999999'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(999999); + }); + + it('should reject negative token', () => { + const result = validateToken('-1'); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be non-negative'); + }); + + it('should reject non-numeric token', () => { + const result = validateToken('abc'); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a valid integer'); + }); + + it('should handle string zero', () => { + const result = validateToken('0'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe(0); + }); + }); +}); diff --git a/api/__tests__/verify-schema.test.js b/api/__tests__/verify-schema.test.js new file mode 100644 index 0000000..e03c118 --- /dev/null +++ b/api/__tests__/verify-schema.test.js @@ -0,0 +1,365 @@ +const { query, closePool } = require('../db/db_APIconnection'); + +/** + * Jest Test Suite: Verify Database Schema for Collection and Catalog Tables + * Checks that both tables have all required columns with valid data + */ + +describe('Database Schema Verification', () => { + + afterAll(async () => { + await closePool(); + }); + + describe('Collection Table Structure', () => { + let tableInfo; + + beforeAll(async () => { + tableInfo = await query(` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_name = 'collection' + ORDER BY ordinal_position + `); + }); + + test('should have table structure', () => { + expect(tableInfo.rowCount).toBeGreaterThan(0); + }); + + test('should have at least 14 columns', () => { + expect(tableInfo.rowCount).toBeGreaterThanOrEqual(14); + }); + }); + + describe('Collection Table - Column Data Integrity', () => { + test.each([ + ['id', 'integer'], + // ['stac_id', 'text'], // Column does not exist in database + ['title', 'text'], + ['description', 'text'], + ['license', 'text'], + ['spatial_extend', 'USER-DEFINED'], + ['full_json', 'jsonb'], + ['is_active', 'boolean'], + ['is_api', 'boolean'] + ])('column %s should exist with type %s', async (colName, expectedType) => { + const stats = await query(` + SELECT + COUNT(*) as total_rows, + COUNT(${colName}) as non_null_count + FROM collection + `); + + const stat = stats.rows[0]; + expect(parseInt(stat.total_rows)).toBeGreaterThanOrEqual(0); + + // If table has data, check that non-spatial columns have data + if (parseInt(stat.total_rows) > 0 && colName !== 'spatial_extend') { + expect(parseInt(stat.non_null_count)).toBeGreaterThan(0); + } + }); + + test('should have valid geometry type in spatial_extend if data exists', async () => { + const geomType = await query(` + SELECT ST_GeometryType(spatial_extend) as geom_type + FROM collection + WHERE spatial_extend IS NOT NULL + LIMIT 1 + `); + + // Only check geometry type if there is data + if (geomType.rows.length > 0) { + expect(geomType.rows[0].geom_type).toMatch(/^ST_/); + } else { + expect(geomType.rows.length).toBe(0); // Pass if no data + } + }); + + test('should have valid JSONB data in full_json if data exists', async () => { + const sample = await query(` + SELECT full_json + FROM collection + WHERE full_json IS NOT NULL + LIMIT 1 + `); + + // Only check JSONB if there is data + if (sample.rows.length > 0) { + expect(typeof sample.rows[0].full_json).toBe('object'); + expect(Object.keys(sample.rows[0].full_json).length).toBeGreaterThan(0); + } else { + expect(sample.rows.length).toBe(0); // Pass if no data + } + }); + + test('should have valid timestamps if data exists', async () => { + const sample = await query(` + SELECT created_at, updated_at + FROM collection + LIMIT 1 + `); + + // Only check timestamps if there is data + if (sample.rows.length > 0) { + expect(sample.rows[0].created_at).toBeInstanceOf(Date); + expect(sample.rows[0].updated_at).toBeInstanceOf(Date); + } else { + expect(sample.rows.length).toBe(0); // Pass if no data + } + }); + }); + + describe('Collection Table - Indexes', () => { + test('should have indexes', async () => { + const indexCheck = await query(` + SELECT + indexname, + indexdef + FROM pg_indexes + WHERE tablename = 'collection' + `); + + expect(indexCheck.rowCount).toBeGreaterThan(0); + }); + }); + + describe('Collection Table - Overall Statistics', () => { + test('should be queryable (may be empty)', async () => { + const countResult = await query(`SELECT COUNT(*) as count FROM collection`); + const count = parseInt(countResult.rows[0].count); + + expect(count).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Catalog Table Structure', () => { + let tableInfo; + + beforeAll(async () => { + tableInfo = await query(` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_name = 'catalog' + ORDER BY ordinal_position + `); + }); + + test('should have table structure', () => { + expect(tableInfo.rowCount).toBeGreaterThan(0); + }); + + test('should have at least 7 columns', () => { + expect(tableInfo.rowCount).toBeGreaterThanOrEqual(7); + }); + }); + + describe('Catalog Table - Column Data Integrity', () => { + test.each([ + ['id', 'integer'], + // ['stac_id', 'text'], // Column does not exist in database + ['stac_version', 'text'], + ['type', 'text'], + ['description', 'text'] + ])('column %s should exist with type %s', async (colName, expectedType) => { + const stats = await query(` + SELECT + COUNT(*) as total_rows, + COUNT(${colName}) as non_null_count + FROM catalog + `); + + const stat = stats.rows[0]; + expect(parseInt(stat.total_rows)).toBeGreaterThanOrEqual(0); + + // If table has data, check that columns have data + if (parseInt(stat.total_rows) > 0) { + expect(parseInt(stat.non_null_count)).toBeGreaterThan(0); + } + }); + + test('should have valid timestamps if data exists', async () => { + const sample = await query(` + SELECT created_at, updated_at + FROM catalog + LIMIT 1 + `); + + // Only check timestamps if there is data + if (sample.rows.length > 0) { + expect(sample.rows[0].created_at).toBeInstanceOf(Date); + expect(sample.rows[0].updated_at).toBeInstanceOf(Date); + } else { + expect(sample.rows.length).toBe(0); // Pass if no data + } + }); + }); + + describe('Catalog Table - Overall Statistics', () => { + test('should be queryable (may be empty)', async () => { + const countResult = await query(`SELECT COUNT(*) as count FROM catalog`); + const count = parseInt(countResult.rows[0].count); + + expect(count).toBeGreaterThanOrEqual(0); + }); + }); +}); + +// Legacy function for backwards compatibility (not used in tests) +async function verifyTableSchema(tableName, displayName) { + console.log(`=== ${displayName} Schema Verification ===\n`); + + try { + // Get table structure + console.log(`1. Checking ${tableName} table structure...`); + const tableInfo = await query(` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_name = $1 + ORDER BY ordinal_position + `, [tableName]); + + console.log(`✓ Found ${tableInfo.rowCount} columns in ${tableName} table\n`); + + console.log('2. Verifying all columns and their data...\n'); + + let allColumnsValid = true; + let columnsWithData = 0; + let columnsWithNulls = 0; + + // Check each column for data integrity + for (const col of tableInfo.rows) { + const colName = col.column_name; + const dataType = col.data_type; + + try { + // Get statistics for this column + const stats = await query(` + SELECT + COUNT(*) as total_rows, + COUNT(${colName}) as non_null_count, + COUNT(*) - COUNT(${colName}) as null_count + FROM ${tableName} + `); + + const stat = stats.rows[0]; + const percentNonNull = stat.total_rows > 0 + ? ((stat.non_null_count / stat.total_rows) * 100).toFixed(1) + : 0; + + if (stat.non_null_count > 0) { + columnsWithData++; + console.log(`✓ ${colName} (${dataType})`); + console.log(` └─ ${stat.non_null_count}/${stat.total_rows} rows (${percentNonNull}% filled)`); + + // Sample a value to verify data format + if (colName !== 'spatial_extend') { // Skip geometry for display + const sample = await query(` + SELECT ${colName} + FROM ${tableName} + WHERE ${colName} IS NOT NULL + LIMIT 1 + `); + + if (sample.rows[0]) { + let sampleValue = sample.rows[0][colName]; + + // Format output based on data type + if (typeof sampleValue === 'object' && sampleValue !== null) { + sampleValue = JSON.stringify(sampleValue).substring(0, 80) + '...'; + } else if (typeof sampleValue === 'string') { + sampleValue = sampleValue.substring(0, 60) + (sampleValue.length > 60 ? '...' : ''); + } + + console.log(` └─ Sample: ${sampleValue}`); + } + } else { + // For geometry, show type + const geomType = await query(` + SELECT ST_GeometryType(${colName}) as geom_type + FROM ${tableName} + WHERE ${colName} IS NOT NULL + LIMIT 1 + `); + if (geomType.rows[0]) { + console.log(` └─ Geometry type: ${geomType.rows[0].geom_type}`); + } + } + console.log(''); + } else if (stat.total_rows > 0) { + columnsWithNulls++; + console.log(`⚠ ${colName} (${dataType})`); + console.log(` └─ All ${stat.total_rows} rows are NULL`); + console.log(''); + } else { + console.log(`⚠ ${colName} (${dataType})`); + console.log(` └─ No data in table`); + console.log(''); + } + + } catch (error) { + console.log(`✗ ${colName} (${dataType})`); + console.log(` └─ Error checking data: ${error.message}`); + console.log(''); + allColumnsValid = false; + } + } + + console.log(`Summary: ${columnsWithData} columns with data, ${columnsWithNulls} columns all NULL\n`); + + // Check for indexes + console.log('3. Checking indexes...'); + const indexCheck = await query(` + SELECT + indexname, + indexdef + FROM pg_indexes + WHERE tablename = $1 + `, [tableName]); + + if (indexCheck.rowCount > 0) { + console.log(`✓ Found ${indexCheck.rowCount} index(es):`); + indexCheck.rows.forEach(idx => { + console.log(` - ${idx.indexname}`); + }); + } else { + console.log('⚠ No indexes found'); + } + + // Check row count + console.log('\n4. Checking overall data statistics...'); + const countResult = await query(`SELECT COUNT(*) as count FROM ${tableName}`); + console.log(`✓ ${displayName} table contains ${countResult.rows[0].count} rows`); + + console.log(`\n=== ${displayName} Schema Verification Complete ===`); + + if (allColumnsValid) { + console.log('\n✓ All required columns present'); + process.exit(0); + } else { + console.log('\n✗ Some required columns are missing'); + process.exit(1); + } + + } catch (error) { + console.error('✗ Schema verification failed:', error.message); + return false; + } +} + +// Export for manual testing if needed +if (require.main === module) { + verifyTableSchema('collection', 'Collection').then(() => process.exit(0)); +} diff --git a/api/app.js b/api/app.js new file mode 100644 index 0000000..bf5f4f5 --- /dev/null +++ b/api/app.js @@ -0,0 +1,69 @@ +require('dotenv').config(); +const express = require('express'); +const logger = require('morgan'); +const cors = require('cors'); +const swaggerUi = require('swagger-ui-express'); +const YAML = require('yamljs'); +const path = require('path'); + +// Import routes +const indexRouter = require('./routes/index'); +const conformanceRouter = require('./routes/conformance'); +const collectionsRouter = require('./routes/collections'); +const queryablesRouter = require('./routes/queryables'); + +const app = express(); + +// Middleware +app.use(logger('dev')); +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); + +// CORS configuration - allow requests from frontend +app.use(cors({ + origin: process.env.CORS_ORIGIN || '*', + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); + +// Content-Type header for all JSON responses +app.use((req, res, next) => { + res.setHeader('Content-Type', 'application/json'); + next(); +}); + +// STAC API routes +app.use('/', indexRouter); +app.use('/conformance', conformanceRouter); +app.use('/collections', collectionsRouter); +app.use('/queryables', queryablesRouter); + +// Swagger/OpenAPI documentation (if openapi.yaml exists) +try { + const swaggerDocument = YAML.load(path.join(__dirname, 'docs', 'openapi.yaml')); + app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); +} catch (err) { + console.log('OpenAPI documentation not found. Create docs/openapi.yaml to enable Swagger UI.'); +} + +// 404 handler +app.use((req, res, next) => { + res.status(404).json({ + code: 'NotFound', + description: `The requested resource '${req.url}' was not found on this server.` + }); +}); + +// Error handler +app.use((err, req, res, next) => { + // Set locals, only providing error in development + const isDev = req.app.get('env') === 'development'; + + res.status(err.status || 500).json({ + code: err.code || 'InternalServerError', + description: err.message || 'An internal server error occurred', + ...(isDev && { stack: err.stack }) + }); +}); + +module.exports = app; \ No newline at end of file diff --git a/api/bin/www b/api/bin/www new file mode 100644 index 0000000..81cb39b --- /dev/null +++ b/api/bin/www @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +var app = require('../app'); +var debug = require('debug')('api:server'); +var http = require('http'); + +/** + * Get port from environment and store in Express. + */ + +var port = normalizePort(process.env.PORT || '3000'); +app.set('port', port); + +/** + * Create HTTP server. + */ + +var server = http.createServer(app); + +/** + * Listen on provided port, on all network interfaces. + */ + +server.listen(port); +server.on('error', onError); +server.on('listening', onListening); + +/** + * Normalize a port into a number, string, or false. + */ + +function normalizePort(val) { + var port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + +/** + * Event listener for HTTP server "error" event. + */ + +function onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + var bind = typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening() { + var addr = server.address(); + var bind = typeof addr === 'string' + ? 'pipe ' + addr + : 'port ' + addr.port; + debug('Listening on ' + bind); +} diff --git a/api/config/conformanceURIS.js b/api/config/conformanceURIS.js new file mode 100644 index 0000000..2154168 --- /dev/null +++ b/api/config/conformanceURIS.js @@ -0,0 +1,25 @@ +// config/conformanceURIS.js + +// Shared list of conformance URIs used by both: +// - GET / +// - GET /conformance + +const CONFORMANCE_URIS = [ + 'https://api.stacspec.org/v1.0.0/core', + 'https://api.stacspec.org/v1.0.0/collections', + // Collection Search conformance classes + 'https://api.stacspec.org/v1.0.0/collection-search', + 'http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query', // Simple Query (bbox, datetime, limit) + 'https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text', // Free-text search + 'https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter', // CQL2 Filter' + 'https://api.stacspec.org/v1.1.0/collection-search#sort', // Sorting + // CQL2 conformance classes + "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2", // Basic CQL2 + "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json", // CQL2 JSON-Querys + "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", // CQL2 Text-Querys + "http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-functions" // Basic Spatial Functions + ]; + +module.exports = { + CONFORMANCE_URIS +}; diff --git a/api/data/collections.js b/api/data/collections.js new file mode 100644 index 0000000..e58bcbf --- /dev/null +++ b/api/data/collections.js @@ -0,0 +1,105 @@ +// Small in-memory sample of collections for basic GET /collections implementation +// +// This file is intentionally simple and used only for local testing and +// unit-tests. Each entry represents a minimal STAC Collection-like object +// containing common STAC fields (id, title, description, keywords, extent, etc). +// In a production deployment this should be replaced by a database query +// that returns fully validated STAC Collection objects. +module.exports = [ + { + id: 'sentinel-2-l2a', + stac_version: '1.0.0', + type: 'Collection', + title: 'Sentinel-2 L2A Collection', + description: 'Sentinel-2 Level-2A processed imagery from Copernicus', + keywords: ['sentinel-2', 'optical', 'multispectral'], + license: 'CC-BY-4.0', + providers: [ + { + name: 'ESA', + roles: ['producer', 'licensor'], + url: 'https://www.esa.int/' + } + ], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-23T00:00:00Z', null]] } + }, + links: [ + { + rel: 'self', + href: 'https://example.com/collections/sentinel-2-l2a', + type: 'application/json' + }, + { + rel: 'parent', + href: 'https://example.com/', + type: 'application/json' + } + ] + }, + { + id: 'landsat-8-l1', + stac_version: '1.0.0', + type: 'Collection', + title: 'Landsat 8 Level-1', + description: 'Landsat 8 Collection 1 Level 1 data', + keywords: ['landsat', 'optical', 'multispectral'], + license: 'CC0-1.0', + providers: [ + { + name: 'USGS', + roles: ['producer'], + url: 'https://www.usgs.gov/' + } + ], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2013-02-11T00:00:00Z', null]] } + }, + links: [ + { + rel: 'self', + href: 'https://example.com/collections/landsat-8-l1', + type: 'application/json' + }, + { + rel: 'parent', + href: 'https://example.com/', + type: 'application/json' + } + ] + }, + { + id: 'modis', + stac_version: '1.0.0', + type: 'Collection', + title: 'MODIS Daily', + description: 'MODIS daily composites from NASA Earth Observatories', + keywords: ['modis', 'daily', 'thermal', 'visible'], + license: 'CC0-1.0', + providers: [ + { + name: 'NASA', + roles: ['producer', 'licensor'], + url: 'https://www.nasa.gov/' + } + ], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2000-02-24T00:00:00Z', null]] } + }, + links: [ + { + rel: 'self', + href: 'https://example.com/collections/modis', + type: 'application/json' + }, + { + rel: 'parent', + href: 'https://example.com/', + type: 'application/json' + } + ] + } +]; diff --git a/api/db/buildCollectionSearchQuery.js b/api/db/buildCollectionSearchQuery.js new file mode 100644 index 0000000..cc1d435 --- /dev/null +++ b/api/db/buildCollectionSearchQuery.js @@ -0,0 +1,220 @@ + +/* function buildCollectionSearchQuery + * Dynamically constructs a parameterized SQL query for the /collections endpoint. + * + * This function converts validated API search parameters into a safe, optimized, + * database-ready SQL statement. It supports multiple filter types (full-text, spatial, + * temporal), dynamic SELECT column injection (rank), sorting and pagination. + * + * The SELECT part focuses on the core STAC collection metadata, as described in the bid + * and the database schema: + * - id, stac_version, type, title, description, license + * - spatial_extend, temporal_extend_start, temporal_extend_end + * - created_at, updated_at, is_api, is_active + * - full_json (complete STAC Collection document as JSONB) + * + * @param {Object} params + * @param {string|undefined} params.q + * Full-text search query. Currently searches in: + * - collection.title + * - collection.description + * + * The bid requires full-text search across title, description and keywords + * (and possibly providers). Integration of keywords/providers into the + * tsvector (via join or dedicated search_vector column) is planned as a + * follow-up refinement. + * + * Note: Keywords are not yet part of the full-text vector. They will be added + * in a follow-up step once the database exposes a canonical keyword aggregation + * + * When `q` is present, a tsvector is built from title/description + * using the same expression as the GIN index + * (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))). + * We use plainto_tsquery('simple', $n) and add ts_rank_cd(...) AS rank + * to the SELECT list so we can order by relevance. + * + * @param {number[]|undefined} params.bbox + * Spatial filter as [minX, minY, maxX, maxY] in EPSG:4326. + * When present, the query adds: + * ST_Intersects(spatial_extend, ST_MakeEnvelope($x, $y, $z, $w, 4326)) + * + * @param {string|undefined} params.datetime + * Temporal filter in ISO8601: + * - single instant: "2020-01-01T00:00:00Z" + * - closed interval: "2019-01-01/2021-12-31" + * - open start/end: "../2021-12-31" or "2019-01-01/.." + * + * The collection is matched if its temporal_extend_start/temporal_extend_end + * overlap the requested interval. + * + * @param {{field: string, direction: 'ASC'|'DESC'}|undefined} params.sortby + * Normalized sort description. Field is restricted to an allowed + * whitelist (id, title, license, created_at, updated_at, …). + * If provided, ORDER BY is used. + * If omitted and `q` is present, results are ordered by rank DESC, id ASC. + * If omitted and `q` is not present, results are ordered by id ASC. + * + * @param {number} params.limit + * Maximum number of rows to return. Already validated to be + * within [1, 10000]. Translated to LIMIT $n. + * + * @param {number} params.token + * Offset for pagination (0-based). Translated to OFFSET $n. + * + * @returns {{ sql: string, values: any[] }} + * sql – complete parameterized SQL string + * values – array of bind parameters in the correct order + */ + +function buildCollectionSearchQuery(params) { + const { + q, + bbox, + datetime, + sortby, + limit, + token + } = params; + + // Base SELECT columns. We may append a relevance `rank` column below when `q` is present. + // + // Rationale: we build the SELECT portion separately into `selectPart` so that + // we can conditionally append computed columns (for example the `rank` from + // full-text search) *before* the `FROM` clause. Keeping `sql` fixed with a + // `FROM` already included would make inserting additional selected columns + // harder and error-prone when building the query dynamically. + let selectPart = ` + SELECT + id, + stac_version, + type, + title, + description, + license, + spatial_extend, + temporal_extend_start, + temporal_extend_end, + created_at, + updated_at, + is_api, + is_active, + full_json + `; + + const where = []; + const values = []; + let i = 1; + + // Full-text search using weighted tsvector across title (weight A) and description (weight B). + // + // Notes: + // - Currently only title and description are included in the weighted tsvector. + // Collection keywords must also participate in full-text search. + // This will be added once the database team finalizes how keywords should be aggregated (JOIN + string_agg or dedicated tsvector). + // - We use `plainto_tsquery` to convert user-entered text into a tsquery. This keeps + // behaviour simple and predictable for short queries entered by users. + // - `ts_rank_cd` computes a relevance score; we add it to the SELECT list as `rank` + // so it can be used for ordering (when no explicit `sortby` is provided). + // - currently we are using on-the-fly tsvector expressions (matching to the 05_indexes.sql): + // A persistant tsvector collumn could be added later for large-scale indexing (watch Database Issues) + // + // Use the same parameter index for both the WHERE clause and the computed rank so the + // prepared statement uses a single bind parameter for the query text. + if (q) { + const queryIndex = i; // remember index to reuse for rank and condition + + // Weighted combined tsvector expression + const vectorExpr = `to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))`; + + // Add rank to selected columns (ts_rank_cd => constant-duration ranking function) + // The computed `rank` is available in the result rows and used for ordering + // when no explicit `sortby` is provided. + selectPart += `, ts_rank_cd(${vectorExpr}, plainto_tsquery('simple', $${queryIndex})) AS rank`; + + // WHERE clause uses plainto_tsquery for user-entered search text + where.push(`${vectorExpr} @@ plainto_tsquery('simple', $${queryIndex})`); + + values.push(q); + i++; + } + + // BBOX with PostGIS + if (bbox) { + const [minX, minY, maxX, maxY] = bbox; + + where.push(` + ST_Intersects( + spatial_extend, + ST_MakeEnvelope($${i}, $${i + 1}, $${i + 2}, $${i + 3}, 4326) + ) + `); + + values.push(minX, minY, maxX, maxY); + i += 4; + } + + // datetime: Point or interval + if (datetime) { + if (datetime.includes('/')) { + // interval: start/end, ../end, start/.. + const [start, end] = datetime.split('/'); + + if (start !== '..') { + // Collection should run after start + where.push(`temporal_extend_end >= $${i}`); + values.push(start); + i++; + } + + if (end !== '..') { + // Collection should run before end + where.push(`temporal_extend_start <= $${i}`); + values.push(end); + i++; + } + } else { + // single datetime: collections active at that time + where.push(` + temporal_extend_start <= $${i} + AND temporal_extend_end >= $${i} + `); + values.push(datetime); + i++; + } + } + + // Build final SQL from selectPart and add FROM clause. + // We delayed adding `FROM collection` to allow conditional additions to the + // selected columns above (notably `rank`). The final `sql` string includes the + // selected columns, the source table and any WHERE conditions constructed earlier. + let sql = selectPart + `\n FROM collection\n `; + + if (where.length > 0) { + sql += ` WHERE ` + where.join(' AND '); + } + + // Sorting: if a sort is explicitly requested use it; otherwise prefer relevance when + // a text query was provided (descending), falling back to id ascending. + // + // Behaviour summary: + // - `sortby` provided → use that (same as before) + // - no `sortby` & `q` present → order by `rank DESC, id ASC` so higher relevance comes first + // - no `sortby` & no `q` → order by `id ASC` (legacy default) + if (sortby) { + sql += ` ORDER BY ${sortby.field} ${sortby.direction}`; + } else if (q) { + sql += ` ORDER BY rank DESC, id ASC`; + } else { + sql += ` ORDER BY id ASC`; + } + + // Pagination (only add if limit is provided) + if (limit !== null && limit !== undefined) { + sql += ` LIMIT $${i} OFFSET $${i + 1}`; + values.push(limit, token || 0); + } + + return { sql, values }; +} + +module.exports = { buildCollectionSearchQuery }; diff --git a/api/db/db_APIconnection.js b/api/db/db_APIconnection.js new file mode 100644 index 0000000..b3a93fc --- /dev/null +++ b/api/db/db_APIconnection.js @@ -0,0 +1,268 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +// PostgreSQL/PostGIS database connection +// Support both DATABASE_URL and individual environment variables +let pool; + +// Pool configuration with connection limits and timeouts +const poolConfig = { + max: parseInt(process.env.DB_POOL_MAX), // Maximum number of clients in the pool + min: parseInt(process.env.DB_POOL_MIN), // Minimum number of clients in the pool + idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT), // Close time for idle clients + connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT), // Waiting time before timing out + allowExitOnIdle: false // Keep the pool alive even when all clients are idle +}; + +if (process.env.DATABASE_URL) { + // Use DATABASE_URL if provided + pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + + }); +} else { + // Fallback to individual environment variables + const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD']; + const missingVars = requiredEnvVars.filter(varName => !process.env[varName]); + if (missingVars.length > 0) { + throw new Error(`Missing required environment variables: ${missingVars.join(', ')} or DATABASE_URL`); + } + + pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + ...poolConfig + }); +} + +// Handle pool errors +pool.on('error', (err) => { + console.error('Unexpected database pool error:', err); +}); + +// Handle pool connection events for monitoring (only in non-test environments) +if (process.env.NODE_ENV !== 'test') { + pool.on('connect', (client) => { + console.log('New client connected to pool'); + }); + + pool.on('acquire', (client) => { + console.log('Client acquired from pool'); + }); + + pool.on('remove', (client) => { + console.log('Client removed from pool'); + }); +} + +// Graceful shutdown handlers +process.on('SIGTERM', async () => { + console.log('SIGTERM received, closing database pool...'); + await closePool(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('SIGINT received, closing database pool...'); + await closePool(); + process.exit(0); +}); + +// execute query +async function query(text, params = []) { + try { + const result = await pool.query(text, params); + return result; + } catch (error) { + // log detailed error information + console.error('Database query error:', { + message: error.message, + code: error.code, + detail: error.detail, + query: text.substring(0, 100) + (text.length > 100 ? '...' : '') + }); + + // throw enhanced error + const enhancedError = new Error(`Database query failed: ${error.message}`); + enhancedError.code = error.code; + enhancedError.detail = error.detail; + enhancedError.originalError = error; + throw enhancedError; + } +} + +// Connection test with retry logic and pool info +async function testConnection(retries = 3, delay = 2000) { + for (let i = 0; i < retries; i++) { + try { + const result = await pool.query('SELECT 1 as connected, version() as version, current_database() as database'); + const poolInfo = { + totalCount: pool.totalCount, + idleCount: pool.idleCount, + waitingCount: pool.waitingCount + }; + + console.log('✓ Database connection successful'); + console.log(` Database: ${result.rows[0].database}`); + console.log(` PostgreSQL version: ${result.rows[0].version.split(',')[0]}`); + console.log(` Pool status: ${poolInfo.totalCount} total, ${poolInfo.idleCount} idle, ${poolInfo.waitingCount} waiting`); + return true; + } catch (error) { + console.error(`✗ Connection attempt ${i + 1}/${retries} failed:`, error.message); + + if (i < retries - 1) { + console.log(` Retrying in ${delay / 1000} seconds...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + console.error('✗ All connection attempts failed'); + return false; +} + +// Get current pool statistics +function getPoolStats() { + return { + total: pool.totalCount, + idle: pool.idleCount, + waiting: pool.waitingCount + }; +} + +// PostGIS: Bounding Box Query +// @param {string} table - table name +// @param {Array} bbox - [west, south, east, north] +// @param {string} geomColumn - name of the geometry column (default: spatial_extend) +// @returns {Promise} query result +async function queryByBBox(table, bbox, geomColumn = 'spatial_extend') { + const [west, south, east, north] = bbox; + + // validate bbox ranges + if (west < -180 || west > 180 || east < -180 || east > 180) { + throw new Error('Longitude must be between -180 and 180'); + } + if (south < -90 || south > 90 || north < -90 || north > 90) { + throw new Error('Latitude must be between -90 and 90'); + } + if (west >= east) { + throw new Error('West coordinate must be less than east coordinate'); + } + if (south >= north) { + throw new Error('South coordinate must be less than north coordinate'); + } + + try { + const sql = ` + SELECT * FROM ${table} + WHERE ST_Intersects( + ${geomColumn}, + ST_MakeEnvelope($1, $2, $3, $4, 4326) + ) + `; + return await query(sql, [west, south, east, north]); + } catch (error) { + throw new Error(`BBox query failed: ${error.message}`); + } +} + +// PostGIS: Geometry Query +// @param {string} table - table name +// @param {Object} geojson - GeoJSON Geometry +// @param {string} predicate - Spatial Predicate (intersects, contains, within) +// @param {string} geomColumn - name of the geometry column (default: spatial_extend) +// @returns {Promise} query result +async function queryByGeometry(table, geojson, predicate = 'intersects', geomColumn = 'spatial_extend') { + // validate inputs + if (!table || typeof table !== 'string') { + throw new Error('Table name must be a non-empty string'); + } + if (!geojson || typeof geojson !== 'object') { + throw new Error('GeoJSON must be a valid object'); + } + if (!geojson.type || !geojson.coordinates) { + throw new Error('GeoJSON must have type and coordinates properties'); + } + + const predicates = { + intersects: 'ST_Intersects', + contains: 'ST_Contains', + within: 'ST_Within' + }; + + if (!predicates[predicate.toLowerCase()]) { + throw new Error(`Invalid predicate: ${predicate}. Must be one of: ${Object.keys(predicates).join(', ')}`); + } + + const func = predicates[predicate.toLowerCase()]; + + try { + const sql = ` + SELECT * FROM ${table} + WHERE ${func}( + ${geomColumn}, + ST_SetSRID(ST_GeomFromGeoJSON($1), 4326) + ) + `; + return await query(sql, [JSON.stringify(geojson)]); + } catch (error) { + throw new Error(`Geometry query failed: ${error.message}`); + } +} + +// PostGIS: Distance Query +// @param {string} table - table name +// @param {Array} point - [lon, lat] +// @param {number} distance - distance in meters +// @param {string} geomColumn - name of the geometry column (default: spatial_extend) +// @returns {Promise} query result +async function queryByDistance(table, point, distance, geomColumn = 'spatial_extend') { + const [lon, lat] = point; + + // Use geometry type with ST_Centroid to avoid antipodal edge errors + // ST_Centroid provides a single point from potentially large geometries + const sql = ` + SELECT *, + ST_Distance( + ST_Centroid(${geomColumn})::geography, + ST_SetSRID(ST_Point($1, $2), 4326)::geography + ) as distance + FROM ${table} + WHERE ST_DWithin( + ST_Centroid(${geomColumn})::geography, + ST_SetSRID(ST_Point($1, $2), 4326)::geography, + $3 + ) + ORDER BY distance + `; + return await query(sql, [lon, lat, distance]); +} + +// close connection +async function closePool() { + try { + await pool.end(); + console.log('✓ Database connection pool closed'); + } catch (error) { + console.error('Error closing database pool:', error.message); + throw error; + } +} + +module.exports = { + pool, + query, + testConnection, + closePool, + getPoolStats, + + // PostGIS functions + queryByBBox, + queryByGeometry, + queryByDistance +}; diff --git a/api/docs/collection-search-parameters.md b/api/docs/collection-search-parameters.md new file mode 100644 index 0000000..d9c520f --- /dev/null +++ b/api/docs/collection-search-parameters.md @@ -0,0 +1,262 @@ +# Collection Search Parameters + +This document describes the query parameters supported by the STAC Atlas Collection Search API (`GET /collections`). + +## Overview + +The Collection Search endpoint supports filtering and pagination through query parameters. All parameters are optional and can be combined to refine search results. + +## Supported Parameters + +### `q` - Free-Text Search + +**Type:** String +**Required:** No +**Description:** Free-text search across collection `title`, `description`, and `keywords` fields. + +**Constraints:** +- Maximum length: 500 characters +- Whitespace is trimmed + +**Examples:** +``` +GET /collections?q=sentinel +GET /collections?q=landsat%20Münster +``` + +**Implementation Note:** When database is connected, this will use PostgreSQL full-text search (TSVector) for efficient matching. + +--- + +### `bbox` - Bounding Box Filter + +**Type:** String (comma-separated) or Array +**Required:** No +**Format:** `minX,minY,maxX,maxY` or `[west, south, east, north]` + +**Description:** Spatial filter to find collections whose spatial extent intersects with the specified bounding box. + +**Constraints:** +- Must contain exactly 4 coordinates +- Longitude (X): -180 to 180 +- Latitude (Y): -90 to 90 +- minX < maxX +- minY < maxY + +**Examples:** +``` +GET /collections?bbox=-10,40,10,50 +GET /collections?bbox=-122.4,37.8,-122.3,37.9 +``` + +**Implementation Note:** Will use PostGIS spatial intersection queries (`ST_Intersects`) when database is connected. + +--- + +### `datetime` - Temporal Filter + +**Type:** String (ISO8601) +**Required:** No +**Description:** Temporal filter to find collections whose temporal extent overlaps with the specified time range. + +**Formats Supported:** +1. **Single datetime:** `2020-01-01T00:00:00Z` +2. **Closed interval:** `2019-01-01/2021-12-31` +3. **Open start:** `../2021-12-31` (all collections ending before date) +4. **Open end:** `2019-01-01/..` (all collections starting after date) + +**Constraints:** +- Must be valid ISO8601 format +- Intervals must have exactly one `/` separator +- Cannot be unbounded on both sides (`../..` is invalid) + +**Examples:** +``` +GET /collections?datetime=2020-01-01T00:00:00Z +GET /collections?datetime=2019-01-01/2021-12-31 +GET /collections?datetime=../2021-12-31 +GET /collections?datetime=2020-06-01/.. +``` + +**Implementation Note:** Will query `temporal_extent_start` and `temporal_extent_end` columns with overlap logic. + +--- + +### `limit` - Result Limit + +**Type:** Integer +**Required:** No +**Default:** 10 +**Description:** Maximum number of collections to return in a single response. + +**Constraints:** +- Minimum: 1 +- Maximum: 10000 +- Default: 10 + +**Examples:** +``` +GET /collections?limit=50 +GET /collections?limit=100 +``` + +**Pagination Note:** Use together with `token` parameter to paginate through large result sets. + +--- + +### `sortby` - Sort Order + +**Type:** String +**Required:** No +**Format:** `[+|-]field` +**Description:** Specifies the field and direction for sorting results. + +**Direction Syntax:** +- `+field` or `field` = Ascending order (A-Z, 0-9) +- `-field` = Descending order (Z-A, 9-0) + +**Allowed Fields:** +- `title` - Collection title (alphabetical) +- `id` - Collection identifier +- `license` - License identifier +- `created` - Creation timestamp +- `updated` - Last update timestamp + +**Examples:** +``` +GET /collections?sortby=title # Ascending by title (default) +GET /collections?sortby=+title # Explicit ascending +GET /collections?sortby=-created # Newest first +GET /collections?sortby=-updated # Most recently updated first +``` + +**Default Behavior:** When no `sortby` is specified, results are returned in database order (typically by ID). + +--- + +### `token` - Pagination Token + +**Type:** Integer +**Required:** No +**Default:** 0 +**Description:** Pagination continuation token (offset) to retrieve the next page of results. + +**Constraints:** +- Must be non-negative integer +- Value represents the offset into the result set + +**Examples:** +``` +GET /collections?limit=10&token=0 # First page (results 0-9) +GET /collections?limit=10&token=10 # Second page (results 10-19) +GET /collections?limit=50&token=100 # Results 100-149 +``` + +**Pagination Workflow:** +1. Initial request: `GET /collections?limit=10` +2. Response includes `links` with `rel: "next"` containing next token +3. Follow next link: `GET /collections?limit=10&token=10` +4. Repeat until no `next` link is present + +**Response Links:** +```json +{ + "collections": [...], + "links": [ + { "rel": "self", "href": "/collections?limit=10&token=0" }, + { "rel": "next", "href": "/collections?limit=10&token=10" }, + { "rel": "prev", "href": "/collections?limit=10&token=0" } + ], + "context": { + "returned": 10, + "limit": 10, + "matched": 156 + } +} +``` + +--- + +## Combining Parameters + +Multiple parameters can be combined to create complex queries: + +``` +GET /collections?q=sentinel&bbox=-10,40,10,50&datetime=2020-01-01/2021-12-31&limit=20&sortby=-created +``` + +This query searches for: +- Collections matching "sentinel" +- Within the specified bounding box +- With temporal extent overlapping 2020-2021 +- Returns 20 results +- Sorted by creation date (newest first) + +--- + +## Error Responses + +All validation errors return HTTP **400 Bad Request** with the following format: + +```json +{ + "code": "InvalidParameterValue", + "description": "Parameter \"bbox\" minX must be less than maxX" +} +``` + +Multiple errors are concatenated: + +```json +{ + "code": "InvalidParameterValue", + "description": "Parameter \"limit\" must be at least 1; Parameter \"bbox\" contains invalid numeric values" +} +``` + +--- + +## Conformance Classes + +This API implements the following STAC Collection Search conformance classes: + +- **Simple Query** (`http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query`) + - Parameters: `bbox`, `datetime`, `limit` + +- **Free-Text Search** (`https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text`) + - Parameter: `q` + +- **Sorting** (`https://api.stacspec.org/v1.1.0/collection-search#sort`) + - Parameter: `sortby` + +--- + +## Implementation Status + +| Parameter | Status | Notes | +|-----------|--------|-------| +| `q` | Validated | TODO: Implement full-text search in DB | +| `bbox` | Validated | TODO: Implement PostGIS spatial query | +| `datetime` | Validated | TODO: Implement temporal overlap query | +| `limit` | Implemented | Working with in-memory store | +| `sortby` | Validated | TODO: Apply sorting in DB query | +| `token` | Implemented | Working with in-memory store | + +--- + +## Future Extensions + +The following parameters are defined in `bid.md` but not yet implemented: + +- `provider` - Filter by data provider name +- `license` - Filter by license identifier + +These will be added in a future release as extended search parameters beyond the standard conformance classes. Or the bid will be changed with a change-request. + +--- + +## See Also + +- [STAC API Specification](https://github.com/radiantearth/stac-api-spec) +- [Collection Search Extension](https://github.com/stac-api-extensions/collection-search) +- [OGC API - Features](https://docs.ogc.org/is/17-069r4/17-069r4.html) diff --git a/api/examples/README.md b/api/examples/README.md new file mode 100644 index 0000000..d71fc95 --- /dev/null +++ b/api/examples/README.md @@ -0,0 +1,62 @@ +# Routes - Database Integration Guide + +This guide explains how to connect API routes to the database using the existing database connection module. + +## Basic Pattern + +Define this helper function once at the top of your route file: + +```javascript +const express = require('express'); +const router = express.Router(); +const db = require('../db/db_APIconnection'); + +// Define once per file +async function runQuery(sql, params = []) { + try { + const result = await db.query(sql, params); + return result.rows; + } catch (error) { + console.error('Query error:', error); + throw error; + } +} + +// Now use it everywhere in this file +router.get('/endpoint', async (req, res, next) => { + try { + const rows = await runQuery('SELECT * FROM table WHERE id = $1', [req.params.id]); + res.json(rows); + } catch (error) { + next(error); + } +}); + +module.exports = router; +``` + +Every database call in your routes can now use this simple pattern: + +```javascript +const rows = await runQuery('SELECT * FROM table WHERE id = $1', [123]); +``` + +## Example + +```javascript +// get list of collections + +const collections = await runQuery('SELECT * FROM collection'); + +// find collection via ID +const rows = await runQuery('SELECT * FROM collection WHERE id = $1', [123]); +if (rows.length === 0) { + return res.status(404).json({ code: 'NotFound' }); +} +const collection = rows[0]; + +// Filter by multiple conditions +const filtered = await runQuery( + 'SELECT * FROM collection WHERE is_active = $1 AND license = $2', + [true, 'CC-BY-4.0'] +); \ No newline at end of file diff --git a/api/jest.config.js b/api/jest.config.js new file mode 100644 index 0000000..9cb9ab0 --- /dev/null +++ b/api/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + testEnvironment: 'node', + coverageDirectory: 'coverage', + collectCoverageFrom: [ + 'routes/**/*.js', + 'controllers/**/*.js', + 'services/**/*.js', + '!node_modules/**' + ], + testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'], + verbose: true, + // Force exit after tests to prevent hanging + forceExit: true, + // Detect open handles (useful for debugging) + detectOpenHandles: false, + // Increase test timeout for slow database queries in CI + testTimeout: 30000 +}; diff --git a/api/jest.teardown.js b/api/jest.teardown.js new file mode 100644 index 0000000..8e2bb26 --- /dev/null +++ b/api/jest.teardown.js @@ -0,0 +1,14 @@ +// jest.teardown.js +// Global teardown to close database connections after all tests + +const { closePool } = require('./db/db_APIconnection'); + +module.exports = async () => { + // Close the database connection pool + try { + await closePool(); + console.log('Jest teardown: Database pool closed successfully'); + } catch (error) { + console.error('Jest teardown: Error closing database pool:', error.message); + } +}; diff --git a/api/middleware/validateCollectionSearch.js b/api/middleware/validateCollectionSearch.js new file mode 100644 index 0000000..f19aa5a --- /dev/null +++ b/api/middleware/validateCollectionSearch.js @@ -0,0 +1,100 @@ +// middleware/validateCollectionSearch.js + +const { + validateQ, + validateBbox, + validateDatetime, + validateLimit, + validateSortby, + validateToken +} = require('../validators/collectionSearchParams'); + +/** + * Express middleware to validate Collection Search query parameters + * + * Validates all supported query parameters and returns 400 with detailed + * error message if validation fails. On success, attaches normalized + * parameters to req.validatedParams for use in route handlers. + * + * Supported parameters: + * - q: Free-text search + * - bbox: Bounding box spatial filter + * - datetime: Temporal filter (ISO8601) + * - limit: Result limit (default 10, max 10000) + * - sortby: Sort specification (+/-field) + * - token: Pagination continuation token + * + * @param {Request} req - Express request object + * @param {Response} res - Express response object + * @param {Function} next - Express next middleware function + */ +function validateCollectionSearchParams(req, res, next) { + const errors = []; + const normalized = {}; + + // Extract query parameters + const { q, bbox, datetime, limit, sortby, token } = req.query; + + // Validate q (free-text search) + const qResult = validateQ(q); + if (!qResult.valid) { + errors.push(qResult.error); + } else if (qResult.normalized !== undefined) { + normalized.q = qResult.normalized; + } + + // Validate bbox (spatial filter) + const bboxResult = validateBbox(bbox); + if (!bboxResult.valid) { + errors.push(bboxResult.error); + } else if (bboxResult.normalized) { + normalized.bbox = bboxResult.normalized; + } + + // Validate datetime (temporal filter) + const datetimeResult = validateDatetime(datetime); + if (!datetimeResult.valid) { + errors.push(datetimeResult.error); + } else if (datetimeResult.normalized !== undefined) { + normalized.datetime = datetimeResult.normalized; + } + + // Validate limit (pagination) + const limitResult = validateLimit(limit); + if (!limitResult.valid) { + errors.push(limitResult.error); + } else { + normalized.limit = limitResult.normalized; + } + + // Validate sortby (sorting) + const sortbyResult = validateSortby(sortby); + if (!sortbyResult.valid) { + errors.push(sortbyResult.error); + } else if (sortbyResult.normalized) { + normalized.sortby = sortbyResult.normalized; + } + + // Validate token (pagination continuation) + const tokenResult = validateToken(token); + if (!tokenResult.valid) { + errors.push(tokenResult.error); + } else { + normalized.token = tokenResult.normalized; + } + + // If any validation errors occurred, return 400 with details + if (errors.length > 0) { + return res.status(400).json({ + code: 'InvalidParameterValue', + description: errors.join('; ') + }); + } + + // Attach normalized params to request for use in route handler + req.validatedParams = normalized; + + next(); +} + +module.exports = { validateCollectionSearchParams }; diff --git a/api/package-lock.json b/api/package-lock.json new file mode 100644 index 0000000..a12b09e --- /dev/null +++ b/api/package-lock.json @@ -0,0 +1,6372 @@ +{ + "name": "stac-atlas-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stac-atlas-api", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "cors": "^2.8.5", + "debug": "~2.6.9", + "dotenv": "^17.2.3", + "express": "^4.22.1", + "morgan": "^1.10.1", + "pg": "^8.16.3", + "swagger-ui-express": "^5.0.1", + "yamljs": "^0.3.0" + }, + "devDependencies": { + "eslint": "^8.57.1", + "jest": "^29.7.0", + "nodemon": "^3.1.11", + "prettier": "^3.6.2", + "supertest": "^7.1.4" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", + "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.26.tgz", + "integrity": "sha512-73lC1ugzwoaWCLJ1LvOgrR5xsMLTqSKIEoMHVtL9E/HNk0PXtTM76ZIm84856/SF7Nv8mPZxKoBsgpm0tR1u1Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.250", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.250.tgz", + "integrity": "sha512-/5UMj9IiGDMOFBnN4i7/Ry5onJrAGSbOGo3s9FEKmwobGq6xw832ccET0CE3CkkMBZ8GJSlUIesZofpyurqDXw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", + "integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.30.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.2.tgz", + "integrity": "sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yamljs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.3.0.tgz", + "integrity": "sha512-C/FsVVhht4iPQYXOInoxUM/1ELSf9EsgKH34FofQOp6hwCPrW4vG4w5++TED3xRUo8gD7l0P1J1dLlDYzODsTQ==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "glob": "^7.0.5" + }, + "bin": { + "json2yaml": "bin/json2yaml", + "yaml2json": "bin/yaml2json" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..4f1ea4c --- /dev/null +++ b/api/package.json @@ -0,0 +1,43 @@ +{ + "name": "stac-atlas-api", + "version": "0.1.0", + "description": "STAC API for STAC Atlas - A centralized platform for managing STAC Collection metadata", + "private": true, + "scripts": { + "start": "node ./bin/www", + "dev": "nodemon ./bin/www", + "test": "jest", + "test:watch": "jest --watch", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write \"**/*.{js,json,md}\"" + }, + "keywords": [ + "stac", + "api", + "geospatial", + "collections" + ], + "author": "STAC Atlas Team", + "license": "Apache-2.0", + "dependencies": { + "cors": "^2.8.5", + "debug": "~2.6.9", + "dotenv": "^17.2.3", + "express": "^4.22.1", + "morgan": "^1.10.1", + "pg": "^8.16.3", + "swagger-ui-express": "^5.0.1", + "yamljs": "^0.3.0" + }, + "devDependencies": { + "eslint": "^8.57.1", + "jest": "^29.7.0", + "nodemon": "^3.1.11", + "prettier": "^3.6.2", + "supertest": "^7.1.4" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/api/routes/collections.js b/api/routes/collections.js new file mode 100644 index 0000000..6f83a2f --- /dev/null +++ b/api/routes/collections.js @@ -0,0 +1,174 @@ +const express = require('express'); +const router = express.Router(); +const collectionsStore = require('../data/collections'); // change with the real collections when we have them +const { validateCollectionSearchParams } = require('../middleware/validateCollectionSearch'); +const { query } = require('../db/db_APIconnection'); +const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); + +// helper to run the built query (from documentation) +async function runQuery(sql, params = []) { + try { + const result = await query(sql, params); + return result.rows; + } catch (error) { + console.error('Query error in /collections:', error); + throw error; + } +} + +/** + * GET /collections + * Returns a paginated list of collections with optional filtering + * + * Supported query parameters: + * - q: Free-text search across title, description, keywords + * - bbox: Spatial filter as minX,minY,maxX,maxY + * - datetime: Temporal filter (ISO8601 single or interval) + * - limit: Number of results (default 10, max 10000) + * - sortby: Sort by field (+field for ASC, -field for DESC) + * - token: Pagination continuation token (offset) + * + * All parameters are validated by validateCollectionSearchParams middleware. + * Validated/normalized values are available in req.validatedParams. + */ +router.get('/', validateCollectionSearchParams, async (req, res, next) => { + // TODO: Think about the parameters `provider` and `license` - They are mentioned in the bid, but not in the STAC spec + // TODO: Implement CQL2 filtering (GET endpoint) and add validator for `filter`, filter-lang` parameters + try { + // validated parameters from middleware + const { q, bbox, datetime, limit, sortby, token } = req.validatedParams; + + // build SQL querry and parameters + const { sql, values } = buildCollectionSearchQuery({ + q, + bbox, + datetime, + limit, + sortby, + token + }); + + // execute Query against database + const collections = await runQuery(sql, values); + const returned = collections.length; + + // Get total count for matched field + // Build count query using same WHERE conditions + const { sql: countSql, values: countValues } = buildCollectionSearchQuery({ + q, + bbox, + datetime, + limit: null, // No limit for count + sortby: null, // No sorting for count + token: null // No offset for count + }); + + // Replace SELECT with COUNT(*) + const countQuery = countSql + .replace(/SELECT[\s\S]*?FROM/, 'SELECT COUNT(*) as total FROM') + .replace(/ORDER BY.*$/, '') + .replace(/LIMIT.*$/, ''); + + const countResult = await runQuery(countQuery, countValues); + const matched = parseInt(countResult[0]?.total || 0); + + // Base URL for links + const baseHost = `${req.protocol}://${req.get('host')}`; + const baseUrl = `${baseHost}${req.baseUrl}`; + + const buildLink = (rel, tokenValue) => ({ + rel, + href: `${baseUrl}?limit=${limit}&token=${tokenValue}`, + type: 'application/json' + }); + + const links = [ + buildLink('self', token), + { + rel: 'root', + href: baseHost, + type: 'application/json' + } + ]; + + // "next": only if returned === limit AND token + limit < matched + if (returned === limit && token + limit < matched) { + links.push(buildLink('next', token + limit)); + } + + // "prev": only if token > 0 + if (token > 0) { + const prevToken = Math.max(0, token - limit); + links.push(buildLink('prev', prevToken)); + } + + res.json({ + type: 'FeatureCollection', + collections, + links, + context: { + returned, + limit, + matched + } + }); + } catch (error) { + next(error); + } +}); + +/** + * GET /collections/:id + * Returns a single collection by ID. Includes all STAC Collection fields + * (stac_version, type, title, description, license, extent, links, etc). + * + * Returns: + * - 200 OK with full Collection object if found + * - 404 NotFound with proper error format if collection does not exist + */ +router.get('/:id', (req, res) => { + // TODO: Create a proper validator middleware for :id parameter to avoid SQL injection, etc. + const { id } = req.params; + + // Look up the collection in the data store by ID + // When connected to a DB, replace this with a SQL query (SELECT * FROM collections WHERE id = ?) + const collection = collectionsStore.find(c => c.id === id); + + if (!collection) { + // Return 404 with standardized error format + return res.status(404).json({ + code: 'NotFound', + description: `Collection with id '${id}' not found`, + id: id + }); + } + + // Return the full STAC Collection object + // Ensure the response includes at least self, root and parent links. + // Start from any links the collection already provides and add missing ones. + const baseHost = `${req.protocol}://${req.get('host')}`; + const selfHref = `${baseHost}/collections/${id}`; + const rootHref = baseHost; + + const existingLinks = Array.isArray(collection.links) ? collection.links.slice() : []; + + const hasRel = (rel) => existingLinks.some(l => l && l.rel === rel); + + if (!hasRel('self')) { + existingLinks.push({ rel: 'self', href: selfHref, type: 'application/json' }); + } + + if (!hasRel('root')) { + existingLinks.push({ rel: 'root', href: rootHref, type: 'application/json' }); + } + + // Prefer an existing parent link if present, otherwise fall back to root + if (!hasRel('parent')) { + existingLinks.push({ rel: 'parent', href: rootHref, type: 'application/json' }); + } + + // Return the collection with a normalized `links` array + res.json(Object.assign({}, collection, { links: existingLinks })); +}); + +module.exports = router; diff --git a/api/routes/conformance.js b/api/routes/conformance.js new file mode 100644 index 0000000..c9b35b1 --- /dev/null +++ b/api/routes/conformance.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const { CONFORMANCE_URIS } = require('../config/conformanceURIS'); + +/** + * GET /conformance + * Returns the list of conformance classes this API implements + */ +router.get('/', (req, res) => { + res.json({ + conformsTo: CONFORMANCE_URIS + }); +}); + +module.exports = router; diff --git a/api/routes/index.js b/api/routes/index.js new file mode 100644 index 0000000..b389488 --- /dev/null +++ b/api/routes/index.js @@ -0,0 +1,68 @@ +const express = require('express'); +const router = express.Router(); +const { CONFORMANCE_URIS } = require('../config/conformanceURIS'); + +/** + * GET / + * STAC API Landing Page + * Returns basic information about the API and available endpoints + * Source: https://docs.ogc.org/cs/25-005/25-005.html + */ +router.get('/', (req, res) => { + const baseUrl = `${req.protocol}://${req.get('host')}`; + + res.json({ + type: 'Catalog', + id: 'stac-atlas', + title: 'STAC Atlas', + description: 'A centralized platform for managing, indexing, and providing STAC Collection metadata from distributed catalogs and APIs.', + stac_version: '1.0.0', + conformsTo: CONFORMANCE_URIS, + links: [ + { + rel: 'self', + href: baseUrl, + type: 'application/json', + title: 'STAC Atlas Landing Page' + }, + { + rel: 'root', + href: baseUrl, + type: 'application/json', + title: 'STAC Atlas root catalog' + }, + { + rel: 'conformance', + href: `${baseUrl}/conformance`, + type: 'application/json', + title: 'STAC/OGC conformance classes' + }, + { + rel: 'data', + href: `${baseUrl}/collections`, + type: 'application/json', + title: 'STAC Collections' + }, + { + rel: 'queryables', + href: `${baseUrl}/collection-queryables`, // TODO: Check with Mohr if this is the correct endpoint + type: 'application/schema+json', + title: 'Queryables for Collections' + }, + { + rel: 'service-doc', // This should be the Swagger UI or similar + href: `${baseUrl}/api-docs`, + type: 'text/html', + title: 'API documentation' + }, + { + rel: 'service-desc', // This should point to the OpenAPI spec (machine-readable) + href: `${baseUrl}/openapi.yaml`, + type: 'application/vnd.oai.openapi+json;version=3.0', + title: 'OpenAPI specification' + } + ] + }); +}); + +module.exports = router; diff --git a/api/routes/queryables.js b/api/routes/queryables.js new file mode 100644 index 0000000..1cfbe3b --- /dev/null +++ b/api/routes/queryables.js @@ -0,0 +1,92 @@ +const express = require('express'); +const router = express.Router(); + +/** + * GET /collections-queryables + * Returns the list of queryable properties for collections + */ +router.get('/', (req, res) => { + res.json({ + $schema: 'https://json-schema.org/draft/2019-09/schema', + $id: `${req.protocol}://${req.get('host')}/collections-queryables`, + type: 'object', + title: 'STAC Atlas Collections Queryables', + description: 'Queryable properties for STAC Collection Search', + properties: { + id: { + title: 'Collection ID', + type: 'string' + }, + title: { + title: 'Collection Title', + type: 'string' + }, + description: { + title: 'Collection Description', + type: 'string' + }, + keywords: { + title: 'Keywords', + type: 'array', + items: { + type: 'string' + } + }, + license: { + title: 'License', + type: 'string' + }, + providers: { + title: 'Providers', + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string' + } + } + } + }, + 'extent.spatial.bbox': { + title: 'Spatial Extent (Bounding Box)', + type: 'array', + items: { + type: 'number' + } + }, + 'extent.temporal.interval': { + title: 'Temporal Extent', + type: 'array' + }, + doi: { + title: 'DOI', + type: 'string' + }, + 'summaries.platform': { + title: 'Platform', + type: 'array', + items: { + type: 'string' + } + }, + 'summaries.constellation': { + title: 'Constellation', + type: 'array', + items: { + type: 'string' + } + }, + 'summaries.gsd': { + title: 'Ground Sample Distance', + type: 'number' + }, + 'summaries.processing:level': { + title: 'Processing Level', + type: 'string' + } + } + }); +}); + +module.exports = router; diff --git a/api/validators/collectionSearchParams.js b/api/validators/collectionSearchParams.js new file mode 100644 index 0000000..7f24faf --- /dev/null +++ b/api/validators/collectionSearchParams.js @@ -0,0 +1,274 @@ +/** + * Validators for STAC Collection Search query parameters + * + * Each validator returns an object with: + * - valid: boolean indicating if validation passed + * - error: string with error message (if invalid) + * - normalized: the normalized/parsed value (if valid) + */ + +/** + * Validates the 'q' (free-text search) parameter + * @param {string} q - Free text search query + * @returns {Object} { valid: boolean, error?: string, normalized?: string } + */ +function validateQ(q) { + if (!q) return { valid: true }; // optional parameter + + if (typeof q !== 'string') { + return { valid: false, error: 'Parameter "q" must be a string' }; + } + + if (q.length > 500) { + return { valid: false, error: 'Parameter "q" exceeds maximum length of 500 characters' }; + } + + return { valid: true, normalized: q.trim() }; +} + +/** + * Validates bbox parameter + * Format: [minX, minY, maxX, maxY] or comma-separated string "minX,minY,maxX,maxY" + * Also known as: [west, south, east, north] + * + * @param {string|Array} bbox - Bounding box coordinates + * @returns {Object} { valid: boolean, error?: string, normalized?: Array } + */ +function validateBbox(bbox) { + if (!bbox) return { valid: true }; + + let coords; + if (typeof bbox === 'string') { + coords = bbox.split(',').map(v => parseFloat(v.trim())); + } else if (Array.isArray(bbox)) { + coords = bbox.map(v => parseFloat(v)); + } else { + return { valid: false, error: 'Parameter "bbox" must be an array or comma-separated string' }; + } + + if (coords.length !== 4) { + return { valid: false, error: 'Parameter "bbox" must contain exactly 4 coordinates [minX, minY, maxX, maxY]' }; + } + + if (coords.some(isNaN)) { + return { valid: false, error: 'Parameter "bbox" contains invalid numeric values' }; + } + + const [minX, minY, maxX, maxY] = coords; + + // Validate longitude range + if (minX < -180 || minX > 180 || maxX < -180 || maxX > 180) { + return { valid: false, error: 'Parameter "bbox" longitude values must be between -180 and 180' }; + } + + // Validate latitude range + if (minY < -90 || minY > 90 || maxY < -90 || maxY > 90) { + return { valid: false, error: 'Parameter "bbox" latitude values must be between -90 and 90' }; + } + + // Validate logical ordering + if (minX >= maxX) { + return { valid: false, error: 'Parameter "bbox" minX must be less than maxX' }; + } + + if (minY >= maxY) { + return { valid: false, error: 'Parameter "bbox" minY must be less than maxY' }; + } + + return { valid: true, normalized: coords }; +} + +/** + * Validates datetime parameter (ISO8601) + * Formats supported: + * - Single datetime: "2020-01-01T00:00:00Z" + * - Closed interval: "2019-01-01/2021-12-31" + * - Open start: "../2021-12-31" + * - Open end: "2019-01-01/.." + * + * @param {string} datetime - ISO8601 datetime or interval + * @returns {Object} { valid: boolean, error?: string, normalized?: string } + */ +function validateDatetime(datetime) { + if (!datetime) return { valid: true }; + + if (typeof datetime !== 'string') { + return { valid: false, error: 'Parameter "datetime" must be a string' }; + } + + // Check for interval format + if (datetime.includes('/')) { + const parts = datetime.split('/'); + if (parts.length !== 2) { + return { valid: false, error: 'Parameter "datetime" interval must have exactly one "/" separator' }; + } + + const [start, end] = parts; + + // Validate start (unless open-ended "..") + if (start !== '..' && !isValidISO8601(start)) { + return { valid: false, error: `Parameter "datetime" start value "${start}" is not valid ISO8601` }; + } + + // Validate end (unless open-ended "..") + if (end !== '..' && !isValidISO8601(end)) { + return { valid: false, error: `Parameter "datetime" end value "${end}" is not valid ISO8601` }; + } + + // Check that at least one bound is specified + if (start === '..' && end === '..') { + return { valid: false, error: 'Parameter "datetime" interval cannot be unbounded on both sides' }; + } + + return { valid: true, normalized: datetime }; + } + + // Single datetime + if (!isValidISO8601(datetime)) { + return { valid: false, error: `Parameter "datetime" value "${datetime}" is not valid ISO8601` }; + } + + return { valid: true, normalized: datetime }; +} + +/** + * Helper function to validate ISO8601 datetime strings + * @param {string} dateString - ISO8601 datetime string + * @returns {boolean} true if valid ISO8601 + */ +function isValidISO8601(dateString) { + // Basic ISO8601 regex - supports dates with optional time + // Examples: 2020-01-01, 2020-01-01T00:00:00Z, 2020-01-01T00:00:00+02:00 + const iso8601Regex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/; + + if (!iso8601Regex.test(dateString)) { + return false; + } + + // Also validate that it's a real date + const date = new Date(dateString); + return !isNaN(date.getTime()); +} + +/** + * Validates limit parameter + * @param {string|number} limit - Maximum number of results to return + * @returns {Object} { valid: boolean, error?: string, normalized?: number } + */ +function validateLimit(limit) { + if (!limit) return { valid: true, normalized: 10 }; // default value + + // Check if limit contains a decimal point (reject floats) + if (typeof limit === 'string' && limit.includes('.')) { + return { valid: false, error: 'Parameter "limit" must be an integer, not a decimal' }; + } + + const num = parseInt(limit, 10); + + if (isNaN(num)) { + return { valid: false, error: 'Parameter "limit" must be a valid integer' }; + } + + if (num < 1) { + return { valid: false, error: 'Parameter "limit" must be at least 1' }; + } + + if (num > 10000) { + return { valid: false, error: 'Parameter "limit" must not exceed 10000' }; + } + + return { valid: true, normalized: num }; +} + +/** + * Validates sortby parameter + * Format: "+field" (ascending) or "-field" (descending) + * Allowed fields: title, id, license, created, updated + * + * @param {string} sortby - Sort specification + * @returns {Object} { valid: boolean, error?: string, normalized?: Object } + */ +function validateSortby(sortby) { + // sortby is optional – if not provided, validation passes with undefined normalized value + if (sortby === undefined || sortby === null) { + return { valid: true, normalized: undefined }; + } + + const allowedFields = ['title', 'id', 'license', 'created', 'updated']; + + // Map API field names to database column names + const fieldMapping = { + 'title': 'title', + 'id': 'id', + 'license': 'license', + 'created': 'created_at', + 'updated': 'updated_at' + }; + + if (typeof sortby !== 'string') { + return { valid: false, error: 'Parameter "sortby" must be a string' }; + } + + // Extract direction prefix and field name + let direction = 'ASC'; + let field = sortby.trim(); + + if (field.startsWith('+')) { + direction = 'ASC'; + field = field.substring(1).trim(); + } else if (field.startsWith('-')) { + direction = 'DESC'; + field = field.substring(1).trim(); + } + + // Check if field is empty (either empty string or only prefix without field name) + if (!field) { + return { + valid: false, + error: `Parameter "sortby" must specify a field. Allowed fields: ${allowedFields.join(', ')}` + }; + } + + // Check if field is in allowed list + if (!allowedFields.includes(field)) { + return { + valid: false, + error: `Parameter "sortby" field "${field}" is not supported. Allowed fields: ${allowedFields.join(', ')}` + }; + } + + // Map to actual database column name + const dbField = fieldMapping[field]; + + return { valid: true, normalized: { field: dbField, direction } }; +} + +/** + * Validates token parameter (pagination continuation token) + * @param {string|number} token - Pagination token (offset) + * @returns {Object} { valid: boolean, error?: string, normalized?: number } + */ +function validateToken(token) { + if (!token) return { valid: true, normalized: 0 }; // default to start + + const num = parseInt(token, 10); + + if (isNaN(num)) { + return { valid: false, error: 'Parameter "token" must be a valid integer' }; + } + + if (num < 0) { + return { valid: false, error: 'Parameter "token" must be non-negative' }; + } + + return { valid: true, normalized: num }; +} + +module.exports = { + validateQ, + validateBbox, + validateDatetime, + validateLimit, + validateSortby, + validateToken +}; diff --git a/bid.md b/bid.md index a4bcedf..5771fd9 100644 --- a/bid.md +++ b/bid.md @@ -18,7 +18,7 @@ Die Abnahmekriterien definieren die zwingend erforderlichen Funktionalitäten de #### Crawler - Automatisches Crawlen und Indexieren von STAC Collections aus verschiedenen Quellen -- Erfassung von mehr als 95% der Collections vom STAC Index +- Erfassung aller Collections im STAC Index - Rekursive Navigation durch STAC-Kataloge - Wöchentliches Re-Crawling zur Aktualisierung der Daten - Robustes Error-Handling mit Retry-Logic @@ -34,13 +34,17 @@ Die Abnahmekriterien definieren die zwingend erforderlichen Funktionalitäten de #### STAC API - Konforme Implementierung der STAC API Specification - Implementierung der Collection Search Extension -- Bereitstellung von Collections (GET /collections) - Abruf einzelner Collections (GET /collections/{id}) -- Erweiterte Suchfunktion (GET /search) mit Filterung nach: - - Schlüsselwörtern +- Erweiterte Suchfunktion (GET /colllections) mit Filterung nach: + - id + - Titel + - Beschreibung - Räumlicher Ausdehnung - - Zeitraum - - Provider und Lizenz + - Zeitlicher Ausdehnung + - Schlüsselwörtern + - Provider + - Lizenz + - DOIs - CQL2-Filterung für komplexe Abfragen - Parallele Verarbeitung von mindestens 100 Anfragen - Antwortzeiten: einfache Abfragen ≤ 1s, komplexe Abfragen ≤ 5s @@ -52,6 +56,8 @@ Die Abnahmekriterien definieren die zwingend erforderlichen Funktionalitäten de - Bounding Box / räumlicher Ausdehnung - Zeitraum - Thema / Keywords + - Auswahlliste für Lizenzen + - Textsuche mit Vorschlägen für Provider - Responsive Design für verschiedene Bildschirmgrößen - Sprache (Deutsch oder Englisch)) - Barrierefreiheit (farbenblindentauglich) @@ -138,13 +144,14 @@ Datenanbieter und -kuratoren, die ihre STAC-Kataloge bekannter machen und die Nu - Als Infrastrukturbetreiber möchte ich, dass mein STAC-Katalog automatisch gecrawlt wird, um ohne zusätzlichen Aufwand in der Plattform präsent zu sein. ## 3. Produkt-Umgebung - Die Produktumgebung beschreibt die technischen Rahmenbedingungen für Entwicklung, Betrieb und Integration der drei Hauptkomponenten des Projekts – **Crawler**, **STAC API** und **Frontend**. Alle Komponenten werden in einer modernen, containerisierten Umgebung entwickelt und bereitgestellt, um eine einheitliche und reproduzierbare Laufzeitumgebung sicherzustellen. +[![](https://mermaid.ink/img/pako:eNptUsGO0zAQ_RVrTkVkS-K4JckBqWQLVCrQbVqQIBysZJoGErvYjlpo--_YYVOtBPJlZt6bN_OsOUMhS4QEdo08FnuuDFmuc0HIGyWFQVF-zWEIyehThyHZLp7l8M1xZquFhbPNLHUhGX2wUuPvmrwg89NBodYD8f615a2kNpXC7GFJnhOXvF1kj3i6nn1ezteWlCp-bFDdtAaF7ON2nc4zy5ifDCrBG9LPzWSnCtRk1GcLUeLJIyk3vJGV9siw26POU2Pk7u7V5d1ms8rIxVH-wdKHJb0hzp8ruu1_dqhq1Bdr68nyPbw9aLRfmMqmwcLUUvyXlUPhbJLRzvLkkTS1-OE2vAw2wYNK1SUkO95o9KBF1XKXw9lp5WD22GIOiQ3tE1fbcODii5QtJEZ1tkXJrtrfBLpDyQ3e17xSvL1VlbWKKpWdMJAEMWW9CiRnOEES0_HLCQ2jyPd9FkU08uAXJCwMxiGjIZsGccD8KZ1cPfjdz_XHUTil08mEsShgMY1DD7CsjVTv_55Yf2nXP1fHwN0?type=png)](https://mermaid.live/edit#pako:eNptUsGO0zAQ_RVrTkVkS-K4JckBqWQLVCrQbVqQIBysZJoGErvYjlpo--_YYVOtBPJlZt6bN_OsOUMhS4QEdo08FnuuDFmuc0HIGyWFQVF-zWEIyehThyHZLp7l8M1xZquFhbPNLHUhGX2wUuPvmrwg89NBodYD8f615a2kNpXC7GFJnhOXvF1kj3i6nn1ezteWlCp-bFDdtAaF7ON2nc4zy5ifDCrBG9LPzWSnCtRk1GcLUeLJIyk3vJGV9siw26POU2Pk7u7V5d1ms8rIxVH-wdKHJb0hzp8ruu1_dqhq1Bdr68nyPbw9aLRfmMqmwcLUUvyXlUPhbJLRzvLkkTS1-OE2vAw2wYNK1SUkO95o9KBF1XKXw9lp5WD22GIOiQ3tE1fbcODii5QtJEZ1tkXJrtrfBLpDyQ3e17xSvL1VlbWKKpWdMJAEMWW9CiRnOEES0_HLCQ2jyPd9FkU08uAXJCwMxiGjIZsGccD8KZ1cPfjdz_XHUTil08mEsShgMY1DD7CsjVTv_55Yf2nXP1fHwN0) ### 3.1 Crawler -Der Crawler wird in Python implementiert und ist zuständig für das automatische Auffinden und Einlesen von STAC Collections aus dem STAC Index sowie verlinkten Katalogen/APIs. -Er schreibt die Daten in die Datenbank und führt regelmäßige, inkrementelle Aktualisierungen durch, um eine aktuelle Indexierung sicherzustellen. Protokollierung (z. B. Zeitstempel/Status) stellt Nachvollziehbarkeit sicher. +Der Crawler wird in JavaScript implementiert und ist zuständig für das automatische Auffinden und Einlesen von STAC Collections aus dem STAC Index sowie verlinkten Katalogen/APIs (6.1.1.1, 6.1.1.2). (6.2.4.2) +Er schreibt die Daten in die Datenbank (6.1.1.7, 6.1.1.3) und führt regelmäßige, inkrementelle Aktualisierungen durch, um eine aktuelle Indexierung sicherzustellen (6.1.1.6). +Protokollierung (z. B. Zeitstempel/Status) stellt Nachvollziehbarkeit sicher (6.1.1.4, 6.1.1.12). Dokumentation zu Build/Deployment/Testing wird pro Komponente bereitgestellt (6.2.4.3). ### 3.2 Datenbankmanagementsystem PostgreSQL in Kombination mit PostGIS bildet die zentrale Datengrundlage. @@ -155,15 +162,16 @@ CQL2-Filter werden serverseitig in SQL-WHERE-Klauseln übersetzt. Inkrementelle Updates und Soft-Deletes (active = false) sichern Integrität und Revisionsfähigkeit. ### 3.3 STAC API-konforme Schnittstelle -Das Backend stellt eine API bereit, die vollständig mit der STAC API-Spezifikation kompatibel ist und standardisierte Zugriffe auf die gespeicherten STAC Collections ermöglicht, unter anderem die Endpunkte /collections, /collections/{id}, /search und /conformance. -Die API wird primär in JavaScript / Node.js (20) mit Express umgesetzt. -Für die Übersetzung und Auswertung von CQL2-Abfragen wird cql2-rs (Rust) zu WebAssembly kompiliert und in-process im Node-Prozess eingebunden (geringe Latenz, einfache Containerisierung). -Als Fallback bleibt alternativ pycql2; sollten sich gravierende Schwierigkeiten mit cql2-rs ergeben, kann optional ein Python-Backend (z. B. FastAPI) implementiert werden, das die Anfrageverarbeitung und CQL2-Übersetzung übernimmt. +Das Backend stellt eine API bereit, die vollständig mit der STAC API-Spezifikation kompatibel ist und standardisierte Zugriffe auf die gespeicherten STAC Collections ermöglicht, unter anderem die Endpunkte `/` (Landing), `/conformance`, `/collections`, `/collections/{id}` und `/queryables` (global und/oder pro Collection) (6.1.2.1, 6.1.2.2, 6.1.2.3). +Die API wird primär in JavaScript / Node.js (22) mit Express umgesetzt (6.2.4.2). +Für die Übersetzung und Auswertung von CQL2-Abfragen wird cql2-rs (Rust) zu WebAssembly kompiliert und in-process im Node-Prozess eingebunden (geringe Latenz, einfache Containerisierung) (6.1.2.4, 6.1.2.5, 6.1.2.6). +Als Fallback bleibt alternativ pycql2; sollten sich gravierende Schwierigkeiten mit cql2-rs ergeben, kann optional ein Python-Backend (z. B. FastAPI) implementiert werden, das die Anfrageverarbeitung und CQL2-Übersetzung übernimmt (6.1.2.7). Die API ist klar vom Crawler getrennt und fokussiert auf Abfrage und Filterung der gespeicherten Collections. ### 3.4 UI (Web-Frontend) -Das Web-Frontend wird mit Vue.js (Version 3) entwickelt und bietet eine benutzerfreundliche Oberfläche zur Suche, Filterung und Visualisierung der STAC Collections, inklusive Kartenansicht. -Die Kommunikation zwischen Frontend und Backend erfolgt ausschließlich über die STAC API. +Das Web-Frontend wird mit Vue.js (Version 3) entwickelt (6.1.3.2) und bietet eine benutzerfreundliche Oberfläche zur Suche, Filterung und Visualisierung der STAC Collections, inklusive Kartenansicht (6.1.3.1, 6.1.3.3, 6.1.3.4, 6.1.3.5, 6.1.3.7, 6.1.3.8, 6.1.3.9). +Die Kommunikation zwischen Frontend und Backend erfolgt ausschließlich über die STAC API. Zusätzlich stellt die UI Links zur Originalquelle (STAC Catalog / API) bereit und kann optional Verweise zur Item-Search einer Collection zeigen (6.1.3.6). +Die UI und die zugehörigen Dokumentationen/Demos sollen so gestaltet sein, dass Schulungs- und Abnahmezwecke unterstützt werden (6.2.1.1, 6.2.1.2) und die Benutzererfahrung folgende Anforderungen erfüllt: intuitive/responsive UI, Accessibility, aussagekräftige Fehlerbehandlung und Sprachunterstützung (6.2.2.1, 6.2.2.2, 6.2.2.3, 6.2.2.4). ### 3.5 Containerisierung Alle Komponenten (Crawler, Datenbank, STAC-API, UI) werden einzeln mittels Docker containerisiert und als Komplett-Paket miteinander verknüpft, zum Beispiel via Docker Compose, um sowohl die getrennte Verwendung einzelner Komponenten als auch den Betrieb des vollständigen Systems zu ermöglichen. @@ -174,38 +182,44 @@ Docker gewährleistet eine konsistente Laufzeitumgebung und erleichtert die Inte Im folgenden werden die Produktfunktionen nach den einzelnen Komponenten unterteilt, nummeriert und beschrieben. Zusätzlich wird eine Priorität zur Orientierung in der Implementierung angegeben inkl. einer kurzen Beschreibung und einem groben Akzeptanzkriterium. Auf Basis der optionalen Elemente des Lastenhefts wurde auch eine Spalte "Optional" gefüllt, welche Features markiert, welche mit nachrangiger Priorität nach der Entwicklung der Hauptfunktionalitäten entwickelt werden, sollte dafür noch Zeit sein. -| ID | Komponente | Funktion (Kurzbeschreibung) | Optional | Akzeptanzkriterium | Prio | +Komponente | Funktion (Kurzbeschreibung) | Optionale Umsetzung | Akzeptanzkriterium | Prio | Lastenheft-Referenz | |---|---|---|---|---|---| -| PF-CR-01 | Crawler | Alle im STAC Index gelisteten statischen Kataloge und STAC-APIs nach Collections crawlen | – | Mind. 87 Quellen gecrawlt; Trefferquote ≥ 95 % | M | -| PF-CR-02 | Crawler | Collections in nahezu beliebiger Verschachtelungstiefe erfassen (nested catalogs) | – | Maximale Tiefe < 1024; keine Duplikate | M | -| PF-CR-03 | Crawler | Metadaten extrahieren: id, title, description, spatial/temporal extent, keywords, provider, license, DOI, summaries(platform/constellation/gsd/processing:level) | – | ≥ 95 % Felder gefüllt bei Stichprobe n=50 | H | -| PF-CR-04 | Crawler | Quell-URL, Quell-Titel, „zuletzt gecrawlt“ speichern | – | Felder in DB vorhanden und befüllt | M | -| PF-CR-05 | Crawler | Alle stabilen STAC-Versionen unterstützen (alte Ressourcen werden automatisch auf 1.1 migriert) | – | Collections unterschiedl. Versionen werden gespeichert und ggf. migriert | M | -| PF-CR-06 | Crawler | Inkrementelle Updates und periodisches Re-Crawling | – | Änderungen können ohne vollständige Neuindexierung hinzugefügt werden | H | -| PF-CR-07 | Crawler | Vollständige STAC-Collection + extrahierte Suchfelder persistent ablegen | – | ≥ 95 % Felder identisch zwischen Quelle und Datenbank bei Stichprobe n=50 | H | -| PF-CR-08 | Crawler | Erweiterbares DB-Design für zusätzliche Felder vorschlagen (siehe 5. Produktdaten) | – | Schema-Entwurf dokumentiert & abgenommen | M | -| PF-CR-09 | Crawler | Rate-Limiting einhalten (Quellen nicht überlasten) | – | Keine 429-Antworten/Blockings in Testlauf über 12 h | M | -| PF-CR-10 | Crawler | Konfigurierbare Crawl-Zeitpläne/Frequenzen | ✔ | CRON/Intervall vom Anwender frei konfigurierbar | L | -| PF-CR-11 | Crawler | Fehlerbehandlung + Retry; problematische Quellen überspringen | ✔ | Backoff/Retry-Logik; Fehlerbericht vorhanden | M | -| PF-CR-12 | Crawler | Logging & Monitoring der Crawl-Aktivitäten | ✔ | Dashboards/Metriken (Rate, Fehler, Status) | M | -| PF-CR-13 | Crawler | Version-agnostische STAC-Extensions erkennen und als Tags speichern (EO, SAR, Point Cloud) | ✔ | Extensions-Tags in DB & Queryables sichtbar | L | -| PF-API-01 | STAC-API | API gemäß relevanten Spezifikationen gültig (STAC API und Collection Search Extension) | – | `/conformance` enthält zutreffende URIs | H | -| PF-API-02 | STAC-API | Erweiterung der bestehenden STAC Index API; bleibt selbst gültige STAC-API | – | Root/Collections gültig - Getestet durch `STAC Validator` und `STAC API Validator` | H | -| PF-API-03 | STAC-API | Collection Search: Freitext `q`, Filter, Sortierung | – | Beispiel-Queries liefern erwartete Treffer | H | -| PF-API-04 | STAC-API | CQL2-Filtering (Basic CQL2 (`AND`, `OR`, `NOT`, `=`, `<>`, `<`, `<=`, `>`, `>=`, `IS NULL`)) für Collection-Eigenschaften | – | Gültige Filter → 200 Antworten; ungültige → 400 Antworten mit Fehlerbeschreibung | H | -| PF-API-05 | STAC-API | Zusätzliche CQL2-Fähigkeiten (Advanced Comparison Operators (`LIKE/BETWEEN/IN`, `casei/accenti`, `Spatial/Temporal`, `Arrays`)) | ✔ | Conformance-URIs ergänzt; Tests grün | M | -| PF-API-06 | STAC-API | CQL2 als Standalone-Library bereitstellen | ✔ | Lib mit Parser/Validation + README | L | -| PF-API-07 | STAC-API | Integration der neuen Funktionen in bestehende STAC Index API | ✔ | End-to-End-Tests (Crawler→API→UI) grün | M | -| PF-UI-01 | Web-UI | Intuitive Suchoberfläche für Collections | – | Usability-Test: Kernflows bestehen | H | -| PF-UI-02 | Web-UI | Implementierung in Vue (v3) zur Einbindung in STAC Index | – | Build integriert; Routing/State funktionsfähig | M | -| PF-UI-03 | Web-UI | Interaktive Auswahl von Bounding Box und Zeitintervall | – | BBox/Datetime erzeugen korrekte Parameter | H | -| PF-UI-04 | Web-UI | Composable Queryables in der UI → generiert CQL2-Ausdruck | – | UI-Builder erzeugt valide CQL2 (Server-OK) | H | -| PF-UI-05 | Web-UI | Kartenansicht mit Visualisierung räumlicher Extents | – | Extents werden auf interaktiver Karte dargestellt | M | -| PF-UI-06 | Web-UI | Links zur Originalquelle (Katalog/API) und optional zur Item Search | – | Links korrekt & erreichbar | M | -| PF-UI-07 | Web-UI | Inspection-Ansicht für Collections (Details) | – | Detailseite zeigt alle Kernfelder | M | -| PF-UI-08 | Web-UI | Items der Collections inspizieren können | ✔ | Item-Liste/Detail aufrufbar | L | -| PF-UI-09 | Web-UI | Collections vergleichen (Mehrfachauswahl & Vergleich) | ✔ | Vergleichsansicht mit minimum 2 Collections | L | - +| Crawler | Alle im STAC Index gelisteten statischen Kataloge und STAC-APIs nach Collections crawlen | – | Mind. 87 Quellen gecrawlt; Trefferquote ≥ 95 % | M | 6.1.1 1. | +| Crawler | Collections in nahezu beliebiger Verschachtelungstiefe erfassen (nested catalogs) | – | Maximale Tiefe < 1024 | M | 6.1.1 2. | +| Crawler | Metadaten extrahieren: id, title, description, spatial/temporal extent, keywords, provider, license, DOI, summaries(platform/constellation/gsd/processing:level) | – | ≥ 95 % Felder gefüllt bei Stichprobe n=50 | H | 6.1.1 3. | +| Crawler | Quell-URL, Quell-Titel, „zuletzt gecrawlt“ speichern | – | Felder in DB vorhanden und befüllt | M | 6.1.1 4. | +| Crawler | Alle stabilen STAC-Versionen unterstützen (alte Ressourcen werden automatisch auf 1.1 migriert) | – | Collections unterschiedl. Versionen werden gespeichert und ggf. migriert | M | 6.1.1 5. | +| Crawler | Inkrementelle Updates und periodisches Re-Crawling | – | Änderungen können ohne vollständige Neuindexierung hinzugefügt werden | H | 6.1.1 6. | +| Crawler | Vollständige STAC-Collection + extrahierte Suchfelder persistent ablegen | – | ≥ 95 % Felder identisch zwischen Quelle und Datenbank bei Stichprobe n=50 | H | 6.1.1 7. | +| Crawler | Erweiterbares DB-Design für zusätzliche Felder vorschlagen (siehe 5. Produktdaten) | – | Schema-Entwurf dokumentiert & abgenommen | M | 6.1.1 8. | +| Crawler | Rate-Limiting einhalten (Quellen nicht überlasten) | – | Keine 429-Antworten/Blockings in Testlauf über 12 h | M | 6.1.1 9. | +| Crawler | Konfigurierbare Crawl-Zeitpläne/Frequenzen | % | CRON/Intervall vom Anwender frei konfigurierbar | L | 6.1.1 10. | +| Crawler | Fehlerbehandlung + Retry; problematische Quellen überspringen | % | Backoff/Retry-Logik; Fehlerbericht vorhanden | M | 6.1.1 11. | +| Crawler | Logging & Monitoring der Crawl-Aktivitäten | % | Dashboards/Metriken (Rate, Fehler, Status) | M | 6.1.1 12. | +| Crawler | Version-agnostische STAC-Extensions erkennen und als Tags speichern (EO, SAR, Point Cloud) | ✔ | Extensions-Tags in DB & Queryables sichtbar | $ | 6.1.1 13. | +| STAC-API | API gemäß relevanten Spezifikationen gültig (STAC API und Collection Search Extension) | – | GET `/` und `/conformance` enthält zutreffende URIs | H | 6.1.2 1. | +| STAC-API | Erweiterung der bestehenden STAC Index API; bleibt selbst gültige STAC-API | – | Root/Collections gültig - Getestet durch `STAC Validator` und `STAC API Validator` und Jest-Tests für die Collection Search Extension | H | 6.1.2 2. | +| STAC-API | Collection Search: Freitext `q`, Filter, Sortierung | – | Beispiel-Queries liefern erwartete Treffer | H | 6.1.2 3. | +| STAC-API | CQL2-Filtering (Basic CQL2 (`AND`, `OR`, `NOT`, `=`, `<>`, `<`, `<=`, `>`, `>=`, `IS NULL`)) für Collection-Eigenschaften | – | Gültige Filter → 200 Antworten; ungültige → 400 Antworten mit Fehlerbeschreibung | H | 6.1.2 4. | +| STAC-API | Zusätzliche CQL2-Fähigkeiten (Advanced Comparison Operators (`LIKE/BETWEEN/IN`, `casei/accenti`, `Spatial/Temporal`, `Arrays`)) | % | Conformance-URIs ergänzt; Tests erfolgreich | M | 6.1.2 5. (optional) | +| STAC-API | CQL2 als Standalone-Library bereitstellen | $ | Lib mit Parser/Validation + README | L | 6.1.2 6. (optional) | +| STAC-API | Integration der neuen Funktionen in bestehende STAC Index API | $ | End-to-End-Tests (Crawler→API→UI) grün | M | 6.1.2 7. | +| Web-UI | Intuitive Suchoberfläche für Collections | – | Usability-Test: Kernflows bestehen | H | 6.1.3 1. | +| Web-UI | Implementierung in Vue (v3) zur Einbindung in STAC Index | – | Build integriert; Routing/State funktionsfähig | M | 6.1.3 2. | +| Web-UI | Interaktive Auswahl von Bounding Box und Zeitintervall | – | BBox/Datetime erzeugen korrekte Parameter | H | 6.1.3 3. | +| Web-UI | Composable Queryables in der UI → generiert CQL2-Ausdruck | – | UI-Builder erzeugt valide CQL2 (Server-OK) | H | 6.1.3 4. | +| Web-UI | Kartenansicht mit Visualisierung räumlicher Extents | – | Extents werden auf interaktiver Karte dargestellt | M | 6.1.3 5. | +| Web-UI | Links zur Originalquelle (Katalog/API) und optional zur Item Search | – | Links korrekt & erreichbar | M | 6.1.3 6. | +| Web-UI | Inspection-Ansicht für Collections (Details) | – | Detailseite zeigt alle Kernfelder | M | 6.1.3 7. | +| Web-UI | Items der Collections inspizieren können | % | Item-Liste/Detail aufrufbar | L | 6.1.3 8. (optional) | +| Web-UI | Collections vergleichen (Mehrfachauswahl & Vergleich) | % | Vergleichsansicht mit minimum 2 Collections | L | 6.1.3 9. (optional) | + +Legende Spalte "Umsetzung" +| Zeichen | Bedeutung | +| ------- | --------- | +| - | Diese Funtion ist vom Lastenheft verpflichtend vorgegeben und wird umgesetzt | +| % | Dieses Funktion ist vom Lastenheft optinal vorgegeben und wird umgesetzt | +| $ | Diese Funktion ist vom Lastenheft optional vorgegeben und es ist nicht geplant dieses umzusetzen| ## 5. Produktdaten @@ -219,7 +233,7 @@ Die Tabellen enthalten jeweils Primärschlüssel zur eindeutigen Identifikation ### Tabellenbereich „Catalogs“ -Der Bereich **Catalogs** bildet die hierarchische Struktur der STAC-Kataloge ab. Jeder Katalog speichert seine Metadaten inklusive Versionierung, Typ, Beschreibung und zugehöriger Links. Über Zwischentabellen werden Keywords sowie Erweiterungen (STAC Extensions) referenziert. +Der Bereich **Catalogs** bildet die hierarchische Struktur der STAC-Kataloge ab. Jeder Katalog speichert seine Metadaten inklusive Versionierung, Typ, Beschreibung und zugehöriger Links. Über Zwischentabellen werden Erweiterungen (STAC Extensions) referenziert. #### Tabelle: `catalog` @@ -233,7 +247,7 @@ Der Bereich **Catalogs** bildet die hierarchische Struktur der STAC-Kataloge ab. | created_at | Zeitpunkt der Erstellung | timestamp | | updated_at | Zeitpunkt der letzten Änderung | timestamp | -Die Haupttabelle `catalog` bildet den zentralen Einstiegspunkt der Kataloghierarchie. Sie speichert allgemeine Metadaten und dient als Ankerpunkt für die zugehörigen Relationen. +Die Haupttabelle `catalog` bildet den zentralen Einstiegspunkt der Kataloghierarchie. Sie speichert allgemeine Metadaten und dient als Ankerpunkt für die zugehörigen Relationen. Diese Tabelle ist notwenidg um zu schauen, ob der crawler die aktueller Version des `catalogs` gespeichert hat, oder diesen `catalog` erneut crawlen muss. Somit kann der crawler bei einem erneuten crawl effektiver und schneller arbeiten. #### Tabelle: `catalog_links` @@ -248,14 +262,6 @@ Die Haupttabelle `catalog` bildet den zentralen Einstiegspunkt der Kataloghierar Die Tabelle `catalog_links` beschreibt die Verknüpfungen zwischen einzelnen Katalogen oder externen Referenzen und implementiert damit die STAC-Link-Struktur. -#### Tabelle: catalog:keywords -| Spalte | Beschreibung / Inhalt | Datentyp / Format | -|----------------|---------------------------------------------|-------------------| -| **catalog_id** | Referenz auf `catalog.id` | integer (FK) | -| **keyword_id** | Referenz auf `keyword.id` | integer (FK) | - -Relationstabelle zur Mehrfachzuordnung von Keywords an Catalogs. Dadurch können Sammlungen gezielt über Schlagwörter gefiltert werden. Diese Tabelle wird benötigt, da hier eine (n:n)-Beziehung vorliegt. - #### Tabelle: catalog:stac_extension | Spalte | Beschreibung / Inhalt | Datentyp / Format | @@ -283,9 +289,9 @@ Der Bereich **Collections** bildet die Sammlungen von Collections innerhalb eine | license | Lizenzinformation | text | | created_at | Zeitpunkt der Erstellung | timestamp | | updated_at | Zeitpunkt der letzten Änderung | timestamp | -| spatial_extend | Räumliche Ausdehnung (Bounding Box) | bbox (geometry) | -| temporal_extend_start | Startzeitpunkt des zeitlichen Gültigkeitsbereichs | timestamp | -| temporal_extend_end | Endzeitpunkt des zeitlichen Gültigkeitsbereichs | timestamp | +| spatial_extent | Räumliche Ausdehnung (Bounding Box) | bbox (geometry) | +| temporal_extent_start | Startzeitpunkt des zeitlichen Gültigkeitsbereichs | timestamp | +| temporal_extent_end | Endzeitpunkt des zeitlichen Gültigkeitsbereichs | timestamp | | is_api? | ist die collection in einem catalog oder api | boolean | | is_active? | ist die collection noch aktuell, oder wurde diese gelöscht | boolean | | full_json | Hier werden alle JSON-Daten einer collection gespeichert | JSON | @@ -412,10 +418,10 @@ Indizes auf allen relevanten Attributen (IDs, Zeitstempel, Textfelder und Geomet Die Crawler-Komponente soll eine hohe Effizienz, Stabilität und Skalierbar sein, um große Mengen an STAC-Katalogen und -APIs regelmäißg und zuverlässig zu erfassen. #### 6.1.1 Crawling Leistung -Der Crawler soll in der Lage sein aus dem STAC-Index Quellen innerhalb einer Woche zu analysieren. In folge dessen soll auch die Aktualisierung aller bekannter und neuer Quellen maximal eine Woche betragen. Die einzelnen STAC-Collections sollen jeweils innerhalb von < 5 Sekunden abgerufen und verarbeitet werden. Zudem soll der Crawler alle vorgegebenen Rate-Limits einhalten, um die externen Dienste nicht zu überlasten (z.B. max. 5 Request/Sekunde pro Quelle). +Der Crawler soll in der Lage sein aus dem STAC-Index Quellen innerhalb einer Woche zu analysieren. In folge dessen soll auch die Aktualisierung aller bekannter und neuer Quellen maximal eine Woche betragen. Die einzelnen STAC-Collections sollen jeweils innerhalb von < 5 Sekunden abgerufen und verarbeitet werden. Zudem soll der Crawler alle vorgegebenen Rate-Limits einhalten, um die externen Dienste nicht zu überlasten (z.B. max. 20 Request/Minute pro Quelle). #### 6.1.2 Crawling Parallelität und Skalierbarkeit -Die Implementierung soll asynchrones und paralleles Crawling unterstützten. Es wird nur ein einzelene Crawler-Instanz sein, um die Komplexität mit Datenbankkonflikten zu vermeiden. Es wird darauf geachtet eine Modulare weise zu programmieren um in Zukunft horizontale Skalierung mit mehren Cralwern möglich zu machen. +Die Implementierung soll asynchrones und paralleles Crawling unterstützten. Es wird nur ein einzelene Crawler-Instanz sein, um die Komplexität mit Datenbankkonflikten zu vermeiden. Es wird darauf geachtet so zu programmieren, um in Zukunft horizontale Skalierung mit mehren Crawlern möglich zu machen. #### 6.1.3 Crawling Zuverlässigkeit unf Fehlertoleranz Der Crawler darf bei fehlerhaften oder inaktiven Quellen nicht vollständig abbrechen. Die Quellen, die dreimal hintereinander fehlschlagen, sollen als inaktiv bis zum Crawling Event behandelt werden. Fehler und Wiederholungen müssen protokolliert werden. Alle ursprünglich erreichbaren STAC collections und catalogs sollen in der Datenbank dann als inaktiv gekennzeichnet werden. @@ -438,9 +444,9 @@ Die Crawling-Durchläufe sollen über Logging und Metriken wie der Anzahl gecraw #### 6.2.1 Funktionale Leistungsanforderungen 1. **Antwortzeiten der Datenbankabfragen** - - Standardabfragen (z. B. Abruf einer Collection nach ID) müssen innerhalb von **< 5 Sekunde** beantwortet werden. - - Komplexe Suchanfragen mit Filtern (z. B. Freitextsuche, räumliche und zeitliche Filterung) müssen innerhalb von **≤ 60 Sekunden** abgeschlossen sein. - - Langlaufende Abfragen dürfen eine maximale Bearbeitungszeit von **≤ 90 Sekunden** nicht überschreiten. + - Standardabfragen (z. B. Abruf einer Collection nach ID) müssen innerhalb von **< 1 Sekunde** beantwortet werden. + - Komplexe Suchanfragen mit Filtern (z. B. Freitextsuche, räumliche und zeitliche Filterung) müssen innerhalb von **≤ 30 Sekunden** abgeschlossen sein. + - Langlaufende Abfragen dürfen eine maximale Bearbeitungszeit von **≤ 60 Sekunden** nicht überschreiten. 2. **Gleichzeitige Zugriffe (Concurrency)** - Das System muss mindestens **50 gleichzeitige Leseanfragen** und **10 gleichzeitige Schreibanfragen** ohne merkliche Leistungseinbußen (< 10 % längere Antwortzeit) verarbeiten können. @@ -516,7 +522,8 @@ Sie visualisiert Metadaten und räumliche Extents der Collections und ermöglich - Die API-Kommunikation erfolgt in Englisch. - Das Frontend wird zweisprachig (Englisch / Deutsch) bereitgestellt. - **Reaktionszeiten**: - - Benutzerinteraktionen (außer Suchanfragen) sollen innerhalb von 1 Sekunde eine sichtbare Rückmeldung liefern. + - Benutzerinteraktionen (außer Suchanfragen) sollen innerhalb von 1 Sekunde eine sichtbare Rückmeldung liefern. + - Dies kann z. B. durch einen Ladeindikator, Fortschrittsbalken oder ähnliches visuelles Feedback erfolgen. - Einfache Suchanfragen (z. B. Freitextsuche nach Keywords) müssen in unter 5 Sekunden abgeschlossen sein. - Komplexe geometrische oder kombinierte CQL2-Filter dürfen maximal 1 Minute dauern. - **Pagination**: Bei umfangreichen Ergebnismengen erfolgt eine seitenweise Darstellung, um Performance und Übersichtlichkeit zu gewährleisten. @@ -528,7 +535,7 @@ Die nachfolgenden Maßnahmen gewährleisten die Korrektheit, Wartbarkeit, Standa ### 7.1 Code-Qualität und Tests #### 7.1.1 Unit-Tests - - Für alle zentralen Backend-Module (insbesondere STAC-API-Routen, CQL2-Parser, Datenbank-Abfrage-Logik und Crawler-Importfunktionen) werden Unit-Tests mit einem geeigneten Framework (jest and pytest) erstellt. + - Für alle zentralen Backend-Module (insbesondere STAC-API-Routen, CQL2-Parser, Datenbank-Abfrage-Logik und Crawler-Importfunktionen) werden Unit-Tests mit einem geeigneten Framework (jest) erstellt. - Für das Frontend werden Unit-Tests mit einem geeigneten Framework (Jest) erstellt. - Zielabdeckung: mindestens 80 % Branch- und Statement-Coverage. - Tests werden automatisiert bei jedem Commit und Merge-Request in der GitHub-Pipeline ausgeführt. @@ -560,7 +567,8 @@ Die nachfolgenden Maßnahmen gewährleisten die Korrektheit, Wartbarkeit, Standa - beim erstmaligen Import (Crawler-Phase), - bei Änderungen oder Re-Crawls, - zusätzlich regelmäßig in der CI-Pipeline anhand von Stichproben. -- Collections, die nicht vollständig dem aktuellen STAC-Standard entsprechen, werden automatisch angepasst oder konvertiert, bevor sie in den Index übernommen werden. +- Collections, die nicht vollständig dem aktuellen STAC-Standard entsprechen, werden automatisch strukturell angepasst oder konvertiert, bevor sie in den Index übernommen werden. +- Kann eine automatische Anpassung nicht durchgeführt werden, wird die betreffende Collection als inkompatibel markiert, nicht in den Index aufgenommen und in einem separaten Fehlerprotokoll dokumentiert. - Die Validierungsergebnisse, sowie alle Anpassungen werden im Crawler-Log und in den CI-Reports dokumentiert. ### 7.4 STAC-API-Validator @@ -569,11 +577,12 @@ Die nachfolgenden Maßnahmen gewährleisten die Korrektheit, Wartbarkeit, Standa - Geprüfte Aspekte: - Gültigkeit der API-Antworten nach STAC API-Spezifikation (v1.x). - Unterstützung der Collection Search Extension und der CQL2-Query Language (Basic). - - Korrekte Implementierung der Endpoints (/collections, /search, /conformance, /queryables). + - Korrekte Implementierung der Endpoints (`/`, `/conformance`, `/collections`, `/collections/{id}`). +-Die Collection Search Extension wird zusätzlich durch eigene Integrationstests validiert, da der offizielle Validator derzeit keine automatisierte Prüfung dieser Erweiterung unterstützt. - Der Validator wird: - nach jedem erfolgreichen Build in der CI-Pipeline ausgeführt, - manuell vor der Endabgabe für einen vollständigen Compliance-Report verwendet. -- Ziel: 100 % bestehende STAC-Validator-Tests. +- Ziel: 100 % bestehende STAC-Validator-Tests, sowie erfolgreiche interne Validierung der Collection Search Extension. ### 7.5 Dokumentations- und Wartungsqualität - Alle Module werden mit aussagekräftigen Kommentaren dokumentiert, entsprechend der jeweils verwendeten Programmiersprache (z. B. PyDoc für Python-Module oder JSDoc für JavaScript/Vue-Komponenten). @@ -609,6 +618,7 @@ Die nachfolgenden Maßnahmen gewährleisten die Korrektheit, Wartbarkeit, Standa - Sichere Datenbankverbindungen - Eingabevalidierung (SQL-Injection-Schutz) - Sanitization von Nutzereingaben +- Gegen XSS abgesichert - Keine Exposition sensibler Daten in Logs - Protokollierung der Crawl-Aktivitäten - Strukturierte Error-Logs mit konfigurierbaren Log-Levels @@ -630,13 +640,12 @@ Die nachfolgenden Maßnahmen gewährleisten die Korrektheit, Wartbarkeit, Standa ## 9. Gliederung in Teilprodukte ### 9.1 Crawler-Komponente -Der Crawler durchsucht STAC Index nach STAC Kataloge und STAC APIs. Dabei sollen mehr als 95% der Collections erfolgreich erfasst werden. +Der Crawler bekommt über die STAC Index API, alle STAC Kataloge und STAC APIs die gecrawled werden müssen. Dabei sollen alle Collections erfolgreich erfasst werden. Das Crawling erfolgt rekursiv, sodass Collections in nahezu beliebiger Tiefe (<1024 Ebenen) innerhalb verschachtelter Kataloge erkannt werden. Es werden ausschließlich Catalogs und Collections und keine Items erfasst. Die Crawling Vorgänge extrahieren die relevanten Metadaten jeder Collection (6.1.1.3), das vollständige JSON-Objekt jeder Kollektion und speichern sie zusammen mit der Quell-URL, dem Katalognamen und dem Zeitstempel des letzten Crawls. Zusätzlich wird auch ein Parameter, über die aktuelle Verfügbarkeit der Collection hinzugefügt. Es werden alle stabilen STAC-Versionen, durch Migration unterstützt. Eine Crawling-Plan (Schedule) ermöglicht die zeitliche Steuerung einzelner Crawl-Vorgänge. Es soll eine wöchentliche Aktualisierungen des Indexes durchgeführt werden. - -Für die Umsetzung werden PySTAC und asyncio zur Verarbeitung genutzt. Die Ergebnisse werden mittels PypgSTAC in einer PostgreSQL-Datenbank gespeichert. +Die Ergebnisse werden in einer PostgreSQL-Datenbank gespeichert. ### 9.2 Datenbank-Komponente Die Datenbankkomponente stellt die zentrale Grundlage für die Speicherung, Verwaltung und Abfrage aller vom Crawler erfassten Metadaten dar. Sie dient der persistenten Ablage sämtlicher Inhalte, einschließlich der vollständigen STAC-JSON-Strukturen, und ermöglicht deren effiziente Weiterverarbeitung innerhalb der Gesamtarchitektur. Als Datenbanksystem wird **PostgreSQL** in Kombination mit der Erweiterung **PostGIS** eingesetzt, um sowohl relationale als auch geographische Abfragen performant unterstützen zu können. @@ -688,9 +697,9 @@ und ermöglicht einen einheitlichen, standardkonformen Zugriff auf alle gespeich - `GET /collections/{id}` -> Liefert die vollständigen Metadaten einer einzelnen Collection 3. Collection Search -- `GET /search` +- `GET /collections` und -- `POST /search` +- `POST /collections` - Ermöglicht die gezielte Filterung und Suche nach Collections innerhalb des Index. - Unterstützt wird sowohl die einfache Query-Parameter-Variante (GET) als auch komplexe CQL2-Abfragen (POST). @@ -755,7 +764,7 @@ Ziel des Crawler‑Moduls ist die automatische Erfassung, Validierung und Speich #### 10.1.1 Technologien Der Crawler wird als Node.js‑Anwendung konzipiert werden. Es wird JavaScript genutzt, um bessere Wartbarkeit und Weiterentwicklung innerhalb der Gruppe zu erreichen und die Probleme mit bestimmten Versionen von z.B. Python zu unterbinden. -Für das STAC‑Handling kommen [stac-js](https://github.com/moregeo-it/stac-js) und [stac-migrate](https://github.com/stac-utils/stac-migrate) zum Migrieren älterer STAC‑Versionen zum Einsatz. Für HTTP‑Zugriffe eignen sich `axios` oder `got`, da beide Timeouts und Retries unterstützen. Alternativ kann `node‑fetch` verwendet werden. +Für das STAC‑Handling kommen [stac-js](https://github.com/moregeo-it/stac-js) und [stac-migrate](https://github.com/stac-utils/stac-migrate) zum Migrieren älterer STAC‑Versionen zum Einsatz. Für HTTP‑Zugriffe eignen sich `axios`, da es Timeouts und Retries unterstützt. Alternativ kann `node‑fetch` verwendet werden. Beim Crawling und Queueing sind für komplexe Szenarien, Frameworks wie `Crawlee (Apify)` oder vergleichbare Lösungen mit integrierter Queue/Retry‑Logik empfehlenswert, für leichtere Implementierungen bieten sich `p‑queue` oder `Bottleneck` zur Steuerung von Parallelität und Rate‑Limits an. Zur zeitgesteuerten Ausführung kann lokal `node‑cron` genutzt werden. Die Validierung erfolgt via JSON‑Schema Validator (z. B. `ajv`) unter Verwendung der offiziellen STAC‑Schemas. Die Anbindung an die Datenbank kann mit `node‑postgres (pg)` erfolgen. @@ -781,7 +790,7 @@ Optional existiert eine Admin UI / API zur Anzeige von Quellen, Fehlerlogs und f 3. Rekursives Crawling und Pagination: Die Engine folgt Link‑Rela‑Typen wie child, catalog und collection sowie paginiert bei STAC APIs; neue URLs/Tasks werden in die Queue aufgenommen und asynchron abgearbeitet, wobei Rate‑Limits und Parallelität berücksichtigt werden. -4. Migration & Modeling: Gefundene STAC‑Objekte werden mit stac‑migrate in eine einheitliche STAC‑Version überführt und anschließend in ein internes Datenmodell (z. B. stac‑js‑Objekt oder DTO) umgewandelt. +4. Migration: Gefundene STAC‑Objekte werden mit stac‑migrate in eine einheitliche STAC‑Version überführt und anschließend in ein internes Datenmodell für die Suche umgewandelt. 5. Extraktion & Normalisierung: Aus den STAC‑Objekten werden Schlüsselattribute extrahiert (z. B. id, title, description, extent – bbox und temporal, providers, license, assets, HREFs). Die BBOX‑Angaben werden in eine PostGIS‑Geometrie konvertiert (z. B. Envelope/Polygon), zeitliche Angaben als TIMESTAMP abgelegt. @@ -830,7 +839,7 @@ Die Implementierung folgt einem klar strukturierten Vorgehen in mehreren Phasen, Anschließend werden die Such- und Filtermechanismen implementiert. Dazu gehört die Integration einer **Volltextsuche** auf Basis von PostgreSQL-TSVector, die Anbindung von **PostGIS** für Bounding-Box- und Distanzabfragen sowie die Umsetzung einer Übersetzungsschicht für **CQL2-Filterausdrücke**. Ergebnis: performante Such- und Filterfunktionen mit optimierten Indizes. -5. **Deployment und Dokumentation (M6)** +5. **Deployment und Dokumentation (M5)** Die produktive Bereitstellung erfolgt über **Docker Compose** Das Prisma-Schema, die Migrationsdateien und die API-Routen werden versioniert und dokumentiert. Eine technische Dokumentation beschreibt die Struktur, Indexierung und Updateprozesse der Datenbank. Ergebnis: einsatzbereite, dokumentierte Datenbankkomponente. @@ -839,10 +848,10 @@ Die Implementierung folgt einem klar strukturierten Vorgehen in mehreren Phasen, ### 10.3 STAC API | ID | Arbeitspaket | Ziel/Output | Schritte (Stichpunkte) | Reuse/Technologien | |----|--------------|-------------|-------------------------|--------------------| -| AP-01 | Projekt-Skeleton & Infrastruktur | Lauffähiges API-Grundgerüst mit Konfiguration & Logging | Repo-Struktur (`/api`, `/docs`, `/ops`); Apache-2.0 LICENSE; ENV-Konfig (Port, DB-URL vom DB-Team); strukturierte Logs; einfache Health-Route `GET /` | Python+FastAPI *oder* Node+Fastify/Express; uvicorn/node pm2; dotenv | +| AP-01 | Projekt-Skeleton & Infrastruktur | Lauffähiges API-Grundgerüst mit Konfiguration & Logging | Repo-Struktur (`/api`, `/docs`); Apache-2.0 LICENSE; ENV-Konfig (Port, DB-URL vom DB-Team); strukturierte Logs; einfache Health-Route `GET /` | Python+FastAPI *oder* Node+Fastify/Express; uvicorn/node pm2; dotenv | | AP-02 | Daten-Vertrag & Queryables (API-Seite) | Konsistentes Feld-Set & ` /queryables` für die UI | Such-/Filterfelder festlegen (id, title, description, extent, keywords, providers.name, license, doi, `summaries.platform/constellation/gsd/processing:level`); Datentypen (CQL2-kompatibel) definieren; `GET /queryables` (global/optional pro Collection); Dokumentation für UI | STAC Collections/Queryables Best Practices; CQL2 Typen | | AP-03 | STAC-Core Endpunkte | STAC-konforme Basisrouten bereitstellen | `GET /` (Landing + Links), `GET /conformance` (Core+Collections vorerst), `GET /collections`, `GET /collections/{id}`; Link-Relationen & Service-Doku referenzieren | OpenAPI/Swagger-UI; STAC API Core/Collections | -| AP-04 | Collection Search – Routen & Parameter | Collection-Search-Schnittstelle mit `q`, `filter`, `sort`, Paging | Route definieren (`/collections/search` oder Parametrisierung von `/collections`); Request-Validierung; Paging-Links | STAC Collection Search Extension; API Framework Middleware | +| AP-04 | Collection Search – Routen & Parameter | Collection-Search-Schnittstelle mit `q`, `filter`, `sort`, Paging | Route definieren (Parametrisierung von `/collections`); Request-Validierung; Paging-Links | STAC Collection Search Extension; API Framework Middleware | | AP-05 | CQL2 Basic – Parsing & Validierung | Gültige CQL2-Basic-Filter erkennen & valide/klare Fehlermeldungen liefern | Bestehende Parser/Validator-Lib einbinden; Request-Modelle (JSON/Text); Fehlermeldungen standardisieren | *cql2-rs* oder *pycql2* | | AP-06 | CQL2-Ausführung – AST → SQL | CQL2-AST in effiziente SQL-Where-Klauseln übersetzen | Visitor/Mapper je Knotentyp (Vergleich, Logik, `IS NULL`, optional `LIKE/IN/BETWEEN`); Parametrisiertes SQL; Schutz vor teuren Scans (Zeit/Seite begrenzen) | — | | AP-07 | Freitext `q` & Sortierung | Relevanzbasierte Freitextsuche + stabile Sortierung | Felder für `q` bestimmen (title, description, keywords, providers); Whitelist für `sortby`; Validierung bei nicht unterstützten Feldern → 400 | API-seitige Param-Validierung | @@ -936,7 +945,7 @@ Filterparameter werden in den Anfragen nach dem CQL2-Standard übergeben. **Hinweis:** Kalenderwochen (KW) entsprechen ISO-Wochen, angegeben mit **Montag–Sonntag**. **Roadmap nach Kalenderwochen** | Zeitraum (Mo–So) | KW | Phase/Meilenstein | Hauptaktivitäten | Artefakte/Outputs | -|---|:--:|---|---|---|---| +|---|:--:|---|---|---| | **12.11.2025** (Mi) | 46 | **Kick-off Implementierung** | Projektstart, Scope & Schnittstellen finalisieren, Tooling/Standards festlegen | Kick-off-Protokoll, To-Do-Übersicht | | 10.11–16.11.2025 | 46 | **Struktur Repo** | Monorepo/Repos anlegen (`/api`, `/crawler`, `/frontend`, `/ops`, `/docs`), CI/Lint/Format einrichten | Repo-Struktur, CI-Baseline, CONTRIBUTING.md | | 10.11–16.11.2025 | 46 | **API-Endpoints (Core)** | API-Skeleton, Routen `/`, `/conformance`, `/collections`, Stubs/Swagger | Laufende API-Instanz, OpenAPI-Draft | diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 46c1139..1bd2e85 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -167,12 +167,12 @@ async function insertOrUpdateCollection(collection) { await client.query('BEGIN'); // Parse spatial extent (bbox) - let spatialExtend = null; + let spatialExtent = null; if (collection.extent?.spatial?.bbox && collection.extent.spatial.bbox[0]) { const bbox = collection.extent.spatial.bbox[0]; if (bbox.length === 4) { // Create polygon from bbox [west, south, east, north] - spatialExtend = `EPSG:4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; + spatialExtent = `EPSG:4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; } } @@ -204,9 +204,9 @@ async function insertOrUpdateCollection(collection) { type = $2, description = $3, license = $4, - spatial_extend = ST_GeomFromEWKT($5), - temporal_extend_start = $6, - temporal_extend_end = $7, + spatial_extent = ST_GeomFromEWKT($5), + temporal_extent_start = $6, + temporal_extent_end = $7, is_api = $8, is_active = $9, full_json = $10, @@ -217,7 +217,7 @@ async function insertOrUpdateCollection(collection) { collection.type || 'Collection', collection.description || null, collection.license || null, - spatialExtend, + spatialExtent, temporalStart, temporalEnd, false, // is_api - will be determined by crawler @@ -231,7 +231,7 @@ async function insertOrUpdateCollection(collection) { const collectionResult = await client.query( `INSERT INTO collection ( stac_version, type, title, description, license, - spatial_extend, temporal_extend_start, temporal_extend_end, + spatial_extent, temporal_extent_start, temporal_extent_end, is_api, is_active, full_json, updated_at ) VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) @@ -242,7 +242,7 @@ async function insertOrUpdateCollection(collection) { collectionTitle, collection.description || null, collection.license || null, - spatialExtend, + spatialExtent, temporalStart, temporalEnd, false, // is_api - will be determined by crawler diff --git a/db/.gitignore b/db/.gitignore index e69de29..2eea525 100644 --- a/db/.gitignore +++ b/db/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/db/README.md b/db/README.md index da1c603..05e1542 100644 --- a/db/README.md +++ b/db/README.md @@ -59,11 +59,12 @@ Comprehensive indexing for optimal query performance: ```bash cd ./db/ docker-compose up +``` ### Connection Details - **Host**: `atlas.stacindex.org` -- **Port**: `5432` +- **Port**: `5432` and `5433` ## Port Configuration @@ -71,15 +72,19 @@ This project exposes the database service on a port that can be changed. Update The database uses port mapping in the format `HOST:CONTAINER`: - **`5432:5432`** means: - - Left side (`5432`): Port on your local machine (host) + - Left side (`5432`): Port on your local machine (host) (must be changed in the `.env`) - Right side (`5432`): Port inside the Docker container -What to change in the Docker Compose file +How to change the environment parameters in the Docker Compose file - Open the `docker-compose.yml`. -- Locate the `ports:` and change the host side: +- Locate e.g. `ports:` and change the host side: - Format: `":"` -- Example: change `5432:5432` to `15432:5432` to expose the container's 5432 on host port 15432. -- TODO: If the compose file references environment variables (e.g. `${DB_PORT}`), change the value in the corresponding `.env` file. +- Example: change `5432:5432` to `5433:5432` to expose the container's 5432 on host port 5433. +- If the compose file references environment variables (e.g. `${DB_PORT}`), change the value in the corresponding `.env` file. + +**Important**: Do not modify the `docker-compose.yml` file directly. Instead, update the port configuration in the `.env` file by changing the `${DB_PORT}`, `${POSTGRES_DB}`, `${POSTGRES_USER}` and `${POSTGRES_PASSWORD}` variable, then restart the service with `docker-compose up`. +- The change in the `.env` does not count for the ``, you can change that directly in the `docker-compose.yml` if needed. +- There is an `example.env` provided that can be renamed into `.env` and then modified. ## Initialization Scripts diff --git a/db/docker-compose.yml b/db/docker-compose.yml index 68a3f35..d45a248 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -3,14 +3,16 @@ services: image: postgis/postgis:16-3.4 container_name: stac_db restart: always + env_file: + - .env environment: - POSTGRES_DB: stac_db - POSTGRES_USER: stac_user - POSTGRES_PASSWORD: stac_password + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - "5432:5432" + - "${DB_PORT}:5432" volumes: - stac_data:/var/lib/postgresql/data diff --git a/db/example.env b/db/example.env new file mode 100644 index 0000000..e175b0b --- /dev/null +++ b/db/example.env @@ -0,0 +1,7 @@ +# PostgreSQL Database Configuration +POSTGRES_DB= # stac_db is the database we are running on +POSTGRES_USER= # add postgres_user here +POSTGRES_PASSWORD= # add postgres_password here + +# Database Port (host:container) +DB_PORT= # 5432 / 5433 (at the moment both are available) \ No newline at end of file diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index 9078d24..a44355f 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -3,7 +3,7 @@ -- Main catalog table: Stores STAC catalog metadata including version, type, title, and description -- Each catalog represents a STAC catalog endpoint that has been discovered and indexed CREATE TABLE catalog ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, type TEXT, title TEXT, @@ -15,7 +15,7 @@ CREATE TABLE catalog ( -- Catalog links table: Stores related links for catalogs (e.g., self, root, child, item links) -- Links define the navigation structure between STAC resources CREATE TABLE catalog_links ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, rel TEXT, href TEXT, @@ -26,21 +26,21 @@ CREATE TABLE catalog_links ( -- Keywords lookup table: Stores unique searchable keywords -- Used by both catalogs and collections for categorization and search CREATE TABLE keywords ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, keyword TEXT UNIQUE ); -- STAC extensions lookup table: Stores unique STAC extension identifiers -- Extensions provide additional standardized fields beyond core STAC spec CREATE TABLE stac_extensions ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_extension TEXT UNIQUE ); -- Crawl log for catalogs: Tracks when each catalog was last crawled for updates -- Used to schedule re-crawling and maintain freshness of catalog data CREATE TABLE crawllog_catalog ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, last_crawled TIMESTAMP ); diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 0fd6197..733bb08 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -4,8 +4,9 @@ -- Collections group related STAC items and define their common properties -- full_json: Complete JSONB representation the whole collection CREATE TABLE collection ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, + stac_id INTEGER, type TEXT, title TEXT, description TEXT, @@ -13,9 +14,9 @@ CREATE TABLE collection ( created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now(), - spatial_extend GEOMETRY(POLYGON, 4326), - temporal_extend_start TIMESTAMP, - temporal_extend_end TIMESTAMP, + spatial_extent GEOMETRY(POLYGON, 4326), + temporal_extent_start TIMESTAMP, + temporal_extent_end TIMESTAMP, is_api BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, @@ -27,7 +28,7 @@ CREATE TABLE collection ( -- represent ranges (min/max), sets of values, or JSON schemas -- Used to describe the range of values found in collection items CREATE TABLE collection_summaries ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, name TEXT, kind TEXT, @@ -40,14 +41,14 @@ CREATE TABLE collection_summaries ( -- Providers lookup table: Stores unique data provider names -- Providers are organizations or entities that produce, host, or process the data CREATE TABLE providers ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, provider TEXT UNIQUE ); -- Assets table: Stores downloadable assets (data files, thumbnails, metadata files, etc.) -- Assets are the actual data products or resources associated with collections CREATE TABLE assets ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT, href TEXT, type TEXT, @@ -59,7 +60,7 @@ CREATE TABLE assets ( -- Used to schedule re-crawling and maintain freshness of collection data -- (same usecase as the crawllog for catalogs) CREATE TABLE crawllog_collection ( - id SERIAL PRIMARY KEY, + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, last_crawled TIMESTAMP ); diff --git a/db/init/05_indexes.sql b/db/init/05_indexes.sql index 76a750f..9349b88 100644 --- a/db/init/05_indexes.sql +++ b/db/init/05_indexes.sql @@ -25,10 +25,10 @@ CREATE INDEX idx_crawllog_catalog_last ON crawllog_catalog (last_crawled); -- Basic collection lookups CREATE INDEX idx_collection_title ON collection (title); -CREATE INDEX idx_collection_temp ON collection (temporal_extend_start, temporal_extend_end); +CREATE INDEX idx_collection_temp ON collection (temporal_extent_start, temporal_extent_end); CREATE INDEX idx_collection_active ON collection (is_active); -CREATE INDEX idx_collection_spatial ON collection USING GIST (spatial_extend); +CREATE INDEX idx_collection_spatial ON collection USING GIST (spatial_extent); CREATE INDEX idx_collection_fulltext ON collection USING GIN (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))); From cc60a7c551e9dbb5f9cdb09af9159a229339db3d Mon Sep 17 00:00:00 2001 From: mammutor Date: Thu, 8 Jan 2026 12:09:17 +0100 Subject: [PATCH 053/134] refactor(api): simplify collections link handling in STAC API root function - Streamlined the logic for retrieving the collections link by directly using the href property if available, improving code clarity and reducing unnecessary checks. --- crawler/apis/api.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index ff02ca5..27a0615 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -181,14 +181,10 @@ async function handleApiRoot({ request, json, crawler, log, indent, results }) { if (typeof stacObj.getApiCollectionsLink === 'function') { const collectionsLink = stacObj.getApiCollectionsLink(); - if (collectionsLink) { - const linkUrl = typeof collectionsLink.getAbsoluteUrl === 'function' - ? collectionsLink.getAbsoluteUrl() - : collectionsLink.href; - if (linkUrl) { - collectionsEndpoint = linkUrl; - log.info(`${indent}Found collections link via stac-js: ${collectionsEndpoint}`); - } + if (collectionsLink && collectionsLink.href) { + // Direkt die href verwenden - diese ist bereits absolut im JSON + collectionsEndpoint = collectionsLink.href; + log.info(`${indent}Found collections link via stac-js: ${collectionsEndpoint}`); } } From 08b398df389175c511cd140ad76fe98f5596b0a5 Mon Sep 17 00:00:00 2001 From: mammutor Date: Thu, 8 Jan 2026 12:11:04 +0100 Subject: [PATCH 054/134] chore(crawler): comment out restart policy in docker-compose.yml --- crawler/docker-compose.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crawler/docker-compose.yml b/crawler/docker-compose.yml index 37ffa14..f154ad5 100644 --- a/crawler/docker-compose.yml +++ b/crawler/docker-compose.yml @@ -4,14 +4,11 @@ services: context: . dockerfile: Dockerfile container_name: stac-crawler - restart: unless-stopped + # restart: unless-stopped environment: - NODE_ENV=production networks: - stac-network - # Optional: Add volume to persist data - # volumes: - # - ./data:/app/data networks: stac-network: From e095d0adec4c726cca30eec753cc0731a15dce6b Mon Sep 17 00:00:00 2001 From: mammutor Date: Thu, 8 Jan 2026 14:26:15 +0100 Subject: [PATCH 055/134] fix(crawler): update .env.example and normalize base URL handling - Added new environment variables for Postgres/PostGis configuration and crawler settings in .env.example. - Improved URL handling in tryCollectionEndpoints by normalizing the base URL to prevent double slashes. --- crawler/.env.example | 14 ++++++++++++++ crawler/utils/endpoints.js | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crawler/.env.example b/crawler/.env.example index 7c52159..d257021 100644 --- a/crawler/.env.example +++ b/crawler/.env.example @@ -1,8 +1,22 @@ # STAC Crawler Configuration # Copy this file to .env and adjust values as needed +# URI of the Postgres/PostGis Host PGHOST=example_db_URL +# Postgres/PostGis Port PGPORT=5432 +# Postgres/PostGis user PGUSER=example_user +# Postgres/PostGis password PGPASSWORD=example_password +# Postgres/PostGis databse PGDATABASE=example_db + +# 'catalogs', 'apis', or 'both' +CRAWL_MODE=both +# Maximum Static Catalogs that are crawled (0 ist unlimited) +MAX_CATALOGS=0 +# Maximum API Catalogs that are crawled (0 ist unlimited) +MAX_APIS=0 +# Timeout of connection of crawler +TIMEOUT_MS=30000 \ No newline at end of file diff --git a/crawler/utils/endpoints.js b/crawler/utils/endpoints.js index 09ebe6f..786d68b 100644 --- a/crawler/utils/endpoints.js +++ b/crawler/utils/endpoints.js @@ -14,6 +14,9 @@ * @param {string} indent - Indentation for logging */ export async function tryCollectionEndpoints(baseUrl, catalogId, depth, crawler, log, indent) { + // Trailing Slash remove, because it causes double slashes + const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + const collectionEndpoints = [ '/collections', '/collections/', @@ -21,7 +24,7 @@ export async function tryCollectionEndpoints(baseUrl, catalogId, depth, crawler, ]; const requests = collectionEndpoints.map(endpoint => ({ - url: `${baseUrl}${endpoint}`, + url: `${normalizedBaseUrl}${endpoint}`, label: 'COLLECTIONS', userData: { catalogUrl: baseUrl, From c65c348f86c6b53514150faabbf970dbeea7f58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 10 Jan 2026 01:21:54 +0100 Subject: [PATCH 056/134] added different users for the api and crawler groups. The api has read-only acces and the crawler user full acces to the database. The acces to the database is now possible by using the users `stac_api`or `stac_crawler`. The admin user (`postgres_user`) is still available but shouldn't be used --- api/.env.example | 8 ++++---- db/docker-compose.yml | 4 ++++ db/example.env | 14 +++++++++++--- db/init/00_users.sh | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 db/init/00_users.sh diff --git a/api/.env.example b/api/.env.example index 5906869..c6b2632 100644 --- a/api/.env.example +++ b/api/.env.example @@ -5,15 +5,15 @@ NODE_ENV=development # Database Configuration (Debian Server) # Option 1: Use DATABASE_URL (PostgreSQL connection string) -# add DB_USER and DB_PASSWORD values -DATABASE_URL= postgresql://[**DB_USER**]:[**DB_PASSWORD**]@atlas.stacindex.org:5432/stac_db +# The api-user is stac_api (read-only) (stac_crawler for crawler-group) +DATABASE_URL=postgresql://stac_api:[PASSWORD]@atlas.stacindex.org:5432/stac_db # Option 2: Use individual variables (currently active) DB_HOST=atlas.stacindex.org DB_PORT=5433 # 5432 for production DB_NAME=stac_db -DB_USER= -DB_PASSWORD= +DB_USER=stac_api +DB_PASSWORD= # Add stac_api password here (api_password) DB_SSL=false # Connection Pool Configuration diff --git a/db/docker-compose.yml b/db/docker-compose.yml index 12c20f8..c8e41f1 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -7,9 +7,13 @@ services: - .env environment: + # Admin user (required for initial database setup) POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + # Application user passwords + STAC_API_PASSWORD: ${STAC_API_PASSWORD} + STAC_CRAWLER_PASSWORD: ${STAC_CRAWLER_PASSWORD} ports: - "${DB_PORT}:5432" diff --git a/db/example.env b/db/example.env index e175b0b..35158a8 100644 --- a/db/example.env +++ b/db/example.env @@ -1,7 +1,15 @@ # PostgreSQL Database Configuration +# Admin user with superuser privileges (required for initial setup) POSTGRES_DB= # stac_db is the database we are running on -POSTGRES_USER= # add postgres_user here -POSTGRES_PASSWORD= # add postgres_password here +POSTGRES_USER= # add postgres_user here (admin user) +POSTGRES_PASSWORD= # add postgres_password here (admin password) # Database Port (host:container) -DB_PORT= # 5432 / 5433 (at the moment both are available) \ No newline at end of file +DB_PORT= # 5432 / 5433 (at the moment both are available) + +# Application Users (created via init scripts) +# stac_api: read-only access for API +STAC_API_PASSWORD= # Password for api user (read-only); add api_password here + +# stac_crawler: full read-write access for crawler +STAC_CRAWLER_PASSWORD= # Password for crawler user (read-write); add crawler_password here \ No newline at end of file diff --git a/db/init/00_users.sh b/db/init/00_users.sh new file mode 100644 index 0000000..03a5885 --- /dev/null +++ b/db/init/00_users.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +# This script creates users for teh api and crawler group with different permissions. + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + + -- Create read-only user for API access + CREATE USER stac_api WITH PASSWORD '$STAC_API_PASSWORD'; + + -- Create read-write user for crawler + CREATE USER stac_crawler WITH PASSWORD '$STAC_CRAWLER_PASSWORD'; + + GRANT CONNECT ON DATABASE stac_db TO stac_api; + GRANT CONNECT ON DATABASE stac_db TO stac_crawler; + GRANT USAGE ON SCHEMA public TO stac_api; + GRANT USAGE ON SCHEMA public TO stac_crawler; + + -- For stac_api: Grant SELECT (read-only) on all existing tables in public + GRANT SELECT ON ALL TABLES IN SCHEMA public TO stac_api; + + -- For stac_crawler: Grant all privileges on all existing tables in public + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO stac_crawler; + + -- For stac_api: Auto-grant SELECT on future tables + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO stac_api; + + -- For stac_crawler: Auto-grant all privileges on future tables + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO stac_crawler; + + -- For stac_crawler: Grant USAGE on all sequences (needed for SERIAL/IDENTITY columns) + GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO stac_crawler; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE ON SEQUENCES TO stac_crawler; +EOSQL From e0b254349f3eddf5ccc0f3fc963687c03b951fe6 Mon Sep 17 00:00:00 2001 From: mammutor Date: Sun, 11 Jan 2026 13:25:45 +0100 Subject: [PATCH 057/134] chore(db): update docker-compose.yml for network configuration - Changed the networks section to use array syntax for consistency. - Added a driver specification for the stac-network to use bridge mode. --- db/docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/docker-compose.yml b/db/docker-compose.yml index d45a248..2e342c0 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -17,12 +17,12 @@ services: volumes: - stac_data:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d - networks: - - stac-network + networks: [stac-network] volumes: stac_data: networks: stac-network: - name: stac-network \ No newline at end of file + name: stac-network + driver: bridge \ No newline at end of file From ef37617c3182be47d179dd0349ca59ab309b7153 Mon Sep 17 00:00:00 2001 From: mammutor Date: Sun, 11 Jan 2026 13:56:49 +0100 Subject: [PATCH 058/134] Refactor(database): SQL trigger definitions for catalog and collection keywords. Moved triggers to 06_triggers.sql for better organization, as they depend on the respective tables created in earlier scripts. --- db/init/02_tables_catalog.sql | 7 +++---- db/init/03_tables_collections.sql | 6 ++---- db/init/06_triggers.sql | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 db/init/06_triggers.sql diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index 3098cd3..6b9bf4c 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -102,7 +102,6 @@ BEGIN END; $$ LANGUAGE plpgsql; -CREATE TRIGGER catalog_keywords_update_vector -AFTER INSERT OR DELETE ON catalog_keywords -FOR EACH ROW -EXECUTE FUNCTION update_catalog_search_vector_on_keyword_change(); +-- NOTE: The trigger for catalog_keywords is defined in 06_triggers.sql +-- because it depends on the catalog_keywords table which is created there + diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 1f72ab7..842c7a8 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -122,7 +122,5 @@ BEGIN END; $$ LANGUAGE plpgsql; -CREATE TRIGGER collection_keywords_update_vector -AFTER INSERT OR DELETE ON collection_keywords -FOR EACH ROW -EXECUTE FUNCTION update_collection_search_vector_on_keyword_change(); +-- NOTE: The trigger for collection_keywords is defined in 06_triggers.sql +-- because it depends on the collection_keywords table which is created there \ No newline at end of file diff --git a/db/init/06_triggers.sql b/db/init/06_triggers.sql new file mode 100644 index 0000000..5e167a0 --- /dev/null +++ b/db/init/06_triggers.sql @@ -0,0 +1,16 @@ +-- ======================================== +-- FULL-TEXT SEARCH TRIGGERS FOR KEYWORDS +-- ======================================== +-- These triggers must be created here (after junction tables exist) + +-- Trigger to update catalog search_vector when keywords change +CREATE TRIGGER catalog_keywords_update_vector +AFTER INSERT OR DELETE ON catalog_keywords +FOR EACH ROW +EXECUTE FUNCTION update_catalog_search_vector_on_keyword_change(); + +-- Trigger to update collection search_vector when keywords change +CREATE TRIGGER collection_keywords_update_vector +AFTER INSERT OR DELETE ON collection_keywords +FOR EACH ROW +EXECUTE FUNCTION update_collection_search_vector_on_keyword_change(); \ No newline at end of file From d140405100c6e222d0689cc4936fa0cb00ea6f34 Mon Sep 17 00:00:00 2001 From: mammutor Date: Sun, 11 Jan 2026 14:15:04 +0100 Subject: [PATCH 059/134] feat(crawler): implement collection flushing to database - Added a new utility function `flushCollectionsToDb` to handle batch saving of collections to the database. - Integrated flushing logic into the crawling process to save collections periodically and at the end of the crawl. - Updated statistics to track saved and failed collections during the flush operation. - Enhanced logging for better visibility of the flushing process. --- crawler/catalogs/catalog.js | 19 +++++++++-- crawler/utils/handlers.js | 68 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index cbb825a..fb8bde2 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -3,8 +3,8 @@ * @module catalogs/catalog */ -import { HttpCrawler } from 'crawlee'; -import { handleCatalog, handleCollections } from '../utils/handlers.js'; +import { HttpCrawler, log as crawleeLog } from 'crawlee'; +import { handleCatalog, handleCollections, flushCollectionsToDb } from '../utils/handlers.js'; /** * Creates and runs a Crawlee HttpCrawler to crawl STAC catalogs @@ -27,6 +27,8 @@ async function crawlCatalogs(initialCatalogs, config = {}) { successfulRequests: 0, failedRequests: 0, collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0, catalogsProcessed: 0, stacCompliant: 0, nonCompliant: 0 @@ -96,6 +98,15 @@ async function crawlCatalogs(initialCatalogs, config = {}) { console.log(`\nStarting Crawlee crawler with ${initialRequests.length} initial catalogs...\n`); await crawler.run(); + // Flush any remaining collections to database + console.log('\nFlushing remaining collections to database...'); + const finalFlush = await flushCollectionsToDb(results, crawleeLog, true); + results.stats.collectionsSaved += finalFlush.saved; + results.stats.collectionsFailed += finalFlush.failed; + + // Clear catalogs array to free memory (we don't need them after crawl) + results.catalogs.length = 0; + console.log('\nCrawl Statistics:'); console.log(` Total Requests: ${results.stats.totalRequests}`); console.log(` Successful: ${results.stats.successfulRequests}`); @@ -103,7 +114,9 @@ async function crawlCatalogs(initialCatalogs, config = {}) { console.log(` STAC Compliant: ${results.stats.stacCompliant}`); console.log(` Non-Compliant: ${results.stats.nonCompliant}`); console.log(` Catalogs Processed: ${results.stats.catalogsProcessed}`); - console.log(` Collections Found: ${results.stats.collectionsFound}\n`); + console.log(` Collections Found: ${results.stats.collectionsFound}`); + console.log(` Collections Saved to DB: ${results.stats.collectionsSaved}`); + console.log(` Collections Failed: ${results.stats.collectionsFailed}\n`); return results; } diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index f60facc..5a7570b 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -6,6 +6,68 @@ import create from 'stac-js'; import { normalizeCollection } from './normalization.js'; import { tryCollectionEndpoints } from './endpoints.js'; +import db from './db.js'; + +/** + * Batch size for saving collections to database + * After this many collections are collected, they will be flushed to DB + * @type {number} + */ +const BATCH_SIZE = 1000; + +/** + * Flushes collected collections to the database and clears the array + * @async + * @param {Object} results - Results object containing collections array + * @param {Object} log - Logger instance + * @param {boolean} force - If true, flush even if below batch size (used at end of crawl) + * @returns {Promise<{saved: number, failed: number}>} Count of saved and failed collections + */ +export async function flushCollectionsToDb(results, log, force = false) { + if (!force && results.collections.length < BATCH_SIZE) { + return { saved: 0, failed: 0 }; + } + + if (results.collections.length === 0) { + return { saved: 0, failed: 0 }; + } + + const collectionsToSave = [...results.collections]; + results.collections.length = 0; // Clear the array to free memory + + let saved = 0; + let failed = 0; + + log.info(`[BATCH] Flushing ${collectionsToSave.length} collections to database...`); + + for (const collection of collectionsToSave) { + try { + await db.insertOrUpdateCollection(collection); + saved++; + } catch (err) { + log.warning(`[BATCH] Failed to save collection ${collection.id}: ${err.message}`); + failed++; + } + } + + log.info(`[BATCH] Saved ${saved} collections, ${failed} failed`); + + return { saved, failed }; +} + +/** + * Checks if batch size is reached and flushes if necessary + * @async + * @param {Object} results - Results object containing collections array + * @param {Object} log - Logger instance + */ +async function checkAndFlush(results, log) { + if (results.collections.length >= BATCH_SIZE) { + const { saved, failed } = await flushCollectionsToDb(results, log, false); + results.stats.collectionsSaved += saved; + results.stats.collectionsFailed += failed; + } +} /** * Handles catalog requests - validates STAC, extracts child catalogs and collections @@ -57,6 +119,9 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); + + // Check if we should flush to database + await checkAndFlush(results, log); } // Try to get collections from this catalog @@ -203,5 +268,8 @@ export async function handleCollections({ request, json, crawler, log, indent, r if (collections.length > 0) { log.info(`${indent} Sample: ${collections[0].id} - ${collections[0].title}`); } + + // Check if we should flush to database + await checkAndFlush(results, log); } } From 8674091c2e34a866f6b540614e4176d0b85b078d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sun, 11 Jan 2026 16:29:48 +0100 Subject: [PATCH 060/134] added source_url for collections and catalogs. Now the full_json doesn`t has to be used for getting the url --- db/init/02_tables_catalog.sql | 1 + db/init/03_tables_collections.sql | 1 + 2 files changed, 2 insertions(+) diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index 6b9bf4c..24c1935 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -18,6 +18,7 @@ CREATE TABLE catalog ( CREATE TABLE catalog_links ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, + source_url TEXT, rel TEXT, href TEXT, type TEXT, diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 842c7a8..a3249d5 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -33,6 +33,7 @@ CREATE TABLE collection_summaries ( collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, name TEXT, kind TEXT, + source_url TEXT, range_min NUMERIC, range_max NUMERIC, set_value TEXT, From 20d8fd2d1aa9b01844956745d6a61e67ca029f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sun, 11 Jan 2026 16:56:30 +0100 Subject: [PATCH 061/134] resolved a Problem I had with git by hand cause I didn't found the function --- db/init/02_tables_catalog.sql | 6 ++---- db/init/03_tables_collections.sql | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index 4b8200e..dca17c4 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -103,7 +103,5 @@ BEGIN END; $$ LANGUAGE plpgsql; -CREATE TRIGGER catalog_keywords_update_vector -AFTER INSERT OR DELETE ON catalog_keywords -FOR EACH ROW -EXECUTE FUNCTION update_catalog_search_vector_on_keyword_change(); +-- NOTE: The trigger for catalog_keywords is defined in 06_triggers.sql +-- because it depends on the catalog_keywords table which is created there diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index e26a8ea..a443aa8 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -123,7 +123,5 @@ BEGIN END; $$ LANGUAGE plpgsql; -CREATE TRIGGER collection_keywords_update_vector -AFTER INSERT OR DELETE ON collection_keywords -FOR EACH ROW -EXECUTE FUNCTION update_collection_search_vector_on_keyword_change(); +-- NOTE: The trigger for collection_keywords is defined in 06_triggers.sql +-- because it depends on the catalog_keywords table which is created there \ No newline at end of file From 81bd2460a01eb32a7aafac7c211f2725dbfc372f Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 13 Jan 2026 15:18:46 +0100 Subject: [PATCH 062/134] feat(crawler): enhance concurrency and storage configuration for crawling - Updated `crawlApis` and `crawlCatalogs` functions to use in-memory storage, preventing file lock race conditions under high concurrency. - Set `maxConcurrency` to 20 and `maxRequestsPerMinute` to 200 to limit request rates and improve stability during high-load scenarios. - Imported `Configuration` from `crawlee` to manage global settings effectively. --- crawler/apis/api.js | 7 ++++++- crawler/catalogs/catalog.js | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 27a0615..2b6c45c 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -3,7 +3,7 @@ * @module apis/api */ -import { HttpCrawler } from 'crawlee'; +import { HttpCrawler, Configuration } from 'crawlee'; import create from 'stac-js'; import { normalizeCollection } from '../utils/normalization.js'; import { handleCollections } from '../utils/handlers.js'; @@ -17,6 +17,9 @@ import { handleCollections } from '../utils/handlers.js'; * @returns {Promise} Results object with collections array and statistics */ async function crawlApis(urls, isApi, config = {}) { + // Use in-memory storage to avoid file lock race conditions under high concurrency + Configuration.getGlobalConfig().set('persistStorage', false); + if (!isApi || !Array.isArray(urls) || urls.length === 0) { return { collections: [], @@ -53,6 +56,8 @@ async function crawlApis(urls, isApi, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, + maxConcurrency: 20, // Limit concurrency to prevent lock file race conditions + maxRequestsPerMinute: 200, // Rate limit to avoid overwhelming targets async requestHandler({ request, json, crawler, log }) { results.stats.totalRequests++; diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index fb8bde2..1bdd918 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -3,7 +3,7 @@ * @module catalogs/catalog */ -import { HttpCrawler, log as crawleeLog } from 'crawlee'; +import { HttpCrawler, log as crawleeLog, Configuration } from 'crawlee'; import { handleCatalog, handleCollections, flushCollectionsToDb } from '../utils/handlers.js'; /** @@ -14,6 +14,9 @@ import { handleCatalog, handleCollections, flushCollectionsToDb } from '../utils * @returns {Promise} Crawl results with collections and statistics */ async function crawlCatalogs(initialCatalogs, config = {}) { + // Use in-memory storage to avoid file lock race conditions under high concurrency + Configuration.getGlobalConfig().set('persistStorage', false); + const timeoutSecs = config.timeout && config.timeout !== Infinity ? Math.ceil(config.timeout / 1000) : 60; @@ -37,6 +40,8 @@ async function crawlCatalogs(initialCatalogs, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, + maxConcurrency: 20, // Limit concurrency to prevent lock file race conditions + maxRequestsPerMinute: 200, // Rate limit to avoid overwhelming targets async requestHandler({ request, json, crawler, log }) { results.stats.totalRequests++; From 3ba13a93b5e247176a06e2aada01c23c11d67d78 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 14 Jan 2026 11:08:54 +0100 Subject: [PATCH 063/134] feat(crawler): implement max depth configuration and batch flushing for collections - Updated the crawler to support a maximum depth for nested catalogs, preventing excessive recursion. - Enhanced the collection handling by implementing batch flushing to the database during the crawl process. - Improved logging to provide detailed statistics on collections found, saved, and failed during the crawl. - Added CLI options for configuring maximum depth and updated the configuration defaults accordingly. --- crawler/apis/api.js | 72 +++++++++++++++++++++++++++++++++++------ crawler/index.js | 34 ++++--------------- crawler/utils/cli.js | 6 ++++ crawler/utils/config.js | 7 ++-- 4 files changed, 80 insertions(+), 39 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 2b6c45c..b10dc13 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -3,10 +3,30 @@ * @module apis/api */ -import { HttpCrawler, Configuration } from 'crawlee'; +import { HttpCrawler, Configuration, log as crawleeLog } from 'crawlee'; import create from 'stac-js'; import { normalizeCollection } from '../utils/normalization.js'; -import { handleCollections } from '../utils/handlers.js'; +import { handleCollections, flushCollectionsToDb } from '../utils/handlers.js'; + +/** + * Batch size for saving collections to database during API crawling + * @type {number} + */ +const BATCH_SIZE = 500; + +/** + * Checks if batch size is reached and flushes if necessary + * @async + * @param {Object} results - Results object containing collections array + * @param {Object} log - Logger instance + */ +async function checkAndFlushApi(results, log) { + if (results.collections.length >= BATCH_SIZE) { + const { saved, failed } = await flushCollectionsToDb(results, log, false); + results.stats.collectionsSaved = (results.stats.collectionsSaved || 0) + saved; + results.stats.collectionsFailed = (results.stats.collectionsFailed || 0) + failed; + } +} /** * Crawls STAC APIs to retrieve collection information without fetching items. @@ -48,12 +68,17 @@ async function crawlApis(urls, isApi, config = {}) { successfulRequests: 0, failedRequests: 0, collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0, apisProcessed: 0, stacCompliant: 0, nonCompliant: 0 } }; + // Maximum depth for nested catalog crawling (0 = unlimited) + const maxDepth = config.maxDepth || 10; + const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, maxConcurrency: 20, // Limit concurrency to prevent lock file race conditions @@ -61,12 +86,13 @@ async function crawlApis(urls, isApi, config = {}) { async requestHandler({ request, json, crawler, log }) { results.stats.totalRequests++; - const indent = ' '; + const depth = request.userData?.depth || 0; + const indent = ' '.repeat(Math.min(depth, 5)); // Cap indent at 5 levels try { // Route based on request label if (request.label === 'API_ROOT') { - await handleApiRoot({ request, json, crawler, log, indent, results }); + await handleApiRoot({ request, json, crawler, log, indent, results, maxDepth }); } else if (request.label === 'API_COLLECTIONS') { await handleCollections({ request, json, crawler, log, indent, results }); } else if (request.label === 'API_COLLECTION') { @@ -109,7 +135,8 @@ async function crawlApis(urls, isApi, config = {}) { label: 'API_ROOT', userData: { apiId: `api-${index}`, - apiUrl: url + apiUrl: url, + depth: 0 } })); @@ -118,6 +145,15 @@ async function crawlApis(urls, isApi, config = {}) { console.log(`\nStarting Crawlee crawler with ${initialRequests.length} APIs...\n`); await crawler.run(); + // Flush any remaining collections to database + console.log('\nFlushing remaining API collections to database...'); + const finalFlush = await flushCollectionsToDb(results, crawleeLog, true); + results.stats.collectionsSaved += finalFlush.saved; + results.stats.collectionsFailed += finalFlush.failed; + + // Clear apis array to free memory (we don't need them after crawl) + results.apis.length = 0; + console.log('\nAPI Crawl Statistics:'); console.log(` Total Requests: ${results.stats.totalRequests}`); console.log(` Successful: ${results.stats.successfulRequests}`); @@ -125,7 +161,9 @@ async function crawlApis(urls, isApi, config = {}) { console.log(` STAC Compliant: ${results.stats.stacCompliant}`); console.log(` Non-Compliant: ${results.stats.nonCompliant}`); console.log(` APIs Processed: ${results.stats.apisProcessed}`); - console.log(` Collections Found: ${results.stats.collectionsFound}\n`); + console.log(` Collections Found: ${results.stats.collectionsFound}`); + console.log(` Collections Saved to DB: ${results.stats.collectionsSaved}`); + console.log(` Collections Failed: ${results.stats.collectionsFailed}\n`); return results; } @@ -141,12 +179,14 @@ async function crawlApis(urls, isApi, config = {}) { * @param {Object} context.log - Logger instance * @param {string} context.indent - Indentation for logging * @param {Object} context.results - Results object to store data + * @param {number} context.maxDepth - Maximum recursion depth (0 = unlimited) */ -async function handleApiRoot({ request, json, crawler, log, indent, results }) { +async function handleApiRoot({ request, json, crawler, log, indent, results, maxDepth = 10 }) { const apiId = request.userData?.apiId || 'unknown'; const apiUrl = request.userData?.apiUrl || request.url; + const depth = request.userData?.depth || 0; - log.info(`${indent}Processing API: ${apiId} at ${apiUrl}`); + log.info(`${indent}Processing API: ${apiId} at ${apiUrl} (depth: ${depth})`); // Validate with stac-js let stacObj; @@ -178,6 +218,9 @@ async function handleApiRoot({ request, json, crawler, log, indent, results }) { results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); + + // Check if we should flush to database + await checkAndFlushApi(results, log); return; // Single collection, no need to look for more } @@ -233,6 +276,13 @@ async function handleApiRoot({ request, json, crawler, log, indent, results }) { if (childLinks.length > 0) { log.info(`${indent}Found ${childLinks.length} child catalog links`); + // Check if we've reached maximum depth + const nextDepth = depth + 1; + if (maxDepth > 0 && nextDepth > maxDepth) { + log.warning(`${indent}Skipping ${childLinks.length} child catalogs - max depth (${maxDepth}) reached`); + return; + } + const childRequests = childLinks .map((link, idx) => { let childUrl; @@ -260,7 +310,8 @@ async function handleApiRoot({ request, json, crawler, log, indent, results }) { userData: { apiId: `${apiId}-child-${idx}`, apiUrl: apiUrl, - parentId: apiId + parentId: apiId, + depth: nextDepth } }; }) @@ -298,6 +349,9 @@ async function handleApiCollection({ request, json, crawler, log, indent, result results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); + + // Check if we should flush to database + await checkAndFlushApi(results, log); } else { log.warning(`${indent}Expected collection but got: ${json.type || 'unknown type'}`); } diff --git a/crawler/index.js b/crawler/index.js index 32cbabd..9b722e3 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -48,7 +48,7 @@ export const crawler = async () => { console.log(`Max Catalogs: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs} (debugging limit)`); console.log(`Max APIs: ${config.maxApis === 0 ? 'unlimited' : config.maxApis} (debugging limit)`); console.log(`Timeout: ${config.timeout === Infinity ? 'unlimited' : config.timeout + 'ms'}`); - console.log(`Crawl Depth: unlimited`); + console.log(`Max Depth: ${config.maxDepth === 0 ? 'unlimited' : config.maxDepth} levels`); console.log('==================================\n'); const response = await axios.get(targetUrl); @@ -109,33 +109,11 @@ export const crawler = async () => { try { const apiResults = await crawlApis(apisToProcess, true, config); - const collections = apiResults.collections || []; - console.log(`\nFetched ${collections.length} collections from APIs.`); - - if (collections.length > 0) { - console.log('First 3 collections found:'); - collections.slice(0, 3).forEach(c => { - console.log(` - ${c.id}: ${c.title}`); - }); - - // Save API collections to database - console.log('\n=== Saving API collections to database ==='); - let apiCollectionsSaved = 0; - let apiCollectionsFailed = 0; - - for (const collection of collections) { - try { - const collectionId = await db.insertOrUpdateCollection(collection); - console.log(` Saved: ${collection.title || collection.id} (DB ID: ${collectionId})`); - apiCollectionsSaved++; - } catch (err) { - console.error(` Failed: ${collection.title || collection.id} - ${err.message}`); - apiCollectionsFailed++; - } - } - - console.log(`\nAPI Collections: ${apiCollectionsSaved} saved, ${apiCollectionsFailed} failed`); - } + // Collections are now saved to DB during the crawl (batch flushing) + console.log(`\nAPI Crawl Complete:`); + console.log(` Collections Found: ${apiResults.stats.collectionsFound}`); + console.log(` Collections Saved: ${apiResults.stats.collectionsSaved}`); + console.log(` Collections Failed: ${apiResults.stats.collectionsFailed}`); } catch (error) { console.error(`Failed to crawl APIs: ${error.message}`); } diff --git a/crawler/utils/cli.js b/crawler/utils/cli.js index 035a46f..ac01f5b 100644 --- a/crawler/utils/cli.js +++ b/crawler/utils/cli.js @@ -23,6 +23,8 @@ export function parseCliArgs() { config.maxApis = parseInt(args[++i], 10); } else if (arg === '--timeout' || arg === '-t') { config.timeout = parseInt(args[++i], 10); + } else if (arg === '--max-depth' || arg === '-d') { + config.maxDepth = parseInt(args[++i], 10); } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); @@ -45,6 +47,8 @@ STAC Crawler Configuration Options: -a, --max-apis Maximum number of APIs to crawl (default: 5, use 0 for unlimited) Note: Limits are for debugging purposes only -t, --timeout Timeout for each crawl operation in ms (default: 30000) + -d, --max-depth Maximum recursion depth for nested catalogs (default: 10, use 0 for unlimited) + Prevents memory issues from deeply nested catalog hierarchies -h, --help Show this help message Environment Variables: @@ -52,11 +56,13 @@ Environment Variables: MAX_CATALOGS Same as --max-catalogs (use 0 for unlimited) MAX_APIS Same as --max-apis (use 0 for unlimited) TIMEOUT_MS Same as --timeout + MAX_DEPTH Same as --max-depth (use 0 for unlimited) Examples: node index.js --mode catalogs --max-catalogs 20 node index.js -m apis -a 10 -t 60000 node index.js -m both -c 0 -a 0 # Unlimited mode (no debugging limits) + node index.js -m apis -d 5 # Limit nesting depth to 5 levels CRAWL_MODE=both MAX_CATALOGS=50 node index.js `); } diff --git a/crawler/utils/config.js b/crawler/utils/config.js index 6fa883b..39efed5 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -22,7 +22,8 @@ function getConfig() { mode: 'both', // 'catalogs', 'apis', or 'both' maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl - timeout: 30000 // Timeout in milliseconds (30 seconds) + timeout: 30000, // Timeout in milliseconds (30 seconds) + maxDepth: 10 // Maximum recursion depth for nested catalogs (0 = unlimited) }; // Build configuration with precedence: CLI > ENV > Defaults @@ -33,7 +34,9 @@ function getConfig() { maxApis: cliArgs.maxApis !== undefined ? cliArgs.maxApis : (process.env.MAX_APIS ? parseInt(process.env.MAX_APIS, 10) : defaults.maxApis), timeout: cliArgs.timeout !== undefined ? cliArgs.timeout : - (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout) + (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout), + maxDepth: cliArgs.maxDepth !== undefined ? cliArgs.maxDepth : + (process.env.MAX_DEPTH ? parseInt(process.env.MAX_DEPTH, 10) : defaults.maxDepth) }; // Validate mode From 44b986a49f988e8f09158bc12f337ecc72bb55c9 Mon Sep 17 00:00:00 2001 From: Jakob <70800454+vertrox78@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:18:53 +0100 Subject: [PATCH 064/134] Crawler: implemented rate limiting, and implements some bug fixes (#211) * added crawling time statistics * #189, added 7 day scheduling, created time util for easier managment/readability * #202, feat(crawler): add rate limiting and STAC link discovery, fixed bug with wndpoint logic and additional MIME types * #227, fixed bug catalogs that where listed as catlogs were treated as apis, no will be handled by the catalog crawler --- crawler/apis/api.js | 57 +++++++++++++++-------------- crawler/catalogs/catalog.js | 12 +++++++ crawler/index.js | 30 +++++++++++----- crawler/utils/cli.js | 27 +++++++++++--- crawler/utils/config.js | 37 +++++++++++++++++-- crawler/utils/endpoints.js | 71 +++++++++++++++++++++++++++---------- crawler/utils/handlers.js | 4 +-- 7 files changed, 174 insertions(+), 64 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 2b6c45c..61ff9fa 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -9,6 +9,9 @@ import { normalizeCollection } from '../utils/normalization.js'; import { handleCollections } from '../utils/handlers.js'; /** + * Note: This crawler is for REAL APIs only. Static catalogs (*.json files) + * are now routed to the catalog crawler in index.js + * * Crawls STAC APIs to retrieve collection information without fetching items. * * @param {string[]} urls - Array of API URLs to crawl @@ -59,6 +62,15 @@ async function crawlApis(urls, isApi, config = {}) { maxConcurrency: 20, // Limit concurrency to prevent lock file race conditions maxRequestsPerMinute: 200, // Rate limit to avoid overwhelming targets + // Rate limiting options + maxConcurrency: config.maxConcurrency || 5, + maxRequestsPerMinute: config.maxRequestsPerMinute || 60, + sameDomainDelaySecs: config.sameDomainDelaySecs || 1, + maxRequestRetries: config.maxRequestRetries || 3, + + // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) + additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], + async requestHandler({ request, json, crawler, log }) { results.stats.totalRequests++; const indent = ' '; @@ -94,6 +106,9 @@ async function crawlApis(urls, isApi, config = {}) { log.warning(`${indent}[TIMEOUT] ${apiId} at ${request.url}`); } else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { log.warning(`${indent}[CONNECTION FAILED] ${apiId} at ${request.url}`); + } else if (error.statusCode === 429) { + const retryAfter = error.response?.headers?.['retry-after'] || 'unknown'; + log.warning(`${indent}[RATE LIMITED] ${apiId} at ${request.url} - Retry-After: ${retryAfter}s`); } else if (error.code === 'ERR_NON_2XX_3XX_RESPONSE') { log.warning(`${indent}[HTTP ERROR] ${apiId} at ${request.url} - Status: ${error.statusCode}`); } else { @@ -193,39 +208,23 @@ async function handleApiRoot({ request, json, crawler, log, indent, results }) { } } - // Fallback: try common STAC API endpoints + // Fallback: use standard /collections endpoint if (!collectionsEndpoint) { const baseUrl = request.url.endsWith('/') ? request.url.slice(0, -1) : request.url; - const endpoints = [ - `${baseUrl}/collections`, - `${apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl}/collections` - ]; - - for (const endpoint of endpoints) { - if (endpoint && endpoint !== collectionsEndpoint) { - log.info(`${indent}Trying collections endpoint: ${endpoint}`); - await crawler.addRequests([{ - url: endpoint, - label: 'API_COLLECTIONS', - userData: { - apiId: apiId, - apiUrl: apiUrl - } - }]); - } - } - } else { - // Use the discovered collections endpoint - await crawler.addRequests([{ - url: collectionsEndpoint, - label: 'API_COLLECTIONS', - userData: { - apiId: apiId, - apiUrl: apiUrl - } - }]); + collectionsEndpoint = `${baseUrl}/collections`; + log.debug(`${indent}No collections link found, using fallback: ${collectionsEndpoint}`); } + // Enqueue single collections request + await crawler.addRequests([{ + url: collectionsEndpoint, + label: 'API_COLLECTIONS', + userData: { + apiId: apiId, + apiUrl: apiUrl + } + }]); + // Also check for child links (nested catalogs) if (typeof stacObj.getChildLinks === 'function') { const childLinks = stacObj.getChildLinks(); diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 1bdd918..9883081 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -43,6 +43,15 @@ async function crawlCatalogs(initialCatalogs, config = {}) { maxConcurrency: 20, // Limit concurrency to prevent lock file race conditions maxRequestsPerMinute: 200, // Rate limit to avoid overwhelming targets + // Rate limiting options + maxConcurrency: config.maxConcurrency || 5, + maxRequestsPerMinute: config.maxRequestsPerMinute || 60, + sameDomainDelaySecs: config.sameDomainDelaySecs || 1, + maxRequestRetries: config.maxRequestRetries || 3, + + // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) + additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], + async requestHandler({ request, json, crawler, log }) { results.stats.totalRequests++; const depth = request.userData?.depth || 0; @@ -78,6 +87,9 @@ async function crawlCatalogs(initialCatalogs, config = {}) { log.warning(`${indent}[TIMEOUT] ${catalogId} at ${request.url}`); } else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { log.warning(`${indent}[CONNECTION FAILED] ${catalogId} at ${request.url}`); + } else if (error.statusCode === 429) { + const retryAfter = error.response?.headers?.['retry-after'] || 'unknown'; + log.warning(`${indent}[RATE LIMITED] ${catalogId} at ${request.url} - Retry-After: ${retryAfter}s`); } else if (error.code === 'ERR_NON_2XX_3XX_RESPONSE') { log.warning(`${indent}[HTTP ERROR] ${catalogId} at ${request.url} - Status: ${error.statusCode}`); } else { diff --git a/crawler/index.js b/crawler/index.js index 32cbabd..b9bcbb6 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -7,9 +7,9 @@ import axios from 'axios'; import { processCatalogs } from './utils/normalization.js'; import { crawlCatalogs } from './catalogs/catalog.js'; import { crawlApis } from './apis/api.js'; -import { getConfig } from './utils/config.js'; -import db from './utils/db.js'; +import { getConfig, isStaticCatalogUrl } from './utils/config.js'; import { formatDuration } from './utils/time.js'; +import db from './utils/db.js'; /** * URL of the STAC Index API endpoint @@ -72,17 +72,31 @@ export const crawler = async () => { console.log(`\nCatalogs: ${catalogsSaved} saved, ${catalogsFailed} failed\n`); + // Separate static catalogs from real APIs + const staticCatalogs = catalogs.filter(cat => cat.isApi === true && isStaticCatalogUrl(cat.url)); + const regularCatalogs = catalogs.filter(cat => cat.isApi !== true); + const realApis = catalogs.filter(cat => cat.isApi === true && !isStaticCatalogUrl(cat.url)); + + console.log(`\nCatalog Classification:`); + console.log(` Regular Catalogs: ${regularCatalogs.length}`); + console.log(` Static Catalogs (mismarked as APIs): ${staticCatalogs.length}`); + console.log(` Real APIs: ${realApis.length}\n`); + // Crawl catalogs if mode is 'catalogs' or 'both' if (config.mode === 'catalogs' || config.mode === 'both') { console.log('\nCrawling collections and nested catalogs with Crawlee...\n'); + // Combine regular catalogs with static catalogs (mismarked as APIs) + const allCatalogsToProcess = [...regularCatalogs, ...staticCatalogs]; + // Note: MAX_CATALOGS limit is for debugging purposes only // Set maxCatalogs to 0 or use --max-catalogs 0 for unlimited catalog crawling const catalogsToProcess = config.maxCatalogs === 0 - ? catalogs - : catalogs.slice(0, config.maxCatalogs); + ? allCatalogsToProcess + : allCatalogsToProcess.slice(0, config.maxCatalogs); - console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs})\n`); + console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs})`); + console.log(` (includes ${staticCatalogs.length} static catalogs mismarked as APIs)\n`); try { const results = await crawlCatalogs(catalogsToProcess, config); @@ -97,15 +111,13 @@ export const crawler = async () => { // Crawl APIs if mode is 'apis' or 'both' if (config.mode === 'apis' || config.mode === 'both') { console.log('\nCrawling APIs...'); - const apiUrls = catalogs - .filter(cat => cat.isApi === true) - .map(cat => cat.url); + const apiUrls = realApis.map(cat => cat.url); if (apiUrls.length > 0) { // Note: MAX_APIS limit is for debugging purposes only // Set maxApis to 0 or use --max-apis 0 for unlimited API crawling const apisToProcess = config.maxApis === 0 ? apiUrls : apiUrls.slice(0, config.maxApis); - console.log(`Found ${apiUrls.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); + console.log(`Found ${realApis.length} real APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); try { const apiResults = await crawlApis(apisToProcess, true, config); diff --git a/crawler/utils/cli.js b/crawler/utils/cli.js index 035a46f..71408f9 100644 --- a/crawler/utils/cli.js +++ b/crawler/utils/cli.js @@ -23,6 +23,14 @@ export function parseCliArgs() { config.maxApis = parseInt(args[++i], 10); } else if (arg === '--timeout' || arg === '-t') { config.timeout = parseInt(args[++i], 10); + } else if (arg === '--max-concurrency') { + config.maxConcurrency = parseInt(args[++i], 10); + } else if (arg === '--requests-per-minute' || arg === '--rpm') { + config.maxRequestsPerMinute = parseInt(args[++i], 10); + } else if (arg === '--domain-delay') { + config.sameDomainDelaySecs = parseFloat(args[++i]); + } else if (arg === '--max-retries') { + config.maxRequestRetries = parseInt(args[++i], 10); } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); @@ -45,13 +53,24 @@ STAC Crawler Configuration Options: -a, --max-apis Maximum number of APIs to crawl (default: 5, use 0 for unlimited) Note: Limits are for debugging purposes only -t, --timeout Timeout for each crawl operation in ms (default: 30000) + + Rate Limiting Options: + --max-concurrency Maximum concurrent requests (default: 5) + --requests-per-minute, --rpm Maximum requests per minute (default: 60) + --domain-delay Delay between requests to same domain (default: 1) + --max-retries Maximum retries for failed requests (default: 3) + -h, --help Show this help message Environment Variables: - CRAWL_MODE Same as --mode - MAX_CATALOGS Same as --max-catalogs (use 0 for unlimited) - MAX_APIS Same as --max-apis (use 0 for unlimited) - TIMEOUT_MS Same as --timeout + CRAWL_MODE Same as --mode + MAX_CATALOGS Same as --max-catalogs (use 0 for unlimited) + MAX_APIS Same as --max-apis (use 0 for unlimited) + TIMEOUT_MS Same as --timeout + MAX_CONCURRENCY Same as --max-concurrency + MAX_REQUESTS_PER_MINUTE Same as --requests-per-minute + SAME_DOMAIN_DELAY_SECS Same as --domain-delay + MAX_REQUEST_RETRIES Same as --max-retries Examples: node index.js --mode catalogs --max-catalogs 20 diff --git a/crawler/utils/config.js b/crawler/utils/config.js index 6fa883b..148efd4 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -6,6 +6,25 @@ import dotenv from 'dotenv'; import { parseCliArgs } from './cli.js'; +/** + * Checks if a URL points to a static catalog file rather than an API endpoint + * @param {string} url - URL to check + * @returns {boolean} True if URL appears to be a static file + */ +export function isStaticCatalogUrl(url) { + if (!url || typeof url !== 'string') return false; + + // Check if URL ends with common static catalog file patterns + const staticPatterns = [ + /\.json$/i, // ends with .json + /\/collection\.json/i, // collection.json file + /\/catalog\.json/i, // catalog.json file + /\/stac\.json/i // stac.json file + ]; + + return staticPatterns.some(pattern => pattern.test(url)); +} + // Load environment variables from .env file dotenv.config(); @@ -22,7 +41,12 @@ function getConfig() { mode: 'both', // 'catalogs', 'apis', or 'both' maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl - timeout: 30000 // Timeout in milliseconds (30 seconds) + timeout: 30000, // Timeout in milliseconds (30 seconds) + // Rate limiting options (Crawlee) + maxConcurrency: 5, // Maximum number of concurrent requests + maxRequestsPerMinute: 60, // Maximum requests per minute + sameDomainDelaySecs: 1, // Delay between requests to the same domain + maxRequestRetries: 3 // Maximum number of retries for failed requests }; // Build configuration with precedence: CLI > ENV > Defaults @@ -33,7 +57,16 @@ function getConfig() { maxApis: cliArgs.maxApis !== undefined ? cliArgs.maxApis : (process.env.MAX_APIS ? parseInt(process.env.MAX_APIS, 10) : defaults.maxApis), timeout: cliArgs.timeout !== undefined ? cliArgs.timeout : - (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout) + (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout), + // Rate limiting options + maxConcurrency: cliArgs.maxConcurrency !== undefined ? cliArgs.maxConcurrency : + (process.env.MAX_CONCURRENCY ? parseInt(process.env.MAX_CONCURRENCY, 10) : defaults.maxConcurrency), + maxRequestsPerMinute: cliArgs.maxRequestsPerMinute !== undefined ? cliArgs.maxRequestsPerMinute : + (process.env.MAX_REQUESTS_PER_MINUTE ? parseInt(process.env.MAX_REQUESTS_PER_MINUTE, 10) : defaults.maxRequestsPerMinute), + sameDomainDelaySecs: cliArgs.sameDomainDelaySecs !== undefined ? cliArgs.sameDomainDelaySecs : + (process.env.SAME_DOMAIN_DELAY_SECS ? parseFloat(process.env.SAME_DOMAIN_DELAY_SECS) : defaults.sameDomainDelaySecs), + maxRequestRetries: cliArgs.maxRequestRetries !== undefined ? cliArgs.maxRequestRetries : + (process.env.MAX_REQUEST_RETRIES ? parseInt(process.env.MAX_REQUEST_RETRIES, 10) : defaults.maxRequestRetries) }; // Validate mode diff --git a/crawler/utils/endpoints.js b/crawler/utils/endpoints.js index 786d68b..c595933 100644 --- a/crawler/utils/endpoints.js +++ b/crawler/utils/endpoints.js @@ -4,8 +4,11 @@ */ /** - * Tries common STAC collection endpoints and enqueues successful ones + * Finds the collection endpoint from STAC catalog links + * STAC catalogs should advertise their collection endpoint via rel="data" or rel="collections" + * Falls back to a single /collections endpoint if no link is found * @async + * @param {Object} stacCatalog - Parsed STAC catalog object from stac-js * @param {string} baseUrl - Base catalog URL * @param {string} catalogId - Catalog ID for logging * @param {number} depth - Current depth @@ -13,27 +16,59 @@ * @param {Object} log - Logger * @param {string} indent - Indentation for logging */ -export async function tryCollectionEndpoints(baseUrl, catalogId, depth, crawler, log, indent) { - // Trailing Slash remove, because it causes double slashes - const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; - - const collectionEndpoints = [ - '/collections', - '/collections/', - '/api/v1/collections' - ]; +export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, depth, crawler, log, indent) { + let collectionUrl = null; - const requests = collectionEndpoints.map(endpoint => ({ - url: `${normalizedBaseUrl}${endpoint}`, + // Try to find collection endpoint from STAC links (proper STAC discovery) + if (stacCatalog && typeof stacCatalog.getLinks === 'function') { + const links = stacCatalog.getLinks(); + + // Look for rel="data" (STAC API) or rel="collections" link + const collectionLink = links.find(link => + link.rel === 'data' || link.rel === 'collections' + ); + + if (collectionLink) { + try { + collectionUrl = typeof collectionLink.getAbsoluteUrl === 'function' + ? collectionLink.getAbsoluteUrl() + : collectionLink.href; + + // Handle relative URLs + if (collectionUrl && !collectionUrl.startsWith('http')) { + const basePath = baseUrl.substring(0, baseUrl.lastIndexOf('/')); + collectionUrl = `${basePath}/${collectionUrl}`; + } + + log.info(`${indent}Found collection endpoint via STAC link (rel="${collectionLink.rel}"): ${collectionUrl}`); + } catch (err) { + log.warning(`${indent}Error resolving collection link: ${err.message}`); + } + } + } + + // Fallback: if no link found, try the standard /collections endpoint + if (!collectionUrl) { + // Remove trailing filename (like catalog.json) from base URL + const urlParts = baseUrl.split('/'); + const lastPart = urlParts[urlParts.length - 1]; + + if (lastPart.includes('.json') || lastPart.includes('.')) { + urlParts.pop(); + } + + collectionUrl = urlParts.join('/') + '/collections'; + log.debug(`${indent}No collection link found, using fallback: ${collectionUrl}`); + } + + // Enqueue single collection request + await crawler.addRequests([{ + url: collectionUrl, label: 'COLLECTIONS', userData: { catalogUrl: baseUrl, catalogId, depth - }, - // Don't retry failed endpoint attempts - just try the next one - maxRetries: 0 - })); - - await crawler.addRequests(requests); + } + }]); } diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 5a7570b..f835389 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -124,8 +124,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul await checkAndFlush(results, log); } - // Try to get collections from this catalog - await tryCollectionEndpoints(request.url, catalogId, depth, crawler, log, indent); + // Try to get collections from this catalog (using STAC link discovery) + await tryCollectionEndpoints(stacCatalog, request.url, catalogId, depth, crawler, log, indent); // Extract and enqueue child catalog links using stac-js if (stacCatalog && typeof stacCatalog.getChildLinks === 'function') { From fcbac8b354efa9cbc513fdfd7ed7d0059edd9e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Wed, 14 Jan 2026 15:42:14 +0100 Subject: [PATCH 065/134] to be stac conform the stac_id is not allowed to throw an error when asking for a string. So the stac_id in the database is saved as a TEXT and no longer as a INTEGER --- db/init/03_tables_collections.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index a443aa8..852aa0d 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -6,7 +6,7 @@ CREATE TABLE collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, - stac_id INTEGER, + stac_id TEXT, type TEXT, title TEXT, description TEXT, From fe5ae355386a95f934070771056afb3331fe91c9 Mon Sep 17 00:00:00 2001 From: Lenn Date: Thu, 15 Jan 2026 16:36:58 +0100 Subject: [PATCH 066/134] feat: add source URL extraction for catalogs and collections --- crawler/utils/db.js | 66 +++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 1bd2e85..e8eada4 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -69,6 +69,14 @@ async function insertOrUpdateCatalog(catalog) { const description = catalog.description || null; const stacVersion = catalog.stac_version || catalog.stacVersion || null; + // Extract source URL from links (prefer 'self', fallback to 'root') + let sourceUrl = null; + if (catalog.links && Array.isArray(catalog.links)) { + const selfLink = catalog.links.find(link => link.rel === 'self'); + const rootLink = catalog.links.find(link => link.rel === 'root'); + sourceUrl = selfLink?.href || rootLink?.href || null; + } + // Check if catalog with this title already exists const existingCatalog = await client.query( 'SELECT id FROM catalog WHERE title = $1', @@ -83,27 +91,30 @@ async function insertOrUpdateCatalog(catalog) { `UPDATE catalog SET stac_version = $1, type = $2, - description = $3, + description = $3, + source_url = $4, updated_at = now() - WHERE id = $4`, + WHERE id = $5`, [ stacVersion, catalog.type || 'Catalog', description, + sourceUrl, catalogId ] ); } else { // Insert new catalog const catalogResult = await client.query( - `INSERT INTO catalog (stac_version, type, title, description, updated_at) - VALUES ($1, $2, $3, $4, now()) + `INSERT INTO catalog (stac_version, type, title, description, source_url, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) RETURNING id`, [ stacVersion, catalog.type || 'Catalog', catalogTitle, - description + description, + sourceUrl ] ); catalogId = catalogResult.rows[0].id; @@ -187,6 +198,15 @@ async function insertOrUpdateCollection(collection) { // Insert or update collection const collectionTitle = collection.title || collection.id || 'Unnamed Collection'; + const stacId = collection.id || null; + + // Extract source URL from links (prefer 'self', fallback to 'root') + let sourceUrl = null; + if (collection.links && Array.isArray(collection.links)) { + const selfLink = collection.links.find(link => link.rel === 'self'); + const rootLink = collection.links.find(link => link.rel === 'root'); + sourceUrl = selfLink?.href || rootLink?.href || null; + } // Check if collection with this title already exists const existingCollection = await client.query( @@ -200,19 +220,22 @@ async function insertOrUpdateCollection(collection) { collectionId = existingCollection.rows[0].id; await client.query( `UPDATE collection SET - stac_version = $1, - type = $2, - description = $3, - license = $4, - spatial_extent = ST_GeomFromEWKT($5), - temporal_extent_start = $6, - temporal_extent_end = $7, - is_api = $8, - is_active = $9, - full_json = $10, + stac_id = $1, + stac_version = $2, + type = $3, + description = $4, + license = $5, + spatial_extent = ST_GeomFromEWKT($6), + temporal_extent_start = $7, + temporal_extent_end = $8, + is_api = $9, + is_active = $10, + full_json = $11, + source_url = $12, updated_at = now() - WHERE id = $11`, + WHERE id = $13`, [ + stacId, collection.stac_version || null, collection.type || 'Collection', collection.description || null, @@ -223,6 +246,7 @@ async function insertOrUpdateCollection(collection) { false, // is_api - will be determined by crawler true, // is_active JSON.stringify(collection), + sourceUrl, collectionId ] ); @@ -230,13 +254,14 @@ async function insertOrUpdateCollection(collection) { // Insert new collection const collectionResult = await client.query( `INSERT INTO collection ( - stac_version, type, title, description, license, + stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, - is_api, is_active, full_json, updated_at + is_api, is_active, full_json, source_url, updated_at ) - VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) + VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13, now()) RETURNING id`, [ + stacId, collection.stac_version || null, collection.type || 'Collection', collectionTitle, @@ -247,7 +272,8 @@ async function insertOrUpdateCollection(collection) { temporalEnd, false, // is_api - will be determined by crawler true, // is_active - JSON.stringify(collection) + JSON.stringify(collection), + sourceUrl ] ); collectionId = collectionResult.rows[0].id; From acd118026a8b684b848298943a7f87f73ea93078 Mon Sep 17 00:00:00 2001 From: Lenn Date: Thu, 15 Jan 2026 17:20:13 +0100 Subject: [PATCH 067/134] feat: update insertOrUpdateCatalog to only process catalogs for traversal, no longer saving to database --- crawler/utils/db.js | 119 ++++---------------------------------------- 1 file changed, 10 insertions(+), 109 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index e8eada4..df12a44 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -51,118 +51,19 @@ async function initDb() { } /** - * Insert or update a catalog in the database + * Process a catalog for traversal only - catalogs are not saved to database + * Only collections are saved. Catalogs are only used to traverse deeper into the tree. * @param {Object} catalog - STAC catalog object - * @returns {Promise} catalog ID + * @returns {Promise} always returns null (no catalog saved) */ async function insertOrUpdateCatalog(catalog) { if (!catalog || typeof catalog !== 'object') return null; - - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - // Insert or update catalog - const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; - - // Handle both STAC format and STAC Index API format - const description = catalog.description || null; - const stacVersion = catalog.stac_version || catalog.stacVersion || null; - - // Extract source URL from links (prefer 'self', fallback to 'root') - let sourceUrl = null; - if (catalog.links && Array.isArray(catalog.links)) { - const selfLink = catalog.links.find(link => link.rel === 'self'); - const rootLink = catalog.links.find(link => link.rel === 'root'); - sourceUrl = selfLink?.href || rootLink?.href || null; - } - - // Check if catalog with this title already exists - const existingCatalog = await client.query( - 'SELECT id FROM catalog WHERE title = $1', - [catalogTitle] - ); - - let catalogId; - if (existingCatalog.rows.length > 0) { - // Update existing catalog - catalogId = existingCatalog.rows[0].id; - await client.query( - `UPDATE catalog SET - stac_version = $1, - type = $2, - description = $3, - source_url = $4, - updated_at = now() - WHERE id = $5`, - [ - stacVersion, - catalog.type || 'Catalog', - description, - sourceUrl, - catalogId - ] - ); - } else { - // Insert new catalog - const catalogResult = await client.query( - `INSERT INTO catalog (stac_version, type, title, description, source_url, updated_at) - VALUES ($1, $2, $3, $4, $5, now()) - RETURNING id`, - [ - stacVersion, - catalog.type || 'Catalog', - catalogTitle, - description, - sourceUrl - ] - ); - catalogId = catalogResult.rows[0].id; - } - - // Insert links - if (catalog.links && Array.isArray(catalog.links)) { - await client.query('DELETE FROM catalog_links WHERE catalog_id = $1', [catalogId]); - for (const link of catalog.links) { - await client.query( - 'INSERT INTO catalog_links (catalog_id, rel, href, type, title) VALUES ($1, $2, $3, $4, $5)', - [catalogId, link.rel, link.href, link.type || null, link.title || null] - ); - } - } - - // Insert keywords - // STAC Index API uses 'categories' instead of 'keywords' - const keywords = catalog.keywords || catalog.categories || []; - if (Array.isArray(keywords) && keywords.length > 0) { - await insertKeywords(client, catalogId, keywords, 'catalog'); - } - - // Insert STAC extensions - const extensions = catalog.stac_extensions || catalog.stacExtensions || []; - if (Array.isArray(extensions) && extensions.length > 0) { - await insertStacExtensions(client, catalogId, extensions, 'catalog'); - } - - // Update crawl log - await client.query( - 'DELETE FROM crawllog_catalog WHERE catalog_id = $1', - [catalogId] - ); - await client.query( - 'INSERT INTO crawllog_catalog (catalog_id, last_crawled) VALUES ($1, now())', - [catalogId] - ); - - await client.query('COMMIT'); - return catalogId; - } catch (error) { - await client.query('ROLLBACK'); - console.error('Error inserting catalog:', error.message); - throw error; - } finally { - client.release(); - } + + // Catalogs are not saved to database - only used for tree traversal + // Only collections will be saved + console.log(`Skipping catalog save (used for traversal only): ${catalog.title || catalog.id}`); + + return null; } /** @@ -171,7 +72,7 @@ async function insertOrUpdateCatalog(catalog) { * @returns {Promise} collection ID */ async function insertOrUpdateCollection(collection) { - if (!collection || typeof collection !== 'object') return null; + const client = await pool.connect(); try { From df81112e6762d4ca54ff0f7ac3968fe51ceadcbb Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:00:12 +0100 Subject: [PATCH 068/134] merge from dev to dev-crawler-humam and now to dev-crawler (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): initialize STAC Atlas API with collections, conformance, and queryables routes - Added package.json for project dependencies and scripts. - Implemented GET endpoint for collections. - Created conformance endpoint to list supported conformance classes. - Developed landing page for the API with links to collections and documentation. - Added queryables endpoint to return queryable properties for collections. (If i'm correct this can be removed) * Added all Remarks to the bid and finished it (#74) * changed Texts 1,2 and 8 according to the remarks of the customer * Did my fixes to 4. and 10.3 * added remark why we want to save every catalog * deleted keywords for catalog * changed everything related to the database component * Updated 3.1, 3.3, 3,4 (References to Lastenheft/small other changes) * Added small Graph to 3. Produktumgebung * Update bid.md 7.3STAC-Validator added the handling of collections that cannot be validated automatically. * Update bid.md 7.3STAC-API-Validator changed the way we validate the collection search extension. * Update bid.md 9.3.2Endpunkte small fix collection search extension. * added Skizze for 3, and updated 6.1, 10.1 * Updated 3. Produktumgebung * Update bid.md 7.Qualitätsanforderungen minor fixes * Update 6.4.2 added remark about loading feedback * Minor change to Table in "11 Zeitplan". A collum was missing in the head, therefore the table wasn't rendering correctly.. --------- Co-authored-by: Jakob Co-authored-by: Sönke Hoffmann Co-authored-by: jklaer Co-authored-by: VincentKuehn Co-authored-by: mammutor Co-authored-by: Humam <44206081+Mammutor@users.noreply.github.com> Co-authored-by: Justin K * fix(api): enhance STAC API landing page and conformance links - Overhaul of first idea landing page - Added some more tests for the required elements in the landingpage-Catalog * feat(api): implement shared conformance URIs and add tests for conformance endpoint - implemented condormance endpoint * Implemented 2.3 and 2.4 (#111) * Temporary mock data for testing and frontend development * Added API middleware layer for error handling and validation * TODOs ready? pls review * Added API utilities for query parsing, validation, and response formatting * Added swagger and openapi.yaml * Update queryables.js Refactor queryables endpoint into /collections/queryables * Renamed the collections.js file to mocks-collections.js to better reflect its purpose and improve project clarity * changed README "Projektstruktur" * restart from dev-api 22.11..2025 * API: 2.3 Implement Collections List Endpoint done (added explanations as comments in the code) * API: 2.4 Implement Single Collection Endpoint (added explanations as comments in the code) * Update api/routes/collections.js Co-authored-by: Robin Tammo Gummels * Changed some of the code with the comments on Github (i will finish it tomorrow morning) * Implement most of the feedback and comments (need to talk about some other changes) * Update api/routes/index.js Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/README.md Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/README.md Removed missing folder * Update api/routes/collections.js Removed TODOs from wrong lines * Update api/routes/collections.js Added TODOs * Update api/routes/queryables.js Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/routes/queryables.js Changed wording from `/collections/queryables` to `/collections-queryables` --------- Co-authored-by: VincentKuehn Co-authored-by: Robin Tammo Gummels * feat(api): add collection search parameters and validation middleware (#159) * feat(api): add collection search parameters and validation middleware * Added unit-test for validator-functions and integration-tests for `GET /collections`-Querys. - Also minor bugfix, because the validator accepted deecimals as tokens. * API: 3 Database Integration first version (#161) * database connection in implementated. The parameters for the connection have to added in the .env-file. Also there is test-file for testing and console messages (installed `pg`) * support for spatial queries via postgis + error handling for datatbase operations changed language to english * error handling * added DATABASE_URL There is an issue with the distance query. Changed the error handling and testing, the console messages are now way better structured * found the Problem with the distance query. The layer are so big, that they reach over the 180° long (PostgGIS can't handel that). Now the calc is done by degree and not meters. * The two files `test-data-retrieval.js` and `verify-schema.js` have been added. `test-data-retrieval` (theoretical, checks against the spezification): ``` Discovers all tables and columns and validates against expected schema. ``` The second files `verify-schema.js` (practical, checks against the real data): ``` Discovers all tables and columns, validates against expected schema ``` * pooling error hanling and log imporoved. renamed tests files to actual test-files * standalone node tests were convertad into JEST * write file `validateRequest.js`. Validates every incoming API request, whether the request is valid and logical. * commented `stac_id` from the tests, it is not in both databases, so the tests for `stac_id` will always fail Added explanation to the `.env.example`, which port is which database * added example pattern for API - database connection. * deleted `validateRequest` cause it's already implemented by @robinGummels --------- Co-authored-by: Sönke Hoffmann * Added environment variables and a `.env` for `docker-compose.yml` (#164) * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels * Added a CI/CD Pipeline to prevent pull-requests without functioning tests and proper linting. * fixed errors suggested by the linter. - Some lines used tab and spaces... * Implemented Collection Search extension including a DB-Connection (#165) * added SQLQuery-builder with these parameters: q,bbox,datetime,sortby,limit and token * finalised bbox and datetime * adapted to DB, QueryBuilder and added helperfunction runQuery * added question-TODOs * added bbox+datetime to the Query-Builder from Jonas * added tests for Query-Builder from Jonas * added tests from George * added falsely deleted TODOs again * fixed collumn names to match our DB and adjusted full text search to match 05_indexes.sql correctly * Used a formatter and linter on `buildCollectionSearchQuery.js * Did some major and minor fixes to the collection search. - Updated `buildCollectionSearchQuery` to support pagination and improved text search with English language settings. - Modified tests in `buildCollectionsSearchQuery.basic.test.js`, `collections-pagination.test.js`, and `collections-sort.test.js` to reflect new query behavior and validation logic. - Enhanced sort validation in `validators.test.js` and `collectionSearchParams.js` to map API fields to database column names. - Implemented total count retrieval for matched results in `collections.js`. * Added a internal .env creation in the CI/CD Pipeline. It utilzes GitHub Repository Secrets to not publish any private Logins and stuff. * Forgot that the second Job of the CI/CD pipeline runs seperatly and needs a internal .env file too. * Enhance documentation for buildCollectionSearchQuery Updated the documentation for: - the buildCollectionSearchQuery function - the fulltextsearch * Refactor buildCollectionSearchQuery and updated SELECT part Changed the SELECT part to match our bid and the database shema. Updated comments and for clarity. Changed full-text search to use 'simple' configuration instead of 'english'. * Update api/routes/collections.js small typo Co-authored-by: Robin Tammo Gummels * Remove sorting TODO from collections route Removed TODO comment about sorting based on sortby parameter. * Explicitly return undefined for normalized in validateSortby Update validateSortby function to explicitly return undefined for normalized when sortby is not provided. * small fix in buildCollectionSearch.fulltext.test.js Change plainto_tsquery language from 'english' to 'simple' * Fix duplicate SELECT keyword in query Remove duplicate 'SELECT' keyword in SQL query. * Fix missing newline at end of collectionSearchParams.js * Fixed missing bracket in collectionSearchParams.js * Refactor validateSortby for optional parameter handling Refactor validateSortby function to handle optional sortby parameter and improve validation logic. * Stabilize API test pipeline by running Jest in-band with extended timeout Run Jest in CI with --runInBand and a higher default --testTimeout to stabilize database-backed integration tests. Multiple Jest workers were competing for the same PostgreSQL connection pool and some long-running /collections queries exceeded the default 5s timeout, causing failures in existing test suites (e.g. collectionSearch and DBconnection). * Fixed leaking tests that blocked CI/CD-Pipeline. - Added a global Teardown for jest and force-exited the tests to prevent leaking. - Made a change to db_APIconnection to only log the pool-(dis)connection if it isn't run in a test enviroment. * Did a minimum amount of Formatting to the discription * Used `npm audit fix --force` to fix all vulnerabilties in our used packages. * Fixed curious doublechecking for empty Strings for the sortby-Parameter. - Now we only check once for a empty sortby - And added a test which distinguish between `sortby=""` and `sortby="+"` * Update api/routes/collections.js Removed the TODO about switching from mock-data to the real db * Removed globalTeardown as i brought up some problems corresponding to long db-queries (for example BBOX). Instead i increased the maximal testTimeout. --------- Co-authored-by: Robin Tammo Gummels * Update api/.env.example * latest database Version (#187) with `stac_id` and changed definition of `primary Keys` * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md * added `stac_id` for collections * all IDs are now written in the newer PostgrSQL standart: ```SQL id SERIAL PRIMARY KEY, ``` changed to ```SQL id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ``` * changed `extend` to `extent`. * Changed language used in `./api/README.md` from german to english. I wanted to thsi anyway at some point, but this is now more like a Test-commit to see if the CI/CD Pipeline triggers... --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels * Added `GET /collections/{id}`-Endpoint, more Fields in the responses and more Queryables-Parameters (#204) * Updated Query-Builder to get all necessary fields from all db_tables for collections. Added some tests and fixed some already existing tests, becuase now the tablenames start with the alias `c.`. * Updated Query-Builder to get all necessary fields from all db_tables for collections. Added some tests and fixed some already existing tests, becuase now the tablenames start with the alias `c.`. * Added `openapi.yaml` (now http://localhost:3000/api-docs/ is working). - needed to do some modifying to the app.js * Added discription on how to use `stac-api-validator`. Currently we are onyl valid to `core`. * Changed API-Version name to 1.1.0 instead of 1.0.0 * Revert "API is now responding with all necessary fields for each collection" (#195) Reverts #185 @SonkeHoffmann accidentally didn't squash correctly. * Revert "Revert "API is now responding with all necessary fields for each collection"" (#185) (#195) (#196) dev-api: prepare v1.1.0 + API docs + query builder fixes - Change API version to 1.1.0 - Add OpenAPI spec so /api-docs works locally - Document stac-api-validator usage - Update api/.env.example - Query builder: select required fields for collections across db_tables; adjust tests (alias `c.`) Commits included: - 34bf962 Changed API-Version name to 1.1.0 instead of 1.0.0 - b047389 Added description on how to use `stac-api-validator` (currently only valid for `core`) - b811288 Added `openapi.yaml` (so http://localhost:3000/api-docs/ works); modified app.js accordingly - 6e5ab3e Merge branch 'dev-api-robin' of github.com:SpatioCore/STAC-Atlas into dev-api-robin - 70dc043 Updated Query-Builder to get all necessary fields from all db_tables for collections. Added tests and fixed existing tests (table names now start with alias `c.`) - d83eeb4 Update api/.env.example - 5a7af5b Updated Query-Builder to get all necessary fields from all db_tables for collections. Added tests and fixed existing tests (table names now start with alias `c.`) Co-authored-by: Robin Tammo Gummels * Implement more Queryable-Fields and add the keywords-field to q fulltext search (#200) * Add provider and license filters to collection search API - Updated buildCollectionSearchQuery to include provider and license parameters for filtering collections. - Enhanced validateCollectionSearchParams middleware to validate provider and license query parameters. - Modified collections route to handle new provider and license filters in search queries. - Implemented validation functions for provider and license parameters in collectionSearchParams. * Add validation tests for provider and license * Enhance full-text search by including keywords in the tsvector expression and update related tests * Add provider and license to query parameter extraction in collection search validation * Revert "Enhance full-text search by including keywords in the tsvector expression and update related tests" This reverts commit 872443d8e83c55834ef5f0d275c54fefb4b74e2d. * Implement GET /collections/{id} endpoint with validation and QueryBuilder integration (#186) * added SQLQuery-builder with these parameters: q,bbox,datetime,sortby,limit and token * finalised bbox and datetime * adapted to DB, QueryBuilder and added helperfunction runQuery * added question-TODOs * added bbox+datetime to the Query-Builder from Jonas * added tests for Query-Builder from Jonas * added tests from George * added falsely deleted TODOs again * fixed collumn names to match our DB and adjusted full text search to match 05_indexes.sql correctly * Used a formatter and linter on `buildCollectionSearchQuery.js * Did some major and minor fixes to the collection search. - Updated `buildCollectionSearchQuery` to support pagination and improved text search with English language settings. - Modified tests in `buildCollectionsSearchQuery.basic.test.js`, `collections-pagination.test.js`, and `collections-sort.test.js` to reflect new query behavior and validation logic. - Enhanced sort validation in `validators.test.js` and `collectionSearchParams.js` to map API fields to database column names. - Implemented total count retrieval for matched results in `collections.js`. * Added a internal .env creation in the CI/CD Pipeline. It utilzes GitHub Repository Secrets to not publish any private Logins and stuff. * Forgot that the second Job of the CI/CD pipeline runs seperatly and needs a internal .env file too. * Enhance documentation for buildCollectionSearchQuery Updated the documentation for: - the buildCollectionSearchQuery function - the fulltextsearch * Refactor buildCollectionSearchQuery and updated SELECT part Changed the SELECT part to match our bid and the database shema. Updated comments and for clarity. Changed full-text search to use 'simple' configuration instead of 'english'. * Update api/routes/collections.js small typo Co-authored-by: Robin Tammo Gummels * Remove sorting TODO from collections route Removed TODO comment about sorting based on sortby parameter. * Explicitly return undefined for normalized in validateSortby Update validateSortby function to explicitly return undefined for normalized when sortby is not provided. * small fix in buildCollectionSearch.fulltext.test.js Change plainto_tsquery language from 'english' to 'simple' * Fix duplicate SELECT keyword in query Remove duplicate 'SELECT' keyword in SQL query. * Fix missing newline at end of collectionSearchParams.js * Fixed missing bracket in collectionSearchParams.js * Refactor validateSortby for optional parameter handling Refactor validateSortby function to handle optional sortby parameter and improve validation logic. * Stabilize API test pipeline by running Jest in-band with extended timeout Run Jest in CI with --runInBand and a higher default --testTimeout to stabilize database-backed integration tests. Multiple Jest workers were competing for the same PostgreSQL connection pool and some long-running /collections queries exceeded the default 5s timeout, causing failures in existing test suites (e.g. collectionSearch and DBconnection). * Fixed leaking tests that blocked CI/CD-Pipeline. - Added a global Teardown for jest and force-exited the tests to prevent leaking. - Made a change to db_APIconnection to only log the pool-(dis)connection if it isn't run in a test enviroment. * Did a minimum amount of Formatting to the discription * Used `npm audit fix --force` to fix all vulnerabilties in our used packages. * Fixed curious doublechecking for empty Strings for the sortby-Parameter. - Now we only check once for a empty sortby - And added a test which distinguish between `sortby=""` and `sortby="+"` * Update api/routes/collections.js Removed the TODO about switching from mock-data to the real db * Removed globalTeardown as i brought up some problems corresponding to long db-queries (for example BBOX). Instead i increased the maximal testTimeout. * added validator for collections{id} and correctly implemented collections{id} * added test for collections{id} * removed unnecessary parameter * added id parameter to the Query (temporary fix) * test-fixes to match our current tests and a fix to the baseURL for collection{id} * test fix * fixed problem with tests in api.test.js and adjusted the "invalid-id-test" in the validator. * Update api/routes/collections.js - Renamed `collection.id` to `c.collection.id` * added test for negative ids * deleted the whole "existing links" part and build base Links * fixed bug in validateCollectionId.js * Refactor negative ID test i encoded the "-1" value in the negative ID test instead of directly putting it into the path. * Removed a german comment in `api/routes/collections.js` --------- Co-authored-by: Robin Tammo Gummels --------- Co-authored-by: Sönke Hoffmann Co-authored-by: JonasK <156602337+BrokeJ@users.noreply.github.com> Co-authored-by: Vincent Kühn * Dev database: trigger function for better keyword handling (#209) * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md * added `stac_id` for collections * all IDs are now written in the newer PostgrSQL standart: ```SQL id SERIAL PRIMARY KEY, ``` changed to ```SQL id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ``` * changed `extend` to `extent`. * Changed language used in `./api/README.md` from german to english. I wanted to thsi anyway at some point, but this is now more like a Test-commit to see if the CI/CD Pipeline triggers... * added triggering function for an auto-update search_vector, both for collections and catalogs. The search_vector includes title, description and keywords * changed the CI-Pipeline. Now also Changes in the /db will be acceped by the Pipeline --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels * Dev database: added different users (for api and crawler) (#214) * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md * added `stac_id` for collections * all IDs are now written in the newer PostgrSQL standart: ```SQL id SERIAL PRIMARY KEY, ``` changed to ```SQL id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ``` * changed `extend` to `extent`. * Changed language used in `./api/README.md` from german to english. I wanted to thsi anyway at some point, but this is now more like a Test-commit to see if the CI/CD Pipeline triggers... * added triggering function for an auto-update search_vector, both for collections and catalogs. The search_vector includes title, description and keywords * changed the CI-Pipeline. Now also Changes in the /db will be acceped by the Pipeline * added different users for the api and crawler groups. The api has read-only acces and the crawler user full acces to the database. The acces to the database is now possible by using the users `stac_api`or `stac_crawler`. The admin user (`postgres_user`) is still available but shouldn't be used --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels * Dev database: source_url and filter trigger (#220) * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md * added `stac_id` for collections * all IDs are now written in the newer PostgrSQL standart: ```SQL id SERIAL PRIMARY KEY, ``` changed to ```SQL id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ``` * changed `extend` to `extent`. * Changed language used in `./api/README.md` from german to english. I wanted to thsi anyway at some point, but this is now more like a Test-commit to see if the CI/CD Pipeline triggers... * added triggering function for an auto-update search_vector, both for collections and catalogs. The search_vector includes title, description and keywords * changed the CI-Pipeline. Now also Changes in the /db will be acceped by the Pipeline * added different users for the api and crawler groups. The api has read-only acces and the crawler user full acces to the database. The acces to the database is now possible by using the users `stac_api`or `stac_crawler`. The admin user (`postgres_user`) is still available but shouldn't be used * Refactor(database): SQL trigger definitions for catalog and collection keywords. Moved triggers to 06_triggers.sql for better organization, as they depend on the respective tables created in earlier scripts. * added source_url for collections and catalogs. Now the full_json doesn`t has to be used for getting the url * resolved a Problem I had with git by hand cause I didn't found the function --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels Co-authored-by: mammutor --------- Co-authored-by: RobinGummels Co-authored-by: Jakob Co-authored-by: Sönke Hoffmann Co-authored-by: jklaer Co-authored-by: VincentKuehn Co-authored-by: Justin K Co-authored-by: Georgios Voulgaris Co-authored-by: Sönke Hoffmann Co-authored-by: JonasK <156602337+BrokeJ@users.noreply.github.com> --- .github/workflows/api-ci.yml | 6 +- api/.env.example | 10 +- api/README.md | 48 ++- api/__tests__/api.test.js | 15 +- ...ldCollectionSearchQuery.aggregates.test.js | 221 +++++++++++ ...uildCollectionSearchQuery.fulltext.test.js | 4 +- ...dCollectionSearchQuery.integration.test.js | 322 ++++++++++++++++ .../buildCollectionsSearchQuery.basic.test.js | 8 +- api/__tests__/collections-id.test.js | 87 +++++ api/__tests__/validators.test.js | 80 +++- api/app.js | 30 +- api/db/buildCollectionSearchQuery.js | 163 ++++++-- api/docs/openapi.yaml | 351 ++++++++++++++++++ api/middleware/validateCollectionId.js | 25 ++ api/middleware/validateCollectionSearch.js | 24 +- api/routes/collections.js | 109 +++--- api/routes/index.js | 2 +- api/validators/collectionSearchParams.js | 53 ++- db/docker-compose.yml | 4 + db/example.env | 14 +- db/init/00_users.sh | 34 ++ db/init/02_tables_catalog.sql | 63 +++- db/init/03_tables_collections.sql | 63 +++- db/init/05_indexes.sql | 8 +- db/init/06_triggers.sql | 16 + 25 files changed, 1644 insertions(+), 116 deletions(-) create mode 100644 api/__tests__/buildCollectionSearchQuery.aggregates.test.js create mode 100644 api/__tests__/buildCollectionSearchQuery.integration.test.js create mode 100644 api/__tests__/collections-id.test.js create mode 100644 api/docs/openapi.yaml create mode 100644 api/middleware/validateCollectionId.js create mode 100644 db/init/00_users.sh create mode 100644 db/init/06_triggers.sql diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index 68c87c0..1b49ad6 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -9,6 +9,7 @@ on: - main paths: - 'api/**' + - 'db/**' - '.github/workflows/api-ci.yml' pull_request: branches: @@ -17,6 +18,7 @@ on: - main paths: - 'api/**' + - 'db/**' - '.github/workflows/api-ci.yml' jobs: @@ -71,7 +73,7 @@ jobs: # API Configuration API_TITLE=STAC Atlas API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata - API_VERSION=1.0.0 + API_VERSION=1.1.0 EOF # Step 4: Install dependencies @@ -164,7 +166,7 @@ jobs: # API Configuration API_TITLE=STAC Atlas API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata - API_VERSION=1.0.0 + API_VERSION=1.1.0 EOF - name: Install dependencies diff --git a/api/.env.example b/api/.env.example index 039ac70..c6b2632 100644 --- a/api/.env.example +++ b/api/.env.example @@ -5,15 +5,15 @@ NODE_ENV=development # Database Configuration (Debian Server) # Option 1: Use DATABASE_URL (PostgreSQL connection string) -# add DB_USER and DB_PASSWORD values -DATABASE_URL= postgresql://[**DB_USER**]:[**DB_PASSWORD**]@atlas.stacindex.org:5432/stac_db +# The api-user is stac_api (read-only) (stac_crawler for crawler-group) +DATABASE_URL=postgresql://stac_api:[PASSWORD]@atlas.stacindex.org:5432/stac_db # Option 2: Use individual variables (currently active) DB_HOST=atlas.stacindex.org DB_PORT=5433 # 5432 for production DB_NAME=stac_db -DB_USER= -DB_PASSWORD= +DB_USER=stac_api +DB_PASSWORD= # Add stac_api password here (api_password) DB_SSL=false # Connection Pool Configuration @@ -28,4 +28,4 @@ CORS_ORIGIN=* # API Configuration API_TITLE=STAC Atlas API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata -API_VERSION=1.0.0 +API_VERSION=1.1.0 diff --git a/api/README.md b/api/README.md index 96d89e2..c337643 100644 --- a/api/README.md +++ b/api/README.md @@ -156,13 +156,59 @@ CORS_ORIGIN=* This API implements: -- ✅ STAC API Core (v1.0.0) +- ✅ STAC API Core (v1.1.0) - ✅ OGC API Features Core - ✅ STAC Collections - ✅ Collection Search Extension - 🚧 CQL2 Basic Filtering (in development) - 🚧 CQL2 Advanced Operators (in development) +### STAC API Validator + +The API can be tested using the official [STAC API Validator](https://github.com/stac-utils/stac-api-validator): + +#### Installation + +```bash +# Python 3.11 required +pip install stac-api-validator +``` + +#### Usage + +```bash +# Validate Core Conformance Class +python -m stac_api_validator --root-url http://localhost:3000 --conformance core + +# Validate Collections Extension (requires collection ID) +python -m stac_api_validator \ + --root-url http://localhost:3000 \ + --conformance core \ + --conformance collections \ + --collection + +# With spatial filtering (requires geometry in dataset) +python -m stac_api_validator \ + --root-url http://localhost:3000 \ + --conformance core \ + --conformance collections \ + --collection \ + --geometry '{"type": "Polygon", "coordinates": [[[7.0, 51.0], [8.0, 51.0], [8.0, 52.0], [7.0, 52.0], [7.0, 51.0]]]}' +``` + +#### Validation Status + +| Conformance Class | Status | Date | Errors | Warnings | +|-------------------|--------|------|--------|----------| +| **STAC API - Core** | ✅ Passed | 2025-12-10 | 0 | 0 | +| STAC API - Collections | ⏳ Pending | - | - | - | +| STAC API - Features | ⏳ Pending | - | - | - | +| STAC API - Item Search | ⏳ Pending | - | - | - | +| CQL2 - Basic | ⏳ Pending | - | - | - | +| CQL2 - Advanced | ⏳ Pending | - | - | - | + +**Note:** The Collection Search Extension is not currently validated automatically by the validator and is instead validated through custom Jest integration tests (see `__tests__/`). + ## 📦 Next Steps ### TODO diff --git a/api/__tests__/api.test.js b/api/__tests__/api.test.js index 6c77a1f..ad4b867 100644 --- a/api/__tests__/api.test.js +++ b/api/__tests__/api.test.js @@ -120,13 +120,16 @@ describe('STAC API Core Endpoints', () => { }); }); - describe('GET /collections/:id', () => { - it('should return 404 for non-existent collection', async () => { - const response = await request(app).get('/collections/non-existent-id').expect(404); + describe('GET /collections/:id', () => { + it('should return 404 for non-existent collection', async () => { + const nonExistingId = 999999999; - expect(response.body).toHaveProperty('code', 'NotFound'); - expect(response.body).toHaveProperty('description'); - expect(response.body).toHaveProperty('id', 'non-existent-id'); + const response = await request(app) + .get(`/collections/${nonExistingId}`) + .expect(404); + + expect(response.body).toHaveProperty('code', 'NotFound'); + expect(response.body).toHaveProperty('description'); }); }); }); diff --git a/api/__tests__/buildCollectionSearchQuery.aggregates.test.js b/api/__tests__/buildCollectionSearchQuery.aggregates.test.js new file mode 100644 index 0000000..069ea46 --- /dev/null +++ b/api/__tests__/buildCollectionSearchQuery.aggregates.test.js @@ -0,0 +1,221 @@ +const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); + +describe('buildCollectionSearchQuery - aggregated fields', () => { + test('SELECT includes all collection base columns with alias c', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + // Core collection fields should be prefixed with 'c.' + expect(sql).toMatch(/c\.id/); + expect(sql).toMatch(/c\.stac_version/); + expect(sql).toMatch(/c\.type/); + expect(sql).toMatch(/c\.title/); + expect(sql).toMatch(/c\.description/); + expect(sql).toMatch(/c\.license/); + expect(sql).toMatch(/c\.spatial_extend/); + expect(sql).toMatch(/c\.temporal_extend_start/); + expect(sql).toMatch(/c\.temporal_extend_end/); + expect(sql).toMatch(/c\.created_at/); + expect(sql).toMatch(/c\.updated_at/); + expect(sql).toMatch(/c\.is_api/); + expect(sql).toMatch(/c\.is_active/); + expect(sql).toMatch(/c\.full_json/); + }); + + test('SELECT includes aggregated relation fields', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + // Aggregated fields from LATERAL JOINs + expect(sql).toMatch(/kw\.keywords/); + expect(sql).toMatch(/ext\.stac_extensions/); + expect(sql).toMatch(/prov\.providers/); + expect(sql).toMatch(/a\.assets/); + expect(sql).toMatch(/s\.summaries/); + expect(sql).toMatch(/cl\.last_crawled/); + }); + + test('FROM clause uses collection alias c', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/FROM collection c/); + }); + + describe('LATERAL JOINs for normalized data', () => { + test('includes LATERAL JOIN for keywords', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/LEFT JOIN LATERAL/); + expect(sql).toMatch(/jsonb_agg\(k\.keyword ORDER BY k\.keyword\) AS keywords/); + expect(sql).toMatch(/FROM collection_keywords ck/); + expect(sql).toMatch(/JOIN keywords k ON k\.id = ck\.keyword_id/); + expect(sql).toMatch(/WHERE ck\.collection_id = c\.id/); + }); + + test('includes LATERAL JOIN for stac_extensions', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/jsonb_agg\(se\.stac_extension ORDER BY se\.stac_extension\) AS stac_extensions/); + expect(sql).toMatch(/FROM collection_stac_extension cse/); + expect(sql).toMatch(/JOIN stac_extensions se ON se\.id = cse\.stac_extension_id/); + expect(sql).toMatch(/WHERE cse\.collection_id = c\.id/); + }); + + test('includes LATERAL JOIN for providers with roles', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/jsonb_agg\(jsonb_build_object\(/); + expect(sql).toMatch(/'name', p\.provider/); + expect(sql).toMatch(/'roles', cpr\.collection_provider_roles/); + expect(sql).toMatch(/FROM collection_providers cpr/); + expect(sql).toMatch(/JOIN providers p ON p\.id = cpr\.provider_id/); + expect(sql).toMatch(/WHERE cpr\.collection_id = c\.id/); + }); + + test('includes LATERAL JOIN for assets with metadata', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/'name', a\.name/); + expect(sql).toMatch(/'href', a\.href/); + expect(sql).toMatch(/'type', a\.type/); + expect(sql).toMatch(/'roles', a\.roles/); + expect(sql).toMatch(/'metadata', a\.metadata/); + expect(sql).toMatch(/'collection_roles', ca\.collection_asset_roles/); + expect(sql).toMatch(/FROM collection_assets ca/); + expect(sql).toMatch(/JOIN assets a ON a\.id = ca\.asset_id/); + expect(sql).toMatch(/WHERE ca\.collection_id = c\.id/); + }); + + test('includes LATERAL JOIN for summaries with CASE logic', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/jsonb_object_agg\(s\.name, s\.s_summary\) AS summaries/); + expect(sql).toMatch(/WHEN cs\.kind = 'range' THEN jsonb_build_object\('min', cs\.range_min, 'max', cs\.range_max\)/); + expect(sql).toMatch(/WHEN cs\.kind = 'set' THEN to_jsonb\(cs\.set_value\)/); + expect(sql).toMatch(/FROM collection_summaries cs/); + expect(sql).toMatch(/WHERE cs\.collection_id = c\.id/); + }); + + test('includes LATERAL JOIN for last_crawled timestamp', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/MAX\(clc\.last_crawled\) AS last_crawled/); + expect(sql).toMatch(/FROM crawllog_collection clc/); + expect(sql).toMatch(/WHERE clc\.collection_id = c\.id/); + }); + }); + + describe('WHERE clauses use collection alias c', () => { + test('bbox filter uses c.spatial_extend', () => { + const bbox = [-10, 40, 10, 50]; + const { sql } = buildCollectionSearchQuery({ bbox, limit: 10, token: 0 }); + + expect(sql).toMatch(/c\.spatial_extend/); + expect(sql).toMatch(/ST_Intersects\(\s*c\.spatial_extend/); + }); + + test('datetime filter uses c.temporal_extend_start and c.temporal_extend_end', () => { + const datetime = '2020-01-01/2021-12-31'; + const { sql } = buildCollectionSearchQuery({ datetime, limit: 10, token: 0 }); + + expect(sql).toMatch(/c\.temporal_extend_end >= \$/); + expect(sql).toMatch(/c\.temporal_extend_start <= \$/); + }); + + test('fulltext search uses c.title and c.description', () => { + const q = 'satellite'; + const { sql } = buildCollectionSearchQuery({ q, limit: 10, token: 0 }); + + expect(sql).toMatch(/coalesce\(c\.title,''\)/); + expect(sql).toMatch(/coalesce\(c\.description,''\)/); + expect(sql).toMatch(/to_tsvector\('simple', coalesce\(c\.title,''\) \|\| ' ' \|\| coalesce\(c\.description,''\)\)/); + }); + }); + + describe('ORDER BY uses collection alias c', () => { + test('default ORDER BY uses c.id', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + expect(sql).toMatch(/ORDER BY c\.id ASC/); + }); + + test('sortby parameter uses c. prefix', () => { + const sortby = { field: 'title', direction: 'DESC' }; + const { sql } = buildCollectionSearchQuery({ sortby, limit: 10, token: 0 }); + + expect(sql).toMatch(/ORDER BY c\.title DESC/); + }); + + test('fulltext search with rank orders by rank DESC, c.id ASC', () => { + const q = 'satellite'; + const { sql } = buildCollectionSearchQuery({ q, limit: 10, token: 0 }); + + expect(sql).toMatch(/ORDER BY rank DESC, c\.id ASC/); + }); + }); + + describe('Parameterized values remain correct', () => { + test('bbox parameters are in correct order', () => { + const bbox = [-10, 40, 10, 50]; + const { values } = buildCollectionSearchQuery({ bbox, limit: 10, token: 0 }); + + expect(values.slice(0, 4)).toEqual(bbox); + expect(values[4]).toBe(10); // limit + expect(values[5]).toBe(0); // token + }); + + test('datetime interval parameters are in correct order', () => { + const datetime = '2020-01-01/2021-12-31'; + const { values } = buildCollectionSearchQuery({ datetime, limit: 10, token: 0 }); + + expect(values[0]).toBe('2020-01-01'); + expect(values[1]).toBe('2021-12-31'); + expect(values[2]).toBe(10); // limit + expect(values[3]).toBe(0); // token + }); + + test('fulltext query parameter is bound correctly', () => { + const q = 'satellite'; + const { values } = buildCollectionSearchQuery({ q, limit: 10, token: 0 }); + + expect(values[0]).toBe('satellite'); + expect(values[1]).toBe(10); // limit + expect(values[2]).toBe(0); // token + }); + + test('combined filters maintain parameter order', () => { + const bbox = [-10, 40, 10, 50]; + const datetime = '2020-01-01/2021-12-31'; + const q = 'satellite'; + const { values } = buildCollectionSearchQuery({ q, bbox, datetime, limit: 10, token: 0 }); + + // Order: q (1), bbox (4), datetime start (1), datetime end (1), limit (1), token (1) = 9 values + expect(values[0]).toBe('satellite'); + expect(values.slice(1, 5)).toEqual(bbox); + expect(values[5]).toBe('2020-01-01'); + expect(values[6]).toBe('2021-12-31'); + expect(values[7]).toBe(10); + expect(values[8]).toBe(0); + }); + }); + + describe('SQL structure validation', () => { + test('no DISTINCT in jsonb_agg to avoid ORDER BY conflict', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + // DISTINCT should NOT appear in any jsonb_agg calls + // (PostgreSQL requires ORDER BY expressions to appear in DISTINCT argument list) + const distinctPattern = /jsonb_agg\(DISTINCT/gi; + const matches = sql.match(distinctPattern); + + expect(matches).toBeNull(); + }); + + test('all LATERAL JOINs are LEFT JOIN', () => { + const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + + // Count LEFT JOIN LATERAL occurrences (should be 6: kw, ext, prov, a, s, cl) + const leftJoinLateralCount = (sql.match(/LEFT JOIN LATERAL/gi) || []).length; + + expect(leftJoinLateralCount).toBe(6); + }); + }); +}); diff --git a/api/__tests__/buildCollectionSearchQuery.fulltext.test.js b/api/__tests__/buildCollectionSearchQuery.fulltext.test.js index 5c8100d..99a9f07 100644 --- a/api/__tests__/buildCollectionSearchQuery.fulltext.test.js +++ b/api/__tests__/buildCollectionSearchQuery.fulltext.test.js @@ -13,7 +13,7 @@ describe('buildCollectionSearchQuery - full-text search and ranking', () => { expect(sql).toMatch(/AS rank/); // Ordering defaults to rank DESC when q present and no sortby - expect(sql).toMatch(/ORDER BY rank DESC, id ASC/); + expect(sql).toMatch(/ORDER BY rank DESC, c\.id ASC/); // values: [q, limit, token] expect(values[0]).toBe('forest'); @@ -24,7 +24,7 @@ describe('buildCollectionSearchQuery - full-text search and ranking', () => { test('explicit sortby overrides rank ordering', () => { const { sql } = buildCollectionSearchQuery({ q: 'lake', sortby: { field: 'title', direction: 'ASC' }, limit: 5, token: 0 }); - expect(sql).toMatch(/ORDER BY title ASC/); + expect(sql).toMatch(/ORDER BY c\.title ASC/); // rank still present in select expect(sql).toMatch(/AS rank/); }); diff --git a/api/__tests__/buildCollectionSearchQuery.integration.test.js b/api/__tests__/buildCollectionSearchQuery.integration.test.js new file mode 100644 index 0000000..2885fa0 --- /dev/null +++ b/api/__tests__/buildCollectionSearchQuery.integration.test.js @@ -0,0 +1,322 @@ +const { query, closePool } = require('../db/db_APIconnection'); +const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); + +/** + * Integration Tests: Aggregated Fields in Collection Search Query + * + * These tests verify that the LATERAL JOINs correctly aggregate data from + * normalized tables (keywords, providers, assets, stac_extensions, summaries, crawllog). + * + * Prerequisites: + * - Database must be initialized with schema (01-05_*.sql) + * - Test data should include collections with related entities + */ + +describe('Integration: Collection Search with Aggregated Fields', () => { + afterAll(async () => { + await closePool(); + }); + + describe('Query Execution', () => { + test('should execute query successfully without errors', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 5, token: 0 }); + + await expect(query(sql, values)).resolves.not.toThrow(); + }); + + test('should return rows with aggregated fields', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 5, token: 0 }); + const result = await query(sql, values); + + expect(result.rows).toBeDefined(); + expect(Array.isArray(result.rows)).toBe(true); + + // If there are collections in DB, verify structure + if (result.rows.length > 0) { + const firstRow = result.rows[0]; + + // Core collection fields + expect(firstRow).toHaveProperty('id'); + expect(firstRow).toHaveProperty('title'); + expect(firstRow).toHaveProperty('description'); + expect(firstRow).toHaveProperty('license'); + expect(firstRow).toHaveProperty('full_json'); + + // Aggregated fields (may be null if no related data) + expect(firstRow).toHaveProperty('keywords'); + expect(firstRow).toHaveProperty('stac_extensions'); + expect(firstRow).toHaveProperty('providers'); + expect(firstRow).toHaveProperty('assets'); + expect(firstRow).toHaveProperty('summaries'); + expect(firstRow).toHaveProperty('last_crawled'); + } + }); + }); + + describe('Aggregated Field Types', () => { + test('keywords should be JSONB array or null', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + const result = await query(sql, values); + + result.rows.forEach(row => { + if (row.keywords !== null) { + expect(Array.isArray(row.keywords)).toBe(true); + // Each keyword should be a string + row.keywords.forEach(kw => { + expect(typeof kw).toBe('string'); + }); + } + }); + }); + + test('stac_extensions should be JSONB array or null', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + const result = await query(sql, values); + + result.rows.forEach(row => { + if (row.stac_extensions !== null) { + expect(Array.isArray(row.stac_extensions)).toBe(true); + row.stac_extensions.forEach(ext => { + expect(typeof ext).toBe('string'); + }); + } + }); + }); + + test('providers should be JSONB array of objects or null', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + const result = await query(sql, values); + + result.rows.forEach(row => { + if (row.providers !== null) { + expect(Array.isArray(row.providers)).toBe(true); + row.providers.forEach(provider => { + expect(provider).toHaveProperty('name'); + expect(provider).toHaveProperty('roles'); + expect(typeof provider.name).toBe('string'); + }); + } + }); + }); + + test('assets should be JSONB array of objects or null', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + const result = await query(sql, values); + + result.rows.forEach(row => { + if (row.assets !== null) { + expect(Array.isArray(row.assets)).toBe(true); + row.assets.forEach(asset => { + expect(asset).toHaveProperty('name'); + expect(asset).toHaveProperty('href'); + expect(asset).toHaveProperty('type'); + expect(asset).toHaveProperty('roles'); + expect(asset).toHaveProperty('metadata'); + expect(asset).toHaveProperty('collection_roles'); + }); + } + }); + }); + + test('summaries should be JSONB object or null', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + const result = await query(sql, values); + + result.rows.forEach(row => { + if (row.summaries !== null) { + expect(typeof row.summaries).toBe('object'); + expect(Array.isArray(row.summaries)).toBe(false); + + // Each summary should be a range, set, or schema object + Object.values(row.summaries).forEach(summary => { + const hasRange = summary.min !== undefined && summary.max !== undefined; + const isSet = Array.isArray(summary) || typeof summary === 'string'; + const isSchema = typeof summary === 'object'; + + expect(hasRange || isSet || isSchema).toBe(true); + }); + } + }); + }); + + test('last_crawled should be timestamp or null', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + const result = await query(sql, values); + + result.rows.forEach(row => { + if (row.last_crawled !== null) { + // Should be a valid Date or parseable timestamp + const date = new Date(row.last_crawled); + expect(date.toString()).not.toBe('Invalid Date'); + } + }); + }); + }); + + describe('Filter Compatibility with Aggregated Fields', () => { + test('bbox filter works with aggregated fields', async () => { + const bbox = [-180, -90, 180, 90]; // World bbox + const { sql, values } = buildCollectionSearchQuery({ bbox, limit: 10, token: 0 }); + + const result = await query(sql, values); + + expect(result.rows).toBeDefined(); + // All returned rows should have the aggregated structure + result.rows.forEach(row => { + expect(row).toHaveProperty('keywords'); + expect(row).toHaveProperty('providers'); + expect(row).toHaveProperty('assets'); + }); + }); + + test('datetime filter works with aggregated fields', async () => { + const datetime = '2000-01-01/2030-12-31'; + const { sql, values } = buildCollectionSearchQuery({ datetime, limit: 10, token: 0 }); + + const result = await query(sql, values); + + expect(result.rows).toBeDefined(); + result.rows.forEach(row => { + expect(row).toHaveProperty('stac_extensions'); + expect(row).toHaveProperty('summaries'); + expect(row).toHaveProperty('last_crawled'); + }); + }); + + test('fulltext search works with aggregated fields', async () => { + const q = 'test'; + const { sql, values } = buildCollectionSearchQuery({ q, limit: 10, token: 0 }); + + const result = await query(sql, values); + + expect(result.rows).toBeDefined(); + result.rows.forEach(row => { + expect(row).toHaveProperty('keywords'); + expect(row).toHaveProperty('providers'); + }); + }); + + test('combined filters work with aggregated fields', async () => { + const bbox = [-180, -90, 180, 90]; + const datetime = '2000-01-01/2030-12-31'; + const q = 'satellite'; + const { sql, values } = buildCollectionSearchQuery({ q, bbox, datetime, limit: 5, token: 0 }); + + const result = await query(sql, values); + + expect(result.rows).toBeDefined(); + result.rows.forEach(row => { + // All aggregated fields should be present + expect(row).toHaveProperty('keywords'); + expect(row).toHaveProperty('stac_extensions'); + expect(row).toHaveProperty('providers'); + expect(row).toHaveProperty('assets'); + expect(row).toHaveProperty('summaries'); + expect(row).toHaveProperty('last_crawled'); + }); + }); + }); + + describe('Sorting with Aggregated Fields', () => { + test('default sort by c.id works with aggregated fields', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); + const result = await query(sql, values); + + if (result.rows.length > 1) { + // IDs should be in ascending order + for (let i = 1; i < result.rows.length; i++) { + expect(result.rows[i].id).toBeGreaterThanOrEqual(result.rows[i - 1].id); + } + } + }); + + test('sort by title works with aggregated fields', async () => { + const sortby = { field: 'title', direction: 'ASC' }; + const { sql, values } = buildCollectionSearchQuery({ sortby, limit: 10, token: 0 }); + const result = await query(sql, values); + + // Verify SQL contains ORDER BY c.title ASC + expect(sql).toMatch(/ORDER BY c\.title ASC/); + + // Verify all aggregated fields are present + expect(result.rows).toBeDefined(); + result.rows.forEach(row => { + expect(row).toHaveProperty('keywords'); + expect(row).toHaveProperty('providers'); + expect(row).toHaveProperty('assets'); + }); + }); + + test('fulltext rank sort works with aggregated fields', async () => { + const q = 'satellite'; + const { sql, values } = buildCollectionSearchQuery({ q, limit: 10, token: 0 }); + const result = await query(sql, values); + + // Should execute without error; rank ordering is implicit in SQL + expect(result.rows).toBeDefined(); + result.rows.forEach(row => { + expect(row).toHaveProperty('keywords'); + expect(row).toHaveProperty('providers'); + }); + }); + }); + + describe('Pagination with Aggregated Fields', () => { + test('first page returns correct structure', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 3, token: 0 }); + const result = await query(sql, values); + + expect(result.rows.length).toBeLessThanOrEqual(3); + result.rows.forEach(row => { + expect(row).toHaveProperty('id'); + expect(row).toHaveProperty('keywords'); + expect(row).toHaveProperty('providers'); + }); + }); + + test('second page returns different rows with same structure', async () => { + const page1 = await query(...Object.values(buildCollectionSearchQuery({ limit: 3, token: 0 }))); + const page2 = await query(...Object.values(buildCollectionSearchQuery({ limit: 3, token: 3 }))); + + if (page1.rows.length > 0 && page2.rows.length > 0) { + // IDs should be different + const page1Ids = page1.rows.map(r => r.id); + const page2Ids = page2.rows.map(r => r.id); + + const overlap = page1Ids.filter(id => page2Ids.includes(id)); + expect(overlap.length).toBe(0); + + // Both pages should have same structure + page2.rows.forEach(row => { + expect(row).toHaveProperty('keywords'); + expect(row).toHaveProperty('providers'); + expect(row).toHaveProperty('assets'); + }); + } + }); + }); + + describe('Performance and Cardinality', () => { + test('LATERAL JOINs do not duplicate collection rows', async () => { + const { sql, values } = buildCollectionSearchQuery({ limit: 100, token: 0 }); + const result = await query(sql, values); + + // Collect all IDs + const ids = result.rows.map(r => r.id); + const uniqueIds = [...new Set(ids)]; + + // No duplicates: each collection should appear exactly once + expect(ids.length).toBe(uniqueIds.length); + }); + + test('query executes in reasonable time (<5s for small dataset)', async () => { + const start = Date.now(); + const { sql, values } = buildCollectionSearchQuery({ limit: 50, token: 0 }); + await query(sql, values); + const duration = Date.now() - start; + + // Should complete within 5 seconds for typical test datasets + expect(duration).toBeLessThan(5000); + }, 10000); // 10s timeout for Jest + }); +}); diff --git a/api/__tests__/buildCollectionsSearchQuery.basic.test.js b/api/__tests__/buildCollectionsSearchQuery.basic.test.js index 8756a5f..4acd7b9 100644 --- a/api/__tests__/buildCollectionsSearchQuery.basic.test.js +++ b/api/__tests__/buildCollectionsSearchQuery.basic.test.js @@ -4,8 +4,8 @@ describe('buildCollectionSearchQuery - basic cases', () => { test('no params returns base SQL with LIMIT/OFFSET placeholders', () => { const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 }); - expect(sql).toMatch(/FROM collection/); - expect(sql).toMatch(/ORDER BY id ASC/); + expect(sql).toMatch(/FROM collection c/); + expect(sql).toMatch(/ORDER BY c\.id ASC/); // there should be LIMIT and OFFSET placeholders expect(sql).toMatch(/LIMIT \$1 OFFSET \$2/); expect(Array.isArray(values)).toBe(true); @@ -30,8 +30,8 @@ describe('buildCollectionSearchQuery - basic cases', () => { const datetime = '2020-01-01/2021-12-31'; const { sql, values } = buildCollectionSearchQuery({ datetime, limit: 10, token: 0 }); - expect(sql).toMatch(/temporal_extend_end >= \$1/); // TODO: Adjust naming to temporal_extent_end, when DB names are updated - expect(sql).toMatch(/temporal_extend_start <= \$2/); // TODO: Adjust naming to temporal_extent_start, when DB names are updated + expect(sql).toMatch(/c\.temporal_extend_end >= \$1/); // TODO: Adjust naming to temporal_extent_end, when DB names are updated + expect(sql).toMatch(/c\.temporal_extend_start <= \$2/); // TODO: Adjust naming to temporal_extent_start, when DB names are updated // values order: start, end, limit, token expect(values[0]).toBe('2020-01-01'); expect(values[1]).toBe('2021-12-31'); diff --git a/api/__tests__/collections-id.test.js b/api/__tests__/collections-id.test.js new file mode 100644 index 0000000..63f89c8 --- /dev/null +++ b/api/__tests__/collections-id.test.js @@ -0,0 +1,87 @@ +const request = require('supertest'); +const app = require('../app'); + +describe('GET /collections/:id - Single collection retrieval', () => { + + /** + * Helper: fetch a valid collection id via the public /collections endpoint. + * This avoids hard-coding any specific id from the database. + */ + async function getAnyExistingCollectionId() { + const res = await request(app) + .get('/collections?limit=1&token=0') + .expect(200); + + expect(Array.isArray(res.body.collections)).toBe(true); + expect(res.body.collections.length).toBeGreaterThan(0); + + return res.body.collections[0].id; + } + + test('should return a single collection with matching id and STAC-style links', async () => { + const existingId = await getAnyExistingCollectionId(); + + const res = await request(app) + .get(`/collections/${existingId}`) + .expect(200); + + const collection = res.body; + + // id should match + expect(collection).toBeDefined(); + expect(collection.id).toBe(existingId); + + // basic structure + expect(collection).toHaveProperty('title'); + expect(collection).toHaveProperty('license'); + + + // links should be an array with self, root and parent + expect(Array.isArray(collection.links)).toBe(true); + + const rels = collection.links.map(l => l.rel); + + expect(rels).toContain('self'); + expect(rels).toContain('root'); + expect(rels).toContain('parent'); + + // self link should point to this resource + const selfLink = collection.links.find(l => l.rel === 'self'); + expect(selfLink).toBeDefined(); + expect(selfLink.href).toContain(`/collections/${existingId}`); + }); + + test('should return 400 for an invalid (non-numeric) id', async () => { + const res = await request(app) + .get('/collections/not-a-number') + .expect(400); + + expect(res.body).toHaveProperty('code', 'InvalidParameter'); + expect(res.body.description).toMatch(/id/i); +}); + + test('should return 400 for a negative id', async () => { + const negativeId = '-1'; + + const res = await request(app) + .get(`/collections/${encodeURIComponent(negativeId)}`) + .expect(400); + + expect(res.body).toHaveProperty('code', 'InvalidParameter'); + expect(res.body.description).toMatch(/id/i); +}) + + test('should return 404 for a non-existing numeric id', async () => { + // use a very large id that is unlikely to exist + const nonExistingId = 999999999; + + const res = await request(app) + .get(`/collections/${nonExistingId}`) + .expect(404); + + expect(res.body).toHaveProperty('code', 'NotFound'); + expect(res.body).toHaveProperty('description'); + expect(res.body.description).toMatch(/not found/i); + expect(res.body).toHaveProperty('id', String(nonExistingId)); + }); +}); diff --git a/api/__tests__/validators.test.js b/api/__tests__/validators.test.js index c0e50cd..f6a9cb5 100644 --- a/api/__tests__/validators.test.js +++ b/api/__tests__/validators.test.js @@ -6,7 +6,9 @@ const { validateDatetime, validateLimit, validateSortby, - validateToken + validateToken, + validateProvider, + validateLicense } = require('../validators/collectionSearchParams'); describe('Collection Search Parameter Validators', () => { @@ -404,4 +406,80 @@ describe('Collection Search Parameter Validators', () => { expect(result.normalized).toBe(0); }); }); + + describe('validateProvider - Provider name', () => { + it('should accept valid provider string', () => { + const result = validateProvider('Copernicus'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('Copernicus'); + }); + + it('should trim whitespace from provider', () => { + const result = validateProvider(' Test Provider '); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('Test Provider'); + }); + + it('should accept undefined provider', () => { + const result = validateProvider(undefined); + expect(result.valid).toBe(true); + }); + + it('should reject non-string provider', () => { + const result = validateProvider(123); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a string'); + }); + + it('should reject empty provider', () => { + const result = validateProvider(' '); + expect(result.valid).toBe(false); + expect(result.error).toContain('must not be empty'); + }); + + it('should reject provider exceeding max length', () => { + const long = 'a'.repeat(256); + const result = validateProvider(long); + expect(result.valid).toBe(false); + expect(result.error).toContain('exceeds maximum length'); + }); + }); + + describe('validateLicense - License identifier', () => { + it('should accept valid license', () => { + const result = validateLicense('CC-BY-4.0'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('CC-BY-4.0'); + }); + + it('should trim whitespace from license', () => { + const result = validateLicense(' CC0 '); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('CC0'); + }); + + it('should accept undefined license', () => { + const result = validateLicense(undefined); + expect(result.valid).toBe(true); + }); + + it('should reject non-string license', () => { + const result = validateLicense(123); + expect(result.valid).toBe(false); + expect(result.error).toContain('must be a string'); + }); + + it('should reject empty license', () => { + const result = validateLicense(' '); + expect(result.valid).toBe(false); + expect(result.error).toContain('must not be empty'); + }); + + it('should reject license exceeding max length', () => { + const long = 'a'.repeat(256); + const result = validateLicense(long); + expect(result.valid).toBe(false); + expect(result.error).toContain('exceeds maximum length'); + }); + }); }); diff --git a/api/app.js b/api/app.js index bf5f4f5..a5b0393 100644 --- a/api/app.js +++ b/api/app.js @@ -26,7 +26,27 @@ app.use(cors({ allowedHeaders: ['Content-Type', 'Authorization'] })); -// Content-Type header for all JSON responses +// OpenAPI spec endpoint (YAML file with correct content-type) - MUST be before Content-Type middleware +app.get('/openapi.yaml', (req, res, next) => { + try { + const openapiPath = path.join(__dirname, 'docs', 'openapi.yaml'); + res.setHeader('Content-Type', 'application/vnd.oai.openapi+json;version=3.0'); + res.sendFile(openapiPath); + } catch (err) { + next(err); + } +}); + +// Swagger/OpenAPI documentation (if openapi.yaml exists) - MUST be before Content-Type middleware +try { + const swaggerDocument = YAML.load(path.join(__dirname, 'docs', 'openapi.yaml')); + app.use('/api-docs', swaggerUi.serve); + app.get('/api-docs', swaggerUi.setup(swaggerDocument)); +} catch (err) { + console.log('OpenAPI documentation not found. Create docs/openapi.yaml to enable Swagger UI.'); +} + +// Content-Type header for JSON responses (set AFTER special endpoints) app.use((req, res, next) => { res.setHeader('Content-Type', 'application/json'); next(); @@ -38,14 +58,6 @@ app.use('/conformance', conformanceRouter); app.use('/collections', collectionsRouter); app.use('/queryables', queryablesRouter); -// Swagger/OpenAPI documentation (if openapi.yaml exists) -try { - const swaggerDocument = YAML.load(path.join(__dirname, 'docs', 'openapi.yaml')); - app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); -} catch (err) { - console.log('OpenAPI documentation not found. Create docs/openapi.yaml to enable Swagger UI.'); -} - // 404 handler app.use((req, res, next) => { res.status(404).json({ diff --git a/api/db/buildCollectionSearchQuery.js b/api/db/buildCollectionSearchQuery.js index cc1d435..064a7d1 100644 --- a/api/db/buildCollectionSearchQuery.js +++ b/api/db/buildCollectionSearchQuery.js @@ -61,6 +61,12 @@ * @param {number} params.token * Offset for pagination (0-based). Translated to OFFSET $n. * + * @param {string|undefined} params.provider + * Provider name to filter collections by their provider (case-insensitive match). + * + * @param {string|undefined} params.license + * License identifier to filter collections by `collection.license`. + * * @returns {{ sql: string, values: any[] }} * sql – complete parameterized SQL string * values – array of bind parameters in the correct order @@ -68,9 +74,12 @@ function buildCollectionSearchQuery(params) { const { + id, q, bbox, datetime, + provider, + license, sortby, limit, token @@ -83,28 +92,43 @@ function buildCollectionSearchQuery(params) { // full-text search) *before* the `FROM` clause. Keeping `sql` fixed with a // `FROM` already included would make inserting additional selected columns // harder and error-prone when building the query dynamically. + // + // We use alias 'c' for the collection table to simplify JOIN expressions and + // distinguish collection columns from aggregated relation data (keywords, providers, etc.). let selectPart = ` SELECT - id, - stac_version, - type, - title, - description, - license, - spatial_extend, - temporal_extend_start, - temporal_extend_end, - created_at, - updated_at, - is_api, - is_active, - full_json + c.id, + c.stac_version, + c.type, + c.title, + c.description, + c.license, + c.spatial_extend, + c.temporal_extend_start, + c.temporal_extend_end, + c.created_at, + c.updated_at, + c.is_api, + c.is_active, + c.full_json, + kw.keywords, + ext.stac_extensions, + prov.providers, + a.assets, + s.summaries, + cl.last_crawled `; const where = []; const values = []; let i = 1; + if (id !== undefined && id !== null) { + where.push(`id = $${i}`); + values.push(id); + i++; + } + // Full-text search using weighted tsvector across title (weight A) and description (weight B). // // Notes: @@ -123,8 +147,8 @@ function buildCollectionSearchQuery(params) { if (q) { const queryIndex = i; // remember index to reuse for rank and condition - // Weighted combined tsvector expression - const vectorExpr = `to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))`; + // Weighted combined tsvector expression (using alias 'c' for collection table) + const vectorExpr = `to_tsvector('simple', coalesce(c.title,'') || ' ' || coalesce(c.description,''))`; // Add rank to selected columns (ts_rank_cd => constant-duration ranking function) // The computed `rank` is available in the result rows and used for ordering @@ -144,7 +168,7 @@ function buildCollectionSearchQuery(params) { where.push(` ST_Intersects( - spatial_extend, + c.spatial_extend, ST_MakeEnvelope($${i}, $${i + 1}, $${i + 2}, $${i + 3}, 4326) ) `); @@ -161,33 +185,111 @@ function buildCollectionSearchQuery(params) { if (start !== '..') { // Collection should run after start - where.push(`temporal_extend_end >= $${i}`); + where.push(`c.temporal_extend_end >= $${i}`); values.push(start); i++; } if (end !== '..') { // Collection should run before end - where.push(`temporal_extend_start <= $${i}`); + where.push(`c.temporal_extend_start <= $${i}`); values.push(end); i++; } } else { // single datetime: collections active at that time where.push(` - temporal_extend_start <= $${i} - AND temporal_extend_end >= $${i} + c.temporal_extend_start <= $${i} + AND c.temporal_extend_end >= $${i} `); values.push(datetime); i++; } } - // Build final SQL from selectPart and add FROM clause. + // Provider filter: match collections that have a provider with the given name (case-insensitive) + if (provider) { + where.push(`EXISTS ( + SELECT 1 FROM collection_providers cp + JOIN providers p ON cp.provider_id = p.id + WHERE cp.collection_id = c.id + AND lower(p.provider) = lower($${i}) + )`); + values.push(provider); + i++; + } + + // License filter: direct match on collection.license + if (license) { + where.push(`c.license = $${i}`); + values.push(license); + i++; + } + + // Build final SQL from selectPart and add FROM clause with LATERAL JOINs. // We delayed adding `FROM collection` to allow conditional additions to the // selected columns above (notably `rank`). The final `sql` string includes the // selected columns, the source table and any WHERE conditions constructed earlier. - let sql = selectPart + `\n FROM collection\n `; + // + // LATERAL JOINs aggregate related data (keywords, extensions, providers, assets, summaries, + // and crawl timestamps) from normalized tables without duplicating collection rows. + // Each LEFT JOIN LATERAL subquery returns a single aggregated row per collection. + let sql = selectPart + ` + FROM collection c + LEFT JOIN LATERAL ( + SELECT jsonb_agg(k.keyword ORDER BY k.keyword) AS keywords + FROM collection_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.collection_id = c.id + ) kw ON TRUE + LEFT JOIN LATERAL ( + SELECT jsonb_agg(se.stac_extension ORDER BY se.stac_extension) AS stac_extensions + FROM collection_stac_extension cse + JOIN stac_extensions se ON se.id = cse.stac_extension_id + WHERE cse.collection_id = c.id + ) ext ON TRUE + LEFT JOIN LATERAL ( + SELECT jsonb_agg(jsonb_build_object( + 'name', p.provider, + 'roles', cpr.collection_provider_roles + ) ORDER BY p.provider) AS providers + FROM collection_providers cpr + JOIN providers p ON p.id = cpr.provider_id + WHERE cpr.collection_id = c.id + ) prov ON TRUE + LEFT JOIN LATERAL ( + SELECT jsonb_agg(jsonb_build_object( + 'name', a.name, + 'href', a.href, + 'type', a.type, + 'roles', a.roles, + 'metadata', a.metadata, + 'collection_roles', ca.collection_asset_roles + ) ORDER BY a.name) AS assets + FROM collection_assets ca + JOIN assets a ON a.id = ca.asset_id + WHERE ca.collection_id = c.id + ) a ON TRUE + LEFT JOIN LATERAL ( + SELECT jsonb_object_agg(s.name, s.s_summary) AS summaries + FROM ( + SELECT + cs.name, + CASE + WHEN cs.kind = 'range' THEN jsonb_build_object('min', cs.range_min, 'max', cs.range_max) + WHEN cs.kind = 'set' THEN to_jsonb(cs.set_value) + ELSE cs.json_schema + END AS s_summary + FROM collection_summaries cs + WHERE cs.collection_id = c.id + ) s + ) s ON TRUE + LEFT JOIN LATERAL ( + SELECT MAX(clc.last_crawled) AS last_crawled + FROM crawllog_collection clc + WHERE clc.collection_id = c.id + ) cl ON TRUE + `; if (where.length > 0) { sql += ` WHERE ` + where.join(' AND '); @@ -197,15 +299,18 @@ function buildCollectionSearchQuery(params) { // a text query was provided (descending), falling back to id ascending. // // Behaviour summary: - // - `sortby` provided → use that (same as before) - // - no `sortby` & `q` present → order by `rank DESC, id ASC` so higher relevance comes first - // - no `sortby` & no `q` → order by `id ASC` (legacy default) + // - `sortby` provided → use that (with 'c.' prefix for collection columns) + // - no `sortby` & `q` present → order by `rank DESC, c.id ASC` so higher relevance comes first + // - no `sortby` & no `q` → order by `c.id ASC` (legacy default) + // + // Note: sortby.field is validated against a whitelist in the calling code; only collection + // table columns are allowed for sorting (not aggregated fields like keywords/providers). if (sortby) { - sql += ` ORDER BY ${sortby.field} ${sortby.direction}`; + sql += ` ORDER BY c.${sortby.field} ${sortby.direction}`; } else if (q) { - sql += ` ORDER BY rank DESC, id ASC`; + sql += ` ORDER BY rank DESC, c.id ASC`; } else { - sql += ` ORDER BY id ASC`; + sql += ` ORDER BY c.id ASC`; } // Pagination (only add if limit is provided) diff --git a/api/docs/openapi.yaml b/api/docs/openapi.yaml new file mode 100644 index 0000000..abcce52 --- /dev/null +++ b/api/docs/openapi.yaml @@ -0,0 +1,351 @@ +openapi: 3.0.3 +info: + title: STAC Atlas API + description: A centralized platform for managing, indexing, and providing STAC Collection metadata from distributed catalogs and APIs. + version: 1.1.0 + contact: + name: SpatioCore + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: + - url: http://localhost:3000 + description: Local development server + +paths: + /: + get: + summary: Landing Page + description: Returns the STAC API landing page with links to available resources + operationId: getLandingPage + tags: + - STAC Core + responses: + '200': + description: STAC API landing page + content: + application/json: + schema: + $ref: '#/components/schemas/LandingPage' + + /conformance: + get: + summary: Conformance Classes + description: Returns the conformance classes that this API implements + operationId: getConformance + tags: + - STAC Core + responses: + '200': + description: Conformance classes + content: + application/json: + schema: + $ref: '#/components/schemas/Conformance' + + /collections: + get: + summary: List Collections + description: Returns a list of STAC Collections with optional filtering + operationId: getCollections + tags: + - Collections + parameters: + - name: limit + in: query + description: Maximum number of collections to return + required: false + schema: + type: integer + minimum: 1 + maximum: 10000 + default: 10 + - name: offset + in: query + description: Number of collections to skip + required: false + schema: + type: integer + minimum: 0 + default: 0 + - name: bbox + in: query + description: Bounding box to filter collections [minLon,minLat,maxLon,maxLat] + required: false + schema: + type: array + items: + type: number + minItems: 4 + maxItems: 6 + - name: datetime + in: query + description: Temporal filter (single datetime or interval) + required: false + schema: + type: string + - name: q + in: query + description: Full-text search query + required: false + schema: + type: string + - name: filter + in: query + description: CQL2 filter expression + required: false + schema: + type: string + - name: filter-lang + in: query + description: Filter language (cql2-text or cql2-json) + required: false + schema: + type: string + enum: + - cql2-text + - cql2-json + default: cql2-text + - name: sortby + in: query + description: Sort order for results + required: false + schema: + type: string + responses: + '200': + description: List of collections + content: + application/json: + schema: + $ref: '#/components/schemas/Collections' + '400': + description: Bad request (invalid parameters) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /collections/{collectionId}: + get: + summary: Get Collection + description: Returns a single STAC Collection by ID + operationId: getCollection + tags: + - Collections + parameters: + - name: collectionId + in: path + description: Collection identifier + required: true + schema: + type: string + responses: + '200': + description: A STAC Collection + content: + application/json: + schema: + $ref: '#/components/schemas/Collection' + '404': + description: Collection not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /queryables: + get: + summary: Global Queryables + description: Returns queryable properties for collection search + operationId: getQueryables + tags: + - Queryables + responses: + '200': + description: Queryables schema + content: + application/schema+json: + schema: + type: object + +components: + schemas: + LandingPage: + type: object + required: + - type + - id + - description + - links + - conformsTo + properties: + type: + type: string + enum: + - Catalog + id: + type: string + title: + type: string + description: + type: string + stac_version: + type: string + conformsTo: + type: array + items: + type: string + links: + type: array + items: + $ref: '#/components/schemas/Link' + + Conformance: + type: object + required: + - conformsTo + properties: + conformsTo: + type: array + items: + type: string + + Collections: + type: object + required: + - collections + - links + properties: + collections: + type: array + items: + $ref: '#/components/schemas/Collection' + links: + type: array + items: + $ref: '#/components/schemas/Link' + context: + $ref: '#/components/schemas/Context' + + Collection: + type: object + required: + - type + - id + - description + - license + - extent + - links + properties: + type: + type: string + enum: + - Collection + stac_version: + type: string + stac_extensions: + type: array + items: + type: string + id: + type: string + title: + type: string + description: + type: string + keywords: + type: array + items: + type: string + license: + type: string + providers: + type: array + items: + type: object + extent: + type: object + required: + - spatial + - temporal + properties: + spatial: + type: object + required: + - bbox + properties: + bbox: + type: array + items: + type: array + items: + type: number + temporal: + type: object + required: + - interval + properties: + interval: + type: array + items: + type: array + items: + type: string + nullable: true + links: + type: array + items: + $ref: '#/components/schemas/Link' + summaries: + type: object + assets: + type: object + + Link: + type: object + required: + - rel + - href + properties: + rel: + type: string + href: + type: string + type: + type: string + title: + type: string + + Context: + type: object + properties: + returned: + type: integer + minimum: 0 + limit: + type: integer + minimum: 1 + matched: + type: integer + minimum: 0 + + Error: + type: object + required: + - code + - description + properties: + code: + type: string + description: + type: string + +tags: + - name: STAC Core + description: STAC API Core endpoints + - name: Collections + description: Collection search and retrieval + - name: Queryables + description: Queryable properties diff --git a/api/middleware/validateCollectionId.js b/api/middleware/validateCollectionId.js new file mode 100644 index 0000000..2cb1bb7 --- /dev/null +++ b/api/middleware/validateCollectionId.js @@ -0,0 +1,25 @@ +/** + * Middleware to validate the :id route parameter for /collections/:id. + * + * - Ensures the id looks like a positive integer (all digits). + * - Prevents obviously malformed input reaching the database layer. + * - On error, responds with a 404 JSON body that matches the "NotFound" error + * format used elsewhere in the API tests. + */ +function validateCollectionId(req, res, next) { + const { id } = req.params; + + // id must be present and must be a sequence of digits (no minus, no spaces, no letters) + if (!id || !/^\d+$/u.test(id)) { + return res.status(400).json({ + code: 'InvalidParameter', + description: 'The "id" parameter must be a non-negative integer (digits only).', + parameter: 'id', + value: id + }); + } + + next(); +} + +module.exports = { validateCollectionId }; \ No newline at end of file diff --git a/api/middleware/validateCollectionSearch.js b/api/middleware/validateCollectionSearch.js index f19aa5a..0aa2c74 100644 --- a/api/middleware/validateCollectionSearch.js +++ b/api/middleware/validateCollectionSearch.js @@ -6,7 +6,9 @@ const { validateDatetime, validateLimit, validateSortby, - validateToken + validateToken, + validateProvider, + validateLicense } = require('../validators/collectionSearchParams'); /** @@ -23,6 +25,8 @@ const { * - limit: Result limit (default 10, max 10000) * - sortby: Sort specification (+/-field) * - token: Pagination continuation token + * - provider: Provider name — filter by data provider + * - license: License identifier — filter by collection license * * @param {Request} req - Express request object * @param {Response} res - Express response object @@ -33,7 +37,7 @@ function validateCollectionSearchParams(req, res, next) { const normalized = {}; // Extract query parameters - const { q, bbox, datetime, limit, sortby, token } = req.query; + const { q, bbox, datetime, limit, sortby, token, provider, license } = req.query; // Validate q (free-text search) const qResult = validateQ(q); @@ -82,6 +86,22 @@ function validateCollectionSearchParams(req, res, next) { } else { normalized.token = tokenResult.normalized; } + + // Validate provider (filter by data provider) + const providerResult = validateProvider(provider); + if (!providerResult.valid) { + errors.push(providerResult.error); + } else if (providerResult.normalized !== undefined) { + normalized.provider = providerResult.normalized; + } + + // Validate license (filter by collection license) + const licenseResult = validateLicense(license); + if (!licenseResult.valid) { + errors.push(licenseResult.error); + } else if (licenseResult.normalized !== undefined) { + normalized.license = licenseResult.normalized; + } // If any validation errors occurred, return 400 with details if (errors.length > 0) { diff --git a/api/routes/collections.js b/api/routes/collections.js index 6f83a2f..5e81101 100644 --- a/api/routes/collections.js +++ b/api/routes/collections.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const collectionsStore = require('../data/collections'); // change with the real collections when we have them +const { validateCollectionId } = require('../middleware/validateCollectionId'); const { validateCollectionSearchParams } = require('../middleware/validateCollectionSearch'); const { query } = require('../db/db_APIconnection'); const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); @@ -27,6 +27,8 @@ async function runQuery(sql, params = []) { * - limit: Number of results (default 10, max 10000) * - sortby: Sort by field (+field for ASC, -field for DESC) * - token: Pagination continuation token (offset) + * - provider: Provider name — filter by data provider + * - license: License identifier — filter by collection license * * All parameters are validated by validateCollectionSearchParams middleware. * Validated/normalized values are available in req.validatedParams. @@ -36,13 +38,15 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { // TODO: Implement CQL2 filtering (GET endpoint) and add validator for `filter`, filter-lang` parameters try { // validated parameters from middleware - const { q, bbox, datetime, limit, sortby, token } = req.validatedParams; + const { q, bbox, datetime, limit, sortby, token, provider, license } = req.validatedParams; // build SQL querry and parameters const { sql, values } = buildCollectionSearchQuery({ q, bbox, datetime, + provider, + license, limit, sortby, token @@ -58,6 +62,8 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { q, bbox, datetime, + provider, + license, limit: null, // No limit for count sortby: null, // No sorting for count token: null // No offset for count @@ -119,56 +125,71 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { /** * GET /collections/:id - * Returns a single collection by ID. Includes all STAC Collection fields - * (stac_version, type, title, description, license, extent, links, etc). - * - * Returns: - * - 200 OK with full Collection object if found - * - 404 NotFound with proper error format if collection does not exist + * Returns a single collection by ID. + * + * Behaviour: + * - Uses the shared buildCollectionSearchQuery helper with an `id` filter + * so that GET /collections and GET /collections/:id stay aligned. + * - Returns: + * - 200 OK with a single Collection object if found + * - 404 NotFound with standardized error body if the collection does not exist + * + * Note: + * - The exact shape / fields of the returned collection are controlled by the + * SELECT part in buildCollectionSearchQuery. This allows the query builder + * (and later a mapping layer) to evolve without touching this route. */ -router.get('/:id', (req, res) => { - // TODO: Create a proper validator middleware for :id parameter to avoid SQL injection, etc. - const { id } = req.params; - - // Look up the collection in the data store by ID - // When connected to a DB, replace this with a SQL query (SELECT * FROM collections WHERE id = ?) - const collection = collectionsStore.find(c => c.id === id); - - if (!collection) { - // Return 404 with standardized error format - return res.status(404).json({ - code: 'NotFound', - description: `Collection with id '${id}' not found`, - id: id +router.get('/:id', validateCollectionId, async (req, res, next) => { + try { + const { id } = req.params; + + // id is already syntactically validated by validateCollectionId. + // For the database we use a numeric id, matching the c.collection.id column type. + const numericId = parseInt(id, 10); + + // Reuse the shared query builder with an exact id filter. + // We request a single row (LIMIT 1) and no offset. + const { sql, values } = buildCollectionSearchQuery({ + id: numericId, + limit: 1, + token: 0, }); - } - - // Return the full STAC Collection object - // Ensure the response includes at least self, root and parent links. - // Start from any links the collection already provides and add missing ones. - const baseHost = `${req.protocol}://${req.get('host')}`; - const selfHref = `${baseHost}/collections/${id}`; - const rootHref = baseHost; - const existingLinks = Array.isArray(collection.links) ? collection.links.slice() : []; + const rows = await runQuery(sql, values); - const hasRel = (rel) => existingLinks.some(l => l && l.rel === rel); + if (!rows || rows.length === 0) { + // Return 404 with standardized error format + return res.status(404).json({ + code: 'NotFound', + description: `Collection with id '${id}' not found`, + id: id + }); + } - if (!hasRel('self')) { - existingLinks.push({ rel: 'self', href: selfHref, type: 'application/json' }); - } + const collection = rows[0]; - if (!hasRel('root')) { - existingLinks.push({ rel: 'root', href: rootHref, type: 'application/json' }); - } + const baseHost = `${req.protocol}://${req.get('host')}`; + const selfHref = `${baseHost}${req.originalUrl}`; + const rootHref = baseHost; + + // TODO: + // Currently we always construct a minimal set of STAC-style links here. + // The crawler already stores the upstream links in full_json, but we do + // not extract or persist them as a separate links column yet. + // In the future we might want to parse those links and merge them here. + const links = [ + { rel: 'self', href: selfHref, type: 'application/json' }, + { rel: 'root', href: rootHref, type: 'application/json' }, + { rel: 'parent', href: rootHref, type: 'application/json' } + ]; - // Prefer an existing parent link if present, otherwise fall back to root - if (!hasRel('parent')) { - existingLinks.push({ rel: 'parent', href: rootHref, type: 'application/json' }); + // Return the collection with a normalized `links` array. + // The rest of the attributes (id, title, extent, full_json, …) come directly + // from the query builder / database. + res.json(Object.assign({}, collection, { links })); + } catch (error) { + next(error); } - - // Return the collection with a normalized `links` array - res.json(Object.assign({}, collection, { links: existingLinks })); }); module.exports = router; diff --git a/api/routes/index.js b/api/routes/index.js index b389488..14a7aca 100644 --- a/api/routes/index.js +++ b/api/routes/index.js @@ -16,7 +16,7 @@ router.get('/', (req, res) => { id: 'stac-atlas', title: 'STAC Atlas', description: 'A centralized platform for managing, indexing, and providing STAC Collection metadata from distributed catalogs and APIs.', - stac_version: '1.0.0', + stac_version: '1.1.0', conformsTo: CONFORMANCE_URIS, links: [ { diff --git a/api/validators/collectionSearchParams.js b/api/validators/collectionSearchParams.js index 7f24faf..b15c263 100644 --- a/api/validators/collectionSearchParams.js +++ b/api/validators/collectionSearchParams.js @@ -264,11 +264,62 @@ function validateToken(token) { return { valid: true, normalized: num }; } +/** + * Validates provider parameter + * @param {string} provider - Provider name + * @returns {Object} { valid: boolean, error?: string, normalized?: string } + */ +function validateProvider(provider) { + if (!provider) return { valid: true }; + + if (typeof provider !== 'string') { + return { valid: false, error: 'Parameter "provider" must be a string' }; + } + + const trimmed = provider.trim(); + if (trimmed.length === 0) { + return { valid: false, error: 'Parameter "provider" must not be empty' }; + } + + if (trimmed.length > 255) { + return { valid: false, error: 'Parameter "provider" exceeds maximum length of 255 characters' }; + } + + return { valid: true, normalized: trimmed }; +} + +/** + * Validates license parameter + * @param {string} license - License identifier or name + * @returns {Object} { valid: boolean, error?: string, normalized?: string } + */ +function validateLicense(license) { + if (!license) return { valid: true }; + + if (typeof license !== 'string') { + return { valid: false, error: 'Parameter "license" must be a string' }; + } + + const trimmed = license.trim(); + if (trimmed.length === 0) { + return { valid: false, error: 'Parameter "license" must not be empty' }; + } + + if (trimmed.length > 255) { + return { valid: false, error: 'Parameter "license" exceeds maximum length of 255 characters' }; + } + + return { valid: true, normalized: trimmed }; +} + module.exports = { validateQ, validateBbox, validateDatetime, validateLimit, validateSortby, - validateToken + validateToken, + validateProvider, + validateLicense }; + diff --git a/db/docker-compose.yml b/db/docker-compose.yml index 2e342c0..94848fb 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -7,9 +7,13 @@ services: - .env environment: + # Admin user (required for initial database setup) POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + # Application user passwords + STAC_API_PASSWORD: ${STAC_API_PASSWORD} + STAC_CRAWLER_PASSWORD: ${STAC_CRAWLER_PASSWORD} ports: - "${DB_PORT}:5432" diff --git a/db/example.env b/db/example.env index e175b0b..ad3b4b9 100644 --- a/db/example.env +++ b/db/example.env @@ -1,7 +1,15 @@ # PostgreSQL Database Configuration +# Admin user with superuser privileges (required for initial setup) POSTGRES_DB= # stac_db is the database we are running on -POSTGRES_USER= # add postgres_user here -POSTGRES_PASSWORD= # add postgres_password here +POSTGRES_USER= # add postgres_user here (admin user) +POSTGRES_PASSWORD= # add postgres_password here (admin password) # Database Port (host:container) -DB_PORT= # 5432 / 5433 (at the moment both are available) \ No newline at end of file +DB_PORT= # 5432 / 5433 (at the moment both are available) + +# Application Users (created via init scripts) +# stac_api: read-only access for API +STAC_API_PASSWORD= # Password for api user (read-only); add api_password here + +# stac_crawler: full read-write access for crawler +STAC_CRAWLER_PASSWORD= # Password for crawler user (read-write); add crawler_password here diff --git a/db/init/00_users.sh b/db/init/00_users.sh new file mode 100644 index 0000000..03a5885 --- /dev/null +++ b/db/init/00_users.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +# This script creates users for teh api and crawler group with different permissions. + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + + -- Create read-only user for API access + CREATE USER stac_api WITH PASSWORD '$STAC_API_PASSWORD'; + + -- Create read-write user for crawler + CREATE USER stac_crawler WITH PASSWORD '$STAC_CRAWLER_PASSWORD'; + + GRANT CONNECT ON DATABASE stac_db TO stac_api; + GRANT CONNECT ON DATABASE stac_db TO stac_crawler; + GRANT USAGE ON SCHEMA public TO stac_api; + GRANT USAGE ON SCHEMA public TO stac_crawler; + + -- For stac_api: Grant SELECT (read-only) on all existing tables in public + GRANT SELECT ON ALL TABLES IN SCHEMA public TO stac_api; + + -- For stac_crawler: Grant all privileges on all existing tables in public + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO stac_crawler; + + -- For stac_api: Auto-grant SELECT on future tables + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO stac_api; + + -- For stac_crawler: Auto-grant all privileges on future tables + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO stac_crawler; + + -- For stac_crawler: Grant USAGE on all sequences (needed for SERIAL/IDENTITY columns) + GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO stac_crawler; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE ON SEQUENCES TO stac_crawler; +EOSQL diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index a44355f..dca17c4 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -9,7 +9,8 @@ CREATE TABLE catalog ( title TEXT, description TEXT, created_at TIMESTAMP DEFAULT now(), - updated_at TIMESTAMP DEFAULT now() + updated_at TIMESTAMP DEFAULT now(), + search_vector tsvector ); -- Catalog links table: Stores related links for catalogs (e.g., self, root, child, item links) @@ -17,6 +18,7 @@ CREATE TABLE catalog ( CREATE TABLE catalog_links ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, + source_url TEXT, rel TEXT, href TEXT, type TEXT, @@ -44,3 +46,62 @@ CREATE TABLE crawllog_catalog ( catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, last_crawled TIMESTAMP ); + +-- ======================================== +-- FULL-TEXT SEARCH TRIGGERS +-- ======================================== + +-- Trigger function to auto-update search_vector when catalog is inserted or updated +-- Includes title, description, and all associated keywords for comprehensive search +CREATE OR REPLACE FUNCTION update_catalog_search_vector() +RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := to_tsvector('simple', + coalesce(NEW.title, '') || ' ' || + coalesce(NEW.description, '') || ' ' || + coalesce( + ( + SELECT string_agg(k.keyword, ' ') + FROM catalog_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.catalog_id = NEW.id + ), + '' + ) + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER catalog_search_vector_update +BEFORE INSERT OR UPDATE ON catalog +FOR EACH ROW +EXECUTE FUNCTION update_catalog_search_vector(); + +-- Trigger function to update search_vector when keywords are added/removed +-- Ensures search index stays in sync with keyword changes +CREATE OR REPLACE FUNCTION update_catalog_search_vector_on_keyword_change() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE catalog + SET search_vector = to_tsvector('simple', + coalesce(title, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce( + ( + SELECT string_agg(k.keyword, ' ') + FROM catalog_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.catalog_id = catalog.id + ), + '' + ) + ) + WHERE id = COALESCE(NEW.catalog_id, OLD.catalog_id); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- NOTE: The trigger for catalog_keywords is defined in 06_triggers.sql +-- because it depends on the catalog_keywords table which is created there diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 733bb08..a443aa8 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -21,7 +21,8 @@ CREATE TABLE collection ( is_api BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, - full_json JSONB + full_json JSONB, + search_vector tsvector ); -- Collection summaries: Stores summaries for collection properties @@ -32,6 +33,7 @@ CREATE TABLE collection_summaries ( collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, name TEXT, kind TEXT, + source_url TEXT, range_min NUMERIC, range_max NUMERIC, set_value TEXT, @@ -64,3 +66,62 @@ CREATE TABLE crawllog_collection ( collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, last_crawled TIMESTAMP ); + +-- ======================================== +-- FULL-TEXT SEARCH TRIGGERS +-- ======================================== + +-- Trigger function to auto-update search_vector when collection is inserted or updated +-- Includes title, description, and all associated keywords for comprehensive search +CREATE OR REPLACE FUNCTION update_collection_search_vector() +RETURNS TRIGGER AS $$ +BEGIN + NEW.search_vector := to_tsvector('simple', + coalesce(NEW.title, '') || ' ' || + coalesce(NEW.description, '') || ' ' || + coalesce( + ( + SELECT string_agg(k.keyword, ' ') + FROM collection_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.collection_id = NEW.id + ), + '' + ) + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER collection_search_vector_update +BEFORE INSERT OR UPDATE ON collection +FOR EACH ROW +EXECUTE FUNCTION update_collection_search_vector(); + +-- Trigger function to update search_vector when keywords are added/removed +-- Ensures search index stays in sync with keyword changes +CREATE OR REPLACE FUNCTION update_collection_search_vector_on_keyword_change() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE collection + SET search_vector = to_tsvector('simple', + coalesce(title, '') || ' ' || + coalesce(description, '') || ' ' || + coalesce( + ( + SELECT string_agg(k.keyword, ' ') + FROM collection_keywords ck + JOIN keywords k ON k.id = ck.keyword_id + WHERE ck.collection_id = collection.id + ), + '' + ) + ) + WHERE id = COALESCE(NEW.collection_id, OLD.collection_id); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- NOTE: The trigger for collection_keywords is defined in 06_triggers.sql +-- because it depends on the catalog_keywords table which is created there \ No newline at end of file diff --git a/db/init/05_indexes.sql b/db/init/05_indexes.sql index 9349b88..7c0d74a 100644 --- a/db/init/05_indexes.sql +++ b/db/init/05_indexes.sql @@ -9,8 +9,8 @@ CREATE INDEX idx_catalog_title ON catalog (title); CREATE INDEX idx_catalog_updated_at ON catalog (updated_at); -CREATE INDEX idx_catalog_fulltext ON catalog -USING GIN (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))); +-- Full-text search index on computed search_vector column (includes title, description, and keywords) +CREATE INDEX idx_catalog_search_vector ON catalog USING GIN (search_vector); CREATE INDEX idx_catalog_links_catalog_id ON catalog_links (catalog_id); CREATE INDEX idx_catalog_keywords_catalog ON catalog_keywords (catalog_id); @@ -30,8 +30,8 @@ CREATE INDEX idx_collection_active ON collection (is_active); CREATE INDEX idx_collection_spatial ON collection USING GIST (spatial_extent); -CREATE INDEX idx_collection_fulltext ON collection -USING GIN (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(description,''))); +-- Full-text search index on computed search_vector column (includes title, description, and keywords) +CREATE INDEX idx_collection_search_vector ON collection USING GIN (search_vector); CREATE INDEX idx_collection_jsonb ON collection USING GIN (full_json); diff --git a/db/init/06_triggers.sql b/db/init/06_triggers.sql new file mode 100644 index 0000000..5e167a0 --- /dev/null +++ b/db/init/06_triggers.sql @@ -0,0 +1,16 @@ +-- ======================================== +-- FULL-TEXT SEARCH TRIGGERS FOR KEYWORDS +-- ======================================== +-- These triggers must be created here (after junction tables exist) + +-- Trigger to update catalog search_vector when keywords change +CREATE TRIGGER catalog_keywords_update_vector +AFTER INSERT OR DELETE ON catalog_keywords +FOR EACH ROW +EXECUTE FUNCTION update_catalog_search_vector_on_keyword_change(); + +-- Trigger to update collection search_vector when keywords change +CREATE TRIGGER collection_keywords_update_vector +AFTER INSERT OR DELETE ON collection_keywords +FOR EACH ROW +EXECUTE FUNCTION update_collection_search_vector_on_keyword_change(); \ No newline at end of file From 27507ed5275b0ee7348a5bfbe4831c8d6951ee48 Mon Sep 17 00:00:00 2001 From: mammutor Date: Thu, 15 Jan 2026 23:30:37 +0100 Subject: [PATCH 069/134] Refactor: crawler configuration and memory management - Updated rate limiting options in `HttpCrawler` to prevent memory buildup from queue overflow. - Introduced a new constant `CATALOG_CLEAR_BATCH_SIZE` to periodically clear the catalogs array, reducing memory usage. - Enhanced `handleCatalog` function to accept a configuration object, allowing for dynamic max depth checks during catalog processing. - Improved logging to provide clearer insights into catalog processing and memory management. --- crawler/catalogs/catalog.js | 6 ++---- crawler/utils/config.js | 4 ++-- crawler/utils/handlers.js | 36 ++++++++++++++++++++++++++++++++---- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 9883081..bef8f82 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -40,10 +40,8 @@ async function crawlCatalogs(initialCatalogs, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - maxConcurrency: 20, // Limit concurrency to prevent lock file race conditions - maxRequestsPerMinute: 200, // Rate limit to avoid overwhelming targets - // Rate limiting options + // Rate limiting options - prevents memory buildup from queue overflow maxConcurrency: config.maxConcurrency || 5, maxRequestsPerMinute: config.maxRequestsPerMinute || 60, sameDomainDelaySecs: config.sameDomainDelaySecs || 1, @@ -60,7 +58,7 @@ async function crawlCatalogs(initialCatalogs, config = {}) { try { // Route based on request label if (request.label === 'CATALOG') { - await handleCatalog({ request, json, crawler, log, indent, results }); + await handleCatalog({ request, json, crawler, log, indent, results, config }); } else if (request.label === 'COLLECTIONS') { await handleCollections({ request, json, crawler, log, indent, results }); } diff --git a/crawler/utils/config.js b/crawler/utils/config.js index 99c6f55..e0ce5ca 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -42,7 +42,7 @@ function getConfig() { maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl timeout: 30000, // Timeout in milliseconds (30 seconds) - maxDepth: 10 // Maximum recursion depth for nested catalogs (0 = unlimited) + maxDepth: 10, // Maximum recursion depth for nested catalogs (0 = unlimited) // Rate limiting options (Crawlee) maxConcurrency: 5, // Maximum number of concurrent requests maxRequestsPerMinute: 60, // Maximum requests per minute @@ -60,7 +60,7 @@ function getConfig() { timeout: cliArgs.timeout !== undefined ? cliArgs.timeout : (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout), maxDepth: cliArgs.maxDepth !== undefined ? cliArgs.maxDepth : - (process.env.MAX_DEPTH ? parseInt(process.env.MAX_DEPTH, 10) : defaults.maxDepth) + (process.env.MAX_DEPTH ? parseInt(process.env.MAX_DEPTH, 10) : defaults.maxDepth), // Rate limiting options maxConcurrency: cliArgs.maxConcurrency !== undefined ? cliArgs.maxConcurrency : (process.env.MAX_CONCURRENCY ? parseInt(process.env.MAX_CONCURRENCY, 10) : defaults.maxConcurrency), diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index f835389..9209b1b 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -13,7 +13,14 @@ import db from './db.js'; * After this many collections are collected, they will be flushed to DB * @type {number} */ -const BATCH_SIZE = 1000; +const BATCH_SIZE = 500; + +/** + * Batch size for clearing catalogs array to free memory + * The catalogs array is only used for statistics, so we clear it periodically + * @type {number} + */ +const CATALOG_CLEAR_BATCH_SIZE = 500; /** * Flushes collected collections to the database and clears the array @@ -57,6 +64,7 @@ export async function flushCollectionsToDb(results, log, force = false) { /** * Checks if batch size is reached and flushes if necessary + * Also clears the catalogs array periodically to free memory * @async * @param {Object} results - Results object containing collections array * @param {Object} log - Logger instance @@ -67,6 +75,13 @@ async function checkAndFlush(results, log) { results.stats.collectionsSaved += saved; results.stats.collectionsFailed += failed; } + + // Clear catalogs array periodically to free memory + // The catalogs array is only used for end statistics, which we track in stats object + if (results.catalogs.length >= CATALOG_CLEAR_BATCH_SIZE) { + log.info(`[MEMORY] Clearing ${results.catalogs.length} catalogs from memory`); + results.catalogs.length = 0; + } } /** @@ -79,12 +94,14 @@ async function checkAndFlush(results, log) { * @param {Object} context.log - Logger instance * @param {string} context.indent - Indentation for logging * @param {Object} context.results - Results object to store data + * @param {Object} context.config - Configuration object with maxDepth */ -export async function handleCatalog({ request, json, crawler, log, indent, results }) { +export async function handleCatalog({ request, json, crawler, log, indent, results, config = {} }) { const depth = request.userData?.depth || 0; const catalogId = request.userData?.catalogId || 'unknown'; + const maxDepth = config.maxDepth || 0; // 0 = unlimited - log.info(`${indent}Processing catalog: ${catalogId} (depth: ${depth})`); + log.info(`${indent}Processing catalog: ${catalogId} (depth: ${depth}${maxDepth > 0 ? `/${maxDepth}` : ''})`); // Validate with stac-js let stacCatalog; @@ -134,6 +151,14 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul if (childLinks.length > 0) { log.info(`${indent}Found ${childLinks.length} child catalog links`); + // Check maxDepth before enqueueing children + if (maxDepth > 0 && depth >= maxDepth) { + log.info(`${indent}Max depth (${maxDepth}) reached, skipping ${childLinks.length} child catalogs`); + // Clear memory and return early - don't enqueue children + await checkAndFlush(results, log); + return; + } + // Log first child link structure for debugging if (childLinks[0]) { log.debug(`${indent}Sample child link structure:`, { @@ -203,6 +228,9 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul } } } + + // Ensure memory is cleared periodically even if no collections were found + await checkAndFlush(results, log); } /** @@ -272,4 +300,4 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Check if we should flush to database await checkAndFlush(results, log); } -} +} \ No newline at end of file From e5f6e33a42223c0866259a8602cd70d49a69945a Mon Sep 17 00:00:00 2001 From: Lenn Date: Fri, 16 Jan 2026 00:23:13 +0100 Subject: [PATCH 070/134] Crawler added source_url to collection_summaries and changed stac_id, just write collections (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): initialize STAC Atlas API with collections, conformance, and queryables routes - Added package.json for project dependencies and scripts. - Implemented GET endpoint for collections. - Created conformance endpoint to list supported conformance classes. - Developed landing page for the API with links to collections and documentation. - Added queryables endpoint to return queryable properties for collections. (If i'm correct this can be removed) * Added all Remarks to the bid and finished it (#74) * changed Texts 1,2 and 8 according to the remarks of the customer * Did my fixes to 4. and 10.3 * added remark why we want to save every catalog * deleted keywords for catalog * changed everything related to the database component * Updated 3.1, 3.3, 3,4 (References to Lastenheft/small other changes) * Added small Graph to 3. Produktumgebung * Update bid.md 7.3STAC-Validator added the handling of collections that cannot be validated automatically. * Update bid.md 7.3STAC-API-Validator changed the way we validate the collection search extension. * Update bid.md 9.3.2Endpunkte small fix collection search extension. * added Skizze for 3, and updated 6.1, 10.1 * Updated 3. Produktumgebung * Update bid.md 7.Qualitätsanforderungen minor fixes * Update 6.4.2 added remark about loading feedback * Minor change to Table in "11 Zeitplan". A collum was missing in the head, therefore the table wasn't rendering correctly.. --------- Co-authored-by: Jakob Co-authored-by: Sönke Hoffmann Co-authored-by: jklaer Co-authored-by: VincentKuehn Co-authored-by: mammutor Co-authored-by: Humam <44206081+Mammutor@users.noreply.github.com> Co-authored-by: Justin K * fix(api): enhance STAC API landing page and conformance links - Overhaul of first idea landing page - Added some more tests for the required elements in the landingpage-Catalog * feat(api): implement shared conformance URIs and add tests for conformance endpoint - implemented condormance endpoint * Implemented 2.3 and 2.4 (#111) * Temporary mock data for testing and frontend development * Added API middleware layer for error handling and validation * TODOs ready? pls review * Added API utilities for query parsing, validation, and response formatting * Added swagger and openapi.yaml * Update queryables.js Refactor queryables endpoint into /collections/queryables * Renamed the collections.js file to mocks-collections.js to better reflect its purpose and improve project clarity * changed README "Projektstruktur" * restart from dev-api 22.11..2025 * API: 2.3 Implement Collections List Endpoint done (added explanations as comments in the code) * API: 2.4 Implement Single Collection Endpoint (added explanations as comments in the code) * Update api/routes/collections.js Co-authored-by: Robin Tammo Gummels * Changed some of the code with the comments on Github (i will finish it tomorrow morning) * Implement most of the feedback and comments (need to talk about some other changes) * Update api/routes/index.js Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/README.md Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/README.md Removed missing folder * Update api/routes/collections.js Removed TODOs from wrong lines * Update api/routes/collections.js Added TODOs * Update api/routes/queryables.js Changed wording from `/collections/queryables` to `/collections-queryables` * Update api/routes/queryables.js Changed wording from `/collections/queryables` to `/collections-queryables` --------- Co-authored-by: VincentKuehn Co-authored-by: Robin Tammo Gummels * feat(api): add collection search parameters and validation middleware (#159) * feat(api): add collection search parameters and validation middleware * Added unit-test for validator-functions and integration-tests for `GET /collections`-Querys. - Also minor bugfix, because the validator accepted deecimals as tokens. * API: 3 Database Integration first version (#161) * database connection in implementated. The parameters for the connection have to added in the .env-file. Also there is test-file for testing and console messages (installed `pg`) * support for spatial queries via postgis + error handling for datatbase operations changed language to english * error handling * added DATABASE_URL There is an issue with the distance query. Changed the error handling and testing, the console messages are now way better structured * found the Problem with the distance query. The layer are so big, that they reach over the 180° long (PostgGIS can't handel that). Now the calc is done by degree and not meters. * The two files `test-data-retrieval.js` and `verify-schema.js` have been added. `test-data-retrieval` (theoretical, checks against the spezification): ``` Discovers all tables and columns and validates against expected schema. ``` The second files `verify-schema.js` (practical, checks against the real data): ``` Discovers all tables and columns, validates against expected schema ``` * pooling error hanling and log imporoved. renamed tests files to actual test-files * standalone node tests were convertad into JEST * write file `validateRequest.js`. Validates every incoming API request, whether the request is valid and logical. * commented `stac_id` from the tests, it is not in both databases, so the tests for `stac_id` will always fail Added explanation to the `.env.example`, which port is which database * added example pattern for API - database connection. * deleted `validateRequest` cause it's already implemented by @robinGummels --------- Co-authored-by: Sönke Hoffmann * Added environment variables and a `.env` for `docker-compose.yml` (#164) * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels * Added a CI/CD Pipeline to prevent pull-requests without functioning tests and proper linting. * fixed errors suggested by the linter. - Some lines used tab and spaces... * Implemented Collection Search extension including a DB-Connection (#165) * added SQLQuery-builder with these parameters: q,bbox,datetime,sortby,limit and token * finalised bbox and datetime * adapted to DB, QueryBuilder and added helperfunction runQuery * added question-TODOs * added bbox+datetime to the Query-Builder from Jonas * added tests for Query-Builder from Jonas * added tests from George * added falsely deleted TODOs again * fixed collumn names to match our DB and adjusted full text search to match 05_indexes.sql correctly * Used a formatter and linter on `buildCollectionSearchQuery.js * Did some major and minor fixes to the collection search. - Updated `buildCollectionSearchQuery` to support pagination and improved text search with English language settings. - Modified tests in `buildCollectionsSearchQuery.basic.test.js`, `collections-pagination.test.js`, and `collections-sort.test.js` to reflect new query behavior and validation logic. - Enhanced sort validation in `validators.test.js` and `collectionSearchParams.js` to map API fields to database column names. - Implemented total count retrieval for matched results in `collections.js`. * Added a internal .env creation in the CI/CD Pipeline. It utilzes GitHub Repository Secrets to not publish any private Logins and stuff. * Forgot that the second Job of the CI/CD pipeline runs seperatly and needs a internal .env file too. * Enhance documentation for buildCollectionSearchQuery Updated the documentation for: - the buildCollectionSearchQuery function - the fulltextsearch * Refactor buildCollectionSearchQuery and updated SELECT part Changed the SELECT part to match our bid and the database shema. Updated comments and for clarity. Changed full-text search to use 'simple' configuration instead of 'english'. * Update api/routes/collections.js small typo Co-authored-by: Robin Tammo Gummels * Remove sorting TODO from collections route Removed TODO comment about sorting based on sortby parameter. * Explicitly return undefined for normalized in validateSortby Update validateSortby function to explicitly return undefined for normalized when sortby is not provided. * small fix in buildCollectionSearch.fulltext.test.js Change plainto_tsquery language from 'english' to 'simple' * Fix duplicate SELECT keyword in query Remove duplicate 'SELECT' keyword in SQL query. * Fix missing newline at end of collectionSearchParams.js * Fixed missing bracket in collectionSearchParams.js * Refactor validateSortby for optional parameter handling Refactor validateSortby function to handle optional sortby parameter and improve validation logic. * Stabilize API test pipeline by running Jest in-band with extended timeout Run Jest in CI with --runInBand and a higher default --testTimeout to stabilize database-backed integration tests. Multiple Jest workers were competing for the same PostgreSQL connection pool and some long-running /collections queries exceeded the default 5s timeout, causing failures in existing test suites (e.g. collectionSearch and DBconnection). * Fixed leaking tests that blocked CI/CD-Pipeline. - Added a global Teardown for jest and force-exited the tests to prevent leaking. - Made a change to db_APIconnection to only log the pool-(dis)connection if it isn't run in a test enviroment. * Did a minimum amount of Formatting to the discription * Used `npm audit fix --force` to fix all vulnerabilties in our used packages. * Fixed curious doublechecking for empty Strings for the sortby-Parameter. - Now we only check once for a empty sortby - And added a test which distinguish between `sortby=""` and `sortby="+"` * Update api/routes/collections.js Removed the TODO about switching from mock-data to the real db * Removed globalTeardown as i brought up some problems corresponding to long db-queries (for example BBOX). Instead i increased the maximal testTimeout. --------- Co-authored-by: Robin Tammo Gummels * Update api/.env.example * latest database Version (#187) with `stac_id` and changed definition of `primary Keys` * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md * added `stac_id` for collections * all IDs are now written in the newer PostgrSQL standart: ```SQL id SERIAL PRIMARY KEY, ``` changed to ```SQL id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ``` * changed `extend` to `extent`. * Changed language used in `./api/README.md` from german to english. I wanted to thsi anyway at some point, but this is now more like a Test-commit to see if the CI/CD Pipeline triggers... --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels * bug fix: extent was written wrong * feat: add source URL extraction for catalogs and collections * feat: update insertOrUpdateCatalog to only process catalogs for traversal, no longer saving to database --------- Co-authored-by: RobinGummels Co-authored-by: Jakob Co-authored-by: Sönke Hoffmann Co-authored-by: jklaer Co-authored-by: VincentKuehn Co-authored-by: mammutor Co-authored-by: Humam <44206081+Mammutor@users.noreply.github.com> Co-authored-by: Justin K Co-authored-by: Georgios Voulgaris Co-authored-by: Sönke Hoffmann --- crawler/utils/db.js | 153 ++++++++++++-------------------------------- 1 file changed, 40 insertions(+), 113 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 1bd2e85..df12a44 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -51,107 +51,19 @@ async function initDb() { } /** - * Insert or update a catalog in the database + * Process a catalog for traversal only - catalogs are not saved to database + * Only collections are saved. Catalogs are only used to traverse deeper into the tree. * @param {Object} catalog - STAC catalog object - * @returns {Promise} catalog ID + * @returns {Promise} always returns null (no catalog saved) */ async function insertOrUpdateCatalog(catalog) { if (!catalog || typeof catalog !== 'object') return null; - - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - // Insert or update catalog - const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; - - // Handle both STAC format and STAC Index API format - const description = catalog.description || null; - const stacVersion = catalog.stac_version || catalog.stacVersion || null; - - // Check if catalog with this title already exists - const existingCatalog = await client.query( - 'SELECT id FROM catalog WHERE title = $1', - [catalogTitle] - ); - - let catalogId; - if (existingCatalog.rows.length > 0) { - // Update existing catalog - catalogId = existingCatalog.rows[0].id; - await client.query( - `UPDATE catalog SET - stac_version = $1, - type = $2, - description = $3, - updated_at = now() - WHERE id = $4`, - [ - stacVersion, - catalog.type || 'Catalog', - description, - catalogId - ] - ); - } else { - // Insert new catalog - const catalogResult = await client.query( - `INSERT INTO catalog (stac_version, type, title, description, updated_at) - VALUES ($1, $2, $3, $4, now()) - RETURNING id`, - [ - stacVersion, - catalog.type || 'Catalog', - catalogTitle, - description - ] - ); - catalogId = catalogResult.rows[0].id; - } - - // Insert links - if (catalog.links && Array.isArray(catalog.links)) { - await client.query('DELETE FROM catalog_links WHERE catalog_id = $1', [catalogId]); - for (const link of catalog.links) { - await client.query( - 'INSERT INTO catalog_links (catalog_id, rel, href, type, title) VALUES ($1, $2, $3, $4, $5)', - [catalogId, link.rel, link.href, link.type || null, link.title || null] - ); - } - } - - // Insert keywords - // STAC Index API uses 'categories' instead of 'keywords' - const keywords = catalog.keywords || catalog.categories || []; - if (Array.isArray(keywords) && keywords.length > 0) { - await insertKeywords(client, catalogId, keywords, 'catalog'); - } - - // Insert STAC extensions - const extensions = catalog.stac_extensions || catalog.stacExtensions || []; - if (Array.isArray(extensions) && extensions.length > 0) { - await insertStacExtensions(client, catalogId, extensions, 'catalog'); - } - - // Update crawl log - await client.query( - 'DELETE FROM crawllog_catalog WHERE catalog_id = $1', - [catalogId] - ); - await client.query( - 'INSERT INTO crawllog_catalog (catalog_id, last_crawled) VALUES ($1, now())', - [catalogId] - ); - - await client.query('COMMIT'); - return catalogId; - } catch (error) { - await client.query('ROLLBACK'); - console.error('Error inserting catalog:', error.message); - throw error; - } finally { - client.release(); - } + + // Catalogs are not saved to database - only used for tree traversal + // Only collections will be saved + console.log(`Skipping catalog save (used for traversal only): ${catalog.title || catalog.id}`); + + return null; } /** @@ -160,7 +72,7 @@ async function insertOrUpdateCatalog(catalog) { * @returns {Promise} collection ID */ async function insertOrUpdateCollection(collection) { - if (!collection || typeof collection !== 'object') return null; + const client = await pool.connect(); try { @@ -187,6 +99,15 @@ async function insertOrUpdateCollection(collection) { // Insert or update collection const collectionTitle = collection.title || collection.id || 'Unnamed Collection'; + const stacId = collection.id || null; + + // Extract source URL from links (prefer 'self', fallback to 'root') + let sourceUrl = null; + if (collection.links && Array.isArray(collection.links)) { + const selfLink = collection.links.find(link => link.rel === 'self'); + const rootLink = collection.links.find(link => link.rel === 'root'); + sourceUrl = selfLink?.href || rootLink?.href || null; + } // Check if collection with this title already exists const existingCollection = await client.query( @@ -200,19 +121,22 @@ async function insertOrUpdateCollection(collection) { collectionId = existingCollection.rows[0].id; await client.query( `UPDATE collection SET - stac_version = $1, - type = $2, - description = $3, - license = $4, - spatial_extent = ST_GeomFromEWKT($5), - temporal_extent_start = $6, - temporal_extent_end = $7, - is_api = $8, - is_active = $9, - full_json = $10, + stac_id = $1, + stac_version = $2, + type = $3, + description = $4, + license = $5, + spatial_extent = ST_GeomFromEWKT($6), + temporal_extent_start = $7, + temporal_extent_end = $8, + is_api = $9, + is_active = $10, + full_json = $11, + source_url = $12, updated_at = now() - WHERE id = $11`, + WHERE id = $13`, [ + stacId, collection.stac_version || null, collection.type || 'Collection', collection.description || null, @@ -223,6 +147,7 @@ async function insertOrUpdateCollection(collection) { false, // is_api - will be determined by crawler true, // is_active JSON.stringify(collection), + sourceUrl, collectionId ] ); @@ -230,13 +155,14 @@ async function insertOrUpdateCollection(collection) { // Insert new collection const collectionResult = await client.query( `INSERT INTO collection ( - stac_version, type, title, description, license, + stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, - is_api, is_active, full_json, updated_at + is_api, is_active, full_json, source_url, updated_at ) - VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, now()) + VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13, now()) RETURNING id`, [ + stacId, collection.stac_version || null, collection.type || 'Collection', collectionTitle, @@ -247,7 +173,8 @@ async function insertOrUpdateCollection(collection) { temporalEnd, false, // is_api - will be determined by crawler true, // is_active - JSON.stringify(collection) + JSON.stringify(collection), + sourceUrl ] ); collectionId = collectionResult.rows[0].id; From c5b2e8f6e65c71c53c155111b23022b24f558b81 Mon Sep 17 00:00:00 2001 From: mammutor Date: Fri, 16 Jan 2026 15:59:59 +0100 Subject: [PATCH 071/134] Enhance memory management in API and handlers - Introduced periodic clearing of the `apis` array in `checkAndFlushApi` to free memory, with a defined batch size. - Updated `checkAndFlush` in `handlers.js` to ensure the `catalogs` array is cleared only if it exists, preventing potential errors. - Improved logging for memory management actions to provide better insights during API operations. --- crawler/apis/api.js | 15 +++++++++++++++ crawler/utils/handlers.js | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index a439a07..df09c6e 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -14,8 +14,16 @@ import { handleCollections, flushCollectionsToDb } from '../utils/handlers.js'; */ const BATCH_SIZE = 500; +/** + * Batch size for clearing apis array to free memory + * The apis array is only used for statistics, so we clear it periodically + * @type {number} + */ +const API_CLEAR_BATCH_SIZE = 500; + /** * Checks if batch size is reached and flushes if necessary + * Also clears the apis array periodically to free memory * @async * @param {Object} results - Results object containing collections array * @param {Object} log - Logger instance @@ -26,6 +34,13 @@ async function checkAndFlushApi(results, log) { results.stats.collectionsSaved = (results.stats.collectionsSaved || 0) + saved; results.stats.collectionsFailed = (results.stats.collectionsFailed || 0) + failed; } + + // Clear apis array periodically to free memory + // The apis array is only used for end statistics, which we track in stats object + if (results.apis && results.apis.length >= API_CLEAR_BATCH_SIZE) { + log.info(`[MEMORY] Clearing ${results.apis.length} APIs from memory`); + results.apis.length = 0; + } } /** diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 9209b1b..c9ee0df 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -78,7 +78,8 @@ async function checkAndFlush(results, log) { // Clear catalogs array periodically to free memory // The catalogs array is only used for end statistics, which we track in stats object - if (results.catalogs.length >= CATALOG_CLEAR_BATCH_SIZE) { + // Note: catalogs may not exist when called from API crawler (which uses apis instead) + if (results.catalogs && results.catalogs.length >= CATALOG_CLEAR_BATCH_SIZE) { log.info(`[MEMORY] Clearing ${results.catalogs.length} catalogs from memory`); results.catalogs.length = 0; } From 9a26ae08d0294c88eebcee77b22be2c32611eb68 Mon Sep 17 00:00:00 2001 From: mammutor Date: Sun, 18 Jan 2026 14:34:12 +0100 Subject: [PATCH 072/134] Refactor database operations in insertOrUpdateCollection and insertSummary functions - Removed the source_url parameter from the insertOrUpdateCollection function, simplifying the SQL query. - Updated the insertSummary function to include source_url in the database insert statement. - Adjusted related calls to insertSummary to pass the new source_url parameter. - Enhanced the handling of collection summaries to ensure proper data insertion. --- crawler/utils/db.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index df12a44..ffc1463 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -132,9 +132,8 @@ async function insertOrUpdateCollection(collection) { is_api = $9, is_active = $10, full_json = $11, - source_url = $12, updated_at = now() - WHERE id = $13`, + WHERE id = $12`, [ stacId, collection.stac_version || null, @@ -147,7 +146,6 @@ async function insertOrUpdateCollection(collection) { false, // is_api - will be determined by crawler true, // is_active JSON.stringify(collection), - sourceUrl, collectionId ] ); @@ -157,9 +155,9 @@ async function insertOrUpdateCollection(collection) { `INSERT INTO collection ( stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, - is_api, is_active, full_json, source_url, updated_at + is_api, is_active, full_json, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13, now()) + VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, now()) RETURNING id`, [ stacId, @@ -173,8 +171,7 @@ async function insertOrUpdateCollection(collection) { temporalEnd, false, // is_api - will be determined by crawler true, // is_active - JSON.stringify(collection), - sourceUrl + JSON.stringify(collection) ] ); collectionId = collectionResult.rows[0].id; @@ -184,7 +181,7 @@ async function insertOrUpdateCollection(collection) { if (collection.summaries && typeof collection.summaries === 'object') { await client.query('DELETE FROM collection_summaries WHERE collection_id = $1', [collectionId]); for (const [name, value] of Object.entries(collection.summaries)) { - await insertSummary(client, collectionId, name, value); + await insertSummary(client, collectionId, name, value, sourceUrl); } } @@ -289,7 +286,7 @@ async function insertStacExtensions(client, parentId, extensions, type) { /** * Helper function to insert collection summaries */ -async function insertSummary(client, collectionId, name, value) { +async function insertSummary(client, collectionId, name, value, sourceUrl) { let kind = 'unknown'; let rangeMin = null; let rangeMax = null; @@ -314,8 +311,8 @@ async function insertSummary(client, collectionId, name, value) { } await client.query( - 'INSERT INTO collection_summaries (collection_id, name, kind, range_min, range_max, set_value, json_schema) VALUES ($1, $2, $3, $4, $5, $6, $7)', - [collectionId, name, kind, rangeMin, rangeMax, setValue, jsonSchema] + 'INSERT INTO collection_summaries (collection_id, name, kind, source_url, range_min, range_max, set_value, json_schema) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)', + [collectionId, name, kind, sourceUrl, rangeMin, rangeMax, setValue, jsonSchema] ); } From 8e9d504064c1829f21c41b3edfd641d363bb5e1d Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:41:57 +0100 Subject: [PATCH 073/134] Delete .env.example in root we have to decide later what exactly to do --- .env.example | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index e0d6ba7..0000000 --- a/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -# Database Configuration -# PostgreSQL connection parameters -PGHOST=atlas.example.org -PGPORT=5432 -PGUSER=example_user -PGPASSWORD=example_password -PGDATABASE=example_db - -# Crawler Configuration -# Crawl mode: 'catalogs', 'apis', or 'both' -CRAWL_MODE=both - -# Maximum number of catalogs to crawl (default: 10) -MAX_CATALOGS=10 - -# Maximum number of APIs to crawl (default: 5) -MAX_APIS=5 - -# Timeout for each crawl operation in milliseconds (default: 30000) -TIMEOUT_MS=30000 - -# Node Environment -NODE_ENV=development From 70004cb6ac02bf632ff2f46dcac97a9b173cc194 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 20 Jan 2026 12:35:19 +0100 Subject: [PATCH 074/134] fix(crawler-db): Enhance collection extent handling in insertOrUpdateCollection function - Updated the function to support both normalized (bbox) and original STAC format (extent.spatial.bbox) for spatial extent. - Improved temporal extent parsing to accommodate both normalized (temporal) and original STAC format (extent.temporal.interval). - Refactored code for better clarity and maintainability. --- crawler/utils/db.js | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index ffc1463..e93434e 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -79,20 +79,40 @@ async function insertOrUpdateCollection(collection) { await client.query('BEGIN'); // Parse spatial extent (bbox) + // Support both normalized format (bbox) and original STAC format (extent.spatial.bbox) let spatialExtent = null; - if (collection.extent?.spatial?.bbox && collection.extent.spatial.bbox[0]) { - const bbox = collection.extent.spatial.bbox[0]; - if (bbox.length === 4) { - // Create polygon from bbox [west, south, east, north] - spatialExtent = `EPSG:4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; - } + let bbox = null; + + // Try normalized format first (from normalizeCollection) + if (collection.bbox && Array.isArray(collection.bbox)) { + bbox = collection.bbox; + } + // Fallback to original STAC format + else if (collection.extent?.spatial?.bbox && collection.extent.spatial.bbox[0]) { + bbox = collection.extent.spatial.bbox[0]; + } + + if (bbox && bbox.length === 4) { + // Create polygon from bbox [west, south, east, north] + spatialExtent = `EPSG:4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; } // Parse temporal extent + // Support both normalized format (temporal) and original STAC format (extent.temporal.interval) let temporalStart = null; let temporalEnd = null; - if (collection.extent?.temporal?.interval && collection.extent.temporal.interval[0]) { - const interval = collection.extent.temporal.interval[0]; + let interval = null; + + // Try normalized format first (from normalizeCollection) + if (collection.temporal && Array.isArray(collection.temporal)) { + interval = collection.temporal; + } + // Fallback to original STAC format + else if (collection.extent?.temporal?.interval && collection.extent.temporal.interval[0]) { + interval = collection.extent.temporal.interval[0]; + } + + if (interval) { temporalStart = interval[0] ? new Date(interval[0]) : null; temporalEnd = interval[1] ? new Date(interval[1]) : null; } From f53d2e783604242261fbcca07597387ae3194bad Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 20 Jan 2026 12:43:02 +0100 Subject: [PATCH 075/134] fix(crawler-api): update STAC object creation to disable URL migration - Modified the `create` function calls in `handleApiRoot`, `handleApiCollection`, and `handleCatalog` to set the second parameter to `false`, preventing URL migration. - Added comments to clarify the change and its implications for STAC compliance validation. --- crawler/apis/api.js | 6 ++++-- crawler/utils/handlers.js | 11 +++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index df09c6e..cd05f12 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -219,9 +219,10 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max log.info(`${indent}Processing API: ${apiId} at ${apiUrl} (depth: ${depth})`); // Validate with stac-js + // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL let stacObj; try { - stacObj = create(json, request.url); + stacObj = create(json, false); results.stats.stacCompliant++; if (typeof stacObj.isCatalog === 'function' && stacObj.isCatalog()) { @@ -354,9 +355,10 @@ async function handleApiCollection({ request, json, crawler, log, indent, result const apiId = request.userData?.apiId || 'unknown'; // Validate with stac-js + // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL let stacObj; try { - stacObj = create(json, request.url); + stacObj = create(json, false); if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { const collection = normalizeCollection(stacObj, results.collections.length); diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index c9ee0df..d61a931 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -105,9 +105,11 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul log.info(`${indent}Processing catalog: ${catalogId} (depth: ${depth}${maxDepth > 0 ? `/${maxDepth}` : ''})`); // Validate with stac-js + // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL + // Disable migration as it can cause issues with newer STAC versions (1.1.0+) let stacCatalog; try { - stacCatalog = create(json, request.url); + stacCatalog = create(json, false); results.stats.stacCompliant++; // Log STAC object type @@ -249,9 +251,10 @@ export async function handleCollections({ request, json, crawler, log, indent, r const catalogId = request.userData?.catalogId || 'unknown'; // Parse response with stac-js + // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL let stacObj; try { - stacObj = create(json, request.url); + stacObj = create(json, false); } catch (parseError) { log.warning(`${indent}Skipping non-compliant STAC collections at ${request.url}`); return; @@ -266,7 +269,7 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Handle array of collections collectionsData = json.map(col => { try { - return create(col, request.url); + return create(col, false); } catch { return null; } @@ -275,7 +278,7 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Handle nested collections property collectionsData = json.collections.map(col => { try { - return create(col, request.url); + return create(col, false); } catch { return null; } From 23be843d04ba1be7f2d38d64d5a3a32894449d34 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 20 Jan 2026 12:48:51 +0100 Subject: [PATCH 076/134] fix(crawler-api): add defensive JSON validation in API and catalog handlers - Implemented checks in `handleApiRoot` and `handleCatalog` to ensure the JSON response is valid before processing. - Added logging for invalid JSON responses to improve error tracking and handling. - Updated comments to clarify the purpose of disabling URL migration in STAC object creation. --- crawler/apis/api.js | 7 +++++++ crawler/utils/handlers.js | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index cd05f12..6b70c00 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -218,8 +218,15 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max log.info(`${indent}Processing API: ${apiId} at ${apiUrl} (depth: ${depth})`); + // Defensive check: ensure json is valid before passing to stac-js + if (!json || typeof json !== 'object') { + log.warning(`${indent}Invalid JSON response for ${apiId} at ${request.url}`); + throw new Error('Invalid JSON response: null or not an object'); + } + // Validate with stac-js // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL + // Using migrate=false to avoid issues with stac-migrate and newer STAC versions let stacObj; try { stacObj = create(json, false); diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index d61a931..becc5bb 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -104,9 +104,15 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul log.info(`${indent}Processing catalog: ${catalogId} (depth: ${depth}${maxDepth > 0 ? `/${maxDepth}` : ''})`); + // Defensive check: ensure json is valid before passing to stac-js + if (!json || typeof json !== 'object') { + log.warning(`${indent}Invalid JSON response for catalog ${catalogId} at ${request.url}`); + throw new Error('Invalid JSON response: null or not an object'); + } + // Validate with stac-js // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL - // Disable migration as it can cause issues with newer STAC versions (1.1.0+) + // Using migrate=false to avoid issues with stac-migrate and newer STAC versions let stacCatalog; try { stacCatalog = create(json, false); From 3ab484c4773a6481c0edade5b05c83dcc0a18526 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 20 Jan 2026 12:49:54 +0100 Subject: [PATCH 077/134] chore(crawler): update Node.js version in Dockerfile to 20-alpine --- crawler/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crawler/Dockerfile b/crawler/Dockerfile index 6c77666..651de07 100644 --- a/crawler/Dockerfile +++ b/crawler/Dockerfile @@ -1,5 +1,5 @@ # Use official Node.js LTS image -FROM node:18-alpine +FROM node:20-alpine # Set working directory WORKDIR /app From c8aec56e6d5b4a48503cdf28ce44995c18ce99ce Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 20 Jan 2026 12:57:26 +0100 Subject: [PATCH 078/134] fix(crawler): enhance JSON parsing in API and catalog request handlers - Updated request handlers in `crawlApis` and `crawlCatalogs` to include a fallback mechanism for manually parsing JSON responses when automatic parsing fails, because some servers like the DLR usese 14 seconds. - Added logging for successful and failed manual parsing attempts to improve debugging and error tracking. - Adjusted comments to clarify the purpose of the new parsing logic. --- crawler/apis/api.js | 14 +++++++++++++- crawler/catalogs/catalog.js | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 6b70c00..f1aa5a7 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -111,11 +111,23 @@ async function crawlApis(urls, isApi, config = {}) { // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], - async requestHandler({ request, json, crawler, log }) { + async requestHandler({ request, json, body, crawler, log }) { results.stats.totalRequests++; const depth = request.userData?.depth || 0; const indent = ' '.repeat(Math.min(depth, 5)); // Cap indent at 5 levels + // Fallback: manually parse JSON if Crawlee's automatic parsing failed + // This can happen with slow endpoints or unusual response streaming + if (!json && body) { + try { + const bodyStr = typeof body === 'string' ? body : body.toString('utf8'); + json = JSON.parse(bodyStr); + log.debug(`${indent}Manually parsed JSON for ${request.url} (${bodyStr.length} bytes)`); + } catch (parseError) { + log.warning(`${indent}Failed to parse response body as JSON: ${parseError.message}`); + } + } + try { // Route based on request label if (request.label === 'API_ROOT') { diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index bef8f82..e9002ea 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -50,11 +50,23 @@ async function crawlCatalogs(initialCatalogs, config = {}) { // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], - async requestHandler({ request, json, crawler, log }) { + async requestHandler({ request, json, body, crawler, log }) { results.stats.totalRequests++; const depth = request.userData?.depth || 0; const indent = ' '.repeat(depth); + // Fallback: manually parse JSON if Crawlee's automatic parsing failed + // This can happen with slow endpoints or unusual response streaming + if (!json && body) { + try { + const bodyStr = typeof body === 'string' ? body : body.toString('utf8'); + json = JSON.parse(bodyStr); + log.debug(`${indent}Manually parsed JSON for ${request.url} (${bodyStr.length} bytes)`); + } catch (parseError) { + log.warning(`${indent}Failed to parse response body as JSON: ${parseError.message}`); + } + } + try { // Route based on request label if (request.label === 'CATALOG') { From 392b0e9bb9e95c8c511c23cc687c28cc6e68a7a9 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 20 Jan 2026 13:02:04 +0100 Subject: [PATCH 079/134] fix(crawler): add S3 URL handling and relative URL conversion in API and handlers - Implemented conversion of S3 protocol URLs to HTTPS format in `handleApiRoot`, `tryCollectionEndpoints`, and `handleCatalog` functions. - Added logging for successful conversions and warnings for malformed S3 URLs. - Enhanced handling of relative URLs to ensure they are converted to absolute URLs based on the request context. - Improved validation checks for URLs to skip invalid entries with appropriate logging. --- crawler/apis/api.js | 22 ++++++++++++++++++++++ crawler/utils/endpoints.js | 10 ++++++++++ crawler/utils/handlers.js | 14 ++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index f1aa5a7..7205675 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -329,8 +329,30 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max return null; } + // Handle S3 protocol URLs - convert to HTTPS + if (childUrl && typeof childUrl === 'string' && childUrl.startsWith('s3://')) { + // s3://bucket-name/path -> https://bucket-name.s3.amazonaws.com/path + const s3Match = childUrl.match(/^s3:\/\/([^/]+)\/(.*)$/); + if (s3Match) { + const [, bucket, path] = s3Match; + childUrl = `https://${bucket}.s3.amazonaws.com/${path}`; + log.debug(`${indent}Converted S3 URL: ${link.href} -> ${childUrl}`); + } else { + log.warning(`${indent}Skipping malformed S3 URL at index ${idx}: ${childUrl}`); + return null; + } + } + + // If URL is relative, make it absolute using the API URL + if (childUrl && typeof childUrl === 'string' && !childUrl.startsWith('http')) { + const baseUrl = request.url.endsWith('/') ? request.url.slice(0, -1) : request.url; + const basePath = baseUrl.substring(0, baseUrl.lastIndexOf('/')); + childUrl = `${basePath}/${childUrl}`; + } + // Validate URL if (!childUrl || typeof childUrl !== 'string' || !childUrl.startsWith('http')) { + log.warning(`${indent}Skipping invalid URL at index ${idx}: ${childUrl}`); return null; } diff --git a/crawler/utils/endpoints.js b/crawler/utils/endpoints.js index c595933..2feb27c 100644 --- a/crawler/utils/endpoints.js +++ b/crawler/utils/endpoints.js @@ -34,6 +34,16 @@ export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, de ? collectionLink.getAbsoluteUrl() : collectionLink.href; + // Handle S3 protocol URLs - convert to HTTPS + if (collectionUrl && collectionUrl.startsWith('s3://')) { + const s3Match = collectionUrl.match(/^s3:\/\/([^/]+)\/(.*)$/); + if (s3Match) { + const [, bucket, path] = s3Match; + collectionUrl = `https://${bucket}.s3.amazonaws.com/${path}`; + log.debug(`${indent}Converted S3 URL: ${collectionLink.href} -> ${collectionUrl}`); + } + } + // Handle relative URLs if (collectionUrl && !collectionUrl.startsWith('http')) { const basePath = baseUrl.substring(0, baseUrl.lastIndexOf('/')); diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index becc5bb..922ff2e 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -190,6 +190,20 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul return null; } + // Handle S3 protocol URLs - convert to HTTPS + if (childUrl && typeof childUrl === 'string' && childUrl.startsWith('s3://')) { + // s3://bucket-name/path -> https://bucket-name.s3.amazonaws.com/path + const s3Match = childUrl.match(/^s3:\/\/([^/]+)\/(.*)$/); + if (s3Match) { + const [, bucket, path] = s3Match; + childUrl = `https://${bucket}.s3.amazonaws.com/${path}`; + log.debug(`${indent}Converted S3 URL: ${link.href} -> ${childUrl}`); + } else { + log.warning(`${indent}Skipping malformed S3 URL at index ${idx}: ${childUrl}`); + return null; + } + } + // If URL is relative, make it absolute using the catalog URL if (childUrl && typeof childUrl === 'string' && !childUrl.startsWith('http')) { const baseUrl = request.url.endsWith('/') ? request.url.slice(0, -1) : request.url; From 5ee15b61c160824e853690b15fa07efcb9cf4b25 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 20 Jan 2026 13:09:51 +0100 Subject: [PATCH 080/134] fix(crawler): update Dockerfile to omit development dependencies during npm install - Changed the npm install command to use the --omit=dev flag, ensuring that only production dependencies are installed in the Docker image. --- crawler/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crawler/Dockerfile b/crawler/Dockerfile index 651de07..3a15606 100644 --- a/crawler/Dockerfile +++ b/crawler/Dockerfile @@ -8,7 +8,7 @@ WORKDIR /app COPY package*.json ./ # Install dependencies -RUN npm install --production +RUN npm install --omit=dev # Copy application files COPY . . From ef3c498bfc5d80cb2e5e8f86445e8d21aba138ce Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 01:38:10 +0100 Subject: [PATCH 081/134] feat(crawler): implement parallel crawling for APIs and catalogs - Added support for parallel crawling of multiple domains in both API and catalog crawlers. - Introduced new configuration options for parallel domains, max requests per minute per domain, and max concurrency per domain. - Enhanced logging to provide detailed statistics on parallel crawling performance and domain distribution. - Refactored existing crawling functions to accommodate the new parallel execution model, improving overall efficiency and throughput. - Updated CLI arguments and configuration to support the new parallel crawling features. --- crawler/apis/api.js | 200 +++++++++++++++++++++--------------- crawler/catalogs/catalog.js | 118 ++++++++++++++++----- crawler/index.js | 5 + crawler/utils/cli.js | 51 ++++++--- crawler/utils/config.js | 35 +++++-- crawler/utils/parallel.js | 186 +++++++++++++++++++++++++++++++++ 6 files changed, 461 insertions(+), 134 deletions(-) create mode 100644 crawler/utils/parallel.js diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 7205675..3d49198 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -1,5 +1,6 @@ /** * @fileoverview API crawling functionality for STAC Index using Crawlee + * Supports parallel crawling of multiple domains simultaneously * @module apis/api */ @@ -7,6 +8,13 @@ import { HttpCrawler, Configuration, log as crawleeLog } from 'crawlee'; import create from 'stac-js'; import { normalizeCollection } from '../utils/normalization.js'; import { handleCollections, flushCollectionsToDb } from '../utils/handlers.js'; +import { + groupByDomain, + executeWithConcurrency, + aggregateStats, + calculateRateLimits, + logDomainStats +} from '../utils/parallel.js'; /** * Batch size for saving collections to database during API crawling @@ -16,14 +24,12 @@ const BATCH_SIZE = 500; /** * Batch size for clearing apis array to free memory - * The apis array is only used for statistics, so we clear it periodically * @type {number} */ const API_CLEAR_BATCH_SIZE = 500; /** * Checks if batch size is reached and flushes if necessary - * Also clears the apis array periodically to free memory * @async * @param {Object} results - Results object containing collections array * @param {Object} log - Logger instance @@ -35,8 +41,6 @@ async function checkAndFlushApi(results, log) { results.stats.collectionsFailed = (results.stats.collectionsFailed || 0) + failed; } - // Clear apis array periodically to free memory - // The apis array is only used for end statistics, which we track in stats object if (results.apis && results.apis.length >= API_CLEAR_BATCH_SIZE) { log.info(`[MEMORY] Clearing ${results.apis.length} APIs from memory`); results.apis.length = 0; @@ -44,39 +48,24 @@ async function checkAndFlushApi(results, log) { } /** - * Note: This crawler is for REAL APIs only. Static catalogs (*.json files) - * are now routed to the catalog crawler in index.js - * - * Crawls STAC APIs to retrieve collection information without fetching items. - * - * @param {string[]} urls - Array of API URLs to crawl - * @param {boolean} isApi - Boolean flag indicating if the URLs are APIs - * @param {Object} config - Configuration object with timeout settings - * @returns {Promise} Results object with collections array and statistics + * Creates and runs a single Crawlee HttpCrawler for a specific domain + * @async + * @param {Array} urls - Array of API URLs for this domain + * @param {string} domain - The domain being crawled + * @param {Object} config - Configuration object + * @returns {Promise} Crawl results with collections and statistics */ -async function crawlApis(urls, isApi, config = {}) { +async function crawlSingleApiDomain(urls, domain, config = {}) { // Use in-memory storage to avoid file lock race conditions under high concurrency Configuration.getGlobalConfig().set('persistStorage', false); - if (!isApi || !Array.isArray(urls) || urls.length === 0) { - return { - collections: [], - stats: { - totalRequests: 0, - successfulRequests: 0, - failedRequests: 0, - collectionsFound: 0, - apisProcessed: 0, - stacCompliant: 0, - nonCompliant: 0 - } - }; - } - const timeoutSecs = config.timeout && config.timeout !== Infinity ? Math.ceil(config.timeout / 1000) : 60; + // Calculate rate limits for this domain + const rateLimits = calculateRateLimits(config.maxRequestsPerMinutePerDomain || 120); + // Store results const results = { collections: [], @@ -99,25 +88,22 @@ async function crawlApis(urls, isApi, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - maxConcurrency: 20, // Limit concurrency to prevent lock file race conditions - maxRequestsPerMinute: 200, // Rate limit to avoid overwhelming targets - // Rate limiting options - maxConcurrency: config.maxConcurrency || 5, - maxRequestsPerMinute: config.maxRequestsPerMinute || 60, - sameDomainDelaySecs: config.sameDomainDelaySecs || 1, + // Per-domain rate limiting + maxConcurrency: config.maxConcurrencyPerDomain || 10, + maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, + sameDomainDelaySecs: rateLimits.sameDomainDelaySecs, maxRequestRetries: config.maxRequestRetries || 3, - // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) + // Accept additional MIME types additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], async requestHandler({ request, json, body, crawler, log }) { results.stats.totalRequests++; const depth = request.userData?.depth || 0; - const indent = ' '.repeat(Math.min(depth, 5)); // Cap indent at 5 levels + const indent = ' '.repeat(Math.min(depth, 5)); // Fallback: manually parse JSON if Crawlee's automatic parsing failed - // This can happen with slow endpoints or unusual response streaming if (!json && body) { try { const bodyStr = typeof body === 'string' ? body : body.toString('utf8'); @@ -129,7 +115,6 @@ async function crawlApis(urls, isApi, config = {}) { } try { - // Route based on request label if (request.label === 'API_ROOT') { await handleApiRoot({ request, json, crawler, log, indent, results, maxDepth }); } else if (request.label === 'API_COLLECTIONS') { @@ -141,7 +126,7 @@ async function crawlApis(urls, isApi, config = {}) { results.stats.successfulRequests++; } catch (error) { log.error(`${indent}Error handling ${request.label} at ${request.url}: ${error.message}`); - throw error; // Re-throw to trigger failedRequestHandler + throw error; } }, @@ -150,7 +135,6 @@ async function crawlApis(urls, isApi, config = {}) { const indent = ' '; const apiId = request.userData?.apiId || 'unknown'; - // Log detailed failure information if (error.message.includes('STAC validation')) { log.info(`${indent}[STAC VALIDATION FAILED] ${apiId} at ${request.url}`); log.info(`${indent} Reason: ${error.message}`); @@ -176,7 +160,7 @@ async function crawlApis(urls, isApi, config = {}) { url: url, label: 'API_ROOT', userData: { - apiId: `api-${index}`, + apiId: `${domain}-api-${index}`, apiUrl: url, depth: 0 } @@ -184,44 +168,113 @@ async function crawlApis(urls, isApi, config = {}) { await crawler.addRequests(initialRequests); - console.log(`\nStarting Crawlee crawler with ${initialRequests.length} APIs...\n`); + console.log(` [${domain}] Starting API crawl with ${initialRequests.length} APIs (max ${rateLimits.maxRequestsPerMinute} req/min)`); await crawler.run(); // Flush any remaining collections to database - console.log('\nFlushing remaining API collections to database...'); const finalFlush = await flushCollectionsToDb(results, crawleeLog, true); results.stats.collectionsSaved += finalFlush.saved; results.stats.collectionsFailed += finalFlush.failed; - // Clear apis array to free memory (we don't need them after crawl) + // Clear apis array to free memory results.apis.length = 0; - console.log('\nAPI Crawl Statistics:'); - console.log(` Total Requests: ${results.stats.totalRequests}`); - console.log(` Successful: ${results.stats.successfulRequests}`); - console.log(` Failed: ${results.stats.failedRequests}`); - console.log(` STAC Compliant: ${results.stats.stacCompliant}`); - console.log(` Non-Compliant: ${results.stats.nonCompliant}`); - console.log(` APIs Processed: ${results.stats.apisProcessed}`); - console.log(` Collections Found: ${results.stats.collectionsFound}`); - console.log(` Collections Saved to DB: ${results.stats.collectionsSaved}`); - console.log(` Collections Failed: ${results.stats.collectionsFailed}\n`); + console.log(` [${domain}] Finished: ${results.stats.collectionsFound} collections, ${results.stats.successfulRequests}/${results.stats.totalRequests} requests`); return results; } +/** + * Crawls STAC APIs to retrieve collection information without fetching items. + * Groups APIs by domain and crawls multiple domains simultaneously. + * + * @param {string[]} urls - Array of API URLs to crawl + * @param {boolean} isApi - Boolean flag indicating if the URLs are APIs + * @param {Object} config - Configuration object with timeout settings + * @returns {Promise} Results object with collections array and statistics + */ +async function crawlApis(urls, isApi, config = {}) { + if (!isApi || !Array.isArray(urls) || urls.length === 0) { + return { + collections: [], + stats: { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + collectionsFound: 0, + apisProcessed: 0, + stacCompliant: 0, + nonCompliant: 0 + } + }; + } + + // Convert URLs to objects for groupByDomain + const urlObjects = urls.map(url => ({ url })); + const domainMap = groupByDomain(urlObjects); + + // Convert back to URL arrays per domain + const domainUrlMap = new Map(); + for (const [domain, objs] of domainMap.entries()) { + domainUrlMap.set(domain, objs.map(o => o.url)); + } + + // Log domain distribution + logDomainStats(domainMap, 'APIs'); + + // Number of domains to crawl in parallel (default: 5) + const parallelDomains = config.parallelDomains || 5; + const maxRequestsPerMinutePerDomain = config.maxRequestsPerMinutePerDomain || 120; + + console.log(`\n=== Parallel API Crawling Configuration ===`); + console.log(`Parallel domains: ${parallelDomains}`); + console.log(`Max requests/min per domain: ${maxRequestsPerMinutePerDomain}`); + console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); + console.log(`============================================\n`); + + // Create tasks for each domain + const domainTasks = Array.from(domainUrlMap.entries()).map(([domain, domainUrls]) => { + return () => crawlSingleApiDomain(domainUrls, domain, config); + }); + + console.log(`Starting parallel API crawl of ${domainUrlMap.size} domains (${parallelDomains} at a time)...\n`); + + // Execute with concurrency limit + const allResults = await executeWithConcurrency( + domainTasks, + parallelDomains, + (completed, total) => { + console.log(`\n>>> Domain progress: ${completed}/${total} domains completed <<<\n`); + } + ); + + // Aggregate all statistics + const aggregatedStats = aggregateStats(allResults); + + console.log('\n=== Parallel API Crawl Statistics ==='); + console.log(` Domains Processed: ${domainUrlMap.size}`); + console.log(` Total Requests: ${aggregatedStats.totalRequests}`); + console.log(` Successful: ${aggregatedStats.successfulRequests}`); + console.log(` Failed: ${aggregatedStats.failedRequests}`); + console.log(` STAC Compliant: ${aggregatedStats.stacCompliant}`); + console.log(` Non-Compliant: ${aggregatedStats.nonCompliant}`); + console.log(` APIs Processed: ${aggregatedStats.apisProcessed}`); + console.log(` Collections Found: ${aggregatedStats.collectionsFound}`); + console.log(` Collections Saved to DB: ${aggregatedStats.collectionsSaved}`); + console.log(` Collections Failed: ${aggregatedStats.collectionsFailed}`); + console.log('=====================================\n'); + + return { + collections: [], + apis: [], + stats: aggregatedStats + }; +} + /** * Handles API root endpoint - validates STAC, discovers collections endpoints * @async - * @param {Object} context - Request handler context - * @param {Object} context.request - Crawlee request object - * @param {Object} context.json - Parsed JSON response - * @param {Object} context.crawler - Crawlee crawler instance - * @param {Object} context.log - Logger instance - * @param {string} context.indent - Indentation for logging - * @param {Object} context.results - Results object to store data - * @param {number} context.maxDepth - Maximum recursion depth (0 = unlimited) */ async function handleApiRoot({ request, json, crawler, log, indent, results, maxDepth = 10 }) { const apiId = request.userData?.apiId || 'unknown'; @@ -230,15 +283,11 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max log.info(`${indent}Processing API: ${apiId} at ${apiUrl} (depth: ${depth})`); - // Defensive check: ensure json is valid before passing to stac-js if (!json || typeof json !== 'object') { log.warning(`${indent}Invalid JSON response for ${apiId} at ${request.url}`); throw new Error('Invalid JSON response: null or not an object'); } - // Validate with stac-js - // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL - // Using migrate=false to avoid issues with stac-migrate and newer STAC versions let stacObj; try { stacObj = create(json, false); @@ -269,9 +318,8 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); - // Check if we should flush to database await checkAndFlushApi(results, log); - return; // Single collection, no need to look for more + return; } // Try to find collections endpoint using stac-js @@ -280,7 +328,6 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max if (typeof stacObj.getApiCollectionsLink === 'function') { const collectionsLink = stacObj.getApiCollectionsLink(); if (collectionsLink && collectionsLink.href) { - // Direkt die href verwenden - diese ist bereits absolut im JSON collectionsEndpoint = collectionsLink.href; log.info(`${indent}Found collections link via stac-js: ${collectionsEndpoint}`); } @@ -310,7 +357,6 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max if (childLinks.length > 0) { log.info(`${indent}Found ${childLinks.length} child catalog links`); - // Check if we've reached maximum depth const nextDepth = depth + 1; if (maxDepth > 0 && nextDepth > maxDepth) { log.warning(`${indent}Skipping ${childLinks.length} child catalogs - max depth (${maxDepth}) reached`); @@ -331,7 +377,6 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max // Handle S3 protocol URLs - convert to HTTPS if (childUrl && typeof childUrl === 'string' && childUrl.startsWith('s3://')) { - // s3://bucket-name/path -> https://bucket-name.s3.amazonaws.com/path const s3Match = childUrl.match(/^s3:\/\/([^/]+)\/(.*)$/); if (s3Match) { const [, bucket, path] = s3Match; @@ -356,7 +401,6 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max return null; } - // Check if this is a collection link or child catalog const rel = link.rel || ''; const label = rel === 'child' || rel === 'item' ? 'API_ROOT' : 'API_COLLECTION'; @@ -384,19 +428,10 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max /** * Handles individual API collection endpoint * @async - * @param {Object} context - Request handler context - * @param {Object} context.request - Crawlee request object - * @param {Object} context.json - Parsed JSON response - * @param {Object} context.crawler - Crawlee crawler instance - * @param {Object} context.log - Logger instance - * @param {string} context.indent - Indentation for logging - * @param {Object} context.results - Results object to store data */ async function handleApiCollection({ request, json, crawler, log, indent, results }) { const apiId = request.userData?.apiId || 'unknown'; - // Validate with stac-js - // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL let stacObj; try { stacObj = create(json, false); @@ -407,7 +442,6 @@ async function handleApiCollection({ request, json, crawler, log, indent, result results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); - // Check if we should flush to database await checkAndFlushApi(results, log); } else { log.warning(`${indent}Expected collection but got: ${json.type || 'unknown type'}`); diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index e9002ea..09359a2 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -1,19 +1,28 @@ /** * @fileoverview Catalog crawling functionality for STAC Index using Crawlee + * Supports parallel crawling of multiple domains simultaneously * @module catalogs/catalog */ import { HttpCrawler, log as crawleeLog, Configuration } from 'crawlee'; import { handleCatalog, handleCollections, flushCollectionsToDb } from '../utils/handlers.js'; +import { + groupByDomain, + executeWithConcurrency, + aggregateStats, + calculateRateLimits, + logDomainStats +} from '../utils/parallel.js'; /** - * Creates and runs a Crawlee HttpCrawler to crawl STAC catalogs + * Creates and runs a single Crawlee HttpCrawler for a specific domain * @async - * @param {Array} initialCatalogs - Array of catalog objects to start crawling from - * @param {Object} config - Configuration object with timeout and depth settings + * @param {Array} catalogs - Array of catalog objects for this domain + * @param {string} domain - The domain being crawled + * @param {Object} config - Configuration object * @returns {Promise} Crawl results with collections and statistics */ -async function crawlCatalogs(initialCatalogs, config = {}) { +async function crawlSingleDomain(catalogs, domain, config = {}) { // Use in-memory storage to avoid file lock race conditions under high concurrency Configuration.getGlobalConfig().set('persistStorage', false); @@ -21,6 +30,9 @@ async function crawlCatalogs(initialCatalogs, config = {}) { ? Math.ceil(config.timeout / 1000) : 60; + // Calculate rate limits for this domain + const rateLimits = calculateRateLimits(config.maxRequestsPerMinutePerDomain || 120); + // Store results const results = { collections: [], @@ -41,10 +53,10 @@ async function crawlCatalogs(initialCatalogs, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - // Rate limiting options - prevents memory buildup from queue overflow - maxConcurrency: config.maxConcurrency || 5, - maxRequestsPerMinute: config.maxRequestsPerMinute || 60, - sameDomainDelaySecs: config.sameDomainDelaySecs || 1, + // Per-domain rate limiting + maxConcurrency: config.maxConcurrencyPerDomain || 10, + maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, + sameDomainDelaySecs: rateLimits.sameDomainDelaySecs, maxRequestRetries: config.maxRequestRetries || 3, // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) @@ -56,7 +68,6 @@ async function crawlCatalogs(initialCatalogs, config = {}) { const indent = ' '.repeat(depth); // Fallback: manually parse JSON if Crawlee's automatic parsing failed - // This can happen with slow endpoints or unusual response streaming if (!json && body) { try { const bodyStr = typeof body === 'string' ? body : body.toString('utf8'); @@ -78,7 +89,7 @@ async function crawlCatalogs(initialCatalogs, config = {}) { results.stats.successfulRequests++; } catch (error) { log.error(`${indent}Error handling ${request.label} at ${request.url}: ${error.message}`); - throw error; // Re-throw to trigger failedRequestHandler + throw error; } }, @@ -88,7 +99,6 @@ async function crawlCatalogs(initialCatalogs, config = {}) { const indent = ' '.repeat(depth); const catalogId = request.userData?.catalogId || 'unknown'; - // Log detailed failure information if (error.message.includes('STAC validation')) { log.info(`${indent}[STAC VALIDATION FAILED] ${catalogId} at ${request.url}`); log.info(`${indent} Reason: ${error.message}`); @@ -109,8 +119,8 @@ async function crawlCatalogs(initialCatalogs, config = {}) { } }); - // Seed the crawler with initial catalog requests - const initialRequests = initialCatalogs.map(catalog => ({ + // Seed the crawler with catalog requests for this domain + const initialRequests = catalogs.map(catalog => ({ url: catalog.url, label: 'CATALOG', userData: { @@ -122,30 +132,84 @@ async function crawlCatalogs(initialCatalogs, config = {}) { await crawler.addRequests(initialRequests); - console.log(`\nStarting Crawlee crawler with ${initialRequests.length} initial catalogs...\n`); + console.log(` [${domain}] Starting crawl with ${initialRequests.length} catalogs (max ${rateLimits.maxRequestsPerMinute} req/min)`); await crawler.run(); // Flush any remaining collections to database - console.log('\nFlushing remaining collections to database...'); const finalFlush = await flushCollectionsToDb(results, crawleeLog, true); results.stats.collectionsSaved += finalFlush.saved; results.stats.collectionsFailed += finalFlush.failed; - // Clear catalogs array to free memory (we don't need them after crawl) + // Clear catalogs array to free memory results.catalogs.length = 0; - console.log('\nCrawl Statistics:'); - console.log(` Total Requests: ${results.stats.totalRequests}`); - console.log(` Successful: ${results.stats.successfulRequests}`); - console.log(` Failed: ${results.stats.failedRequests}`); - console.log(` STAC Compliant: ${results.stats.stacCompliant}`); - console.log(` Non-Compliant: ${results.stats.nonCompliant}`); - console.log(` Catalogs Processed: ${results.stats.catalogsProcessed}`); - console.log(` Collections Found: ${results.stats.collectionsFound}`); - console.log(` Collections Saved to DB: ${results.stats.collectionsSaved}`); - console.log(` Collections Failed: ${results.stats.collectionsFailed}\n`); + console.log(` [${domain}] Finished: ${results.stats.collectionsFound} collections, ${results.stats.successfulRequests}/${results.stats.totalRequests} requests`); return results; } -export { crawlCatalogs }; \ No newline at end of file +/** + * Creates and runs parallel Crawlee HttpCrawlers to crawl STAC catalogs + * Groups catalogs by domain and crawls multiple domains simultaneously + * @async + * @param {Array} initialCatalogs - Array of catalog objects to start crawling from + * @param {Object} config - Configuration object with timeout, depth, and parallel settings + * @returns {Promise} Crawl results with collections and statistics + */ +async function crawlCatalogs(initialCatalogs, config = {}) { + // Group catalogs by domain + const domainMap = groupByDomain(initialCatalogs); + + // Log domain distribution + logDomainStats(domainMap, 'catalogs'); + + // Number of domains to crawl in parallel (default: 5) + const parallelDomains = config.parallelDomains || 5; + const maxRequestsPerMinutePerDomain = config.maxRequestsPerMinutePerDomain || 120; + + console.log(`\n=== Parallel Catalog Crawling Configuration ===`); + console.log(`Parallel domains: ${parallelDomains}`); + console.log(`Max requests/min per domain: ${maxRequestsPerMinutePerDomain}`); + console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); + console.log(`===============================================\n`); + + // Create tasks for each domain + const domainTasks = Array.from(domainMap.entries()).map(([domain, catalogs]) => { + return () => crawlSingleDomain(catalogs, domain, config); + }); + + console.log(`Starting parallel crawl of ${domainMap.size} domains (${parallelDomains} at a time)...\n`); + + // Execute with concurrency limit + const allResults = await executeWithConcurrency( + domainTasks, + parallelDomains, + (completed, total) => { + console.log(`\n>>> Domain progress: ${completed}/${total} domains completed <<<\n`); + } + ); + + // Aggregate all statistics + const aggregatedStats = aggregateStats(allResults); + + console.log('\n=== Parallel Crawl Statistics ==='); + console.log(` Domains Processed: ${domainMap.size}`); + console.log(` Total Requests: ${aggregatedStats.totalRequests}`); + console.log(` Successful: ${aggregatedStats.successfulRequests}`); + console.log(` Failed: ${aggregatedStats.failedRequests}`); + console.log(` STAC Compliant: ${aggregatedStats.stacCompliant}`); + console.log(` Non-Compliant: ${aggregatedStats.nonCompliant}`); + console.log(` Catalogs Processed: ${aggregatedStats.catalogsProcessed}`); + console.log(` Collections Found: ${aggregatedStats.collectionsFound}`); + console.log(` Collections Saved to DB: ${aggregatedStats.collectionsSaved}`); + console.log(` Collections Failed: ${aggregatedStats.collectionsFailed}`); + console.log('=================================\n'); + + return { + collections: [], + catalogs: [], + stats: aggregatedStats + }; +} + +export { crawlCatalogs }; diff --git a/crawler/index.js b/crawler/index.js index d7d6b1e..31f4840 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -49,6 +49,11 @@ export const crawler = async () => { console.log(`Max APIs: ${config.maxApis === 0 ? 'unlimited' : config.maxApis} (debugging limit)`); console.log(`Timeout: ${config.timeout === Infinity ? 'unlimited' : config.timeout + 'ms'}`); console.log(`Max Depth: ${config.maxDepth === 0 ? 'unlimited' : config.maxDepth} levels`); + console.log('--- Parallel Crawling ---'); + console.log(`Parallel Domains: ${config.parallelDomains}`); + console.log(`Max Requests/Min per Domain: ${config.maxRequestsPerMinutePerDomain}`); + console.log(`Max Concurrency per Domain: ${config.maxConcurrencyPerDomain}`); + console.log(`Theoretical Max Throughput: ${config.parallelDomains * config.maxRequestsPerMinutePerDomain} req/min`); console.log('==================================\n'); const response = await axios.get(targetUrl); diff --git a/crawler/utils/cli.js b/crawler/utils/cli.js index c7246cf..2cf0d2c 100644 --- a/crawler/utils/cli.js +++ b/crawler/utils/cli.js @@ -33,6 +33,13 @@ export function parseCliArgs() { config.sameDomainDelaySecs = parseFloat(args[++i]); } else if (arg === '--max-retries') { config.maxRequestRetries = parseInt(args[++i], 10); + // New parallel crawling options + } else if (arg === '--parallel-domains' || arg === '-p') { + config.parallelDomains = parseInt(args[++i], 10); + } else if (arg === '--rpm-per-domain') { + config.maxRequestsPerMinutePerDomain = parseInt(args[++i], 10); + } else if (arg === '--concurrency-per-domain') { + config.maxConcurrencyPerDomain = parseInt(args[++i], 10); } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); @@ -56,7 +63,14 @@ STAC Crawler Configuration Options: Note: Limits are for debugging purposes only -t, --timeout Timeout for each crawl operation in ms (default: 30000) - Rate Limiting Options: + Parallel Crawling Options (NEW): + -p, --parallel-domains Number of domains to crawl in parallel (default: 5) + Each domain gets its own crawler instance + --rpm-per-domain Max requests per minute PER DOMAIN (default: 120) + Total throughput = parallel-domains × rpm-per-domain + --concurrency-per-domain Max concurrent requests per domain (default: 10) + + Legacy Rate Limiting Options (still supported): --max-concurrency Maximum concurrent requests (default: 5) --requests-per-minute, --rpm Maximum requests per minute (default: 60) --domain-delay Delay between requests to same domain (default: 1) @@ -67,21 +81,32 @@ STAC Crawler Configuration Options: -h, --help Show this help message Environment Variables: - CRAWL_MODE Same as --mode - MAX_CATALOGS Same as --max-catalogs (use 0 for unlimited) - MAX_APIS Same as --max-apis (use 0 for unlimited) - TIMEOUT_MS Same as --timeout - MAX_CONCURRENCY Same as --max-concurrency - MAX_REQUESTS_PER_MINUTE Same as --requests-per-minute - SAME_DOMAIN_DELAY_SECS Same as --domain-delay - MAX_REQUEST_RETRIES Same as --max-retries - MAX_DEPTH Same as --max-depth (use 0 for unlimited) + CRAWL_MODE Same as --mode + MAX_CATALOGS Same as --max-catalogs (use 0 for unlimited) + MAX_APIS Same as --max-apis (use 0 for unlimited) + TIMEOUT_MS Same as --timeout + PARALLEL_DOMAINS Same as --parallel-domains + MAX_REQUESTS_PER_MINUTE_PER_DOMAIN Same as --rpm-per-domain + MAX_CONCURRENCY_PER_DOMAIN Same as --concurrency-per-domain + MAX_CONCURRENCY Same as --max-concurrency + MAX_REQUESTS_PER_MINUTE Same as --requests-per-minute + SAME_DOMAIN_DELAY_SECS Same as --domain-delay + MAX_REQUEST_RETRIES Same as --max-retries + MAX_DEPTH Same as --max-depth (use 0 for unlimited) Examples: + # Basic usage node index.js --mode catalogs --max-catalogs 20 node index.js -m apis -a 10 -t 60000 - node index.js -m both -c 0 -a 0 # Unlimited mode (no debugging limits) - node index.js -m apis -d 5 # Limit nesting depth to 5 levels - CRAWL_MODE=both MAX_CATALOGS=50 node index.js + + # Parallel crawling (recommended for performance) + node index.js -p 5 --rpm-per-domain 120 # 5 domains × 120 req/min = 600 req/min max + node index.js -p 10 --rpm-per-domain 60 # 10 domains × 60 req/min = 600 req/min max + + # Unlimited mode (no debugging limits) + node index.js -m both -c 0 -a 0 + + # With environment variables + PARALLEL_DOMAINS=5 MAX_REQUESTS_PER_MINUTE_PER_DOMAIN=120 node index.js `); } diff --git a/crawler/utils/config.js b/crawler/utils/config.js index e0ce5ca..3858b7c 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -43,9 +43,15 @@ function getConfig() { maxApis: 5, // Maximum number of APIs to crawl timeout: 30000, // Timeout in milliseconds (30 seconds) maxDepth: 10, // Maximum recursion depth for nested catalogs (0 = unlimited) - // Rate limiting options (Crawlee) - maxConcurrency: 5, // Maximum number of concurrent requests - maxRequestsPerMinute: 60, // Maximum requests per minute + + // NEW: Parallel crawling options (recommended for performance) + parallelDomains: 5, // Number of domains to crawl in parallel + maxRequestsPerMinutePerDomain: 120, // Max requests per minute PER domain + maxConcurrencyPerDomain: 10, // Max concurrent requests per domain + + // Legacy rate limiting options (still supported but parallel options are preferred) + maxConcurrency: 5, // Maximum number of concurrent requests (global) + maxRequestsPerMinute: 60, // Maximum requests per minute (global) sameDomainDelaySecs: 1, // Delay between requests to the same domain maxRequestRetries: 3 // Maximum number of retries for failed requests }; @@ -61,7 +67,16 @@ function getConfig() { (process.env.TIMEOUT_MS ? parseInt(process.env.TIMEOUT_MS, 10) : defaults.timeout), maxDepth: cliArgs.maxDepth !== undefined ? cliArgs.maxDepth : (process.env.MAX_DEPTH ? parseInt(process.env.MAX_DEPTH, 10) : defaults.maxDepth), - // Rate limiting options + + // NEW: Parallel crawling options + parallelDomains: cliArgs.parallelDomains !== undefined ? cliArgs.parallelDomains : + (process.env.PARALLEL_DOMAINS ? parseInt(process.env.PARALLEL_DOMAINS, 10) : defaults.parallelDomains), + maxRequestsPerMinutePerDomain: cliArgs.maxRequestsPerMinutePerDomain !== undefined ? cliArgs.maxRequestsPerMinutePerDomain : + (process.env.MAX_REQUESTS_PER_MINUTE_PER_DOMAIN ? parseInt(process.env.MAX_REQUESTS_PER_MINUTE_PER_DOMAIN, 10) : defaults.maxRequestsPerMinutePerDomain), + maxConcurrencyPerDomain: cliArgs.maxConcurrencyPerDomain !== undefined ? cliArgs.maxConcurrencyPerDomain : + (process.env.MAX_CONCURRENCY_PER_DOMAIN ? parseInt(process.env.MAX_CONCURRENCY_PER_DOMAIN, 10) : defaults.maxConcurrencyPerDomain), + + // Legacy rate limiting options maxConcurrency: cliArgs.maxConcurrency !== undefined ? cliArgs.maxConcurrency : (process.env.MAX_CONCURRENCY ? parseInt(process.env.MAX_CONCURRENCY, 10) : defaults.maxConcurrency), maxRequestsPerMinute: cliArgs.maxRequestsPerMinute !== undefined ? cliArgs.maxRequestsPerMinute : @@ -79,15 +94,13 @@ function getConfig() { process.exit(1); } - // NOTE: Removed automatic Infinity override for 'catalogs' mode - // Users can now control limits via CLI args even when mode is 'catalogs' - // Set maxCatalogs or maxApis to 0 for unlimited crawling (for debugging purposes) - - // Validate numeric values (0 means unlimited) + // Validate numeric values (0 means unlimited for some options) if ((config.maxCatalogs < 0) || (config.maxApis < 0) || - (config.timeout !== Infinity && config.timeout < 0)) { - console.error('Numeric configuration values must be non-negative (use 0 for unlimited)'); + (config.timeout !== Infinity && config.timeout < 0) || + (config.parallelDomains < 1) || + (config.maxRequestsPerMinutePerDomain < 1)) { + console.error('Invalid configuration: parallelDomains and maxRequestsPerMinutePerDomain must be >= 1'); process.exit(1); } diff --git a/crawler/utils/parallel.js b/crawler/utils/parallel.js new file mode 100644 index 0000000..c8711e6 --- /dev/null +++ b/crawler/utils/parallel.js @@ -0,0 +1,186 @@ +/** + * @fileoverview Parallel execution utilities for domain-based crawling + * Allows crawling multiple domains simultaneously while respecting per-domain rate limits + * @module utils/parallel + */ + +/** + * Extracts the domain from a URL + * @param {string} url - URL to extract domain from + * @returns {string} The domain (hostname) of the URL + */ +export function getDomain(url) { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return 'unknown'; + } +} + +/** + * Groups items by their URL domain + * @param {Array} items - Array of objects with url property + * @returns {Map>} Map of domain -> items + */ +export function groupByDomain(items) { + const domainMap = new Map(); + + for (const item of items) { + const domain = getDomain(item.url); + if (!domainMap.has(domain)) { + domainMap.set(domain, []); + } + domainMap.get(domain).push(item); + } + + return domainMap; +} + +/** + * Creates batches of domains for parallel processing + * @param {Map} domainMap - Map of domain -> items + * @param {number} batchSize - Number of domains to process in parallel + * @returns {Array>} Array of batches, each containing [domain, items] pairs + */ +export function createDomainBatches(domainMap, batchSize = 5) { + const entries = Array.from(domainMap.entries()); + const batches = []; + + for (let i = 0; i < entries.length; i += batchSize) { + batches.push(entries.slice(i, i + batchSize)); + } + + return batches; +} + +/** + * Aggregates statistics from multiple crawler results + * @param {Array} results - Array of result objects with stats + * @returns {Object} Aggregated statistics + */ +export function aggregateStats(results) { + const aggregated = { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0, + catalogsProcessed: 0, + apisProcessed: 0, + stacCompliant: 0, + nonCompliant: 0 + }; + + for (const result of results) { + if (!result || !result.stats) continue; + + for (const key of Object.keys(aggregated)) { + if (typeof result.stats[key] === 'number') { + aggregated[key] += result.stats[key]; + } + } + } + + return aggregated; +} + +/** + * Executes async functions in parallel with a concurrency limit + * @param {Array} tasks - Array of async functions to execute + * @param {number} concurrency - Maximum number of tasks to run in parallel + * @param {Function} onProgress - Optional callback for progress updates + * @returns {Promise} Array of results from all tasks + */ +export async function executeWithConcurrency(tasks, concurrency, onProgress = null) { + const results = []; + let completed = 0; + let running = 0; + let index = 0; + + return new Promise((resolve) => { + const runNext = async () => { + if (index >= tasks.length) { + if (running === 0) { + resolve(results); + } + return; + } + + const currentIndex = index++; + running++; + + try { + const result = await tasks[currentIndex](); + results[currentIndex] = result; + } catch (error) { + results[currentIndex] = { error: error.message, stats: {} }; + } + + running--; + completed++; + + if (onProgress) { + onProgress(completed, tasks.length); + } + + runNext(); + }; + + // Start initial batch + const initialBatch = Math.min(concurrency, tasks.length); + for (let i = 0; i < initialBatch; i++) { + runNext(); + } + + // Handle empty tasks array + if (tasks.length === 0) { + resolve(results); + } + }); +} + +/** + * Calculates optimal rate limiting based on max requests per minute + * @param {number} maxRequestsPerMinute - Maximum requests per minute (per domain) + * @returns {Object} Rate limiting configuration + */ +export function calculateRateLimits(maxRequestsPerMinute = 120) { + // Calculate delay between requests to stay under the limit + // Add 10% safety margin + const safeLimit = Math.floor(maxRequestsPerMinute * 0.9); + const delayMs = Math.ceil(60000 / safeLimit); + const delaySecs = delayMs / 1000; + + return { + maxRequestsPerMinute: safeLimit, + sameDomainDelaySecs: Math.max(delaySecs, 0.5), // Minimum 0.5s delay + // With 120 req/min target, we get ~108 effective req/min with safety margin + // That's ~1.8 req/sec or ~0.55s between requests + }; +} + +/** + * Logs domain statistics for debugging + * @param {Map} domainMap - Map of domain -> items + * @param {string} itemType - Type of items (e.g., 'catalogs', 'APIs') + */ +export function logDomainStats(domainMap, itemType = 'items') { + console.log(`\n=== Domain Distribution for ${itemType} ===`); + console.log(`Total domains: ${domainMap.size}`); + + const sorted = Array.from(domainMap.entries()) + .sort((a, b) => b[1].length - a[1].length); + + // Show top 10 domains + const top = sorted.slice(0, 10); + for (const [domain, items] of top) { + console.log(` ${domain}: ${items.length} ${itemType}`); + } + + if (sorted.length > 10) { + console.log(` ... and ${sorted.length - 10} more domains`); + } + console.log(''); +} From 14ff676dd879fb443e9ffb3391b706a6544fdee0 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 01:42:59 +0100 Subject: [PATCH 082/134] fix(crawler): enhance normalization of collection metadata extraction - Improved the `normalizeCollection` function to utilize raw data from stac-js objects for more robust metadata extraction. - Added fallback mechanisms for bounding box, temporal extent, self URL, and other properties to ensure reliable data retrieval from both stac-js methods and raw data. - Updated the handling of collection properties to prioritize raw data when available, enhancing overall data integrity. --- crawler/utils/normalization.js | 52 ++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index b58f5b0..34cff2a 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -70,14 +70,37 @@ export function normalizeCatalog(catalog, index) { * @returns {Object} Normalized collection object */ export function normalizeCollection(colObj, index) { - // Use stac-js methods for robust metadata extraction - const bbox = typeof colObj.getBoundingBox === 'function' - ? colObj.getBoundingBox() - : (colObj.extent?.spatial?.bbox?.[0] || null); + // Get raw data from stac-js object if available + // stac-js stores the original data in toJSON() or we can access it directly + const rawData = typeof colObj.toJSON === 'function' ? colObj.toJSON() : colObj; - const temporal = typeof colObj.getTemporalExtent === 'function' - ? colObj.getTemporalExtent() - : (colObj.extent?.temporal?.interval?.[0] || null); + // Extract bbox: try stac-js method first, then fallback to raw data + let bbox = null; + if (typeof colObj.getBoundingBox === 'function') { + bbox = colObj.getBoundingBox(); + } + // Fallback to raw data if stac-js method returned null/undefined + if (!bbox && rawData?.extent?.spatial?.bbox?.[0]) { + bbox = rawData.extent.spatial.bbox[0]; + } + // Final fallback: direct access on colObj + if (!bbox && colObj?.extent?.spatial?.bbox?.[0]) { + bbox = colObj.extent.spatial.bbox[0]; + } + + // Extract temporal: try stac-js method first, then fallback to raw data + let temporal = null; + if (typeof colObj.getTemporalExtent === 'function') { + temporal = colObj.getTemporalExtent(); + } + // Fallback to raw data if stac-js method returned null/undefined + if (!temporal && rawData?.extent?.temporal?.interval?.[0]) { + temporal = rawData.extent.temporal.interval[0]; + } + // Final fallback: direct access on colObj + if (!temporal && colObj?.extent?.temporal?.interval?.[0]) { + temporal = colObj.extent.temporal.interval[0]; + } // Get self URL using stac-js link navigation let selfUrl = null; @@ -87,17 +110,22 @@ export function normalizeCollection(colObj, index) { const selfLink = colObj.links.find(l => l.rel === 'self'); selfUrl = selfLink?.href || null; } + // Fallback to raw data for URL + if (!selfUrl && rawData?.links) { + const selfLink = rawData.links.find(l => l.rel === 'self'); + selfUrl = selfLink?.href || null; + } return { index, - id: colObj.id || 'Unknown', + id: colObj.id || rawData?.id || 'Unknown', url: selfUrl, - title: colObj.title || null, - description: colObj.description || colObj.summary || null, + title: colObj.title || rawData?.title || null, + description: colObj.description || colObj.summary || rawData?.description || rawData?.summary || null, bbox, temporal, - license: colObj.license || null, - keywords: colObj.keywords || [] + license: colObj.license || rawData?.license || null, + keywords: colObj.keywords || rawData?.keywords || [] }; } From 735708962465859ff2a1b2c51a51f4c9ac86461b Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 01:47:22 +0100 Subject: [PATCH 083/134] fix(crawler): enhance normalization of collection to include additional fields for database insertion - Updated the `normalizeCollection` function to extract and preserve additional fields such as links, summaries, and extensions from collection objects. - Ensured compatibility with both stac-js and raw data formats for comprehensive metadata extraction. - Improved overall data integrity by including all necessary fields for database insertion. --- crawler/utils/normalization.js | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index 34cff2a..444c938 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -65,9 +65,10 @@ export function normalizeCatalog(catalog, index) { /** * Normalizes a collection object using stac-js methods for metadata extraction + * Preserves all fields needed for database insertion including summaries, extensions, etc. * @param {Object} colObj - Collection object (stac-js or plain object) * @param {number} index - Index position - * @returns {Object} Normalized collection object + * @returns {Object} Normalized collection object with all fields for db.js */ export function normalizeCollection(colObj, index) { // Get raw data from stac-js object if available @@ -116,6 +117,20 @@ export function normalizeCollection(colObj, index) { selfUrl = selfLink?.href || null; } + // Extract links array (needed for source_url extraction in db.js) + let links = null; + if (Array.isArray(colObj.links)) { + // Convert stac-js link objects to plain objects if needed + links = colObj.links.map(l => ({ + rel: l.rel, + href: l.href, + type: l.type, + title: l.title + })); + } else if (Array.isArray(rawData?.links)) { + links = rawData.links; + } + return { index, id: colObj.id || rawData?.id || 'Unknown', @@ -125,7 +140,16 @@ export function normalizeCollection(colObj, index) { bbox, temporal, license: colObj.license || rawData?.license || null, - keywords: colObj.keywords || rawData?.keywords || [] + keywords: colObj.keywords || rawData?.keywords || [], + + // Additional fields needed for db.js - pass through from raw data + links, + stac_version: colObj.stac_version || rawData?.stac_version || null, + type: colObj.type || rawData?.type || 'Collection', + summaries: colObj.summaries || rawData?.summaries || null, + stac_extensions: colObj.stac_extensions || rawData?.stac_extensions || [], + providers: colObj.providers || rawData?.providers || [], + assets: colObj.assets || rawData?.assets || null }; } From c48840c64bbd1002657c51371069a4e6c315da78 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 02:55:54 +0100 Subject: [PATCH 084/134] fix(crawler): increase max concurrency for improved throughput in API and catalog crawlers - Updated the maxConcurrencyPerDomain setting from 10 to 20 to enhance performance and avoid bottlenecks during crawling. - Adjusted logging to provide detailed information on the number of APIs and catalogs being crawled, including concurrency and rate limits. - Modified rate limiting calculations to ensure minimal delay between requests, allowing for better handling of slow responses. --- crawler/apis/api.js | 10 +++++++--- crawler/catalogs/catalog.js | 10 +++++++--- crawler/utils/config.js | 2 +- crawler/utils/parallel.js | 18 +++++++++--------- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 3d49198..51d5364 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -89,8 +89,11 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - // Per-domain rate limiting - maxConcurrency: config.maxConcurrencyPerDomain || 10, + // Per-domain rate limiting - HIGH concurrency for throughput + // maxConcurrency should be higher than req/sec to avoid bottlenecks + // 120 req/min = 2 req/sec, so we need at least 2-3 concurrent requests + // Using 20 to handle slow responses and maintain throughput + maxConcurrency: config.maxConcurrencyPerDomain || 20, maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, sameDomainDelaySecs: rateLimits.sameDomainDelaySecs, maxRequestRetries: config.maxRequestRetries || 3, @@ -168,7 +171,8 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { await crawler.addRequests(initialRequests); - console.log(` [${domain}] Starting API crawl with ${initialRequests.length} APIs (max ${rateLimits.maxRequestsPerMinute} req/min)`); + const concurrency = config.maxConcurrencyPerDomain || 20; + console.log(` [${domain}] Starting: ${initialRequests.length} APIs, ${rateLimits.maxRequestsPerMinute} req/min, ${concurrency} concurrent, ${rateLimits.sameDomainDelaySecs}s delay`); await crawler.run(); // Flush any remaining collections to database diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 09359a2..197bbe7 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -53,8 +53,11 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - // Per-domain rate limiting - maxConcurrency: config.maxConcurrencyPerDomain || 10, + // Per-domain rate limiting - HIGH concurrency for throughput + // maxConcurrency should be higher than req/sec to avoid bottlenecks + // 120 req/min = 2 req/sec, so we need at least 2-3 concurrent requests + // Using 20 to handle slow responses and maintain throughput + maxConcurrency: config.maxConcurrencyPerDomain || 20, maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, sameDomainDelaySecs: rateLimits.sameDomainDelaySecs, maxRequestRetries: config.maxRequestRetries || 3, @@ -132,7 +135,8 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { await crawler.addRequests(initialRequests); - console.log(` [${domain}] Starting crawl with ${initialRequests.length} catalogs (max ${rateLimits.maxRequestsPerMinute} req/min)`); + const concurrency = config.maxConcurrencyPerDomain || 20; + console.log(` [${domain}] Starting: ${initialRequests.length} catalogs, ${rateLimits.maxRequestsPerMinute} req/min, ${concurrency} concurrent, ${rateLimits.sameDomainDelaySecs}s delay`); await crawler.run(); // Flush any remaining collections to database diff --git a/crawler/utils/config.js b/crawler/utils/config.js index 3858b7c..c7e76ed 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -47,7 +47,7 @@ function getConfig() { // NEW: Parallel crawling options (recommended for performance) parallelDomains: 5, // Number of domains to crawl in parallel maxRequestsPerMinutePerDomain: 120, // Max requests per minute PER domain - maxConcurrencyPerDomain: 10, // Max concurrent requests per domain + maxConcurrencyPerDomain: 20, // Max concurrent requests per domain (must be > req/sec) // Legacy rate limiting options (still supported but parallel options are preferred) maxConcurrency: 5, // Maximum number of concurrent requests (global) diff --git a/crawler/utils/parallel.js b/crawler/utils/parallel.js index c8711e6..efab13c 100644 --- a/crawler/utils/parallel.js +++ b/crawler/utils/parallel.js @@ -147,17 +147,17 @@ export async function executeWithConcurrency(tasks, concurrency, onProgress = nu * @returns {Object} Rate limiting configuration */ export function calculateRateLimits(maxRequestsPerMinute = 120) { - // Calculate delay between requests to stay under the limit - // Add 10% safety margin - const safeLimit = Math.floor(maxRequestsPerMinute * 0.9); - const delayMs = Math.ceil(60000 / safeLimit); - const delaySecs = delayMs / 1000; + // IMPORTANT: In Crawlee, maxRequestsPerMinute and sameDomainDelaySecs work independently + // Using BOTH causes double-throttling. We rely primarily on maxRequestsPerMinute. + // + // sameDomainDelaySecs is set to a minimal value (0.05s = 50ms) just to prevent + // overwhelming a single server with truly simultaneous requests. return { - maxRequestsPerMinute: safeLimit, - sameDomainDelaySecs: Math.max(delaySecs, 0.5), // Minimum 0.5s delay - // With 120 req/min target, we get ~108 effective req/min with safety margin - // That's ~1.8 req/sec or ~0.55s between requests + maxRequestsPerMinute: maxRequestsPerMinute, // Use full limit, no safety margin needed + sameDomainDelaySecs: 0.05, // Minimal delay (50ms) - just prevent request flooding + // With 120 req/min, that's 2 req/sec + // With concurrency 20+, we can achieve this easily }; } From 657bd0a1a4c30c3eefa40ca59b9143a3922c8e1e Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 03:01:20 +0100 Subject: [PATCH 085/134] fix(crawler): optimize concurrency settings for API and catalog crawlers - Introduced configurable concurrency settings to enhance crawling performance, allowing for immediate scaling and minimal delays between requests. - Updated logging to reflect the new concurrency model and removed unnecessary delay parameters for improved throughput. - Adjusted rate limiting calculations to rely solely on maxRequestsPerMinute, ensuring maximum efficiency during crawling operations. --- crawler/apis/api.js | 28 ++++++++++++++++++++-------- crawler/catalogs/catalog.js | 28 ++++++++++++++++++++-------- crawler/utils/parallel.js | 13 ++++--------- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 51d5364..e27741f 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -86,18 +86,31 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { // Maximum depth for nested catalog crawling (0 = unlimited) const maxDepth = config.maxDepth || 10; + const concurrency = config.maxConcurrencyPerDomain || 20; + const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - // Per-domain rate limiting - HIGH concurrency for throughput - // maxConcurrency should be higher than req/sec to avoid bottlenecks - // 120 req/min = 2 req/sec, so we need at least 2-3 concurrent requests - // Using 20 to handle slow responses and maintain throughput - maxConcurrency: config.maxConcurrencyPerDomain || 20, + // Rate limiting - use maxRequestsPerMinute as primary control maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, - sameDomainDelaySecs: rateLimits.sameDomainDelaySecs, maxRequestRetries: config.maxRequestRetries || 3, + // Concurrency settings - disable autoscaling for consistent speed + maxConcurrency: concurrency, + minConcurrency: Math.min(concurrency, 5), // Start with at least 5 concurrent + + // Disable autoscaling delays - we want full speed immediately + autoscaledPoolOptions: { + desiredConcurrency: concurrency, + minConcurrency: Math.min(concurrency, 5), + maxConcurrency: concurrency, + scaleUpStepRatio: 1, // Scale up immediately + scaleDownStepRatio: 0.5, + }, + + // Minimal delays - let maxRequestsPerMinute handle rate limiting + sameDomainDelaySecs: 0, // No delay between requests + // Accept additional MIME types additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], @@ -171,8 +184,7 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { await crawler.addRequests(initialRequests); - const concurrency = config.maxConcurrencyPerDomain || 20; - console.log(` [${domain}] Starting: ${initialRequests.length} APIs, ${rateLimits.maxRequestsPerMinute} req/min, ${concurrency} concurrent, ${rateLimits.sameDomainDelaySecs}s delay`); + console.log(` [${domain}] Starting: ${initialRequests.length} APIs, max ${rateLimits.maxRequestsPerMinute} req/min, ${concurrency} concurrent`); await crawler.run(); // Flush any remaining collections to database diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 197bbe7..f146572 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -50,18 +50,31 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { } }; + const concurrency = config.maxConcurrencyPerDomain || 20; + const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - // Per-domain rate limiting - HIGH concurrency for throughput - // maxConcurrency should be higher than req/sec to avoid bottlenecks - // 120 req/min = 2 req/sec, so we need at least 2-3 concurrent requests - // Using 20 to handle slow responses and maintain throughput - maxConcurrency: config.maxConcurrencyPerDomain || 20, + // Rate limiting - use maxRequestsPerMinute as primary control maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, - sameDomainDelaySecs: rateLimits.sameDomainDelaySecs, maxRequestRetries: config.maxRequestRetries || 3, + // Concurrency settings - disable autoscaling for consistent speed + maxConcurrency: concurrency, + minConcurrency: Math.min(concurrency, 5), // Start with at least 5 concurrent + + // Disable autoscaling delays - we want full speed immediately + autoscaledPoolOptions: { + desiredConcurrency: concurrency, + minConcurrency: Math.min(concurrency, 5), + maxConcurrency: concurrency, + scaleUpStepRatio: 1, // Scale up immediately + scaleDownStepRatio: 0.5, + }, + + // Minimal delays - let maxRequestsPerMinute handle rate limiting + sameDomainDelaySecs: 0, // No delay between requests + // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], @@ -135,8 +148,7 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { await crawler.addRequests(initialRequests); - const concurrency = config.maxConcurrencyPerDomain || 20; - console.log(` [${domain}] Starting: ${initialRequests.length} catalogs, ${rateLimits.maxRequestsPerMinute} req/min, ${concurrency} concurrent, ${rateLimits.sameDomainDelaySecs}s delay`); + console.log(` [${domain}] Starting: ${initialRequests.length} catalogs, max ${rateLimits.maxRequestsPerMinute} req/min, ${concurrency} concurrent`); await crawler.run(); // Flush any remaining collections to database diff --git a/crawler/utils/parallel.js b/crawler/utils/parallel.js index efab13c..bf3247f 100644 --- a/crawler/utils/parallel.js +++ b/crawler/utils/parallel.js @@ -147,17 +147,12 @@ export async function executeWithConcurrency(tasks, concurrency, onProgress = nu * @returns {Object} Rate limiting configuration */ export function calculateRateLimits(maxRequestsPerMinute = 120) { - // IMPORTANT: In Crawlee, maxRequestsPerMinute and sameDomainDelaySecs work independently - // Using BOTH causes double-throttling. We rely primarily on maxRequestsPerMinute. - // - // sameDomainDelaySecs is set to a minimal value (0.05s = 50ms) just to prevent - // overwhelming a single server with truly simultaneous requests. + // Use only maxRequestsPerMinute for rate limiting + // sameDomainDelaySecs is set to 0 - we rely solely on the rate limiter + // This gives us maximum throughput while respecting the rate limit return { - maxRequestsPerMinute: maxRequestsPerMinute, // Use full limit, no safety margin needed - sameDomainDelaySecs: 0.05, // Minimal delay (50ms) - just prevent request flooding - // With 120 req/min, that's 2 req/sec - // With concurrency 20+, we can achieve this easily + maxRequestsPerMinute: maxRequestsPerMinute, }; } From edbf6144bd04a1dd7b8a4f338e5af3a3ea0f49de Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 03:04:35 +0100 Subject: [PATCH 086/134] refactor(crawler): streamline concurrency settings in API and catalog crawlers - Simplified concurrency configuration by removing autoscaling options and unnecessary delay parameters, focusing on high throughput. - Updated comments for clarity on rate limiting and concurrency settings, ensuring better understanding of the crawling process. - Maintained additional MIME type support for diverse content handling during crawls. --- crawler/apis/api.js | 17 ++--------------- crawler/catalogs/catalog.js | 17 ++--------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index e27741f..ec7688c 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -91,25 +91,12 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - // Rate limiting - use maxRequestsPerMinute as primary control + // Rate limiting maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, maxRequestRetries: config.maxRequestRetries || 3, - // Concurrency settings - disable autoscaling for consistent speed + // High concurrency for throughput maxConcurrency: concurrency, - minConcurrency: Math.min(concurrency, 5), // Start with at least 5 concurrent - - // Disable autoscaling delays - we want full speed immediately - autoscaledPoolOptions: { - desiredConcurrency: concurrency, - minConcurrency: Math.min(concurrency, 5), - maxConcurrency: concurrency, - scaleUpStepRatio: 1, // Scale up immediately - scaleDownStepRatio: 0.5, - }, - - // Minimal delays - let maxRequestsPerMinute handle rate limiting - sameDomainDelaySecs: 0, // No delay between requests // Accept additional MIME types additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index f146572..3a6fa09 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -55,25 +55,12 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, - // Rate limiting - use maxRequestsPerMinute as primary control + // Rate limiting maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, maxRequestRetries: config.maxRequestRetries || 3, - // Concurrency settings - disable autoscaling for consistent speed + // High concurrency for throughput maxConcurrency: concurrency, - minConcurrency: Math.min(concurrency, 5), // Start with at least 5 concurrent - - // Disable autoscaling delays - we want full speed immediately - autoscaledPoolOptions: { - desiredConcurrency: concurrency, - minConcurrency: Math.min(concurrency, 5), - maxConcurrency: concurrency, - scaleUpStepRatio: 1, // Scale up immediately - scaleDownStepRatio: 0.5, - }, - - // Minimal delays - let maxRequestsPerMinute handle rate limiting - sameDomainDelaySecs: 0, // No delay between requests // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], From 9cd68c6ed8450e06443e705df3fa22f69a1c94c6 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 11:16:00 +0100 Subject: [PATCH 087/134] fix(crawler): correct spatial extent format in database insertion - Updated the spatial extent string format from EPSG:4326 to SRID=4326 to comply with EWKT requirements. - Ensured proper handling of bounding box coordinates for polygon creation in the database insertion process. --- crawler/utils/db.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index e93434e..07dc46e 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -94,7 +94,8 @@ async function insertOrUpdateCollection(collection) { if (bbox && bbox.length === 4) { // Create polygon from bbox [west, south, east, north] - spatialExtent = `EPSG:4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; + // EWKT format requires SRID=4326, not EPSG:4326 + spatialExtent = `SRID=4326;POLYGON((${bbox[0]} ${bbox[1]}, ${bbox[2]} ${bbox[1]}, ${bbox[2]} ${bbox[3]}, ${bbox[0]} ${bbox[3]}, ${bbox[0]} ${bbox[1]}))`; } // Parse temporal extent From e140246b4fd96a38d028199cf0d81a6965e89bed Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 11:44:48 +0100 Subject: [PATCH 088/134] feat(crawler): add deadlock handling and retry logic for database collection updates - Implemented a deadlock detection mechanism to identify PostgreSQL deadlock errors during collection insertion or update. - Introduced a retry logic with exponential backoff for handling deadlocks, allowing for more robust database operations. - Enhanced the internal collection insertion function to support improved querying by stac_id and source_url, ensuring better handling of collections with the same title from different sources. - Updated error logging to differentiate between deadlock errors and other types of errors during database operations. --- crawler/utils/db.js | 92 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 07dc46e..dc1d428 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -67,13 +67,62 @@ async function insertOrUpdateCatalog(catalog) { } /** - * Insert or update a collection in the database + * Check if an error is a PostgreSQL deadlock error + * @param {Error} error - The error to check + * @returns {boolean} true if it's a deadlock error + */ +function isDeadlockError(error) { + // PostgreSQL deadlock error code is '40P01' + return error.code === '40P01' || error.message?.includes('deadlock detected'); +} + +/** + * Sleep for a given number of milliseconds + * @param {number} ms - Milliseconds to sleep + * @returns {Promise} + */ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Insert or update a collection in the database with deadlock retry logic * @param {Object} collection - STAC collection object + * @param {number} maxRetries - Maximum number of retry attempts for deadlocks (default: 3) * @returns {Promise} collection ID */ -async function insertOrUpdateCollection(collection) { - +async function insertOrUpdateCollection(collection, maxRetries = 3) { + let lastError; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await _insertOrUpdateCollectionInternal(collection); + } catch (error) { + lastError = error; + + if (isDeadlockError(error) && attempt < maxRetries) { + // Exponential backoff: 100ms, 200ms, 400ms, ... + const delayMs = 100 * Math.pow(2, attempt - 1) + Math.random() * 50; + console.warn(`WARN [DB] Deadlock detected for collection "${collection.title || collection.id}", retrying in ${Math.round(delayMs)}ms (attempt ${attempt}/${maxRetries})`); + await sleep(delayMs); + continue; + } + + // Not a deadlock or max retries reached, throw the error + throw error; + } + } + + // Should not reach here, but just in case + throw lastError; +} +/** + * Internal implementation of insertOrUpdateCollection + * @param {Object} collection - STAC collection object + * @returns {Promise} collection ID + */ +async function _insertOrUpdateCollectionInternal(collection) { const client = await pool.connect(); try { await client.query('BEGIN'); @@ -130,11 +179,33 @@ async function insertOrUpdateCollection(collection) { sourceUrl = selfLink?.href || rootLink?.href || null; } - // Check if collection with this title already exists - const existingCollection = await client.query( - 'SELECT id FROM collection WHERE title = $1', - [collectionTitle] - ); + // Check if collection with same stac_id from same source already exists + // This allows collections with the same title from different sources to coexist + let existingCollection; + if (stacId && sourceUrl) { + // Best case: match by stac_id + source_url (most precise) + existingCollection = await client.query( + 'SELECT id FROM collection WHERE stac_id = $1 AND full_json->>\'links\' LIKE $2', + [stacId, `%${sourceUrl}%`] + ); + } + + // Fallback: if no stac_id or source_url, check by title only (legacy behavior) + if (!existingCollection || existingCollection.rows.length === 0) { + if (stacId) { + // Try matching by stac_id + title combination + existingCollection = await client.query( + 'SELECT id FROM collection WHERE stac_id = $1 AND title = $2', + [stacId, collectionTitle] + ); + } else { + // Last resort: match by title only (for collections without stac_id) + existingCollection = await client.query( + 'SELECT id FROM collection WHERE title = $1 AND stac_id IS NULL', + [collectionTitle] + ); + } + } let collectionId; if (existingCollection.rows.length > 0) { @@ -240,7 +311,10 @@ async function insertOrUpdateCollection(collection) { return collectionId; } catch (error) { await client.query('ROLLBACK'); - console.error('Error inserting collection:', error.message); + // Only log non-deadlock errors here, deadlocks are handled by retry wrapper + if (!isDeadlockError(error)) { + console.error('Error inserting collection:', error.message); + } throw error; } finally { client.release(); From 9fe7298ac2da627d22cb0226459bc9df324e9587 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 14:29:58 +0100 Subject: [PATCH 089/134] refactor(crawler): optimize batch sizes and memory management for low RAM servers - Reduced batch sizes for saving collections and clearing arrays to 25 to accommodate servers with limited RAM (2GB). - Updated API and catalog handling to create STAC objects with memory efficiency in mind, minimizing stored data. - Enhanced garbage collection by dereferencing large objects after processing to improve memory usage. - stac migrate true --- crawler/apis/api.js | 21 ++++++++++++++------- crawler/utils/config.js | 10 +++++----- crawler/utils/handlers.js | 39 ++++++++++++++++++++++++++------------- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index ec7688c..3365c17 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -18,15 +18,17 @@ import { /** * Batch size for saving collections to database during API crawling + * Set low (25) for servers with limited RAM (2GB) * @type {number} */ -const BATCH_SIZE = 500; +const BATCH_SIZE = 25; /** * Batch size for clearing apis array to free memory + * Set low (25) for servers with limited RAM (2GB) * @type {number} */ -const API_CLEAR_BATCH_SIZE = 500; +const API_CLEAR_BATCH_SIZE = 25; /** * Checks if batch size is reached and flushes if necessary @@ -293,7 +295,7 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max let stacObj; try { - stacObj = create(json, false); + stacObj = create(json, true); results.stats.stacCompliant++; if (typeof stacObj.isCatalog === 'function' && stacObj.isCatalog()) { @@ -308,10 +310,9 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max } results.stats.apisProcessed++; + // Only track minimal info to reduce memory results.apis.push({ - id: apiId, - url: request.url, - stacType: stacObj.isCollection() ? 'collection' : 'catalog' + id: apiId }); // If this is a STAC Collection directly, extract and store it @@ -426,6 +427,9 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max } } } + + // Help garbage collector by dereferencing large objects + stacObj = null; } /** @@ -437,7 +441,7 @@ async function handleApiCollection({ request, json, crawler, log, indent, result let stacObj; try { - stacObj = create(json, false); + stacObj = create(json, true); if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { const collection = normalizeCollection(stacObj, results.collections.length); @@ -452,6 +456,9 @@ async function handleApiCollection({ request, json, crawler, log, indent, result } catch (parseError) { log.warning(`${indent}Skipping non-compliant STAC collection at ${request.url}`); } + + // Help garbage collector + stacObj = null; } export { diff --git a/crawler/utils/config.js b/crawler/utils/config.js index c7e76ed..e0ebdcf 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -36,7 +36,7 @@ dotenv.config(); function getConfig() { const cliArgs = parseCliArgs(); - // Default configuration + // Default configuration (optimized for 2GB RAM servers) const defaults = { mode: 'both', // 'catalogs', 'apis', or 'both' maxCatalogs: 10, // Maximum number of catalogs to crawl @@ -44,10 +44,10 @@ function getConfig() { timeout: 30000, // Timeout in milliseconds (30 seconds) maxDepth: 10, // Maximum recursion depth for nested catalogs (0 = unlimited) - // NEW: Parallel crawling options (recommended for performance) - parallelDomains: 5, // Number of domains to crawl in parallel - maxRequestsPerMinutePerDomain: 120, // Max requests per minute PER domain - maxConcurrencyPerDomain: 20, // Max concurrent requests per domain (must be > req/sec) + // Parallel crawling options (reduced for 2GB RAM servers) + parallelDomains: 2, // Number of domains to crawl in parallel (reduced from 5) + maxRequestsPerMinutePerDomain: 60, // Max requests per minute PER domain (reduced from 120) + maxConcurrencyPerDomain: 5, // Max concurrent requests per domain (reduced from 20) // Legacy rate limiting options (still supported but parallel options are preferred) maxConcurrency: 5, // Maximum number of concurrent requests (global) diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 922ff2e..907bae1 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -11,16 +11,18 @@ import db from './db.js'; /** * Batch size for saving collections to database * After this many collections are collected, they will be flushed to DB + * Set low (25) for servers with limited RAM (2GB) * @type {number} */ -const BATCH_SIZE = 500; +const BATCH_SIZE = 25; /** * Batch size for clearing catalogs array to free memory * The catalogs array is only used for statistics, so we clear it periodically + * Set low (25) for servers with limited RAM (2GB) * @type {number} */ -const CATALOG_CLEAR_BATCH_SIZE = 500; +const CATALOG_CLEAR_BATCH_SIZE = 25; /** * Flushes collected collections to the database and clears the array @@ -115,7 +117,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Using migrate=false to avoid issues with stac-migrate and newer STAC versions let stacCatalog; try { - stacCatalog = create(json, false); + stacCatalog = create(json, true); results.stats.stacCompliant++; // Log STAC object type @@ -132,15 +134,17 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul } results.stats.catalogsProcessed++; + // Only track minimal info to reduce memory - don't store full catalog data results.catalogs.push({ id: catalogId, - url: request.url, - depth, - stacType: stacCatalog.isCatalog() ? 'catalog' : 'collection' + depth }); // If this is a STAC Collection (not a catalog), extract and store it - if (typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection()) { + // Collections don't have /collections endpoints, so we skip tryCollectionEndpoints for them + const isCollection = typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection(); + + if (isCollection) { const collection = normalizeCollection(stacCatalog, results.collections.length); results.collections.push(collection); results.stats.collectionsFound++; @@ -148,11 +152,13 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Check if we should flush to database await checkAndFlush(results, log); + } else { + // Only try /collections endpoint for Catalogs, not Collections + // Static STAC catalogs don't have /collections endpoints - they use rel="child" links + // STAC APIs have /collections endpoints and advertise them via rel="data" or rel="collections" + await tryCollectionEndpoints(stacCatalog, request.url, catalogId, depth, crawler, log, indent); } - // Try to get collections from this catalog (using STAC link discovery) - await tryCollectionEndpoints(stacCatalog, request.url, catalogId, depth, crawler, log, indent); - // Extract and enqueue child catalog links using stac-js if (stacCatalog && typeof stacCatalog.getChildLinks === 'function') { const childLinks = stacCatalog.getChildLinks(); @@ -254,6 +260,9 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Ensure memory is cleared periodically even if no collections were found await checkAndFlush(results, log); + + // Help garbage collector by dereferencing large objects + stacCatalog = null; } /** @@ -274,7 +283,7 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL let stacObj; try { - stacObj = create(json, false); + stacObj = create(json, true); } catch (parseError) { log.warning(`${indent}Skipping non-compliant STAC collections at ${request.url}`); return; @@ -289,7 +298,7 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Handle array of collections collectionsData = json.map(col => { try { - return create(col, false); + return create(col, true); } catch { return null; } @@ -298,7 +307,7 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Handle nested collections property collectionsData = json.collections.map(col => { try { - return create(col, false); + return create(col, true); } catch { return null; } @@ -324,4 +333,8 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Check if we should flush to database await checkAndFlush(results, log); } + + // Help garbage collector by dereferencing large objects + stacObj = null; + collectionsData = null; } \ No newline at end of file From 890e35ec4b6d558ccc001298c5f80e781ca50fe3 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 15:00:20 +0100 Subject: [PATCH 090/134] feat(crawler): integrate global statistics tracking for enhanced metrics - Added a new global statistics module to track and aggregate metrics across all crawlers. - Implemented start and stop methods for tracking total requests, successful requests, and other key metrics. - Updated API and catalog crawlers to utilize global statistics, including logging of final stats and domain processing metrics. - Enhanced request handling to increment statistics for total requests, successful requests, and failures. - Improved logging to provide detailed statistics on crawling performance and throughput. --- crawler/apis/api.js | 46 +++++++-- crawler/catalogs/catalog.js | 48 ++++++++-- crawler/index.js | 15 +-- crawler/utils/globalStats.js | 180 +++++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 23 deletions(-) create mode 100644 crawler/utils/globalStats.js diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 3365c17..96f660f 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -15,6 +15,7 @@ import { calculateRateLimits, logDomainStats } from '../utils/parallel.js'; +import globalStats from '../utils/globalStats.js'; /** * Batch size for saving collections to database during API crawling @@ -100,11 +101,17 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { // High concurrency for throughput maxConcurrency: concurrency, + // Disable periodic statistics logging (we have our own end statistics) + statisticsOptions: { + logIntervalSecs: null, + }, + // Accept additional MIME types additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], async requestHandler({ request, json, body, crawler, log }) { results.stats.totalRequests++; + globalStats.increment('totalRequests'); const depth = request.userData?.depth || 0; const indent = ' '.repeat(Math.min(depth, 5)); @@ -129,6 +136,7 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { } results.stats.successfulRequests++; + globalStats.increment('successfulRequests'); } catch (error) { log.error(`${indent}Error handling ${request.label} at ${request.url}: ${error.message}`); throw error; @@ -137,6 +145,7 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { async failedRequestHandler({ request, error, log }) { results.stats.failedRequests++; + globalStats.increment('failedRequests'); const indent = ' '; const apiId = request.userData?.apiId || 'unknown'; @@ -144,6 +153,7 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { log.info(`${indent}[STAC VALIDATION FAILED] ${apiId} at ${request.url}`); log.info(`${indent} Reason: ${error.message}`); results.stats.nonCompliant++; + globalStats.increment('nonCompliant'); } else if (error.message.includes('timeout')) { log.warning(`${indent}[TIMEOUT] ${apiId} at ${request.url}`); } else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { @@ -173,6 +183,9 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { await crawler.addRequests(initialRequests); + // Register domain as active in global stats + globalStats.domainStarted(domain); + console.log(` [${domain}] Starting: ${initialRequests.length} APIs, max ${rateLimits.maxRequestsPerMinute} req/min, ${concurrency} concurrent`); await crawler.run(); @@ -181,6 +194,16 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { results.stats.collectionsSaved += finalFlush.saved; results.stats.collectionsFailed += finalFlush.failed; + // Update global stats with final counts + globalStats.increment('collectionsSaved', results.stats.collectionsSaved); + globalStats.increment('collectionsFailed', results.stats.collectionsFailed); + globalStats.increment('collectionsFound', results.stats.collectionsFound); + globalStats.increment('apisProcessed', results.stats.apisProcessed); + globalStats.increment('stacCompliant', results.stats.stacCompliant); + + // Register domain as completed + globalStats.domainCompleted(domain); + // Clear apis array to free memory results.apis.length = 0; @@ -229,13 +252,6 @@ async function crawlApis(urls, isApi, config = {}) { // Number of domains to crawl in parallel (default: 5) const parallelDomains = config.parallelDomains || 5; - const maxRequestsPerMinutePerDomain = config.maxRequestsPerMinutePerDomain || 120; - - console.log(`\n=== Parallel API Crawling Configuration ===`); - console.log(`Parallel domains: ${parallelDomains}`); - console.log(`Max requests/min per domain: ${maxRequestsPerMinutePerDomain}`); - console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); - console.log(`============================================\n`); // Create tasks for each domain const domainTasks = Array.from(domainUrlMap.entries()).map(([domain, domainUrls]) => { @@ -244,6 +260,9 @@ async function crawlApis(urls, isApi, config = {}) { console.log(`Starting parallel API crawl of ${domainUrlMap.size} domains (${parallelDomains} at a time)...\n`); + // Track total runtime for throughput calculation + const crawlStartTime = Date.now(); + // Execute with concurrency limit const allResults = await executeWithConcurrency( domainTasks, @@ -253,12 +272,23 @@ async function crawlApis(urls, isApi, config = {}) { } ); + const crawlEndTime = Date.now(); + const totalRuntimeMs = crawlEndTime - crawlStartTime; + const totalRuntimeMinutes = totalRuntimeMs / 60000; + // Aggregate all statistics const aggregatedStats = aggregateStats(allResults); - console.log('\n=== Parallel API Crawl Statistics ==='); + // Calculate actual throughput + const requestsPerMinute = totalRuntimeMinutes > 0 + ? Math.round(aggregatedStats.totalRequests / totalRuntimeMinutes) + : 0; + + console.log('\n=== API Crawl Statistics ==='); console.log(` Domains Processed: ${domainUrlMap.size}`); + console.log(` Total Runtime: ${Math.round(totalRuntimeMs / 1000)}s`); console.log(` Total Requests: ${aggregatedStats.totalRequests}`); + console.log(` Requests/Min (actual): ${requestsPerMinute}`); console.log(` Successful: ${aggregatedStats.successfulRequests}`); console.log(` Failed: ${aggregatedStats.failedRequests}`); console.log(` STAC Compliant: ${aggregatedStats.stacCompliant}`); diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 3a6fa09..67b47de 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -13,6 +13,7 @@ import { calculateRateLimits, logDomainStats } from '../utils/parallel.js'; +import globalStats from '../utils/globalStats.js'; /** * Creates and runs a single Crawlee HttpCrawler for a specific domain @@ -62,11 +63,17 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { // High concurrency for throughput maxConcurrency: concurrency, + // Disable periodic statistics logging (we have our own end statistics) + statisticsOptions: { + logIntervalSecs: null, + }, + // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) additionalMimeTypes: ['application/geo+json', 'text/plain', 'binary/octet-stream', 'application/octet-stream'], async requestHandler({ request, json, body, crawler, log }) { results.stats.totalRequests++; + globalStats.increment('totalRequests'); const depth = request.userData?.depth || 0; const indent = ' '.repeat(depth); @@ -90,6 +97,7 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { } results.stats.successfulRequests++; + globalStats.increment('successfulRequests'); } catch (error) { log.error(`${indent}Error handling ${request.label} at ${request.url}: ${error.message}`); throw error; @@ -98,6 +106,7 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { async failedRequestHandler({ request, error, log }) { results.stats.failedRequests++; + globalStats.increment('failedRequests'); const depth = request.userData?.depth || 0; const indent = ' '.repeat(depth); const catalogId = request.userData?.catalogId || 'unknown'; @@ -106,6 +115,7 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { log.info(`${indent}[STAC VALIDATION FAILED] ${catalogId} at ${request.url}`); log.info(`${indent} Reason: ${error.message}`); results.stats.nonCompliant++; + globalStats.increment('nonCompliant'); } else if (error.message.includes('timeout')) { log.warning(`${indent}[TIMEOUT] ${catalogId} at ${request.url}`); } else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) { @@ -135,6 +145,9 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { await crawler.addRequests(initialRequests); + // Register domain as active in global stats + globalStats.domainStarted(domain); + console.log(` [${domain}] Starting: ${initialRequests.length} catalogs, max ${rateLimits.maxRequestsPerMinute} req/min, ${concurrency} concurrent`); await crawler.run(); @@ -143,6 +156,16 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { results.stats.collectionsSaved += finalFlush.saved; results.stats.collectionsFailed += finalFlush.failed; + // Update global stats with final counts + globalStats.increment('collectionsSaved', results.stats.collectionsSaved); + globalStats.increment('collectionsFailed', results.stats.collectionsFailed); + globalStats.increment('collectionsFound', results.stats.collectionsFound); + globalStats.increment('catalogsProcessed', results.stats.catalogsProcessed); + globalStats.increment('stacCompliant', results.stats.stacCompliant); + + // Register domain as completed + globalStats.domainCompleted(domain); + // Clear catalogs array to free memory results.catalogs.length = 0; @@ -168,13 +191,6 @@ async function crawlCatalogs(initialCatalogs, config = {}) { // Number of domains to crawl in parallel (default: 5) const parallelDomains = config.parallelDomains || 5; - const maxRequestsPerMinutePerDomain = config.maxRequestsPerMinutePerDomain || 120; - - console.log(`\n=== Parallel Catalog Crawling Configuration ===`); - console.log(`Parallel domains: ${parallelDomains}`); - console.log(`Max requests/min per domain: ${maxRequestsPerMinutePerDomain}`); - console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); - console.log(`===============================================\n`); // Create tasks for each domain const domainTasks = Array.from(domainMap.entries()).map(([domain, catalogs]) => { @@ -183,6 +199,9 @@ async function crawlCatalogs(initialCatalogs, config = {}) { console.log(`Starting parallel crawl of ${domainMap.size} domains (${parallelDomains} at a time)...\n`); + // Track total runtime for throughput calculation + const crawlStartTime = Date.now(); + // Execute with concurrency limit const allResults = await executeWithConcurrency( domainTasks, @@ -192,12 +211,23 @@ async function crawlCatalogs(initialCatalogs, config = {}) { } ); + const crawlEndTime = Date.now(); + const totalRuntimeMs = crawlEndTime - crawlStartTime; + const totalRuntimeMinutes = totalRuntimeMs / 60000; + // Aggregate all statistics const aggregatedStats = aggregateStats(allResults); - console.log('\n=== Parallel Crawl Statistics ==='); + // Calculate actual throughput + const requestsPerMinute = totalRuntimeMinutes > 0 + ? Math.round(aggregatedStats.totalRequests / totalRuntimeMinutes) + : 0; + + console.log('\n=== Catalog Crawl Statistics ==='); console.log(` Domains Processed: ${domainMap.size}`); + console.log(` Total Runtime: ${Math.round(totalRuntimeMs / 1000)}s`); console.log(` Total Requests: ${aggregatedStats.totalRequests}`); + console.log(` Requests/Min (actual): ${requestsPerMinute}`); console.log(` Successful: ${aggregatedStats.successfulRequests}`); console.log(` Failed: ${aggregatedStats.failedRequests}`); console.log(` STAC Compliant: ${aggregatedStats.stacCompliant}`); @@ -206,7 +236,7 @@ async function crawlCatalogs(initialCatalogs, config = {}) { console.log(` Collections Found: ${aggregatedStats.collectionsFound}`); console.log(` Collections Saved to DB: ${aggregatedStats.collectionsSaved}`); console.log(` Collections Failed: ${aggregatedStats.collectionsFailed}`); - console.log('=================================\n'); + console.log('=========================================\n'); return { collections: [], diff --git a/crawler/index.js b/crawler/index.js index 31f4840..44c2952 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -10,6 +10,7 @@ import { crawlApis } from './apis/api.js'; import { getConfig, isStaticCatalogUrl } from './utils/config.js'; import { formatDuration } from './utils/time.js'; import db from './utils/db.js'; +import globalStats from './utils/globalStats.js'; /** * URL of the STAC Index API endpoint @@ -87,6 +88,10 @@ export const crawler = async () => { console.log(` Static Catalogs (mismarked as APIs): ${staticCatalogs.length}`); console.log(` Real APIs: ${realApis.length}\n`); + // Start global statistics tracking (no periodic logging, only final stats) + const totalItems = [...regularCatalogs, ...staticCatalogs, ...realApis].length; + globalStats.start(totalItems); + // Crawl catalogs if mode is 'catalogs' or 'both' if (config.mode === 'catalogs' || config.mode === 'both') { console.log('\nCrawling collections and nested catalogs with Crawlee...\n'); @@ -125,12 +130,7 @@ export const crawler = async () => { console.log(`Found ${realApis.length} real APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); try { - const apiResults = await crawlApis(apisToProcess, true, config); - // Collections are now saved to DB during the crawl (batch flushing) - console.log(`\nAPI Crawl Complete:`); - console.log(` Collections Found: ${apiResults.stats.collectionsFound}`); - console.log(` Collections Saved: ${apiResults.stats.collectionsSaved}`); - console.log(` Collections Failed: ${apiResults.stats.collectionsFailed}`); + await crawlApis(apisToProcess, true, config); } catch (error) { console.error(`Failed to crawl APIs: ${error.message}`); } @@ -147,6 +147,9 @@ export const crawler = async () => { crawlError = true; } } finally { + // Stop global statistics tracking and log final stats + globalStats.stop(); + // Close database connection if (!dbError) { try { diff --git a/crawler/utils/globalStats.js b/crawler/utils/globalStats.js new file mode 100644 index 0000000..bc36382 --- /dev/null +++ b/crawler/utils/globalStats.js @@ -0,0 +1,180 @@ +/** + * @fileoverview Global statistics tracker for aggregated crawler metrics + * Provides real-time statistics across all parallel crawlers + * @module utils/globalStats + */ + +import { log as crawleeLog } from 'crawlee'; + +/** + * Global statistics singleton that aggregates metrics from all crawlers + */ +class GlobalStatistics { + constructor() { + this.reset(); + this.intervalId = null; + this.intervalSecs = 60; // Log every 60 seconds like Crawlee + } + + /** + * Reset all statistics + */ + reset() { + this.startTime = null; + this.stats = { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0, + catalogsProcessed: 0, + apisProcessed: 0, + stacCompliant: 0, + nonCompliant: 0 + }; + this.activeDomains = new Set(); + this.completedDomains = 0; + this.totalDomains = 0; + } + + /** + * Start the statistics tracking + * @param {number} totalDomains - Total number of domains to process + * @param {number} intervalSecs - Logging interval in seconds (0 or null to disable periodic logging) + */ + start(totalDomains = 0, intervalSecs = 0) { + this.reset(); + this.startTime = Date.now(); + this.totalDomains = totalDomains; + this.intervalSecs = intervalSecs; + + // Only start periodic logging if intervalSecs > 0 + if (intervalSecs && intervalSecs > 0) { + this.intervalId = setInterval(() => { + this.logStatistics(); + }, this.intervalSecs * 1000); + } + } + + /** + * Stop the statistics tracking + */ + stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + // Log final statistics + this.logStatistics(true); + } + + /** + * Register a domain as active + * @param {string} domain - Domain name + */ + domainStarted(domain) { + this.activeDomains.add(domain); + } + + /** + * Register a domain as completed + * @param {string} domain - Domain name + */ + domainCompleted(domain) { + this.activeDomains.delete(domain); + this.completedDomains++; + } + + /** + * Increment a statistic counter (thread-safe for single-threaded Node.js) + * @param {string} stat - Statistic name + * @param {number} amount - Amount to increment (default: 1) + */ + increment(stat, amount = 1) { + if (this.stats.hasOwnProperty(stat)) { + this.stats[stat] += amount; + } + } + + /** + * Add stats from a completed domain crawl + * @param {Object} domainStats - Statistics object from a domain crawl + */ + addDomainStats(domainStats) { + if (!domainStats) return; + + for (const [key, value] of Object.entries(domainStats)) { + if (typeof value === 'number' && this.stats.hasOwnProperty(key)) { + this.stats[key] += value; + } + } + } + + /** + * Get current runtime in milliseconds + * @returns {number} Runtime in milliseconds + */ + getRuntimeMs() { + if (!this.startTime) return 0; + return Date.now() - this.startTime; + } + + /** + * Calculate requests per minute + * @returns {number} Requests per minute + */ + getRequestsPerMinute() { + const runtimeMinutes = this.getRuntimeMs() / 60000; + if (runtimeMinutes <= 0) return 0; + return Math.round(this.stats.totalRequests / runtimeMinutes); + } + + /** + * Log current statistics using Crawlee's logger + * @param {boolean} isFinal - Whether this is the final log + */ + logStatistics(isFinal = false) { + const runtimeMs = this.getRuntimeMs(); + const runtimeSecs = Math.round(runtimeMs / 1000); + const reqPerMin = this.getRequestsPerMinute(); + + const prefix = isFinal ? 'GlobalStatistics: Final' : 'GlobalStatistics'; + + const statsObj = { + requestsFinishedPerMinute: reqPerMin, + requestsTotal: this.stats.totalRequests, + requestsSuccessful: this.stats.successfulRequests, + requestsFailed: this.stats.failedRequests, + collectionsFound: this.stats.collectionsFound, + collectionsSaved: this.stats.collectionsSaved, + domainsActive: this.activeDomains.size, + domainsCompleted: this.completedDomains, + domainsTotal: this.totalDomains, + crawlerRuntimeSecs: runtimeSecs + }; + + crawleeLog.info(`${prefix}: ${JSON.stringify(statsObj)}`); + } + + /** + * Get current statistics snapshot + * @returns {Object} Current statistics + */ + getStats() { + return { + ...this.stats, + runtimeMs: this.getRuntimeMs(), + requestsPerMinute: this.getRequestsPerMinute(), + activeDomains: this.activeDomains.size, + completedDomains: this.completedDomains, + totalDomains: this.totalDomains + }; + } +} + +// Export singleton instance +const globalStats = new GlobalStatistics(); + +export default globalStats; +export { GlobalStatistics }; From 1e6c134a99754ed13e16720522edb66eb311ee55 Mon Sep 17 00:00:00 2001 From: mammutor Date: Wed, 21 Jan 2026 17:23:23 +0100 Subject: [PATCH 091/134] feat(crawler): enhance storage management and logging for API and catalog crawlers - Set unique storage directories for API and catalog crawlers to prevent conflicts during high concurrency. - Updated logging to provide detailed configuration information, including parallel domains and maximum requests per minute per domain. - Reduced periodic statistics logging frequency to improve performance while maintaining end statistics. - Enhanced error handling in concurrency execution to log task failures with detailed error messages. --- crawler/apis/api.js | 16 +++++++++++++--- crawler/catalogs/catalog.js | 16 +++++++++++++--- crawler/utils/parallel.js | 2 ++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 96f660f..348c862 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -59,7 +59,10 @@ async function checkAndFlushApi(results, log) { * @returns {Promise} Crawl results with collections and statistics */ async function crawlSingleApiDomain(urls, domain, config = {}) { - // Use in-memory storage to avoid file lock race conditions under high concurrency + // Set unique storage directory for this crawler to avoid conflicts + const safeDomain = domain.replace(/[^a-zA-Z0-9]/g, '_'); + const storageDir = `/tmp/crawlee-api-${safeDomain}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + Configuration.getGlobalConfig().set('storageDir', storageDir); Configuration.getGlobalConfig().set('persistStorage', false); const timeoutSecs = config.timeout && config.timeout !== Infinity @@ -101,9 +104,9 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { // High concurrency for throughput maxConcurrency: concurrency, - // Disable periodic statistics logging (we have our own end statistics) + // Reduce periodic statistics logging (we have our own end statistics) statisticsOptions: { - logIntervalSecs: null, + logIntervalSecs: 60, }, // Accept additional MIME types @@ -252,6 +255,13 @@ async function crawlApis(urls, isApi, config = {}) { // Number of domains to crawl in parallel (default: 5) const parallelDomains = config.parallelDomains || 5; + const maxRequestsPerMinutePerDomain = config.maxRequestsPerMinutePerDomain || 120; + + console.log(`\n=== Parallel API Crawling Configuration ===`); + console.log(`Parallel domains: ${parallelDomains}`); + console.log(`Max requests/min per domain: ${maxRequestsPerMinutePerDomain}`); + console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); + console.log(`============================================\n`); // Create tasks for each domain const domainTasks = Array.from(domainUrlMap.entries()).map(([domain, domainUrls]) => { diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 67b47de..4fb43d8 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -24,7 +24,10 @@ import globalStats from '../utils/globalStats.js'; * @returns {Promise} Crawl results with collections and statistics */ async function crawlSingleDomain(catalogs, domain, config = {}) { - // Use in-memory storage to avoid file lock race conditions under high concurrency + // Set unique storage directory for this crawler to avoid conflicts + const safeDomain = domain.replace(/[^a-zA-Z0-9]/g, '_'); + const storageDir = `/tmp/crawlee-catalog-${safeDomain}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + Configuration.getGlobalConfig().set('storageDir', storageDir); Configuration.getGlobalConfig().set('persistStorage', false); const timeoutSecs = config.timeout && config.timeout !== Infinity @@ -63,9 +66,9 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { // High concurrency for throughput maxConcurrency: concurrency, - // Disable periodic statistics logging (we have our own end statistics) + // Reduce periodic statistics logging (we have our own end statistics) statisticsOptions: { - logIntervalSecs: null, + logIntervalSecs: 60, }, // Accept additional MIME types (some STAC endpoints return JSON with incorrect Content-Type) @@ -191,6 +194,13 @@ async function crawlCatalogs(initialCatalogs, config = {}) { // Number of domains to crawl in parallel (default: 5) const parallelDomains = config.parallelDomains || 5; + const maxRequestsPerMinutePerDomain = config.maxRequestsPerMinutePerDomain || 120; + + console.log(`\n=== Parallel Catalog Crawling Configuration ===`); + console.log(`Parallel domains: ${parallelDomains}`); + console.log(`Max requests/min per domain: ${maxRequestsPerMinutePerDomain}`); + console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); + console.log(`===============================================\n`); // Create tasks for each domain const domainTasks = Array.from(domainMap.entries()).map(([domain, catalogs]) => { diff --git a/crawler/utils/parallel.js b/crawler/utils/parallel.js index bf3247f..f6bcd0a 100644 --- a/crawler/utils/parallel.js +++ b/crawler/utils/parallel.js @@ -115,6 +115,8 @@ export async function executeWithConcurrency(tasks, concurrency, onProgress = nu const result = await tasks[currentIndex](); results[currentIndex] = result; } catch (error) { + console.error(`[executeWithConcurrency] Task ${currentIndex} failed: ${error.message}`); + console.error(error.stack); results[currentIndex] = { error: error.message, stats: {} }; } From 6afe0f1360490ab3ea871448456ac88b9cfbd310 Mon Sep 17 00:00:00 2001 From: mammutor Date: Thu, 22 Jan 2026 11:46:33 +0100 Subject: [PATCH 092/134] refactor(crawler): optimize memory management and request limits for low RAM environments - Reduced batch sizes for saving collections and clearing arrays to improve memory efficiency on servers with limited RAM. - Adjusted maximum request retries and concurrency settings to enhance stability and prevent queue explosion. - Implemented limits on the number of child links processed to avoid exponential growth in memory usage. - Updated configuration parameters to reflect new memory safety measures across API and catalog crawlers. --- crawler/apis/api.js | 24 +++++++++++++++++------- crawler/catalogs/catalog.js | 7 +++++-- crawler/utils/config.js | 26 ++++++++++++++++++-------- crawler/utils/handlers.js | 13 ++++++++++--- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 348c862..1134f5d 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -19,17 +19,17 @@ import globalStats from '../utils/globalStats.js'; /** * Batch size for saving collections to database during API crawling - * Set low (25) for servers with limited RAM (2GB) + * Set low (15) for servers with limited RAM * @type {number} */ -const BATCH_SIZE = 25; +const BATCH_SIZE = 15; /** * Batch size for clearing apis array to free memory - * Set low (25) for servers with limited RAM (2GB) + * Set low (10) for servers with limited RAM * @type {number} */ -const API_CLEAR_BATCH_SIZE = 25; +const API_CLEAR_BATCH_SIZE = 10; /** * Checks if batch size is reached and flushes if necessary @@ -99,9 +99,12 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { // Rate limiting maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, - maxRequestRetries: config.maxRequestRetries || 3, + maxRequestRetries: config.maxRequestRetries || 2, - // High concurrency for throughput + // Memory safety: limit total requests per crawl to prevent queue explosion + maxRequestsPerCrawl: config.maxRequestsPerCrawl || 500, + + // Reduced concurrency for memory safety maxConcurrency: concurrency, // Reduce periodic statistics logging (we have our own end statistics) @@ -396,7 +399,7 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max // Also check for child links (nested catalogs) if (typeof stacObj.getChildLinks === 'function') { - const childLinks = stacObj.getChildLinks(); + let childLinks = stacObj.getChildLinks(); if (childLinks.length > 0) { log.info(`${indent}Found ${childLinks.length} child catalog links`); @@ -407,6 +410,13 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max return; } + // Memory safety: limit number of child links to prevent exponential growth + const maxChildLinks = 20; // Same as catalog crawler default + if (childLinks.length > maxChildLinks) { + log.warning(`${indent}Limiting child links from ${childLinks.length} to ${maxChildLinks} (memory safety)`); + childLinks = childLinks.slice(0, maxChildLinks); + } + const childRequests = childLinks .map((link, idx) => { let childUrl; diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 4fb43d8..98de43f 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -61,9 +61,12 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { // Rate limiting maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, - maxRequestRetries: config.maxRequestRetries || 3, + maxRequestRetries: config.maxRequestRetries || 2, - // High concurrency for throughput + // Memory safety: limit total requests per crawl to prevent queue explosion + maxRequestsPerCrawl: config.maxRequestsPerCrawl || 500, + + // Reduced concurrency for memory safety maxConcurrency: concurrency, // Reduce periodic statistics logging (we have our own end statistics) diff --git a/crawler/utils/config.js b/crawler/utils/config.js index e0ebdcf..26eb550 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -42,18 +42,22 @@ function getConfig() { maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl timeout: 30000, // Timeout in milliseconds (30 seconds) - maxDepth: 10, // Maximum recursion depth for nested catalogs (0 = unlimited) + maxDepth: 3, // Maximum recursion depth for nested catalogs (reduced from 10 for memory) // Parallel crawling options (reduced for 2GB RAM servers) - parallelDomains: 2, // Number of domains to crawl in parallel (reduced from 5) - maxRequestsPerMinutePerDomain: 60, // Max requests per minute PER domain (reduced from 120) - maxConcurrencyPerDomain: 5, // Max concurrent requests per domain (reduced from 20) + parallelDomains: 1, // Number of domains to crawl in parallel (reduced to 1 for memory) + maxRequestsPerMinutePerDomain: 30, // Max requests per minute PER domain (reduced from 60) + maxConcurrencyPerDomain: 3, // Max concurrent requests per domain (reduced from 5) + + // Memory safety limits + maxRequestsPerCrawl: 500, // Maximum total requests per domain crawl (prevents queue explosion) + maxChildLinksPerCatalog: 20, // Maximum child links to follow per catalog (prevents exponential growth) // Legacy rate limiting options (still supported but parallel options are preferred) - maxConcurrency: 5, // Maximum number of concurrent requests (global) - maxRequestsPerMinute: 60, // Maximum requests per minute (global) - sameDomainDelaySecs: 1, // Delay between requests to the same domain - maxRequestRetries: 3 // Maximum number of retries for failed requests + maxConcurrency: 3, // Maximum number of concurrent requests (global) - reduced + maxRequestsPerMinute: 30, // Maximum requests per minute (global) - reduced + sameDomainDelaySecs: 2, // Delay between requests to the same domain - increased + maxRequestRetries: 2 // Maximum number of retries for failed requests - reduced }; // Build configuration with precedence: CLI > ENV > Defaults @@ -76,6 +80,12 @@ function getConfig() { maxConcurrencyPerDomain: cliArgs.maxConcurrencyPerDomain !== undefined ? cliArgs.maxConcurrencyPerDomain : (process.env.MAX_CONCURRENCY_PER_DOMAIN ? parseInt(process.env.MAX_CONCURRENCY_PER_DOMAIN, 10) : defaults.maxConcurrencyPerDomain), + // Memory safety limits + maxRequestsPerCrawl: cliArgs.maxRequestsPerCrawl !== undefined ? cliArgs.maxRequestsPerCrawl : + (process.env.MAX_REQUESTS_PER_CRAWL ? parseInt(process.env.MAX_REQUESTS_PER_CRAWL, 10) : defaults.maxRequestsPerCrawl), + maxChildLinksPerCatalog: cliArgs.maxChildLinksPerCatalog !== undefined ? cliArgs.maxChildLinksPerCatalog : + (process.env.MAX_CHILD_LINKS_PER_CATALOG ? parseInt(process.env.MAX_CHILD_LINKS_PER_CATALOG, 10) : defaults.maxChildLinksPerCatalog), + // Legacy rate limiting options maxConcurrency: cliArgs.maxConcurrency !== undefined ? cliArgs.maxConcurrency : (process.env.MAX_CONCURRENCY ? parseInt(process.env.MAX_CONCURRENCY, 10) : defaults.maxConcurrency), diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 907bae1..df55ed2 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -19,10 +19,10 @@ const BATCH_SIZE = 25; /** * Batch size for clearing catalogs array to free memory * The catalogs array is only used for statistics, so we clear it periodically - * Set low (25) for servers with limited RAM (2GB) + * Set very low (10) for servers with limited RAM * @type {number} */ -const CATALOG_CLEAR_BATCH_SIZE = 25; +const CATALOG_CLEAR_BATCH_SIZE = 10; /** * Flushes collected collections to the database and clears the array @@ -161,7 +161,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Extract and enqueue child catalog links using stac-js if (stacCatalog && typeof stacCatalog.getChildLinks === 'function') { - const childLinks = stacCatalog.getChildLinks(); + let childLinks = stacCatalog.getChildLinks(); if (childLinks.length > 0) { log.info(`${indent}Found ${childLinks.length} child catalog links`); @@ -174,6 +174,13 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul return; } + // Memory safety: limit number of child links to prevent exponential growth + const maxChildLinks = config.maxChildLinksPerCatalog || 20; + if (childLinks.length > maxChildLinks) { + log.warning(`${indent}Limiting child links from ${childLinks.length} to ${maxChildLinks} (memory safety)`); + childLinks = childLinks.slice(0, maxChildLinks); + } + // Log first child link structure for debugging if (childLinks[0]) { log.debug(`${indent}Sample child link structure:`, { From f17ae3eb3c08f84eb7721e509c006508b9cc0f08 Mon Sep 17 00:00:00 2001 From: mammutor Date: Thu, 22 Jan 2026 12:02:55 +0100 Subject: [PATCH 093/134] refactor(crawler): adjust batch sizes and request limits for improved memory management - Increased batch sizes for saving collections and clearing arrays to 25 for better performance on servers with limited RAM (2GB). - Updated maximum request retries from 2 to 3 to enhance stability during API and catalog crawling. - Removed memory safety limits on child links processing to streamline handling of nested catalogs. - Revised configuration parameters to reflect the new settings for concurrency and request limits across the crawler modules. --- crawler/apis/api.js | 24 +++++++----------------- crawler/catalogs/catalog.js | 7 ++----- crawler/utils/config.js | 26 ++++++++------------------ crawler/utils/handlers.js | 13 +++---------- 4 files changed, 20 insertions(+), 50 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 1134f5d..348c862 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -19,17 +19,17 @@ import globalStats from '../utils/globalStats.js'; /** * Batch size for saving collections to database during API crawling - * Set low (15) for servers with limited RAM + * Set low (25) for servers with limited RAM (2GB) * @type {number} */ -const BATCH_SIZE = 15; +const BATCH_SIZE = 25; /** * Batch size for clearing apis array to free memory - * Set low (10) for servers with limited RAM + * Set low (25) for servers with limited RAM (2GB) * @type {number} */ -const API_CLEAR_BATCH_SIZE = 10; +const API_CLEAR_BATCH_SIZE = 25; /** * Checks if batch size is reached and flushes if necessary @@ -99,12 +99,9 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { // Rate limiting maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, - maxRequestRetries: config.maxRequestRetries || 2, + maxRequestRetries: config.maxRequestRetries || 3, - // Memory safety: limit total requests per crawl to prevent queue explosion - maxRequestsPerCrawl: config.maxRequestsPerCrawl || 500, - - // Reduced concurrency for memory safety + // High concurrency for throughput maxConcurrency: concurrency, // Reduce periodic statistics logging (we have our own end statistics) @@ -399,7 +396,7 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max // Also check for child links (nested catalogs) if (typeof stacObj.getChildLinks === 'function') { - let childLinks = stacObj.getChildLinks(); + const childLinks = stacObj.getChildLinks(); if (childLinks.length > 0) { log.info(`${indent}Found ${childLinks.length} child catalog links`); @@ -410,13 +407,6 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max return; } - // Memory safety: limit number of child links to prevent exponential growth - const maxChildLinks = 20; // Same as catalog crawler default - if (childLinks.length > maxChildLinks) { - log.warning(`${indent}Limiting child links from ${childLinks.length} to ${maxChildLinks} (memory safety)`); - childLinks = childLinks.slice(0, maxChildLinks); - } - const childRequests = childLinks .map((link, idx) => { let childUrl; diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 98de43f..4fb43d8 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -61,12 +61,9 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { // Rate limiting maxRequestsPerMinute: rateLimits.maxRequestsPerMinute, - maxRequestRetries: config.maxRequestRetries || 2, + maxRequestRetries: config.maxRequestRetries || 3, - // Memory safety: limit total requests per crawl to prevent queue explosion - maxRequestsPerCrawl: config.maxRequestsPerCrawl || 500, - - // Reduced concurrency for memory safety + // High concurrency for throughput maxConcurrency: concurrency, // Reduce periodic statistics logging (we have our own end statistics) diff --git a/crawler/utils/config.js b/crawler/utils/config.js index 26eb550..e0ebdcf 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -42,22 +42,18 @@ function getConfig() { maxCatalogs: 10, // Maximum number of catalogs to crawl maxApis: 5, // Maximum number of APIs to crawl timeout: 30000, // Timeout in milliseconds (30 seconds) - maxDepth: 3, // Maximum recursion depth for nested catalogs (reduced from 10 for memory) + maxDepth: 10, // Maximum recursion depth for nested catalogs (0 = unlimited) // Parallel crawling options (reduced for 2GB RAM servers) - parallelDomains: 1, // Number of domains to crawl in parallel (reduced to 1 for memory) - maxRequestsPerMinutePerDomain: 30, // Max requests per minute PER domain (reduced from 60) - maxConcurrencyPerDomain: 3, // Max concurrent requests per domain (reduced from 5) - - // Memory safety limits - maxRequestsPerCrawl: 500, // Maximum total requests per domain crawl (prevents queue explosion) - maxChildLinksPerCatalog: 20, // Maximum child links to follow per catalog (prevents exponential growth) + parallelDomains: 2, // Number of domains to crawl in parallel (reduced from 5) + maxRequestsPerMinutePerDomain: 60, // Max requests per minute PER domain (reduced from 120) + maxConcurrencyPerDomain: 5, // Max concurrent requests per domain (reduced from 20) // Legacy rate limiting options (still supported but parallel options are preferred) - maxConcurrency: 3, // Maximum number of concurrent requests (global) - reduced - maxRequestsPerMinute: 30, // Maximum requests per minute (global) - reduced - sameDomainDelaySecs: 2, // Delay between requests to the same domain - increased - maxRequestRetries: 2 // Maximum number of retries for failed requests - reduced + maxConcurrency: 5, // Maximum number of concurrent requests (global) + maxRequestsPerMinute: 60, // Maximum requests per minute (global) + sameDomainDelaySecs: 1, // Delay between requests to the same domain + maxRequestRetries: 3 // Maximum number of retries for failed requests }; // Build configuration with precedence: CLI > ENV > Defaults @@ -80,12 +76,6 @@ function getConfig() { maxConcurrencyPerDomain: cliArgs.maxConcurrencyPerDomain !== undefined ? cliArgs.maxConcurrencyPerDomain : (process.env.MAX_CONCURRENCY_PER_DOMAIN ? parseInt(process.env.MAX_CONCURRENCY_PER_DOMAIN, 10) : defaults.maxConcurrencyPerDomain), - // Memory safety limits - maxRequestsPerCrawl: cliArgs.maxRequestsPerCrawl !== undefined ? cliArgs.maxRequestsPerCrawl : - (process.env.MAX_REQUESTS_PER_CRAWL ? parseInt(process.env.MAX_REQUESTS_PER_CRAWL, 10) : defaults.maxRequestsPerCrawl), - maxChildLinksPerCatalog: cliArgs.maxChildLinksPerCatalog !== undefined ? cliArgs.maxChildLinksPerCatalog : - (process.env.MAX_CHILD_LINKS_PER_CATALOG ? parseInt(process.env.MAX_CHILD_LINKS_PER_CATALOG, 10) : defaults.maxChildLinksPerCatalog), - // Legacy rate limiting options maxConcurrency: cliArgs.maxConcurrency !== undefined ? cliArgs.maxConcurrency : (process.env.MAX_CONCURRENCY ? parseInt(process.env.MAX_CONCURRENCY, 10) : defaults.maxConcurrency), diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index df55ed2..907bae1 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -19,10 +19,10 @@ const BATCH_SIZE = 25; /** * Batch size for clearing catalogs array to free memory * The catalogs array is only used for statistics, so we clear it periodically - * Set very low (10) for servers with limited RAM + * Set low (25) for servers with limited RAM (2GB) * @type {number} */ -const CATALOG_CLEAR_BATCH_SIZE = 10; +const CATALOG_CLEAR_BATCH_SIZE = 25; /** * Flushes collected collections to the database and clears the array @@ -161,7 +161,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Extract and enqueue child catalog links using stac-js if (stacCatalog && typeof stacCatalog.getChildLinks === 'function') { - let childLinks = stacCatalog.getChildLinks(); + const childLinks = stacCatalog.getChildLinks(); if (childLinks.length > 0) { log.info(`${indent}Found ${childLinks.length} child catalog links`); @@ -174,13 +174,6 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul return; } - // Memory safety: limit number of child links to prevent exponential growth - const maxChildLinks = config.maxChildLinksPerCatalog || 20; - if (childLinks.length > maxChildLinks) { - log.warning(`${indent}Limiting child links from ${childLinks.length} to ${maxChildLinks} (memory safety)`); - childLinks = childLinks.slice(0, maxChildLinks); - } - // Log first child link structure for debugging if (childLinks[0]) { log.debug(`${indent}Sample child link structure:`, { From 5e8cb1accc22c3644e73780389edfac84785bc52 Mon Sep 17 00:00:00 2001 From: Lenn Date: Tue, 27 Jan 2026 00:39:33 +0100 Subject: [PATCH 094/134] bug: the type should now be a catalog or collection --- crawler/utils/db.js | 5 ++--- crawler/utils/normalization.js | 13 ++++++++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 5b30ae1..f96ab4b 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -229,7 +229,7 @@ async function _insertOrUpdateCollectionInternal(collection) { [ stacId, collection.stac_version || null, - collection.type || 'Collection', + collection.type || null, collection.description || null, collection.license || null, spatialExtent, @@ -238,7 +238,6 @@ async function _insertOrUpdateCollectionInternal(collection) { false, // is_api - will be determined by crawler true, // is_active JSON.stringify(collection), - sourceUrl, collectionId ] ); @@ -255,7 +254,7 @@ async function _insertOrUpdateCollectionInternal(collection) { [ stacId, collection.stac_version || null, - collection.type || 'Collection', + collection.type || null, collectionTitle, collection.description || null, collection.license || null, diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index 444c938..a8e34ac 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -131,6 +131,17 @@ export function normalizeCollection(colObj, index) { links = rawData.links; } + // Determine the type using isCatalog/isCollection methods if available + let type = colObj.type || rawData?.type || null; + if (!type) { + // Use stac-js methods to determine type + if (typeof colObj.isCatalog === 'function' && colObj.isCatalog()) { + type = 'Catalog'; + } else if (typeof colObj.isCollection === 'function' && colObj.isCollection()) { + type = 'Collection'; + } + } + return { index, id: colObj.id || rawData?.id || 'Unknown', @@ -145,7 +156,7 @@ export function normalizeCollection(colObj, index) { // Additional fields needed for db.js - pass through from raw data links, stac_version: colObj.stac_version || rawData?.stac_version || null, - type: colObj.type || rawData?.type || 'Collection', + type: type, summaries: colObj.summaries || rawData?.summaries || null, stac_extensions: colObj.stac_extensions || rawData?.stac_extensions || [], providers: colObj.providers || rawData?.providers || [], From 2cd58b85ec2c00d74c09f2a4f60d981afbdbdd98 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 27 Jan 2026 13:42:17 +0100 Subject: [PATCH 095/134] refactor(crawler): enhance API and catalog handling with full object support - Updated API and catalog crawling functions to accept full API and catalog objects, including slug and title, for improved data management. - Adjusted internal processing to utilize these objects, enhancing the uniqueness of identifiers and improving logging details. - Modified collection handling to incorporate source slugs for unique STAC ID generation, ensuring better differentiation across sources. - Enhanced logging to provide clearer insights into the crawling process and the data being processed. --- crawler/apis/api.js | 49 ++++++++++++++++++++----------------- crawler/catalogs/catalog.js | 3 ++- crawler/index.js | 7 +++--- crawler/utils/db.js | 10 +++++++- crawler/utils/endpoints.js | 4 ++- crawler/utils/handlers.js | 18 ++++++++++---- 6 files changed, 57 insertions(+), 34 deletions(-) diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 348c862..68ac41c 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -53,12 +53,12 @@ async function checkAndFlushApi(results, log) { /** * Creates and runs a single Crawlee HttpCrawler for a specific domain * @async - * @param {Array} urls - Array of API URLs for this domain + * @param {Array} apis - Array of API objects with url, slug, and title for this domain * @param {string} domain - The domain being crawled * @param {Object} config - Configuration object * @returns {Promise} Crawl results with collections and statistics */ -async function crawlSingleApiDomain(urls, domain, config = {}) { +async function crawlSingleApiDomain(apis, domain, config = {}) { // Set unique storage directory for this crawler to avoid conflicts const safeDomain = domain.replace(/[^a-zA-Z0-9]/g, '_'); const storageDir = `/tmp/crawlee-api-${safeDomain}-${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -174,12 +174,13 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { }); // Seed the crawler with initial API requests - const initialRequests = urls.map((url, index) => ({ - url: url, + const initialRequests = apis.map((api, index) => ({ + url: api.url, label: 'API_ROOT', userData: { apiId: `${domain}-api-${index}`, - apiUrl: url, + apiUrl: api.url, + apiSlug: api.slug, depth: 0 } })); @@ -219,13 +220,13 @@ async function crawlSingleApiDomain(urls, domain, config = {}) { * Crawls STAC APIs to retrieve collection information without fetching items. * Groups APIs by domain and crawls multiple domains simultaneously. * - * @param {string[]} urls - Array of API URLs to crawl + * @param {Array} apis - Array of API objects with url, slug, and title * @param {boolean} isApi - Boolean flag indicating if the URLs are APIs * @param {Object} config - Configuration object with timeout settings * @returns {Promise} Results object with collections array and statistics */ -async function crawlApis(urls, isApi, config = {}) { - if (!isApi || !Array.isArray(urls) || urls.length === 0) { +async function crawlApis(apis, isApi, config = {}) { + if (!isApi || !Array.isArray(apis) || apis.length === 0) { return { collections: [], stats: { @@ -240,15 +241,8 @@ async function crawlApis(urls, isApi, config = {}) { }; } - // Convert URLs to objects for groupByDomain - const urlObjects = urls.map(url => ({ url })); - const domainMap = groupByDomain(urlObjects); - - // Convert back to URL arrays per domain - const domainUrlMap = new Map(); - for (const [domain, objs] of domainMap.entries()) { - domainUrlMap.set(domain, objs.map(o => o.url)); - } + // Group API objects by domain (keeps slug intact) + const domainMap = groupByDomain(apis); // Log domain distribution logDomainStats(domainMap, 'APIs'); @@ -263,12 +257,12 @@ async function crawlApis(urls, isApi, config = {}) { console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); console.log(`============================================\n`); - // Create tasks for each domain - const domainTasks = Array.from(domainUrlMap.entries()).map(([domain, domainUrls]) => { - return () => crawlSingleApiDomain(domainUrls, domain, config); + // Create tasks for each domain (pass full API objects including slug) + const domainTasks = Array.from(domainMap.entries()).map(([domain, domainApis]) => { + return () => crawlSingleApiDomain(domainApis, domain, config); }); - console.log(`Starting parallel API crawl of ${domainUrlMap.size} domains (${parallelDomains} at a time)...\n`); + console.log(`Starting parallel API crawl of ${domainMap.size} domains (${parallelDomains} at a time)...\n`); // Track total runtime for throughput calculation const crawlStartTime = Date.now(); @@ -295,7 +289,7 @@ async function crawlApis(urls, isApi, config = {}) { : 0; console.log('\n=== API Crawl Statistics ==='); - console.log(` Domains Processed: ${domainUrlMap.size}`); + console.log(` Domains Processed: ${domainMap.size}`); console.log(` Total Runtime: ${Math.round(totalRuntimeMs / 1000)}s`); console.log(` Total Requests: ${aggregatedStats.totalRequests}`); console.log(` Requests/Min (actual): ${requestsPerMinute}`); @@ -324,6 +318,7 @@ async function crawlApis(urls, isApi, config = {}) { async function handleApiRoot({ request, json, crawler, log, indent, results, maxDepth = 10 }) { const apiId = request.userData?.apiId || 'unknown'; const apiUrl = request.userData?.apiUrl || request.url; + const apiSlug = request.userData?.apiSlug || null; const depth = request.userData?.depth || 0; log.info(`${indent}Processing API: ${apiId} at ${apiUrl} (depth: ${depth})`); @@ -358,6 +353,8 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max // If this is a STAC Collection directly, extract and store it if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { const collection = normalizeCollection(stacObj, results.collections.length); + // Add the API slug to the collection for unique stac_id generation + collection.sourceSlug = apiSlug; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -390,7 +387,8 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max label: 'API_COLLECTIONS', userData: { apiId: apiId, - apiUrl: apiUrl + apiUrl: apiUrl, + catalogSlug: apiSlug // Use catalogSlug for compatibility with handleCollections } }]); @@ -454,6 +452,8 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max userData: { apiId: `${apiId}-child-${idx}`, apiUrl: apiUrl, + apiSlug: apiSlug, + catalogSlug: apiSlug, // Use catalogSlug for compatibility with handleCollections parentId: apiId, depth: nextDepth } @@ -478,6 +478,7 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max */ async function handleApiCollection({ request, json, crawler, log, indent, results }) { const apiId = request.userData?.apiId || 'unknown'; + const apiSlug = request.userData?.apiSlug || request.userData?.catalogSlug || null; let stacObj; try { @@ -485,6 +486,8 @@ async function handleApiCollection({ request, json, crawler, log, indent, result if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { const collection = normalizeCollection(stacObj, results.collections.length); + // Add the API slug to the collection for unique stac_id generation + collection.sourceSlug = apiSlug; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 4fb43d8..9b75e15 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -142,7 +142,8 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { userData: { depth: 0, catalogId: catalog.id, - catalogTitle: catalog.title + catalogTitle: catalog.title, + catalogSlug: catalog.slug } })); diff --git a/crawler/index.js b/crawler/index.js index 44c2952..8088850 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -121,12 +121,13 @@ export const crawler = async () => { // Crawl APIs if mode is 'apis' or 'both' if (config.mode === 'apis' || config.mode === 'both') { console.log('\nCrawling APIs...'); - const apiUrls = realApis.map(cat => cat.url); + // Pass full API objects (including slug) instead of just URLs + const apiObjects = realApis.map(cat => ({ url: cat.url, slug: cat.slug, title: cat.title })); - if (apiUrls.length > 0) { + if (apiObjects.length > 0) { // Note: MAX_APIS limit is for debugging purposes only // Set maxApis to 0 or use --max-apis 0 for unlimited API crawling - const apisToProcess = config.maxApis === 0 ? apiUrls : apiUrls.slice(0, config.maxApis); + const apisToProcess = config.maxApis === 0 ? apiObjects : apiObjects.slice(0, config.maxApis); console.log(`Found ${realApis.length} real APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); try { diff --git a/crawler/utils/db.js b/crawler/utils/db.js index dc1d428..a708fda 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -169,7 +169,15 @@ async function _insertOrUpdateCollectionInternal(collection) { // Insert or update collection const collectionTitle = collection.title || collection.id || 'Unnamed Collection'; - const stacId = collection.id || null; + + // Construct unique stac_id from sourceSlug and collection id + // Format: {sourceSlug}_{collection_id} for uniqueness across different sources + let stacId = null; + if (collection.sourceSlug && collection.id) { + stacId = `${collection.sourceSlug}_${collection.id}`; + } else if (collection.id) { + stacId = collection.id; + } // Extract source URL from links (prefer 'self', fallback to 'root') let sourceUrl = null; diff --git a/crawler/utils/endpoints.js b/crawler/utils/endpoints.js index 2feb27c..4a6c631 100644 --- a/crawler/utils/endpoints.js +++ b/crawler/utils/endpoints.js @@ -15,8 +15,9 @@ * @param {Object} crawler - Crawlee crawler instance * @param {Object} log - Logger * @param {string} indent - Indentation for logging + * @param {string} catalogSlug - Slug of the source catalog for unique ID generation */ -export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, depth, crawler, log, indent) { +export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, depth, crawler, log, indent, catalogSlug = null) { let collectionUrl = null; // Try to find collection endpoint from STAC links (proper STAC discovery) @@ -78,6 +79,7 @@ export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, de userData: { catalogUrl: baseUrl, catalogId, + catalogSlug, depth } }]); diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 907bae1..036e364 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -102,6 +102,7 @@ async function checkAndFlush(results, log) { export async function handleCatalog({ request, json, crawler, log, indent, results, config = {} }) { const depth = request.userData?.depth || 0; const catalogId = request.userData?.catalogId || 'unknown'; + const catalogSlug = request.userData?.catalogSlug || null; const maxDepth = config.maxDepth || 0; // 0 = unlimited log.info(`${indent}Processing catalog: ${catalogId} (depth: ${depth}${maxDepth > 0 ? `/${maxDepth}` : ''})`); @@ -146,6 +147,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul if (isCollection) { const collection = normalizeCollection(stacCatalog, results.collections.length); + // Add the catalog slug to the collection for unique stac_id generation + collection.sourceSlug = catalogSlug; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -156,7 +159,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Only try /collections endpoint for Catalogs, not Collections // Static STAC catalogs don't have /collections endpoints - they use rel="child" links // STAC APIs have /collections endpoints and advertise them via rel="data" or rel="collections" - await tryCollectionEndpoints(stacCatalog, request.url, catalogId, depth, crawler, log, indent); + await tryCollectionEndpoints(stacCatalog, request.url, catalogId, depth, crawler, log, indent, catalogSlug); } // Extract and enqueue child catalog links using stac-js @@ -234,7 +237,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul userData: { depth: depth + 1, catalogId: linkTitle, - parentId: catalogId + parentId: catalogId, + catalogSlug: catalogSlug } }; }) @@ -278,6 +282,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul */ export async function handleCollections({ request, json, crawler, log, indent, results }) { const catalogId = request.userData?.catalogId || 'unknown'; + const catalogSlug = request.userData?.catalogSlug || null; // Parse response with stac-js // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL @@ -318,9 +323,12 @@ export async function handleCollections({ request, json, crawler, log, indent, r log.info(`${indent}Found ${collectionsData.length} collections for catalog ${catalogId}`); // Normalize and store collections - const collections = collectionsData.map((colObj, index) => - normalizeCollection(colObj, index) - ); + const collections = collectionsData.map((colObj, index) => { + const collection = normalizeCollection(colObj, index); + // Add the catalog slug to the collection for unique stac_id generation + collection.sourceSlug = catalogSlug; + return collection; + }); results.collections.push(...collections); results.stats.collectionsFound += collections.length; From 3b7a6d7bee4511368aa866f2aae91abd99eaf346 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 27 Jan 2026 13:48:58 +0100 Subject: [PATCH 096/134] feat(crawler): implement catalog insertion and update functionality in the database - Added the ability to insert or update STAC catalogs in the database, enhancing data persistence and management. - Implemented checks for existing catalogs to either update or insert new entries, including handling of keywords, extensions, and links. - Updated the catalog handling logic in the crawler to save catalogs to the database, ensuring proper logging of operations and error handling. - Enhanced the overall structure for better maintainability and clarity in catalog processing. --- crawler/utils/db.js | 119 ++++++++++++++++++++++++++++++++++---- crawler/utils/handlers.js | 21 ++++++- 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index a708fda..e7b504b 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -51,19 +51,118 @@ async function initDb() { } /** - * Process a catalog for traversal only - catalogs are not saved to database - * Only collections are saved. Catalogs are only used to traverse deeper into the tree. + * Insert or update a catalog in the database * @param {Object} catalog - STAC catalog object - * @returns {Promise} always returns null (no catalog saved) + * @returns {Promise} catalog ID or null if invalid input */ async function insertOrUpdateCatalog(catalog) { if (!catalog || typeof catalog !== 'object') return null; - // Catalogs are not saved to database - only used for tree traversal - // Only collections will be saved - console.log(`Skipping catalog save (used for traversal only): ${catalog.title || catalog.id}`); - - return null; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; + + // Check if catalog with same title already exists + const existingCatalog = await client.query( + 'SELECT id FROM catalog WHERE title = $1', + [catalogTitle] + ); + + let catalogId; + if (existingCatalog.rows.length > 0) { + // Update existing catalog - set updated_at when crawled + catalogId = existingCatalog.rows[0].id; + await client.query( + `UPDATE catalog SET + stac_version = $1, + type = $2, + description = $3, + updated_at = now() + WHERE id = $4`, + [ + catalog.stac_version || null, + catalog.type || 'Catalog', + catalog.description || null, + catalogId + ] + ); + console.log(`Updated catalog: ${catalogTitle} (id: ${catalogId})`); + } else { + // Insert new catalog - updated_at stays NULL until next crawl + const catalogResult = await client.query( + `INSERT INTO catalog (stac_version, type, title, description, updated_at) + VALUES ($1, $2, $3, $4, NULL) + RETURNING id`, + [ + catalog.stac_version || null, + catalog.type || 'Catalog', + catalogTitle, + catalog.description || null + ] + ); + catalogId = catalogResult.rows[0].id; + console.log(`Inserted new catalog: ${catalogTitle} (id: ${catalogId})`); + } + + // Insert keywords if present + if (catalog.keywords && Array.isArray(catalog.keywords)) { + await insertKeywords(client, catalogId, catalog.keywords, 'catalog'); + } + + // Insert STAC extensions if present + if (catalog.stac_extensions && Array.isArray(catalog.stac_extensions)) { + await insertStacExtensions(client, catalogId, catalog.stac_extensions, 'catalog'); + } + + // Insert catalog links if present + if (catalog.links && Array.isArray(catalog.links)) { + // Extract source URL from links (prefer 'self', fallback to 'root') + const selfLink = catalog.links.find(link => link.rel === 'self'); + const rootLink = catalog.links.find(link => link.rel === 'root'); + const sourceUrl = selfLink?.href || rootLink?.href || null; + + // Delete existing links for this catalog + await client.query('DELETE FROM catalog_links WHERE catalog_id = $1', [catalogId]); + + // Insert new links + for (const link of catalog.links) { + if (!link.href) continue; + await client.query( + `INSERT INTO catalog_links (catalog_id, source_url, rel, href, type, title) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + catalogId, + sourceUrl, + link.rel || null, + link.href, + link.type || null, + link.title || null + ] + ); + } + } + + // Update crawl log + await client.query( + 'DELETE FROM crawllog_catalog WHERE catalog_id = $1', + [catalogId] + ); + await client.query( + 'INSERT INTO crawllog_catalog (catalog_id, last_crawled) VALUES ($1, now())', + [catalogId] + ); + + await client.query('COMMIT'); + return catalogId; + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error inserting catalog:', error.message); + throw error; + } finally { + client.release(); + } } /** @@ -250,14 +349,14 @@ async function _insertOrUpdateCollectionInternal(collection) { ] ); } else { - // Insert new collection + // Insert new collection - updated_at stays NULL until next crawl const collectionResult = await client.query( `INSERT INTO collection ( stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, is_api, is_active, full_json, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, now()) + VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, NULL) RETURNING id`, [ stacId, diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 036e364..d697a3e 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -141,10 +141,27 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul depth }); - // If this is a STAC Collection (not a catalog), extract and store it - // Collections don't have /collections endpoints, so we skip tryCollectionEndpoints for them + // Save catalog to database (only for actual Catalogs, not Collections) const isCollection = typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection(); + if (!isCollection) { + try { + await db.insertOrUpdateCatalog({ + id: stacCatalog.id, + title: stacCatalog.title || catalogId, + description: stacCatalog.description, + stac_version: stacCatalog.stac_version, + type: stacCatalog.type || 'Catalog', + keywords: stacCatalog.keywords, + stac_extensions: stacCatalog.stac_extensions, + links: stacCatalog.links + }); + } catch (err) { + log.warning(`${indent}Failed to save catalog ${catalogId} to database: ${err.message}`); + } + } + // If this is a STAC Collection (not a catalog), extract and store it + // Collections don't have /collections endpoints, so we skip tryCollectionEndpoints for them if (isCollection) { const collection = normalizeCollection(stacCatalog, results.collections.length); // Add the catalog slug to the collection for unique stac_id generation From 15f4be09a638c6e62b625ad1eba4704e699f3e00 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 27 Jan 2026 14:19:37 +0100 Subject: [PATCH 097/134] feat(crawler): preserve original STAC JSON during normalization and storage - Updated the normalization process to include the original STAC JSON in the collection object, ensuring that no data is lost during normalization. - Modified the database insertion logic to store the full original JSON instead of the normalized version, enhancing data integrity and traceability. - This change improves the overall handling of STAC collections by maintaining the complete context of the original data. --- crawler/utils/db.js | 8 ++++++-- crawler/utils/normalization.js | 6 +++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index e7b504b..8bba7b0 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -314,6 +314,10 @@ async function _insertOrUpdateCollectionInternal(collection) { } } + // Use originalJson if available (from normalizeCollection), otherwise use the collection object + // This ensures the full original STAC JSON is stored, not the normalized version + const fullJsonData = collection.originalJson || collection; + let collectionId; if (existingCollection.rows.length > 0) { // Update existing collection @@ -344,7 +348,7 @@ async function _insertOrUpdateCollectionInternal(collection) { temporalEnd, false, // is_api - will be determined by crawler true, // is_active - JSON.stringify(collection), + JSON.stringify(fullJsonData), collectionId ] ); @@ -370,7 +374,7 @@ async function _insertOrUpdateCollectionInternal(collection) { temporalEnd, false, // is_api - will be determined by crawler true, // is_active - JSON.stringify(collection) + JSON.stringify(fullJsonData) ] ); collectionId = collectionResult.rows[0].id; diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index 444c938..2c92fca 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -149,7 +149,11 @@ export function normalizeCollection(colObj, index) { summaries: colObj.summaries || rawData?.summaries || null, stac_extensions: colObj.stac_extensions || rawData?.stac_extensions || [], providers: colObj.providers || rawData?.providers || [], - assets: colObj.assets || rawData?.assets || null + assets: colObj.assets || rawData?.assets || null, + + // Preserve the original STAC JSON for full_json storage in database + // This ensures nothing is lost during normalization + originalJson: rawData }; } From f12e26179c17c759535171c0826c2172b03b3a72 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 27 Jan 2026 14:21:53 +0100 Subject: [PATCH 098/134] refactor(crawler): update database insertion logic to include source_url - Adjusted the insertSummary function to remove the source_url parameter, streamlining the summary insertion process. - Ensured that the changes maintain the integrity of the data while improving the overall structure of the database interactions. --- crawler/utils/db.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 8bba7b0..e9d3e73 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -334,9 +334,10 @@ async function _insertOrUpdateCollectionInternal(collection) { temporal_extent_end = $8, is_api = $9, is_active = $10, - full_json = $11, + source_url = $11, + full_json = $12, updated_at = now() - WHERE id = $12`, + WHERE id = $13`, [ stacId, collection.stac_version || null, @@ -348,6 +349,7 @@ async function _insertOrUpdateCollectionInternal(collection) { temporalEnd, false, // is_api - will be determined by crawler true, // is_active + sourceUrl, JSON.stringify(fullJsonData), collectionId ] @@ -358,9 +360,9 @@ async function _insertOrUpdateCollectionInternal(collection) { `INSERT INTO collection ( stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, - is_api, is_active, full_json, updated_at + is_api, is_active, source_url, full_json, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, NULL) + VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13, NULL) RETURNING id`, [ stacId, @@ -374,6 +376,7 @@ async function _insertOrUpdateCollectionInternal(collection) { temporalEnd, false, // is_api - will be determined by crawler true, // is_active + sourceUrl, JSON.stringify(fullJsonData) ] ); @@ -384,7 +387,7 @@ async function _insertOrUpdateCollectionInternal(collection) { if (collection.summaries && typeof collection.summaries === 'object') { await client.query('DELETE FROM collection_summaries WHERE collection_id = $1', [collectionId]); for (const [name, value] of Object.entries(collection.summaries)) { - await insertSummary(client, collectionId, name, value, sourceUrl); + await insertSummary(client, collectionId, name, value); } } @@ -492,7 +495,7 @@ async function insertStacExtensions(client, parentId, extensions, type) { /** * Helper function to insert collection summaries */ -async function insertSummary(client, collectionId, name, value, sourceUrl) { +async function insertSummary(client, collectionId, name, value) { let kind = 'unknown'; let rangeMin = null; let rangeMax = null; @@ -517,8 +520,8 @@ async function insertSummary(client, collectionId, name, value, sourceUrl) { } await client.query( - 'INSERT INTO collection_summaries (collection_id, name, kind, source_url, range_min, range_max, set_value, json_schema) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)', - [collectionId, name, kind, sourceUrl, rangeMin, rangeMax, setValue, jsonSchema] + 'INSERT INTO collection_summaries (collection_id, name, kind, range_min, range_max, set_value, json_schema) VALUES ($1, $2, $3, $4, $5, $6, $7)', + [collectionId, name, kind, rangeMin, rangeMax, setValue, jsonSchema] ); } From 72b01683c3a9f9875cc81bebaec70d751d318baf Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 27 Jan 2026 14:49:57 +0100 Subject: [PATCH 099/134] feat(crawler): enhance source URL handling in collection processing - Updated the logic for extracting the source URL to prioritize the crawled URL, ensuring the most reliable absolute URL is used. - Implemented fallback mechanisms to use self and root links only when the crawled URL is unavailable. - Enhanced collection normalization to store the actual crawled URL during processing, improving data accuracy and traceability. --- crawler/utils/db.js | 9 +++++++-- crawler/utils/handlers.js | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index e9d3e73..0df4fce 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -278,9 +278,14 @@ async function _insertOrUpdateCollectionInternal(collection) { stacId = collection.id; } - // Extract source URL from links (prefer 'self', fallback to 'root') + // Extract source URL - prefer crawledUrl (the actual absolute URL the collection was fetched from) + // Fall back to links only if crawledUrl is not available let sourceUrl = null; - if (collection.links && Array.isArray(collection.links)) { + if (collection.crawledUrl) { + // Use the absolute URL from the crawler (most reliable) + sourceUrl = collection.crawledUrl; + } else if (collection.links && Array.isArray(collection.links)) { + // Fallback to self/root links (may be relative URLs) const selfLink = collection.links.find(link => link.rel === 'self'); const rootLink = collection.links.find(link => link.rel === 'root'); sourceUrl = selfLink?.href || rootLink?.href || null; diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index d697a3e..9a3819a 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -166,6 +166,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul const collection = normalizeCollection(stacCatalog, results.collections.length); // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; + // Store the actual crawled URL as the source URL (not relative links from the JSON) + collection.crawledUrl = request.url; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -339,11 +341,29 @@ export async function handleCollections({ request, json, crawler, log, indent, r if (collectionsData.length > 0) { log.info(`${indent}Found ${collectionsData.length} collections for catalog ${catalogId}`); + // Get base URL for constructing absolute collection URLs + // Remove trailing /collections from the request URL to get the API base + const baseUrl = request.url.replace(/\/collections\/?$/, ''); + // Normalize and store collections const collections = collectionsData.map((colObj, index) => { const collection = normalizeCollection(colObj, index); // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; + + // Store the absolute URL as crawledUrl + // Use stac-js getAbsoluteUrl() if available, otherwise construct from base + id + if (typeof colObj.getAbsoluteUrl === 'function') { + try { + collection.crawledUrl = colObj.getAbsoluteUrl(); + } catch { + // Fallback to constructing URL from base + collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; + } + } else { + collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; + } + return collection; }); From 80684c0cabc17abdfedb056ad64650f56ecfc991 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 27 Jan 2026 14:56:50 +0100 Subject: [PATCH 100/134] refactor(crawler): update catalog and collection insertion logic to set updated_at defaults - Modified the database insertion logic for both catalogs and collections to set the updated_at field to default to the current timestamp, reflecting the data's freshness during insertion. - Enhanced comments for clarity regarding the handling of updated_at during new catalog and collection entries. --- crawler/utils/db.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 0df4fce..00b641f 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -90,10 +90,11 @@ async function insertOrUpdateCatalog(catalog) { ); console.log(`Updated catalog: ${catalogTitle} (id: ${catalogId})`); } else { - // Insert new catalog - updated_at stays NULL until next crawl + // Insert new catalog - updated_at defaults to now() (same as created_at) + // since we know the data is current as of this crawl const catalogResult = await client.query( - `INSERT INTO catalog (stac_version, type, title, description, updated_at) - VALUES ($1, $2, $3, $4, NULL) + `INSERT INTO catalog (stac_version, type, title, description) + VALUES ($1, $2, $3, $4) RETURNING id`, [ catalog.stac_version || null, @@ -360,14 +361,15 @@ async function _insertOrUpdateCollectionInternal(collection) { ] ); } else { - // Insert new collection - updated_at stays NULL until next crawl + // Insert new collection - updated_at defaults to now() (same as created_at) + // since we know the data is current as of this crawl const collectionResult = await client.query( `INSERT INTO collection ( stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, - is_api, is_active, source_url, full_json, updated_at + is_api, is_active, source_url, full_json ) - VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13, NULL) + VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13) RETURNING id`, [ stacId, From 1d3a3df34fbad8b527120ad560e186b4bd594308 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 27 Jan 2026 15:04:41 +0100 Subject: [PATCH 101/134] feat(crawler): improve STAC type determination in normalization process - Enhanced the normalization logic to reliably determine the STAC type (Collection or Catalog) using available methods from stac-js, improving data accuracy. - Updated the type assignment in the normalized output to reflect the determined STAC type, ensuring consistency with the original data structure. --- crawler/utils/normalization.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index 2c92fca..ff87c33 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -75,6 +75,18 @@ export function normalizeCollection(colObj, index) { // stac-js stores the original data in toJSON() or we can access it directly const rawData = typeof colObj.toJSON === 'function' ? colObj.toJSON() : colObj; + // Determine the STAC type using stac-js methods if available + // This is more reliable than trusting the type field in the JSON + let stacType = null; + if (typeof colObj.isCollection === 'function' && colObj.isCollection()) { + stacType = 'Collection'; + } else if (typeof colObj.isCatalog === 'function' && colObj.isCatalog()) { + stacType = 'Catalog'; + } else { + // Fallback to the type field in the data, or default to 'Collection' + stacType = colObj.type || rawData?.type || 'Collection'; + } + // Extract bbox: try stac-js method first, then fallback to raw data let bbox = null; if (typeof colObj.getBoundingBox === 'function') { @@ -145,7 +157,7 @@ export function normalizeCollection(colObj, index) { // Additional fields needed for db.js - pass through from raw data links, stac_version: colObj.stac_version || rawData?.stac_version || null, - type: colObj.type || rawData?.type || 'Collection', + type: stacType, summaries: colObj.summaries || rawData?.summaries || null, stac_extensions: colObj.stac_extensions || rawData?.stac_extensions || [], providers: colObj.providers || rawData?.providers || [], From ce8fc875aba4ab09ebb1861b3dd83f88826ae43c Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:03:48 +0100 Subject: [PATCH 102/134] Database: Docker config for using the same network, STAC_ID now unique and stac_url is now in collections (#262) * Add .gitignore for environment and dependencies; update docker-compose to include network configuration * Update docker-compose.yml to rename network from 'stac_network' to 'stac-network' for consistency. * db(change) source_url is now in collection and stac_id is now unique --- crawler/.gitignore | 3 +++ db/docker-compose.yml | 10 +++++++++- db/init/03_tables_collections.sql | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crawler/.gitignore b/crawler/.gitignore index e69de29..e59219b 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules +storage \ No newline at end of file diff --git a/db/docker-compose.yml b/db/docker-compose.yml index c8e41f1..3008d38 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -22,5 +22,13 @@ services: - stac_data:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d + networks: + - stac-network + volumes: - stac_data: \ No newline at end of file + stac_data: + +networks: + stac-network: + name: stac-network + driver: bridge \ No newline at end of file diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 852aa0d..08687d0 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -6,11 +6,12 @@ CREATE TABLE collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, - stac_id TEXT, + stac_id TEXT UNIQUE, type TEXT, title TEXT, description TEXT, license TEXT, + source_url TEXT, created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now(), @@ -33,7 +34,6 @@ CREATE TABLE collection_summaries ( collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, name TEXT, kind TEXT, - source_url TEXT, range_min NUMERIC, range_max NUMERIC, set_value TEXT, From 934c1abdfcec6a25997bc8f39e7e663c64ad5165 Mon Sep 17 00:00:00 2001 From: Lenn Date: Tue, 27 Jan 2026 17:15:14 +0100 Subject: [PATCH 103/134] saving catalogs into database, with stac_id, source_url and description --- crawler/index.js | 3 +- crawler/utils/db.js | 165 ++++++++++++++++++++++++++++-- crawler/utils/normalization.js | 6 +- db/init/03_tables_collections.sql | 2 +- 4 files changed, 165 insertions(+), 11 deletions(-) diff --git a/crawler/index.js b/crawler/index.js index 31f4840..db9d481 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -64,7 +64,8 @@ export const crawler = async () => { let catalogsSaved = 0; let catalogsFailed = 0; - for (const catalog of response.data) { + // Use normalized catalogs instead of raw response.data + for (const catalog of catalogs) { try { const catalogId = await db.insertOrUpdateCatalog(catalog); console.log(`Saved catalog: ${catalog.title || catalog.id} (DB ID: ${catalogId})`); diff --git a/crawler/utils/db.js b/crawler/utils/db.js index f96ab4b..44de8ec 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -51,19 +51,168 @@ async function initDb() { } /** - * Process a catalog for traversal only - catalogs are not saved to database - * Only collections are saved. Catalogs are only used to traverse deeper into the tree. + * Insert or update a catalog in the database with deadlock retry logic + * Catalogs are stored in the collection table with type='Catalog' * @param {Object} catalog - STAC catalog object - * @returns {Promise} always returns null (no catalog saved) + * @param {number} maxRetries - Maximum number of retry attempts for deadlocks (default: 3) + * @returns {Promise} collection table ID or null */ -async function insertOrUpdateCatalog(catalog) { +async function insertOrUpdateCatalog(catalog, maxRetries = 3) { if (!catalog || typeof catalog !== 'object') return null; - // Catalogs are not saved to database - only used for tree traversal - // Only collections will be saved - console.log(`Skipping catalog save (used for traversal only): ${catalog.title || catalog.id}`); + let lastError; - return null; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await _insertOrUpdateCatalogInternal(catalog); + } catch (error) { + lastError = error; + + if (isDeadlockError(error) && attempt < maxRetries) { + // Exponential backoff: 100ms, 200ms, 400ms, ... + const delayMs = 100 * Math.pow(2, attempt - 1) + Math.random() * 50; + console.warn(`WARN [DB] Deadlock detected for catalog "${catalog.title || catalog.id}", retrying in ${Math.round(delayMs)}ms (attempt ${attempt}/${maxRetries})`); + await sleep(delayMs); + continue; + } + + // Not a deadlock or max retries reached, throw the error + throw error; + } + } + + // Should not reach here, but just in case + throw lastError; +} + +/** + * Internal implementation of insertOrUpdateCatalog + * Stores catalog in the collection table with type='Catalog' + * @param {Object} catalog - STAC catalog object + * @returns {Promise} collection table ID or null + */ +async function _insertOrUpdateCatalogInternal(catalog) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; + const stacId = catalog.id || null; + + // Extract source URL from links (prefer 'self', fallback to 'root') + let sourceUrl = null; + if (catalog.links && Array.isArray(catalog.links)) { + const selfLink = catalog.links.find(link => link.rel === 'self'); + const rootLink = catalog.links.find(link => link.rel === 'root'); + sourceUrl = selfLink?.href || rootLink?.href || null; + } + + // Check if catalog with same stac_id and type='Catalog' already exists + let existingCatalog; + if (stacId && sourceUrl) { + // Match by stac_id + source_url + type + existingCatalog = await client.query( + 'SELECT id FROM collection WHERE stac_id = $1 AND source_url = $2 AND type = $3', + [stacId, sourceUrl, 'Catalog'] + ); + } + + // Fallback: match by title and type='Catalog' + if (!existingCatalog || existingCatalog.rows.length === 0) { + if (stacId) { + existingCatalog = await client.query( + 'SELECT id FROM collection WHERE stac_id = $1 AND title = $2 AND type = $3', + [stacId, catalogTitle, 'Catalog'] + ); + } else { + existingCatalog = await client.query( + 'SELECT id FROM collection WHERE title = $1 AND type = $2', + [catalogTitle, 'Catalog'] + ); + } + } + + let catalogId; + if (existingCatalog.rows.length > 0) { + // Update existing catalog + catalogId = existingCatalog.rows[0].id; + await client.query( + `UPDATE collection SET + stac_id = $1, + stac_version = $2, + type = $3, + description = $4, + source_url = $5, + full_json = $6, + updated_at = now() + WHERE id = $7`, + [ + stacId, + catalog.stac_version || null, + 'Catalog', + catalog.description || null, + sourceUrl, + JSON.stringify(catalog), + catalogId + ] + ); + } else { + // Insert new catalog into collection table + const catalogResult = await client.query( + `INSERT INTO collection ( + stac_id, stac_version, type, title, description, source_url, + is_api, is_active, full_json, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) + RETURNING id`, + [ + stacId, + catalog.stac_version || null, + 'Catalog', + catalogTitle, + catalog.description || null, + sourceUrl, + false, // is_api + true, // is_active + JSON.stringify(catalog) + ] + ); + catalogId = catalogResult.rows[0].id; + } + + // Insert keywords + if (catalog.keywords && Array.isArray(catalog.keywords)) { + await insertKeywords(client, catalogId, catalog.keywords, 'collection'); + } + + // Insert STAC extensions + if (catalog.stac_extensions && Array.isArray(catalog.stac_extensions)) { + await insertStacExtensions(client, catalogId, catalog.stac_extensions, 'collection'); + } + + // Update crawl log (using collection crawl log) + await client.query( + 'DELETE FROM crawllog_collection WHERE collection_id = $1', + [catalogId] + ); + await client.query( + 'INSERT INTO crawllog_collection (collection_id, last_crawled) VALUES ($1, now())', + [catalogId] + ); + + await client.query('COMMIT'); + console.log(`Saved catalog as collection: ${catalogTitle} (ID: ${catalogId}, type: Catalog)`); + return catalogId; + } catch (error) { + await client.query('ROLLBACK'); + // Only log non-deadlock errors here, deadlocks are handled by retry wrapper + if (!isDeadlockError(error)) { + console.error('Error inserting catalog:', error.message); + } + throw error; + } finally { + client.release(); + } } /** diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index a8e34ac..84b4a93 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -41,11 +41,12 @@ export function deriveCategories(catalog) { export function normalizeCatalog(catalog, index) { return { index, - id: catalog.id, + id: catalog.slug || catalog.id?.toString() || `catalog-${index}`, // Use slug as STAC id (string), fallback to numeric id as string url: catalog.url, slug: catalog.slug, title: catalog.title, summary: catalog.summary, + description: catalog.summary, // Map summary to description for database access: catalog.access, created: catalog.created, updated: catalog.updated, @@ -53,6 +54,9 @@ export function normalizeCatalog(catalog, index) { isApi: catalog.isApi, accessInfo: catalog.accessInfo, categories: deriveCategories(catalog), + keywords: deriveCategories(catalog), // Map categories to keywords for database + type: 'Catalog', // Explicitly set type + links: catalog.url ? [{ rel: 'self', href: catalog.url }] : [], // Create links array for db.js // Preserve any additional dynamic properties ...Object.fromEntries( Object.entries(catalog).filter(([key]) => diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index a443aa8..852aa0d 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -6,7 +6,7 @@ CREATE TABLE collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, - stac_id INTEGER, + stac_id TEXT, type TEXT, title TEXT, description TEXT, From 9bb4e9e774038994a8fa6abdd675fadd2dcf0bf0 Mon Sep 17 00:00:00 2001 From: Lenn Date: Tue, 27 Jan 2026 18:17:20 +0100 Subject: [PATCH 104/134] feat(crawler): enhance unique stac_id generation and include stac_version in normalization for catalogs --- crawler/utils/db.js | 16 ++++++++++------ crawler/utils/handlers.js | 1 + crawler/utils/normalization.js | 3 ++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index d01e556..4d55b78 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -97,7 +97,15 @@ async function _insertOrUpdateCatalogInternal(catalog) { await client.query('BEGIN'); const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; - const stacId = catalog.id || null; + + // Construct unique stac_id from sourceSlug and catalog id + // Format: {sourceSlug}_{catalog_id} for uniqueness across different sources + let stacId = null; + if (catalog.sourceSlug && catalog.id) { + stacId = `${catalog.sourceSlug}_${catalog.id}`; + } else if (catalog.id) { + stacId = catalog.id; + } // Extract source URL from links (prefer 'self', fallback to 'root') let sourceUrl = null; @@ -416,11 +424,7 @@ async function _insertOrUpdateCollectionInternal(collection) { `INSERT INTO collection ( stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, -<<<<<<< HEAD - is_api, is_active, full_json, source_url, updated_at -======= - is_api, is_active, source_url, full_json ->>>>>>> origin/dev-crawler-humam + is_api, is_active, full_json, source_url ) VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13) RETURNING id`, diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 9a3819a..a4bce4a 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -147,6 +147,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul try { await db.insertOrUpdateCatalog({ id: stacCatalog.id, + sourceSlug: catalogSlug, // Add sourceSlug for unique stac_id generation title: stacCatalog.title || catalogId, description: stacCatalog.description, stac_version: stacCatalog.stac_version, diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index c80de4e..7ab1051 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -47,6 +47,7 @@ export function normalizeCatalog(catalog, index) { title: catalog.title, summary: catalog.summary, description: catalog.summary, // Map summary to description for database + stac_version: catalog.stac_version || null, // Include stac_version if available access: catalog.access, created: catalog.created, updated: catalog.updated, @@ -61,7 +62,7 @@ export function normalizeCatalog(catalog, index) { ...Object.fromEntries( Object.entries(catalog).filter(([key]) => !['id', 'url', 'slug', 'title', 'summary', 'access', 'created', - 'updated', 'isPrivate', 'isApi', 'accessInfo'].includes(key) + 'updated', 'isPrivate', 'isApi', 'accessInfo', 'stac_version'].includes(key) ) ) }; From 253bda2308015be49580edcca2565cc80aa5800b Mon Sep 17 00:00:00 2001 From: Lenn Date: Tue, 27 Jan 2026 19:11:33 +0100 Subject: [PATCH 105/134] fix(crawler): source_url for catalogs are now completly saved --- crawler/utils/db.js | 9 +++++++-- crawler/utils/handlers.js | 23 +++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 4d55b78..4676084 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -107,9 +107,14 @@ async function _insertOrUpdateCatalogInternal(catalog) { stacId = catalog.id; } - // Extract source URL from links (prefer 'self', fallback to 'root') + // Extract source URL - prefer crawledUrl (the actual absolute URL the catalog was fetched from) + // Fall back to links only if crawledUrl is not available let sourceUrl = null; - if (catalog.links && Array.isArray(catalog.links)) { + if (catalog.crawledUrl) { + // Use the absolute URL from the crawler (most reliable) + sourceUrl = catalog.crawledUrl; + } else if (catalog.links && Array.isArray(catalog.links)) { + // Fallback to self/root links (may be relative URLs) const selfLink = catalog.links.find(link => link.rel === 'self'); const rootLink = catalog.links.find(link => link.rel === 'root'); sourceUrl = selfLink?.href || rootLink?.href || null; diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index a4bce4a..892206f 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -145,9 +145,13 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul const isCollection = typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection(); if (!isCollection) { try { + // Ensure crawledUrl is absolute + const crawledUrl = request.url.startsWith('http') ? request.url : null; + await db.insertOrUpdateCatalog({ id: stacCatalog.id, sourceSlug: catalogSlug, // Add sourceSlug for unique stac_id generation + crawledUrl: crawledUrl, // Store the actual URL where this catalog was crawled from (only if absolute) title: stacCatalog.title || catalogId, description: stacCatalog.description, stac_version: stacCatalog.stac_version, @@ -168,7 +172,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; // Store the actual crawled URL as the source URL (not relative links from the JSON) - collection.crawledUrl = request.url; + // Only store if it's an absolute URL + collection.crawledUrl = request.url.startsWith('http') ? request.url : null; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -344,7 +349,10 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Get base URL for constructing absolute collection URLs // Remove trailing /collections from the request URL to get the API base - const baseUrl = request.url.replace(/\/collections\/?$/, ''); + // Only use if request.url is absolute + const baseUrl = request.url.startsWith('http') + ? request.url.replace(/\/collections\/?$/, '') + : null; // Normalize and store collections const collections = collectionsData.map((colObj, index) => { @@ -352,17 +360,20 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; - // Store the absolute URL as crawledUrl + // Store the absolute URL as crawledUrl (only if we have a valid base URL) // Use stac-js getAbsoluteUrl() if available, otherwise construct from base + id if (typeof colObj.getAbsoluteUrl === 'function') { try { - collection.crawledUrl = colObj.getAbsoluteUrl(); + const absoluteUrl = colObj.getAbsoluteUrl(); + collection.crawledUrl = absoluteUrl?.startsWith('http') ? absoluteUrl : null; } catch { // Fallback to constructing URL from base - collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; + collection.crawledUrl = baseUrl ? `${baseUrl}/collections/${collection.id}` : null; } - } else { + } else if (baseUrl) { collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; + } else { + collection.crawledUrl = null; } return collection; From d6ad7fc80dcd0829e05e8939fed0b2bf9ca86e37 Mon Sep 17 00:00:00 2001 From: Jakob <70800454+vertrox78@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:20:23 +0100 Subject: [PATCH 106/134] #241, Add unit tests for normalization and parallel execution utilities. (#268) --- crawler/README.md | 37 + crawler/__tests__/api.test.js | 561 ++++ crawler/__tests__/normalization.test.js | 448 +++ crawler/__tests__/parallel.test.js | 555 ++++ crawler/apis/api.js | 5 +- crawler/jest.config.js | 21 + crawler/package-lock.json | 3290 +++++++++++++++++++++-- crawler/package.json | 6 +- crawler/utils/normalization.js | 15 +- 9 files changed, 4738 insertions(+), 200 deletions(-) create mode 100644 crawler/__tests__/api.test.js create mode 100644 crawler/__tests__/normalization.test.js create mode 100644 crawler/__tests__/parallel.test.js create mode 100644 crawler/jest.config.js diff --git a/crawler/README.md b/crawler/README.md index 86026dd..b63fb8a 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -120,6 +120,43 @@ npm run docker:compose:up npm run docker:compose:down ``` +## Testing + +### Running Tests + +Run the complete test suite: +```bash +npm test +``` + +Run tests in watch mode during development: +```bash +npm run test:watch +``` + +Run tests with coverage report: +```bash +npm test -- --coverage +``` + +### Test Structure + +The test suite covers utility functions with **110 tests** across three modules: + +- **`normalization.test.js`** (46 tests) - Tests for catalog and collection normalization + - Coverage: 91% statements, 92% branches + - Tests `deriveCategories()`, `normalizeCatalog()`, `normalizeCollection()`, `processCatalogs()` + +- **`parallel.test.js`** (53 tests) - Tests for parallel execution utilities + - Coverage: 100% statements, 100% branches + - Tests `getDomain()`, `groupByDomain()`, `createDomainBatches()`, `aggregateStats()`, `executeWithConcurrency()`, `calculateRateLimits()`, `logDomainStats()` + +- **`api.test.js`** (29 tests) - Tests for API crawling utilities + - Tests batch management, URL validation, STAC API response structures + - Uses real STAC API endpoints (Microsoft Planetary Computer, Element 84, USGS, NASA CMR) + +All tests use **real STAC domain names and collection IDs** from production STAC APIs for realistic testing. + ## Examples ### Example 1: Quick API Test diff --git a/crawler/__tests__/api.test.js b/crawler/__tests__/api.test.js new file mode 100644 index 0000000..0ab9233 --- /dev/null +++ b/crawler/__tests__/api.test.js @@ -0,0 +1,561 @@ +/** + * @fileoverview Unit tests for API crawling utilities + * Tests the actual checkAndFlushApi function with mocked dependencies + */ + +import { jest } from '@jest/globals'; + +// Mock the handlers module before importing +const mockFlushCollectionsToDb = jest.fn(); + +jest.unstable_mockModule('../utils/handlers.js', () => ({ + flushCollectionsToDb: mockFlushCollectionsToDb, + handleCollections: jest.fn() +})); + +// Import the actual module to test +const { checkAndFlushApi, BATCH_SIZE, API_CLEAR_BATCH_SIZE } = await import('../apis/api.js'); + +describe('checkAndFlushApi - Batch Management', () => { + beforeEach(() => { + mockFlushCollectionsToDb.mockClear(); + mockFlushCollectionsToDb.mockResolvedValue({ saved: 0, failed: 0 }); + }); + + test('should flush collections when BATCH_SIZE is reached', async () => { + const results = { + collections: new Array(BATCH_SIZE).fill(null).map((_, i) => ({ + id: `sentinel-2-l2a-${i}`, + title: `Sentinel-2 Collection ${i}` + })), + apis: [], + stats: { + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn() + }; + + mockFlushCollectionsToDb.mockResolvedValueOnce({ saved: 25, failed: 0 }); + await checkAndFlushApi(results, mockLog); + + expect(mockFlushCollectionsToDb).toHaveBeenCalledTimes(1); + expect(mockFlushCollectionsToDb).toHaveBeenCalledWith(results, mockLog, false); + expect(results.stats.collectionsSaved).toBe(25); + expect(results.stats.collectionsFailed).toBe(0); + }); + + test('should not flush when below BATCH_SIZE', async () => { + const results = { + collections: [ + { id: 'landsat-c2-l2', title: 'Landsat Collection 2' } + ], + apis: [], + stats: { + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + const mockLog = { info: jest.fn(), warning: jest.fn() }; + await checkAndFlushApi(results, mockLog); + + expect(mockFlushCollectionsToDb).not.toHaveBeenCalled(); + }); + + test('should clear APIs array when API_CLEAR_BATCH_SIZE is reached', async () => { + const results = { + collections: [], + apis: new Array(API_CLEAR_BATCH_SIZE).fill(null).map((_, i) => ({ id: `api-${i}` })), + stats: { + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + const mockLog = { info: jest.fn(), warning: jest.fn() }; + await checkAndFlushApi(results, mockLog); + + expect(results.apis.length).toBe(0); + expect(mockLog.info).toHaveBeenCalledWith( + expect.stringContaining('[MEMORY] Clearing') + ); + }); + + test('should handle both flush and clear simultaneously', async () => { + const results = { + collections: new Array(BATCH_SIZE).fill({}).map((_, i) => ({ id: `col-${i}` })), + apis: new Array(API_CLEAR_BATCH_SIZE).fill({}).map((_, i) => ({ id: `api-${i}` })), + stats: { + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + const mockLog = { info: jest.fn(), warning: jest.fn() }; + mockFlushCollectionsToDb.mockResolvedValueOnce({ saved: 20, failed: 5 }); + await checkAndFlushApi(results, mockLog); + + expect(mockFlushCollectionsToDb).toHaveBeenCalled(); + expect(results.apis.length).toBe(0); + expect(results.stats.collectionsSaved).toBe(20); + expect(results.stats.collectionsFailed).toBe(5); + }); + + test('should accumulate stats from multiple flushes', async () => { + const results = { + collections: new Array(BATCH_SIZE).fill({}).map((_, i) => ({ id: `col-${i}` })), + apis: [], + stats: { + collectionsSaved: 10, + collectionsFailed: 2 + } + }; + + const mockLog = { info: jest.fn(), warning: jest.fn() }; + mockFlushCollectionsToDb.mockResolvedValueOnce({ saved: 15, failed: 10 }); + await checkAndFlushApi(results, mockLog); + + expect(results.stats.collectionsSaved).toBe(25); // 10 + 15 + expect(results.stats.collectionsFailed).toBe(12); // 2 + 10 + }); +}); + +describe('API Endpoint URL Validation', () => { + test('should recognize valid STAC API URLs', () => { + const validApis = [ + 'https://planetarycomputer.microsoft.com/api/stac/v1', + 'https://earth-search.aws.element84.com/v1', + 'https://landsatlook.usgs.gov/stac-server', + 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD', + 'https://catalogue.dataspace.copernicus.eu/stac', + 'https://stac.terria.io', + 'https://data.lpdaac.earthdatacloud.nasa.gov/stac' + ]; + + validApis.forEach(url => { + expect(() => new URL(url)).not.toThrow(); + expect(new URL(url).protocol).toBe('https:'); + }); + }); + + test('should parse STAC API domains correctly', () => { + const apiUrls = [ + { url: 'https://planetarycomputer.microsoft.com/api/stac/v1', domain: 'planetarycomputer.microsoft.com' }, + { url: 'https://earth-search.aws.element84.com/v1', domain: 'earth-search.aws.element84.com' }, + { url: 'https://landsatlook.usgs.gov/stac-server', domain: 'landsatlook.usgs.gov' }, + { url: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD', domain: 'cmr.earthdata.nasa.gov' } + ]; + + apiUrls.forEach(({ url, domain }) => { + const parsed = new URL(url); + expect(parsed.hostname).toBe(domain); + }); + }); + + test('should construct collections endpoint from API root', () => { + const apiRoots = [ + 'https://planetarycomputer.microsoft.com/api/stac/v1', + 'https://earth-search.aws.element84.com/v1', + 'https://landsatlook.usgs.gov/stac-server' + ]; + + apiRoots.forEach(root => { + const baseUrl = root.endsWith('/') ? root.slice(0, -1) : root; + const collectionsUrl = `${baseUrl}/collections`; + + expect(collectionsUrl).toContain('/collections'); + expect(() => new URL(collectionsUrl)).not.toThrow(); + }); + }); + + test('should handle API URLs with trailing slashes', () => { + const urls = [ + { with: 'https://planetarycomputer.microsoft.com/api/stac/v1/', without: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + { with: 'https://earth-search.aws.element84.com/v1/', without: 'https://earth-search.aws.element84.com/v1' } + ]; + + urls.forEach(({ with: withSlash, without }) => { + const normalized = withSlash.endsWith('/') ? withSlash.slice(0, -1) : withSlash; + expect(normalized).toBe(without); + }); + }); +}); + +describe('STAC API Response Structures', () => { + test('should validate Microsoft Planetary Computer API root structure', () => { + const apiRoot = { + type: 'Catalog', + id: 'microsoft-pc', + title: 'Microsoft Planetary Computer STAC API', + description: 'Catalog of datasets on the Microsoft Planetary Computer', + stac_version: '1.0.0', + conformsTo: [ + 'https://api.stacspec.org/v1.0.0/core', + 'https://api.stacspec.org/v1.0.0/collections', + 'https://api.stacspec.org/v1.0.0/ogcapi-features' + ], + links: [ + { rel: 'self', href: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + { rel: 'root', href: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + { rel: 'data', href: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections' }, + { rel: 'conformance', href: 'https://planetarycomputer.microsoft.com/api/stac/v1/conformance' } + ] + }; + + expect(apiRoot.type).toBe('Catalog'); + expect(apiRoot.stac_version).toBeDefined(); + expect(apiRoot.conformsTo).toBeInstanceOf(Array); + expect(apiRoot.links).toBeInstanceOf(Array); + + const dataLink = apiRoot.links.find(l => l.rel === 'data'); + expect(dataLink).toBeDefined(); + expect(dataLink.href).toContain('/collections'); + }); + + test('should validate Earth Search API collections response structure', () => { + const collectionsResponse = { + collections: [ + { + id: 'sentinel-2-l2a', + type: 'Collection', + title: 'Sentinel-2 Level-2A', + description: 'Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance', + stac_version: '1.0.0', + license: 'proprietary', + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-27T10:25:31Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a' } + ] + } + ], + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections' }, + { rel: 'root', href: 'https://earth-search.aws.element84.com/v1' } + ] + }; + + expect(collectionsResponse.collections).toBeInstanceOf(Array); + expect(collectionsResponse.collections.length).toBeGreaterThan(0); + + const collection = collectionsResponse.collections[0]; + expect(collection.type).toBe('Collection'); + expect(collection.id).toBeDefined(); + expect(collection.extent).toBeDefined(); + expect(collection.extent.spatial).toBeDefined(); + expect(collection.extent.temporal).toBeDefined(); + }); + + test('should validate USGS Landsat collection metadata', () => { + const collection = { + id: 'landsat-c2-l2', + type: 'Collection', + title: 'Landsat Collection 2 Level-2', + description: 'Landsat Collection 2 Level-2 Science Products', + stac_version: '1.0.0', + license: 'proprietary', + keywords: ['landsat', 'usgs', 'nasa', 'satellite', 'global'], + providers: [ + { + name: 'NASA', + roles: ['producer'], + url: 'https://landsat.gsfc.nasa.gov/' + }, + { + name: 'USGS', + roles: ['processor', 'host'], + url: 'https://www.usgs.gov/landsat-missions' + } + ], + extent: { + spatial: { + bbox: [[-180, -90, 180, 90]] + }, + temporal: { + interval: [['1972-07-25T00:00:00Z', null]] + } + }, + summaries: { + platform: ['landsat-4', 'landsat-5', 'landsat-7', 'landsat-8', 'landsat-9'], + instruments: ['tm', 'etm+', 'oli', 'tirs'] + } + }; + + expect(collection.id).toBe('landsat-c2-l2'); + expect(collection.keywords).toContain('landsat'); + expect(collection.providers).toBeInstanceOf(Array); + expect(collection.providers.length).toBeGreaterThan(0); + expect(collection.summaries).toBeDefined(); + expect(collection.summaries.platform).toBeInstanceOf(Array); + }); + + test('should validate NASA CMR STAC API structure', () => { + const cmrCollection = { + id: 'HLSL30.v2.0', + type: 'Collection', + title: 'HLS Landsat Operational Land Imager Surface Reflectance and TOA Brightness Daily Global 30m v2.0', + description: 'The Harmonized Landsat Sentinel-2 (HLS) project provides consistent surface reflectance data from Landsat 8 and Sentinel-2 satellites.', + stac_version: '1.0.0', + license: 'not-provided', + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2013-04-11T00:00:00Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD/collections/HLSL30.v2.0' }, + { rel: 'parent', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD' }, + { rel: 'root', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD' } + ] + }; + + expect(cmrCollection.id).toContain('.'); + expect(cmrCollection.title).toContain('HLS'); + expect(cmrCollection.links.some(l => l.rel === 'parent')).toBe(true); + expect(cmrCollection.links[0].href).toContain('cmr.earthdata.nasa.gov'); + }); +}); + +describe('API Collections Extraction', () => { + test('should extract collection IDs from API responses', () => { + const responses = [ + { + api: 'Microsoft Planetary Computer', + collections: ['landsat-c2-l2', 'sentinel-2-l2a', 'naip', 'cop-dem-glo-30'] + }, + { + api: 'Earth Search', + collections: ['sentinel-2-l2a', 'sentinel-2-l1c', 'landsat-c2-l2', 'cop-dem-glo-30'] + }, + { + api: 'USGS Landsat', + collections: ['landsat-c2l1', 'landsat-c2l2-sr', 'landsat-c2l2-st'] + } + ]; + + responses.forEach(({ api, collections }) => { + expect(collections).toBeInstanceOf(Array); + expect(collections.length).toBeGreaterThan(0); + collections.forEach(id => { + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + }); + }); + }); + + test('should track API processing statistics', () => { + const stats = { + totalRequests: 15, + successfulRequests: 14, + failedRequests: 1, + apisProcessed: 3, + stacCompliant: 3, + nonCompliant: 0, + collectionsFound: 25, + collectionsSaved: 25, + collectionsFailed: 0 + }; + + expect(stats.successfulRequests + stats.failedRequests).toBe(stats.totalRequests); + expect(stats.apisProcessed).toBe(3); + expect(stats.stacCompliant).toBeGreaterThan(0); + expect(stats.collectionsFound).toBeGreaterThan(stats.apisProcessed); + }); +}); + +describe('Batch Flushing for API Collections', () => { + const BATCH_SIZE = 25; + + test('should check if collections reach batch size threshold', () => { + const results = { + collections: new Array(BATCH_SIZE).fill(null).map((_, i) => ({ + id: `sentinel-2-l2a-item-${i}`, + title: `Sentinel-2 Item ${i}`, + bbox: [-180, -90, 180, 90] + })), + stats: { + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + // Verify we have enough collections to trigger a flush + expect(results.collections.length).toBe(BATCH_SIZE); + expect(results.collections.length >= BATCH_SIZE).toBe(true); + }); + + test('should not flush collections below batch size', () => { + const results = { + collections: [ + { id: 'landsat-c2-l2-1', title: 'Landsat 1' }, + { id: 'landsat-c2-l2-2', title: 'Landsat 2' } + ], + stats: { + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + expect(results.collections.length).toBeLessThan(BATCH_SIZE); + expect(results.collections.length >= BATCH_SIZE).toBe(false); + }); + + test('should track batch statistics correctly', () => { + const stats = { + collectionsSaved: 25, + collectionsFailed: 2, + collectionsFound: 27 + }; + + expect(stats.collectionsSaved + stats.collectionsFailed).toBe(stats.collectionsFound); + expect(stats.collectionsSaved).toBeGreaterThan(0); + }); +}); + +describe('API Discovery and Link Following', () => { + test('should identify collections endpoint from API links', () => { + const apiLinks = [ + { rel: 'self', href: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + { rel: 'root', href: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + { rel: 'data', href: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections' }, + { rel: 'search', href: 'https://planetarycomputer.microsoft.com/api/stac/v1/search' } + ]; + + const collectionsLink = apiLinks.find(l => l.rel === 'data' || l.rel === 'collections'); + + expect(collectionsLink).toBeDefined(); + expect(collectionsLink.href).toContain('/collections'); + }); + + test('should handle child catalog links in API responses', () => { + const apiWithChildren = { + type: 'Catalog', + id: 'root-catalog', + links: [ + { rel: 'self', href: 'https://stac.terria.io' }, + { rel: 'child', href: 'https://stac.terria.io/catalogs/cbers', title: 'CBERS' }, + { rel: 'child', href: 'https://stac.terria.io/catalogs/dem', title: 'DEM' }, + { rel: 'child', href: 'https://stac.terria.io/catalogs/aster', title: 'ASTER' } + ] + }; + + const childLinks = apiWithChildren.links.filter(l => l.rel === 'child'); + + expect(childLinks.length).toBe(3); + childLinks.forEach(link => { + expect(link.href).toContain('stac.terria.io'); + expect(() => new URL(link.href)).not.toThrow(); + }); + }); + + test('should construct absolute URLs from relative API links', () => { + const baseUrl = 'https://earth-search.aws.element84.com/v1'; + const relativeLinks = [ + { relative: './collections', expected: 'https://earth-search.aws.element84.com/collections' }, + { relative: 'collections/sentinel-2-l2a', expected: 'https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a' } + ]; + + relativeLinks.forEach(({ relative, expected }) => { + let absoluteUrl; + if (!relative.startsWith('http')) { + const basePath = baseUrl.substring(0, baseUrl.lastIndexOf('/')); + absoluteUrl = relative.startsWith('./') + ? `${basePath}/${relative.slice(2)}` + : `${baseUrl}/${relative}`; + } + + // Basic check that it's now absolute + expect(absoluteUrl || relative).toContain('https://'); + }); + }); +}); + +describe('API Rate Limiting and Concurrency', () => { + test('should calculate rate limits per domain', () => { + const maxRequestsPerMinute = 120; + const rateLimits = { + maxRequestsPerMinute: maxRequestsPerMinute + }; + + expect(rateLimits.maxRequestsPerMinute).toBe(120); + expect(rateLimits.maxRequestsPerMinute).toBeGreaterThan(0); + }); + + test('should group API URLs by domain for parallel crawling', () => { + const apis = [ + { url: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + { url: 'https://earth-search.aws.element84.com/v1' }, + { url: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections' }, + { url: 'https://landsatlook.usgs.gov/stac-server' } + ]; + + const domainMap = new Map(); + apis.forEach(api => { + const domain = new URL(api.url).hostname; + if (!domainMap.has(domain)) { + domainMap.set(domain, []); + } + domainMap.get(domain).push(api); + }); + + expect(domainMap.size).toBe(3); + expect(domainMap.get('planetarycomputer.microsoft.com').length).toBe(2); + expect(domainMap.get('earth-search.aws.element84.com').length).toBe(1); + expect(domainMap.get('landsatlook.usgs.gov').length).toBe(1); + }); + + test('should respect parallel domain concurrency limits', () => { + const config = { + parallelDomains: 5, + maxRequestsPerMinutePerDomain: 120, + maxConcurrencyPerDomain: 20 + }; + + expect(config.parallelDomains).toBeLessThanOrEqual(10); + expect(config.maxConcurrencyPerDomain).toBeGreaterThan(0); + + // Theoretical max throughput + const maxThroughput = config.parallelDomains * config.maxRequestsPerMinutePerDomain; + expect(maxThroughput).toBe(600); + }); +}); + +describe('S3 URL Handling in API Responses', () => { + test('should convert S3 URLs to HTTPS', () => { + const s3Urls = [ + { s3: 's3://usgs-landsat/collection02', expected: 'https://usgs-landsat.s3.amazonaws.com/collection02' }, + { s3: 's3://sentinel-s2-l2a/tiles/10/T/FK', expected: 'https://sentinel-s2-l2a.s3.amazonaws.com/tiles/10/T/FK' } + ]; + + s3Urls.forEach(({ s3, expected }) => { + const s3Match = s3.match(/^s3:\/\/([^/]+)\/(.*)$/); + if (s3Match) { + const [, bucket, path] = s3Match; + const httpsUrl = `https://${bucket}.s3.amazonaws.com/${path}`; + expect(httpsUrl).toBe(expected); + } + }); + }); + + test('should handle malformed S3 URLs gracefully', () => { + const malformedUrls = [ + 's3://', + 's3://bucket-only', + 's3:invalid', + 'not-s3://something' + ]; + + malformedUrls.forEach(url => { + if (url.startsWith('s3://')) { + const s3Match = url.match(/^s3:\/\/([^/]+)\/(.*)$/); + expect(s3Match).toBeFalsy(); + } + }); + }); +}); diff --git a/crawler/__tests__/normalization.test.js b/crawler/__tests__/normalization.test.js new file mode 100644 index 0000000..5080828 --- /dev/null +++ b/crawler/__tests__/normalization.test.js @@ -0,0 +1,448 @@ +/** + * @fileoverview Unit tests for normalization utilities + */ + +import { jest } from '@jest/globals'; +import { + deriveCategories, + normalizeCatalog, + normalizeCollection, + processCatalogs +} from '../utils/normalization.js'; + +describe('deriveCategories', () => { + test('should return empty array for null input', () => { + expect(deriveCategories(null)).toEqual([]); + }); + + test('should return empty array for undefined input', () => { + expect(deriveCategories(undefined)).toEqual([]); + }); + + test('should return empty array for non-object input', () => { + expect(deriveCategories('string')).toEqual([]); + expect(deriveCategories(123)).toEqual([]); + }); + + test('should extract categories from categories field', () => { + const catalog = { categories: ['imagery', 'satellite'] }; + expect(deriveCategories(catalog)).toEqual(['imagery', 'satellite']); + }); + + test('should filter out falsy values from categories', () => { + const catalog = { categories: ['imagery', null, '', 'satellite', undefined] }; + expect(deriveCategories(catalog)).toEqual(['imagery', 'satellite']); + }); + + test('should extract categories from keywords field', () => { + const catalog = { keywords: ['landsat', 'modis'] }; + expect(deriveCategories(catalog)).toEqual(['landsat', 'modis']); + }); + + test('should extract categories from tags field', () => { + const catalog = { tags: ['climate', 'weather'] }; + expect(deriveCategories(catalog)).toEqual(['climate', 'weather']); + }); + + test('should extract category from access field', () => { + const catalog = { access: 'public' }; + expect(deriveCategories(catalog)).toEqual(['public']); + }); + + test('should trim whitespace from access field', () => { + const catalog = { access: ' restricted ' }; + expect(deriveCategories(catalog)).toEqual(['restricted']); + }); + + test('should ignore empty access field', () => { + const catalog = { access: ' ' }; + expect(deriveCategories(catalog)).toEqual([]); + }); + + test('should prioritize categories over keywords', () => { + const catalog = { + categories: ['cat1'], + keywords: ['key1'] + }; + expect(deriveCategories(catalog)).toEqual(['cat1']); + }); + + test('should prioritize keywords over tags', () => { + const catalog = { + keywords: ['key1'], + tags: ['tag1'] + }; + expect(deriveCategories(catalog)).toEqual(['key1']); + }); + + test('should prioritize tags over access', () => { + const catalog = { + tags: ['tag1'], + access: 'public' + }; + expect(deriveCategories(catalog)).toEqual(['tag1']); + }); + + test('should convert non-string array elements to strings', () => { + const catalog = { categories: [1, 2, true, 'test'] }; + expect(deriveCategories(catalog)).toEqual(['1', '2', 'true', 'test']); + }); +}); + +describe('normalizeCatalog', () => { + test('should normalize a basic catalog object', () => { + const catalog = { + id: 'microsoft-pc', + url: 'https://planetarycomputer.microsoft.com/api/stac/v1', + slug: 'microsoft-planetary-computer', + title: 'Microsoft Planetary Computer STAC API', + summary: 'A test catalog', + access: 'public', + created: '2024-01-01', + updated: '2024-01-02', + isPrivate: false, + isApi: true, + accessInfo: 'Free access' + }; + + const result = normalizeCatalog(catalog, 5); + + expect(result.index).toBe(5); + expect(result.id).toBe('microsoft-pc'); + expect(result.url).toBe('https://planetarycomputer.microsoft.com/api/stac/v1'); + expect(result.slug).toBe('microsoft-planetary-computer'); + expect(result.title).toBe('Microsoft Planetary Computer STAC API'); + expect(result.summary).toBe('A test catalog'); + expect(result.access).toBe('public'); + expect(result.created).toBe('2024-01-01'); + expect(result.updated).toBe('2024-01-02'); + expect(result.isPrivate).toBe(false); + expect(result.isApi).toBe(true); + expect(result.accessInfo).toBe('Free access'); + }); + + test('should derive categories from catalog', () => { + const catalog = { + id: 'usgs-landsat', + url: 'https://landsatlook.usgs.gov/stac-server', + categories: ['imagery', 'satellite'] + }; + + const result = normalizeCatalog(catalog, 0); + expect(result.categories).toEqual(['imagery', 'satellite']); + }); + + test('should preserve additional dynamic properties', () => { + const catalog = { + id: 'test', + url: 'https://example.com', + customField: 'custom value', + anotherField: 123 + }; + + const result = normalizeCatalog(catalog, 0); + expect(result.customField).toBe('custom value'); + expect(result.anotherField).toBe(123); + }); + + test('should not duplicate standard properties in dynamic properties', () => { + const catalog = { + id: 'test', + url: 'https://example.com', + title: 'Test' + }; + + const result = normalizeCatalog(catalog, 0); + const keys = Object.keys(result); + const idCount = keys.filter(k => k === 'id').length; + expect(idCount).toBe(1); + }); +}); + +describe('normalizeCollection', () => { + test('should normalize a plain collection object', () => { + const collection = { + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + description: 'Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance', + license: 'proprietary', + keywords: ['sentinel', 'copernicus', 'esa', 'msi', 'reflectance'], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-27T10:25:31Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a' } + ], + stac_version: '1.0.0', + type: 'Collection', + summaries: { 'eo:bands': [] }, + stac_extensions: ['https://stac-extensions.github.io/eo/v1.0.0/schema.json'], + providers: [{ name: 'Test Provider' }], + assets: {} + }; + + const result = normalizeCollection(collection, 0); + + expect(result.index).toBe(0); + expect(result.id).toBe('sentinel-2-l2a'); + expect(result.title).toBe('Sentinel-2 Level-2A'); + expect(result.description).toBe('Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance'); + expect(result.license).toBe('proprietary'); + expect(result.keywords).toEqual(['sentinel', 'copernicus', 'esa', 'msi', 'reflectance']); + expect(result.bbox).toEqual([-180, -90, 180, 90]); + expect(result.temporal).toEqual(['2015-06-27T10:25:31Z', null]); + expect(result.url).toBe('https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a'); + expect(result.stac_version).toBe('1.0.0'); + expect(result.type).toBe('Collection'); + }); + + test('should handle stac-js object with getBoundingBox method', () => { + const collection = { + id: 'test', + getBoundingBox: () => [0, 0, 10, 10], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] } + } + }; + + const result = normalizeCollection(collection, 0); + expect(result.bbox).toEqual([0, 0, 10, 10]); + }); + + test('should handle stac-js object with getTemporalExtent method', () => { + const collection = { + id: 'test', + getTemporalExtent: () => ['2020-01-01', '2023-12-31'], + extent: { + temporal: { interval: [['2019-01-01', '2022-12-31']] } + } + }; + + const result = normalizeCollection(collection, 0); + expect(result.temporal).toEqual(['2020-01-01', '2023-12-31']); + }); + + test('should fallback to extent.spatial.bbox when methods unavailable', () => { + const collection = { + id: 'test', + extent: { + spatial: { bbox: [[1, 2, 3, 4]] } + } + }; + + const result = normalizeCollection(collection, 0); + expect(result.bbox).toEqual([1, 2, 3, 4]); + }); + + test('should fallback to extent.temporal.interval when methods unavailable', () => { + const collection = { + id: 'test', + extent: { + temporal: { interval: [['2020-01-01', null]] } + } + }; + + const result = normalizeCollection(collection, 0); + expect(result.temporal).toEqual(['2020-01-01', null]); + }); + + test('should handle stac-js object with getAbsoluteUrl method', () => { + const collection = { + id: 'test', + getAbsoluteUrl: () => 'https://example.com/absolute' + }; + + const result = normalizeCollection(collection, 0); + expect(result.url).toBe('https://example.com/absolute'); + }); + + test('should extract self link from links array', () => { + const collection = { + id: 'landsat-c2-l2', + links: [ + { rel: 'root', href: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + { rel: 'self', href: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/landsat-c2-l2' } + ] + }; + + const result = normalizeCollection(collection, 0); + expect(result.url).toBe('https://planetarycomputer.microsoft.com/api/stac/v1/collections/landsat-c2-l2'); + }); + + test('should use summary field if description is missing', () => { + const collection = { + id: 'test', + summary: 'This is a summary' + }; + + const result = normalizeCollection(collection, 0); + expect(result.description).toBe('This is a summary'); + }); + + test('should prefer description over summary', () => { + const collection = { + id: 'test', + description: 'Description text', + summary: 'Summary text' + }; + + const result = normalizeCollection(collection, 0); + expect(result.description).toBe('Description text'); + }); + + test('should handle stac-js object with toJSON method', () => { + const collection = { + id: 'test-from-method', + toJSON: () => ({ + id: 'test-from-json', + title: 'JSON Title', + extent: { + spatial: { bbox: [[5, 6, 7, 8]] } + } + }) + }; + + const result = normalizeCollection(collection, 0); + expect(result.id).toBe('test-from-method'); // Direct property takes precedence + expect(result.bbox).toEqual([5, 6, 7, 8]); // Fallback from toJSON + }); + + test('should convert stac-js link objects to plain objects', () => { + const collection = { + id: 'cop-dem-glo-30', + links: [ + { rel: 'self', href: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/cop-dem-glo-30', type: 'application/json', title: 'Self' } + ] + }; + + const result = normalizeCollection(collection, 0); + expect(result.links).toEqual([ + { rel: 'self', href: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/cop-dem-glo-30', type: 'application/json', title: 'Self' } + ]); + }); + + test('should default to Unknown for missing id', () => { + const collection = {}; + + const result = normalizeCollection(collection, 0); + expect(result.id).toBe('Unknown'); + }); + + test('should default to Collection for missing type', () => { + const collection = { id: 'test' }; + + const result = normalizeCollection(collection, 0); + expect(result.type).toBe('Collection'); + }); + + test('should handle null values gracefully', () => { + const collection = { + id: 'test', + title: null, + description: null, + license: null, + bbox: null, + temporal: null + }; + + const result = normalizeCollection(collection, 0); + expect(result.title).toBeNull(); + expect(result.description).toBeNull(); + expect(result.license).toBeNull(); + expect(result.bbox).toBeNull(); + expect(result.temporal).toBeNull(); + }); + + test('should default empty array for keywords', () => { + const collection = { id: 'test' }; + + const result = normalizeCollection(collection, 0); + expect(result.keywords).toEqual([]); + }); + + test('should default empty array for stac_extensions', () => { + const collection = { id: 'test' }; + + const result = normalizeCollection(collection, 0); + expect(result.stac_extensions).toEqual([]); + }); + + test('should default empty array for providers', () => { + const collection = { id: 'test' }; + + const result = normalizeCollection(collection, 0); + expect(result.providers).toEqual([]); + }); +}); + +describe('processCatalogs', () => { + // Mock console.log to avoid clutter in test output + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + console.log.mockRestore(); + }); + + test('should throw error for non-array input', () => { + expect(() => processCatalogs('not an array')).toThrow('Expected an array'); + expect(() => processCatalogs(null)).toThrow('Expected an array'); + expect(() => processCatalogs({})).toThrow('Expected an array'); + }); + + test('should process empty array', () => { + const result = processCatalogs([]); + expect(result).toEqual([]); + expect(result.length).toBe(0); + }); + + test('should normalize all catalogs in array', () => { + const catalogs = [ + { id: 'microsoft-pc', url: 'https://planetarycomputer.microsoft.com/api/stac/v1', title: 'Microsoft Planetary Computer' }, + { id: 'earth-search', url: 'https://earth-search.aws.element84.com/v1', title: 'Earth Search by Element 84' }, + { id: 'usgs-landsat', url: 'https://landsatlook.usgs.gov/stac-server', title: 'USGS Landsat' } + ]; + + const result = processCatalogs(catalogs); + expect(result.length).toBe(3); + expect(result[0].id).toBe('microsoft-pc'); + expect(result[0].index).toBe(0); + expect(result[1].id).toBe('earth-search'); + expect(result[1].index).toBe(1); + expect(result[2].id).toBe('usgs-landsat'); + expect(result[2].index).toBe(2); + }); + + test('should maintain index order', () => { + const catalogs = [ + { id: 'planetary-computer', url: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + { id: 'earth-search', url: 'https://earth-search.aws.element84.com/v1' }, + { id: 'copernicus', url: 'https://catalogue.dataspace.copernicus.eu/stac' } + ]; + + const result = processCatalogs(catalogs); + expect(result[0].index).toBe(0); + expect(result[1].index).toBe(1); + expect(result[2].index).toBe(2); + }); + + test('should log summary information', () => { + const catalogs = [ + { id: 'nasa-cmr', url: 'https://cmr.earthdata.nasa.gov/stac', title: 'NASA CMR STAC', isApi: true, categories: ['satellite', 'nasa'] } + ]; + + processCatalogs(catalogs); + + expect(console.log).toHaveBeenCalledWith('Total: 1 catalogs found\n'); + expect(console.log).toHaveBeenCalledWith('Example - First Catalog:'); + }); + + test('should not log example for empty array', () => { + processCatalogs([]); + + expect(console.log).toHaveBeenCalledWith('Total: 0 catalogs found\n'); + expect(console.log).not.toHaveBeenCalledWith('Example - First Catalog:'); + }); +}); diff --git a/crawler/__tests__/parallel.test.js b/crawler/__tests__/parallel.test.js new file mode 100644 index 0000000..07ca3a0 --- /dev/null +++ b/crawler/__tests__/parallel.test.js @@ -0,0 +1,555 @@ +/** + * @fileoverview Unit tests for parallel execution utilities + */ + +import { jest } from '@jest/globals'; +import { + getDomain, + groupByDomain, + createDomainBatches, + aggregateStats, + executeWithConcurrency, + calculateRateLimits, + logDomainStats +} from '../utils/parallel.js'; + +describe('getDomain', () => { + test('should extract domain from valid URL', () => { + expect(getDomain('https://planetarycomputer.microsoft.com/api/stac/v1')).toBe('planetarycomputer.microsoft.com'); + expect(getDomain('http://earth-search.aws.element84.com/v1')).toBe('earth-search.aws.element84.com'); + expect(getDomain('https://landsatlook.usgs.gov/stac-server')).toBe('landsatlook.usgs.gov'); + }); + + test('should handle URLs with ports', () => { + expect(getDomain('https://planetarycomputer.microsoft.com:8080/path')).toBe('planetarycomputer.microsoft.com'); + expect(getDomain('http://localhost:8080/stac')).toBe('localhost'); + }); + + test('should handle URLs with query parameters', () => { + expect(getDomain('https://earth-search.aws.element84.com/v1/search?limit=10')).toBe('earth-search.aws.element84.com'); + }); + + test('should handle URLs with hash fragments', () => { + expect(getDomain('https://catalogue.dataspace.copernicus.eu/stac#collections')).toBe('catalogue.dataspace.copernicus.eu'); + }); + + test('should return "unknown" for invalid URLs', () => { + expect(getDomain('not a url')).toBe('unknown'); + expect(getDomain('')).toBe('unknown'); + expect(getDomain('//invalid')).toBe('unknown'); + }); + + test('should handle different protocols', () => { + expect(getDomain('ftp://data.lpdaac.earthdatacloud.nasa.gov')).toBe('data.lpdaac.earthdatacloud.nasa.gov'); + expect(getDomain('ws://stac-api.terria.io')).toBe('stac-api.terria.io'); + }); +}); + +describe('groupByDomain', () => { + test('should group items by domain', () => { + const items = [ + { url: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/landsat-c2-l2' }, + { url: 'https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a' }, + { url: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-2-l2a' } + ]; + + const result = groupByDomain(items); + + expect(result.size).toBe(2); + expect(result.get('planetarycomputer.microsoft.com').length).toBe(2); + expect(result.get('earth-search.aws.element84.com').length).toBe(1); + }); + + test('should handle empty array', () => { + const result = groupByDomain([]); + expect(result.size).toBe(0); + }); + + test('should handle single domain', () => { + const items = [ + { url: 'https://landsatlook.usgs.gov/stac-server/collections/landsat-c2l1' }, + { url: 'https://landsatlook.usgs.gov/stac-server/collections/landsat-c2l2-st' }, + { url: 'https://landsatlook.usgs.gov/stac-server/collections/landsat-c2l2-sr' } + ]; + + const result = groupByDomain(items); + + expect(result.size).toBe(1); + expect(result.get('landsatlook.usgs.gov').length).toBe(3); + }); + + test('should preserve item data', () => { + const items = [ + { url: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/landsat-c2-l2', id: 'landsat-c2-l2', data: 'test' }, + { url: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-2-l2a', id: 'sentinel-2-l2a', data: 'test2' } + ]; + + const result = groupByDomain(items); + const domainItems = result.get('planetarycomputer.microsoft.com'); + + expect(domainItems[0].id).toBe('landsat-c2-l2'); + expect(domainItems[0].data).toBe('test'); + expect(domainItems[1].id).toBe('sentinel-2-l2a'); + }); + + test('should handle invalid URLs by grouping under "unknown"', () => { + const items = [ + { url: 'invalid url 1' }, + { url: 'invalid url 2' }, + { url: 'https://earth-search.aws.element84.com/v1' } + ]; + + const result = groupByDomain(items); + + expect(result.has('unknown')).toBe(true); + expect(result.get('unknown').length).toBe(2); + expect(result.get('earth-search.aws.element84.com').length).toBe(1); + }); + + test('should handle subdomains as separate domains', () => { + const items = [ + { url: 'https://stac.terria.io/catalogs/cbers' }, + { url: 'https://data.lpdaac.earthdatacloud.nasa.gov/stac' }, + { url: 'https://cmr.earthdata.nasa.gov/stac' } + ]; + + const result = groupByDomain(items); + + expect(result.size).toBe(3); + expect(result.has('stac.terria.io')).toBe(true); + expect(result.has('data.lpdaac.earthdatacloud.nasa.gov')).toBe(true); + expect(result.has('cmr.earthdata.nasa.gov')).toBe(true); + }); +}); + +describe('createDomainBatches', () => { + test('should create batches of specified size', () => { + const domainMap = new Map([ + ['domain1.com', [1, 2, 3]], + ['domain2.com', [4, 5]], + ['domain3.com', [6]], + ['domain4.com', [7, 8]], + ['domain5.com', [9]], + ['domain6.com', [10]] + ]); + + const batches = createDomainBatches(domainMap, 2); + + expect(batches.length).toBe(3); + expect(batches[0].length).toBe(2); + expect(batches[1].length).toBe(2); + expect(batches[2].length).toBe(2); + }); + + test('should handle remainder in last batch', () => { + const domainMap = new Map([ + ['domain1.com', []], + ['domain2.com', []], + ['domain3.com', []] + ]); + + const batches = createDomainBatches(domainMap, 2); + + expect(batches.length).toBe(2); + expect(batches[0].length).toBe(2); + expect(batches[1].length).toBe(1); + }); + + test('should use default batch size of 5', () => { + const domainMap = new Map([ + ['d1', []], ['d2', []], ['d3', []], ['d4', []], ['d5', []], + ['d6', []], ['d7', []], ['d8', []], ['d9', []], ['d10', []] + ]); + + const batches = createDomainBatches(domainMap); + + expect(batches.length).toBe(2); + expect(batches[0].length).toBe(5); + expect(batches[1].length).toBe(5); + }); + + test('should handle empty domain map', () => { + const domainMap = new Map(); + const batches = createDomainBatches(domainMap, 5); + + expect(batches.length).toBe(0); + }); + + test('should handle domain map smaller than batch size', () => { + const domainMap = new Map([ + ['domain1.com', [1, 2]], + ['domain2.com', [3]] + ]); + + const batches = createDomainBatches(domainMap, 5); + + expect(batches.length).toBe(1); + expect(batches[0].length).toBe(2); + }); + + test('should preserve domain-items pairs correctly', () => { + const domainMap = new Map([ + ['planetarycomputer.microsoft.com', ['landsat-c2-l2', 'sentinel-2-l2a']], + ['earth-search.aws.element84.com', ['sentinel-2-l1c', 'landsat-c2-l1']] + ]); + + const batches = createDomainBatches(domainMap, 2); + + expect(batches[0][0][0]).toBe('planetarycomputer.microsoft.com'); + expect(batches[0][0][1]).toEqual(['landsat-c2-l2', 'sentinel-2-l2a']); + expect(batches[0][1][0]).toBe('earth-search.aws.element84.com'); + expect(batches[0][1][1]).toEqual(['sentinel-2-l1c', 'landsat-c2-l1']); + }); +}); + +describe('aggregateStats', () => { + test('should aggregate statistics from multiple results', () => { + const results = [ + { + stats: { + totalRequests: 10, + successfulRequests: 8, + failedRequests: 2, + collectionsFound: 5, + collectionsSaved: 4, + collectionsFailed: 1 + } + }, + { + stats: { + totalRequests: 20, + successfulRequests: 18, + failedRequests: 2, + collectionsFound: 10, + collectionsSaved: 9, + collectionsFailed: 1 + } + } + ]; + + const aggregated = aggregateStats(results); + + expect(aggregated.totalRequests).toBe(30); + expect(aggregated.successfulRequests).toBe(26); + expect(aggregated.failedRequests).toBe(4); + expect(aggregated.collectionsFound).toBe(15); + expect(aggregated.collectionsSaved).toBe(13); + expect(aggregated.collectionsFailed).toBe(2); + }); + + test('should handle empty results array', () => { + const aggregated = aggregateStats([]); + + expect(aggregated.totalRequests).toBe(0); + expect(aggregated.successfulRequests).toBe(0); + expect(aggregated.failedRequests).toBe(0); + }); + + test('should handle results with missing stats', () => { + const results = [ + { stats: { totalRequests: 10 } }, + { stats: null }, + { otherField: 'value' } + ]; + + const aggregated = aggregateStats(results); + + expect(aggregated.totalRequests).toBe(10); + expect(aggregated.successfulRequests).toBe(0); + }); + + test('should include all standard stat fields', () => { + const results = [ + { + stats: { + totalRequests: 5, + successfulRequests: 4, + failedRequests: 1, + collectionsFound: 2, + collectionsSaved: 2, + collectionsFailed: 0, + catalogsProcessed: 1, + apisProcessed: 0, + stacCompliant: 1, + nonCompliant: 0 + } + } + ]; + + const aggregated = aggregateStats(results); + + expect(aggregated).toHaveProperty('totalRequests'); + expect(aggregated).toHaveProperty('successfulRequests'); + expect(aggregated).toHaveProperty('failedRequests'); + expect(aggregated).toHaveProperty('collectionsFound'); + expect(aggregated).toHaveProperty('collectionsSaved'); + expect(aggregated).toHaveProperty('collectionsFailed'); + expect(aggregated).toHaveProperty('catalogsProcessed'); + expect(aggregated).toHaveProperty('apisProcessed'); + expect(aggregated).toHaveProperty('stacCompliant'); + expect(aggregated).toHaveProperty('nonCompliant'); + }); + + test('should ignore non-numeric values', () => { + const results = [ + { + stats: { + totalRequests: 10, + successfulRequests: 'invalid', + failedRequests: null, + collectionsFound: undefined + } + } + ]; + + const aggregated = aggregateStats(results); + + expect(aggregated.totalRequests).toBe(10); + expect(aggregated.successfulRequests).toBe(0); + expect(aggregated.failedRequests).toBe(0); + expect(aggregated.collectionsFound).toBe(0); + }); + + test('should handle partial stats objects', () => { + const results = [ + { stats: { totalRequests: 5 } }, + { stats: { successfulRequests: 10, collectionsFound: 3 } } + ]; + + const aggregated = aggregateStats(results); + + expect(aggregated.totalRequests).toBe(5); + expect(aggregated.successfulRequests).toBe(10); + expect(aggregated.collectionsFound).toBe(3); + expect(aggregated.failedRequests).toBe(0); + }); +}); + +describe('executeWithConcurrency', () => { + test('should execute tasks with concurrency limit', async () => { + let concurrentCount = 0; + let maxConcurrent = 0; + + const createTask = (delay) => async () => { + concurrentCount++; + maxConcurrent = Math.max(maxConcurrent, concurrentCount); + await new Promise(resolve => setTimeout(resolve, delay)); + concurrentCount--; + return delay; + }; + + const tasks = [ + createTask(50), + createTask(50), + createTask(50), + createTask(50), + createTask(50) + ]; + + const results = await executeWithConcurrency(tasks, 2); + + expect(results.length).toBe(5); + expect(maxConcurrent).toBeLessThanOrEqual(2); + }); + + test('should return results in correct order', async () => { + const tasks = [ + async () => 'first', + async () => 'second', + async () => 'third' + ]; + + const results = await executeWithConcurrency(tasks, 2); + + expect(results).toEqual(['first', 'second', 'third']); + }); + + test('should handle empty task array', async () => { + const results = await executeWithConcurrency([], 5); + expect(results).toEqual([]); + }); + + test('should handle single task', async () => { + const tasks = [async () => 'result']; + const results = await executeWithConcurrency(tasks, 5); + + expect(results).toEqual(['result']); + }); + + test('should handle task errors gracefully', async () => { + const tasks = [ + async () => 'success', + async () => { throw new Error('Task failed'); }, + async () => 'success2' + ]; + + const results = await executeWithConcurrency(tasks, 2); + + expect(results[0]).toBe('success'); + expect(results[1]).toHaveProperty('error', 'Task failed'); + expect(results[1]).toHaveProperty('stats', {}); + expect(results[2]).toBe('success2'); + }); + + test('should call progress callback with correct values', async () => { + const progressUpdates = []; + const onProgress = (completed, total) => { + progressUpdates.push({ completed, total }); + }; + + const tasks = [ + async () => 'a', + async () => 'b', + async () => 'c' + ]; + + await executeWithConcurrency(tasks, 2, onProgress); + + expect(progressUpdates.length).toBe(3); + expect(progressUpdates[0]).toEqual({ completed: 1, total: 3 }); + expect(progressUpdates[1]).toEqual({ completed: 2, total: 3 }); + expect(progressUpdates[2]).toEqual({ completed: 3, total: 3 }); + }); + + test('should work without progress callback', async () => { + const tasks = [async () => 'result']; + const results = await executeWithConcurrency(tasks, 1); + + expect(results).toEqual(['result']); + }); + + test('should handle concurrency of 1', async () => { + let executing = 0; + + const createTask = () => async () => { + executing++; + expect(executing).toBe(1); + await new Promise(resolve => setTimeout(resolve, 10)); + executing--; + return 'done'; + }; + + const tasks = [createTask(), createTask(), createTask()]; + await executeWithConcurrency(tasks, 1); + }); + + test('should handle concurrency greater than task count', async () => { + const tasks = [ + async () => 'a', + async () => 'b' + ]; + + const results = await executeWithConcurrency(tasks, 10); + expect(results).toEqual(['a', 'b']); + }); +}); + +describe('calculateRateLimits', () => { + test('should return rate limit configuration', () => { + const config = calculateRateLimits(120); + + expect(config).toHaveProperty('maxRequestsPerMinute'); + expect(config.maxRequestsPerMinute).toBe(120); + }); + + test('should use default value of 120', () => { + const config = calculateRateLimits(); + + expect(config.maxRequestsPerMinute).toBe(120); + }); + + test('should accept different rate values', () => { + expect(calculateRateLimits(60).maxRequestsPerMinute).toBe(60); + expect(calculateRateLimits(300).maxRequestsPerMinute).toBe(300); + expect(calculateRateLimits(1).maxRequestsPerMinute).toBe(1); + }); + + test('should handle zero and negative values', () => { + expect(calculateRateLimits(0).maxRequestsPerMinute).toBe(0); + expect(calculateRateLimits(-10).maxRequestsPerMinute).toBe(-10); + }); +}); + +describe('logDomainStats', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + console.log.mockRestore(); + }); + + test('should log domain statistics', () => { + const domainMap = new Map([ + ['example.com', [1, 2, 3]], + ['test.org', [4, 5]] + ]); + + logDomainStats(domainMap, 'catalogs'); + + expect(console.log).toHaveBeenCalledWith('\n=== Domain Distribution for catalogs ==='); + expect(console.log).toHaveBeenCalledWith('Total domains: 2'); + }); + + test('should sort domains by item count', () => { + const domainMap = new Map([ + ['landsatlook.usgs.gov', [1]], + ['planetarycomputer.microsoft.com', [1, 2, 3, 4, 5]], + ['earth-search.aws.element84.com', [1, 2, 3]] + ]); + + logDomainStats(domainMap); + + const calls = console.log.mock.calls.map(call => call[0]); + const largeDomainIndex = calls.findIndex(c => c.includes('planetarycomputer.microsoft.com')); + const mediumDomainIndex = calls.findIndex(c => c.includes('earth-search.aws.element84.com')); + const smallDomainIndex = calls.findIndex(c => c.includes('landsatlook.usgs.gov')); + + expect(largeDomainIndex).toBeLessThan(mediumDomainIndex); + expect(mediumDomainIndex).toBeLessThan(smallDomainIndex); + }); + + test('should show only top 10 domains', () => { + const domainMap = new Map(); + for (let i = 0; i < 15; i++) { + domainMap.set(`domain${i}.com`, [1, 2]); + } + + logDomainStats(domainMap); + + const calls = console.log.mock.calls.map(call => call[0]); + const moreDomainsMessage = calls.find(c => c.includes('and 5 more domains')); + + expect(moreDomainsMessage).toBeDefined(); + }); + + test('should not show "more domains" message for 10 or fewer domains', () => { + const domainMap = new Map(); + for (let i = 0; i < 8; i++) { + domainMap.set(`domain${i}.com`, [1]); + } + + logDomainStats(domainMap); + + const calls = console.log.mock.calls.map(call => call[0]); + const moreDomainsMessage = calls.find(c => c.includes('more domains')); + + expect(moreDomainsMessage).toBeUndefined(); + }); + + test('should use default item type of "items"', () => { + const domainMap = new Map([['catalogue.dataspace.copernicus.eu', [1, 2]]]); + + logDomainStats(domainMap); + + expect(console.log).toHaveBeenCalledWith('\n=== Domain Distribution for items ==='); + }); + + test('should handle empty domain map', () => { + const domainMap = new Map(); + + logDomainStats(domainMap, 'test'); + + expect(console.log).toHaveBeenCalledWith('Total domains: 0'); + }); +}); diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 68ac41c..9294cec 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -505,5 +505,8 @@ async function handleApiCollection({ request, json, crawler, log, indent, result } export { - crawlApis + crawlApis, + checkAndFlushApi, + BATCH_SIZE, + API_CLEAR_BATCH_SIZE }; diff --git a/crawler/jest.config.js b/crawler/jest.config.js new file mode 100644 index 0000000..8c35124 --- /dev/null +++ b/crawler/jest.config.js @@ -0,0 +1,21 @@ +export default { + testEnvironment: 'node', + transform: {}, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + testMatch: [ + '**/__tests__/**/*.test.js' + ], + collectCoverageFrom: [ + 'utils/**/*.js', + 'apis/**/*.js', + 'catalogs/**/*.js', + '!**/node_modules/**', + '!**/__tests__/**' + ], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/__tests__/' + ] +}; diff --git a/crawler/package-lock.json b/crawler/package-lock.json index a91cada..fdd1310 100644 --- a/crawler/package-lock.json +++ b/crawler/package-lock.json @@ -14,6 +14,9 @@ "crawlee": "^3.15.3", "dotenv": "^17.2.3", "stac-js": "^0.1.9" + }, + "devDependencies": { + "jest": "^29.7.0" } }, "node_modules/@apify/consts": { @@ -92,12 +95,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -105,6 +108,153 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -114,6 +264,340 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@borewit/text-codec": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", @@ -820,22 +1304,425 @@ "node": ">=18" } }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "license": "MIT" + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } }, - "node_modules/@multiformats/base-x": { - "version": "4.0.1", - "license": "MIT" + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } }, - "node_modules/@radiantearth/stac-migrate": { - "version": "2.0.2", - "license": "Apache-2.0", + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", "dependencies": { - "compare-versions": "^3.6.0", - "multihashes": "^3.1.2", + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, + "node_modules/@multiformats/base-x": { + "version": "4.0.1", + "license": "MIT" + }, + "node_modules/@radiantearth/stac-migrate": { + "version": "2.0.2", + "license": "Apache-2.0", + "dependencies": { + "compare-versions": "^3.6.0", + "multihashes": "^3.1.2", "yargs": "^17.6.2" }, "bin": { @@ -875,6 +1762,13 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.1.tgz", @@ -887,6 +1781,26 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -911,18 +1825,100 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/content-type": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@types/content-type/-/content-type-1.1.9.tgz", "integrity": "sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A==", "license": "MIT" }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, "node_modules/@types/jsdom": { "version": "21.1.7", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", @@ -952,12 +1948,36 @@ "@types/node": "*" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT" }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vladfrangu/async_event_emitter": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", @@ -1047,6 +2067,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1085,36 +2119,152 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -1146,6 +2296,19 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", @@ -1179,6 +2342,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1203,6 +2376,13 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/byte-counter": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz", @@ -1292,6 +2472,16 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001757", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", @@ -1328,6 +2518,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -1391,6 +2591,29 @@ "entities": "^4.4.0" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -1445,6 +2668,24 @@ "node": ">=0.8" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "license": "MIT", @@ -1482,6 +2723,13 @@ "version": "3.6.0", "license": "MIT" }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -1491,6 +2739,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -1557,6 +2812,43 @@ } } }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -1661,6 +2953,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -1695,12 +3012,32 @@ "node": ">=0.4.0" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1551306", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", "integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==", "license": "BSD-3-Clause" }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -1805,6 +3142,19 @@ "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", "license": "ISC" }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "license": "MIT" @@ -1883,6 +3233,20 @@ "node": ">=0.8.0" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/event-stream": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", @@ -1898,12 +3262,105 @@ "through": "~2.3.1" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -1958,6 +3415,19 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -2084,6 +3554,28 @@ "node": ">=14.14" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -2114,6 +3606,16 @@ "node": ">= 0.4" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "license": "ISC", @@ -2143,6 +3645,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "license": "MIT", @@ -2170,19 +3682,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "license": "MIT", + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, "engines": { - "node": ">= 0.4" + "node": "*" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/got": { - "version": "14.6.5", - "resolved": "https://registry.npmjs.org/got/-/got-14.6.5.tgz", + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/got/-/got-14.6.5.tgz", "integrity": "sha512-Su87c0NNeg97de1sO02gy9I8EmE7DCJ1gzcFLcgGpYeq2PnLg4xz73MWrp6HjqbSsjb6Glf4UBDW6JNyZA6uSg==", "license": "MIT", "dependencies": { @@ -2482,6 +4040,16 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -2530,229 +4098,981 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-any-array": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", + "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", + "license": "MIT" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports/node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, "license": "MIT", "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/inherits": { - "version": "2.0.4", - "license": "ISC" + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } }, - "node_modules/inquirer": { - "version": "8.2.7", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", - "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, "license": "MIT", "dependencies": { - "@inquirer/external-editor": "^1.0.0", - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" }, "engines": { - "node": ">=12.0.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" } }, - "node_modules/inquirer/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, "engines": { - "node": ">= 12" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-any-array": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", - "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", - "license": "MIT" - }, - "node_modules/is-arguments": { - "version": "1.2.0", + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } }, - "node_modules/is-callable": { - "version": "1.2.7", + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=6" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, "engines": { - "node": ">=8" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "license": "MIT" - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, - "node_modules/is-regex": { - "version": "1.2.1", + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" }, "engines": { - "node": ">= 0.4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" }, "engines": { - "node": ">= 0.4" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/jquery": { @@ -2848,6 +5168,19 @@ "node": ">=16" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -2887,6 +5220,26 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -3013,6 +5366,45 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, "node_modules/map-stream": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", @@ -3025,6 +5417,27 @@ "node": ">= 0.4" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.52.0", "license": "MIT", @@ -3185,12 +5598,36 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.0.tgz", @@ -3203,6 +5640,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -3221,6 +5671,16 @@ "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -3434,6 +5894,33 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -3559,6 +6046,29 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -3617,6 +6127,48 @@ "node": ">=0.10.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -3655,6 +6207,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -3667,6 +6236,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3688,6 +6264,27 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -3724,6 +6321,16 @@ "node": ">=4" } }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/responselike": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz", @@ -3853,6 +6460,16 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "license": "MIT", @@ -3868,12 +6485,52 @@ "node": ">= 0.4" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -3912,6 +6569,27 @@ "node": ">= 14" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/split": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", @@ -3933,6 +6611,13 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stac-js": { "version": "0.1.9", "license": "Apache-2.0", @@ -3945,6 +6630,29 @@ "url": "https://github.com/sponsors/m-mohr" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/stream-chain": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", @@ -3987,6 +6695,20 @@ "node": "^16.0.0 || >=18.0.0" } }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/string-width": { "version": "4.2.3", "license": "MIT", @@ -4009,6 +6731,39 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", @@ -4037,12 +6792,64 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -4073,6 +6880,26 @@ "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", "license": "MIT" }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/token-types": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", @@ -4121,6 +6948,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -4224,6 +7061,21 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vali-date": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", @@ -4249,6 +7101,16 @@ "node": ">=18" } }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -4323,6 +7185,22 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-typed-array": { "version": "1.1.19", "license": "MIT", @@ -4357,6 +7235,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", @@ -4409,6 +7308,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yargonaut": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/yargonaut/-/yargonaut-1.1.4.tgz", diff --git a/crawler/package.json b/crawler/package.json index 175957d..4f65e9c 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -9,7 +9,8 @@ "docker:run": "docker run --rm stac-crawler", "docker:compose:up": "docker-compose up -d", "docker:compose:down": "docker-compose down", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch" }, "author": "", "license": "ISC", @@ -20,5 +21,8 @@ "crawlee": "^3.15.3", "dotenv": "^17.2.3", "stac-js": "^0.1.9" + }, + "devDependencies": { + "jest": "^29.7.0" } } diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index ff87c33..8faa731 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -133,12 +133,15 @@ export function normalizeCollection(colObj, index) { let links = null; if (Array.isArray(colObj.links)) { // Convert stac-js link objects to plain objects if needed - links = colObj.links.map(l => ({ - rel: l.rel, - href: l.href, - type: l.type, - title: l.title - })); + // Filter out null/undefined and ensure at least rel or href exists + links = colObj.links + .filter(l => l && (l.rel || l.href)) + .map(l => ({ + rel: l.rel || undefined, + href: l.href || undefined, + type: l.type || undefined, + title: l.title || undefined + })); } else if (Array.isArray(rawData?.links)) { links = rawData.links; } From fd1d2bce408ae0c11ef311c110dcecbf0b837a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Wed, 28 Jan 2026 14:47:03 +0100 Subject: [PATCH 107/134] type is no longer needes, since we only need to save collections --- db/init/03_tables_collections.sql | 2 -- 1 file changed, 2 deletions(-) diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index cb8f73a..918bad9 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -7,7 +7,6 @@ CREATE TABLE collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, stac_id TEXT UNIQUE, - type TEXT, title TEXT, description TEXT, license TEXT, @@ -34,7 +33,6 @@ CREATE TABLE collection_summaries ( collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, name TEXT, kind TEXT, - source_url TEXT, range_min NUMERIC, range_max NUMERIC, set_value TEXT, From e38f77dd099319e3d6879201af1a1dc4cb951413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Wed, 28 Jan 2026 14:51:10 +0100 Subject: [PATCH 108/134] added is_valid. So we can save every collection be can also be stac_conform since we can easily sort by valid collections --- db/init/03_tables_collections.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 918bad9..a33ecc6 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -20,6 +20,7 @@ CREATE TABLE collection ( is_api BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, + is_valid BOOLEAN DEFAULT TRUE, full_json JSONB, search_vector tsvector From e67ce0d28056ced4a23debd5114b73331d6dc1a5 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 28 Jan 2026 15:08:01 +0100 Subject: [PATCH 109/134] revert all latest changes because catalogs dont need to save in the database --- crawler/index.js | 3 +- crawler/utils/db.js | 180 ++------------------------------- crawler/utils/handlers.js | 28 ++--- crawler/utils/normalization.js | 18 +--- 4 files changed, 22 insertions(+), 207 deletions(-) diff --git a/crawler/index.js b/crawler/index.js index 27b6aeb..8088850 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -65,8 +65,7 @@ export const crawler = async () => { let catalogsSaved = 0; let catalogsFailed = 0; - // Use normalized catalogs instead of raw response.data - for (const catalog of catalogs) { + for (const catalog of response.data) { try { const catalogId = await db.insertOrUpdateCatalog(catalog); console.log(`Saved catalog: ${catalog.title || catalog.id} (DB ID: ${catalogId})`); diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 4676084..e889c3a 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -51,181 +51,19 @@ async function initDb() { } /** - * Insert or update a catalog in the database with deadlock retry logic - * Catalogs are stored in the collection table with type='Catalog' + * Process a catalog for traversal only - catalogs are not saved to database + * Only collections are saved. Catalogs are only used to traverse deeper into the tree. * @param {Object} catalog - STAC catalog object - * @param {number} maxRetries - Maximum number of retry attempts for deadlocks (default: 3) - * @returns {Promise} collection table ID or null + * @returns {Promise} always returns null (no catalog saved) */ -async function insertOrUpdateCatalog(catalog, maxRetries = 3) { +async function insertOrUpdateCatalog(catalog) { if (!catalog || typeof catalog !== 'object') return null; - let lastError; + // Catalogs are not saved to database - only used for tree traversal + // Only collections will be saved + console.log(`Skipping catalog save (used for traversal only): ${catalog.title || catalog.id}`); - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await _insertOrUpdateCatalogInternal(catalog); - } catch (error) { - lastError = error; - - if (isDeadlockError(error) && attempt < maxRetries) { - // Exponential backoff: 100ms, 200ms, 400ms, ... - const delayMs = 100 * Math.pow(2, attempt - 1) + Math.random() * 50; - console.warn(`WARN [DB] Deadlock detected for catalog "${catalog.title || catalog.id}", retrying in ${Math.round(delayMs)}ms (attempt ${attempt}/${maxRetries})`); - await sleep(delayMs); - continue; - } - - // Not a deadlock or max retries reached, throw the error - throw error; - } - } - - // Should not reach here, but just in case - throw lastError; -} - -/** - * Internal implementation of insertOrUpdateCatalog - * Stores catalog in the collection table with type='Catalog' - * @param {Object} catalog - STAC catalog object - * @returns {Promise} collection table ID or null - */ -async function _insertOrUpdateCatalogInternal(catalog) { - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; - - // Construct unique stac_id from sourceSlug and catalog id - // Format: {sourceSlug}_{catalog_id} for uniqueness across different sources - let stacId = null; - if (catalog.sourceSlug && catalog.id) { - stacId = `${catalog.sourceSlug}_${catalog.id}`; - } else if (catalog.id) { - stacId = catalog.id; - } - - // Extract source URL - prefer crawledUrl (the actual absolute URL the catalog was fetched from) - // Fall back to links only if crawledUrl is not available - let sourceUrl = null; - if (catalog.crawledUrl) { - // Use the absolute URL from the crawler (most reliable) - sourceUrl = catalog.crawledUrl; - } else if (catalog.links && Array.isArray(catalog.links)) { - // Fallback to self/root links (may be relative URLs) - const selfLink = catalog.links.find(link => link.rel === 'self'); - const rootLink = catalog.links.find(link => link.rel === 'root'); - sourceUrl = selfLink?.href || rootLink?.href || null; - } - - // Check if catalog with same stac_id and type='Catalog' already exists - let existingCatalog; - if (stacId && sourceUrl) { - // Match by stac_id + source_url + type - existingCatalog = await client.query( - 'SELECT id FROM collection WHERE stac_id = $1 AND source_url = $2 AND type = $3', - [stacId, sourceUrl, 'Catalog'] - ); - } - - // Fallback: match by title and type='Catalog' - if (!existingCatalog || existingCatalog.rows.length === 0) { - if (stacId) { - existingCatalog = await client.query( - 'SELECT id FROM collection WHERE stac_id = $1 AND title = $2 AND type = $3', - [stacId, catalogTitle, 'Catalog'] - ); - } else { - existingCatalog = await client.query( - 'SELECT id FROM collection WHERE title = $1 AND type = $2', - [catalogTitle, 'Catalog'] - ); - } - } - - let catalogId; - if (existingCatalog.rows.length > 0) { - // Update existing catalog - catalogId = existingCatalog.rows[0].id; - await client.query( - `UPDATE collection SET - stac_id = $1, - stac_version = $2, - type = $3, - description = $4, - source_url = $5, - full_json = $6, - updated_at = now() - WHERE id = $7`, - [ - stacId, - catalog.stac_version || null, - 'Catalog', - catalog.description || null, - sourceUrl, - JSON.stringify(catalog), - catalogId - ] - ); - } else { - // Insert new catalog into collection table - const catalogResult = await client.query( - `INSERT INTO collection ( - stac_id, stac_version, type, title, description, source_url, - is_api, is_active, full_json, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) - RETURNING id`, - [ - stacId, - catalog.stac_version || null, - 'Catalog', - catalogTitle, - catalog.description || null, - sourceUrl, - false, // is_api - true, // is_active - JSON.stringify(catalog) - ] - ); - catalogId = catalogResult.rows[0].id; - } - - // Insert keywords - if (catalog.keywords && Array.isArray(catalog.keywords)) { - await insertKeywords(client, catalogId, catalog.keywords, 'collection'); - } - - // Insert STAC extensions - if (catalog.stac_extensions && Array.isArray(catalog.stac_extensions)) { - await insertStacExtensions(client, catalogId, catalog.stac_extensions, 'collection'); - } - - // Update crawl log (using collection crawl log) - await client.query( - 'DELETE FROM crawllog_collection WHERE collection_id = $1', - [catalogId] - ); - await client.query( - 'INSERT INTO crawllog_collection (collection_id, last_crawled) VALUES ($1, now())', - [catalogId] - ); - - await client.query('COMMIT'); - console.log(`Saved catalog as collection: ${catalogTitle} (ID: ${catalogId}, type: Catalog)`); - return catalogId; - } catch (error) { - await client.query('ROLLBACK'); - // Only log non-deadlock errors here, deadlocks are handled by retry wrapper - if (!isDeadlockError(error)) { - console.error('Error inserting catalog:', error.message); - } - throw error; - } finally { - client.release(); - } + return null; } /** @@ -409,7 +247,7 @@ async function _insertOrUpdateCollectionInternal(collection) { [ stacId, collection.stac_version || null, - collection.type || null, + collection.type || 'Collection', collection.description || null, collection.license || null, spatialExtent, diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 892206f..d1905c5 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -145,13 +145,10 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul const isCollection = typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection(); if (!isCollection) { try { - // Ensure crawledUrl is absolute - const crawledUrl = request.url.startsWith('http') ? request.url : null; + await db.insertOrUpdateCatalog({ id: stacCatalog.id, - sourceSlug: catalogSlug, // Add sourceSlug for unique stac_id generation - crawledUrl: crawledUrl, // Store the actual URL where this catalog was crawled from (only if absolute) title: stacCatalog.title || catalogId, description: stacCatalog.description, stac_version: stacCatalog.stac_version, @@ -172,8 +169,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; // Store the actual crawled URL as the source URL (not relative links from the JSON) - // Only store if it's an absolute URL - collection.crawledUrl = request.url.startsWith('http') ? request.url : null; + collection.crawledUrl = request.url; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -349,10 +345,8 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Get base URL for constructing absolute collection URLs // Remove trailing /collections from the request URL to get the API base - // Only use if request.url is absolute - const baseUrl = request.url.startsWith('http') - ? request.url.replace(/\/collections\/?$/, '') - : null; + const baseUrl = request.url.replace(/\/collections\/?$/, ''); + // Normalize and store collections const collections = collectionsData.map((colObj, index) => { @@ -360,21 +354,19 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; - // Store the absolute URL as crawledUrl (only if we have a valid base URL) + // Store the absolute URL as crawledUrl // Use stac-js getAbsoluteUrl() if available, otherwise construct from base + id if (typeof colObj.getAbsoluteUrl === 'function') { try { - const absoluteUrl = colObj.getAbsoluteUrl(); - collection.crawledUrl = absoluteUrl?.startsWith('http') ? absoluteUrl : null; + collection.crawledUrl = colObj.getAbsoluteUrl(); + } catch { // Fallback to constructing URL from base - collection.crawledUrl = baseUrl ? `${baseUrl}/collections/${collection.id}` : null; + collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; } - } else if (baseUrl) { - collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; } else { - collection.crawledUrl = null; - } + collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; + } return collection; }); diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index 8f66374..c70dbf4 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -41,13 +41,11 @@ export function deriveCategories(catalog) { export function normalizeCatalog(catalog, index) { return { index, - id: catalog.slug || catalog.id?.toString() || `catalog-${index}`, // Use slug as STAC id (string), fallback to numeric id as string + id: catalog.id, url: catalog.url, slug: catalog.slug, title: catalog.title, summary: catalog.summary, - description: catalog.summary, // Map summary to description for database - stac_version: catalog.stac_version || null, // Include stac_version if available access: catalog.access, created: catalog.created, updated: catalog.updated, @@ -55,9 +53,6 @@ export function normalizeCatalog(catalog, index) { isApi: catalog.isApi, accessInfo: catalog.accessInfo, categories: deriveCategories(catalog), - keywords: deriveCategories(catalog), // Map categories to keywords for database - type: 'Catalog', // Explicitly set type - links: catalog.url ? [{ rel: 'self', href: catalog.url }] : [], // Create links array for db.js // Preserve any additional dynamic properties ...Object.fromEntries( Object.entries(catalog).filter(([key]) => @@ -151,16 +146,7 @@ export function normalizeCollection(colObj, index) { links = rawData.links; } - // Determine the type using isCatalog/isCollection methods if available - let type = colObj.type || rawData?.type || null; - if (!type) { - // Use stac-js methods to determine type - if (typeof colObj.isCatalog === 'function' && colObj.isCatalog()) { - type = 'Catalog'; - } else if (typeof colObj.isCollection === 'function' && colObj.isCollection()) { - type = 'Collection'; - } - } + return { index, From 9be2c185e09c11c43a79a06ac93fbb1d3ad01718 Mon Sep 17 00:00:00 2001 From: Jakob <70800454+vertrox78@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:05:01 +0100 Subject: [PATCH 110/134] #270, added stac node validation for static catalogs from stac indedx to reduce errors with not stac compliant catalogs (#271) --- crawler/package-lock.json | 138 +++++++++++++++++++++++++++++++++++--- crawler/package.json | 3 +- crawler/utils/handlers.js | 108 ++++++++++++++++++++++++++--- 3 files changed, 229 insertions(+), 20 deletions(-) diff --git a/crawler/package-lock.json b/crawler/package-lock.json index fdd1310..a56d247 100644 --- a/crawler/package-lock.json +++ b/crawler/package-lock.json @@ -13,7 +13,8 @@ "axios": "^1.13.2", "crawlee": "^3.15.3", "dotenv": "^17.2.3", - "stac-js": "^0.1.9" + "stac-js": "^0.1.9", + "stac-node-validator": "^2.0.0-rc.1" }, "devDependencies": { "jest": "^29.7.0" @@ -1556,7 +1557,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" @@ -1766,7 +1766,6 @@ "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, "license": "MIT" }, "node_modules/@sindresorhus/is": { @@ -2011,6 +2010,39 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -3032,7 +3064,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -3351,6 +3382,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4637,7 +4684,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -4701,7 +4747,6 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -5187,6 +5232,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5220,6 +5271,15 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/klaw": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-4.1.0.tgz", + "integrity": "sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==", + "license": "MIT", + "engines": { + "node": ">=14.14.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6131,7 +6191,6 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -6146,7 +6205,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6240,7 +6298,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, "license": "MIT" }, "node_modules/readable-stream": { @@ -6264,6 +6321,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6630,6 +6696,51 @@ "url": "https://github.com/sponsors/m-mohr" } }, + "node_modules/stac-node-validator": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/stac-node-validator/-/stac-node-validator-2.0.0-rc.1.tgz", + "integrity": "sha512-qY0NfFZhkmTP1TQ+usaVtqpm0MHPEg0qfesY9VVAPNsiMSVWSW0tzZnBmUeLOsdwJo23y6UmrC1LnpDuzN2VrQ==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.8.2", + "ajv-formats": "^2.1.1", + "axios": "^1.7.4", + "compare-versions": "^6.1.0", + "fs-extra": "^10.0.0", + "jest-diff": "^29.0.1", + "klaw": "^4.0.1", + "stac-js": "^0.1.4", + "uri-js": "^4.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "stac-node-validator": "bin/cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/m-mohr" + } + }, + "node_modules/stac-node-validator/node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, + "node_modules/stac-node-validator/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -7040,6 +7151,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/urijs": { "version": "1.19.11", "license": "MIT" diff --git a/crawler/package.json b/crawler/package.json index 4f65e9c..3e4ae16 100644 --- a/crawler/package.json +++ b/crawler/package.json @@ -20,7 +20,8 @@ "axios": "^1.13.2", "crawlee": "^3.15.3", "dotenv": "^17.2.3", - "stac-js": "^0.1.9" + "stac-js": "^0.1.9", + "stac-node-validator": "^2.0.0-rc.1" }, "devDependencies": { "jest": "^29.7.0" diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 9a3819a..e989517 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -4,6 +4,7 @@ */ import create from 'stac-js'; +import validate from 'stac-node-validator'; import { normalizeCollection } from './normalization.js'; import { tryCollectionEndpoints } from './endpoints.js'; import db from './db.js'; @@ -16,6 +17,74 @@ import db from './db.js'; */ const BATCH_SIZE = 25; +/** + * Validates STAC structure using stac-node-validator before attempting migration + * @async + * @param {Object} json - JSON object to validate + * @param {Object} log - Logger instance + * @param {string} indent - Indentation for logging + * @returns {Promise} Validation result with valid flag, errors, and warnings + */ +async function validateStacStructure(json, log, indent = '') { + if (!json || typeof json !== 'object') { + return { + valid: false, + error: 'Invalid JSON: null or not an object', + errors: ['Invalid JSON structure'] + }; + } + + try { + // Use stac-node-validator for full STAC spec validation + const result = await validate(json); + + if (result.valid) { + log.debug(`${indent}STAC validation passed (version: ${result.version}, type: ${result.type})`); + return { valid: true, version: result.version, type: result.type }; + } else { + // Collect all validation errors + const errors = []; + + // Core schema errors + if (result.results.core && result.results.core.length > 0) { + errors.push(...result.results.core.map(err => + `${err.instancePath || 'root'}: ${err.message}` + )); + } + + // Extension errors + if (result.results.extensions) { + Object.entries(result.results.extensions).forEach(([ext, extErrors]) => { + if (extErrors.length > 0) { + errors.push(...extErrors.map(err => + `[${ext}] ${err.instancePath || 'root'}: ${err.message}` + )); + } + }); + } + + // Custom validation errors + if (result.results.custom && result.results.custom.length > 0) { + errors.push(...result.results.custom.map(err => err.message || String(err))); + } + + return { + valid: false, + error: `STAC validation failed with ${errors.length} error(s)`, + errors: errors.slice(0, 5), // Limit to first 5 errors for logging + totalErrors: errors.length + }; + } + } catch (validationError) { + // If validator itself fails, return error + return { + valid: false, + error: `Validator error: ${validationError.message}`, + errors: [validationError.message] + }; + } +} + /** * Batch size for clearing catalogs array to free memory * The catalogs array is only used for statistics, so we clear it periodically @@ -107,15 +176,20 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul log.info(`${indent}Processing catalog: ${catalogId} (depth: ${depth}${maxDepth > 0 ? `/${maxDepth}` : ''})`); - // Defensive check: ensure json is valid before passing to stac-js - if (!json || typeof json !== 'object') { - log.warning(`${indent}Invalid JSON response for catalog ${catalogId} at ${request.url}`); - throw new Error('Invalid JSON response: null or not an object'); + + // Validate STAC structure before attempting migration + const validation = validateStacStructure(json); + if (!validation.valid) { + log.warning(`${indent}Pre-validation failed for catalog ${catalogId} at ${request.url}`); + log.warning(`${indent}Validation error: ${validation.error}`); + log.debug(`${indent}Response preview: ${JSON.stringify(json).substring(0, 200)}...`); + results.stats.nonCompliant++; + throw new Error(`STAC pre-validation failed: ${validation.error}`); } - // Validate with stac-js - // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL - // Using migrate=false to avoid issues with stac-migrate and newer STAC versions + // Migrate and validate with stac-js + // Note: create(data, migrate, updateVersionNumber) - second param enables migration + // Migration will upgrade older STAC versions (>= 0.6.0) to latest version (1.1.0) let stacCatalog; try { stacCatalog = create(json, true); @@ -303,13 +377,27 @@ export async function handleCollections({ request, json, crawler, log, indent, r const catalogId = request.userData?.catalogId || 'unknown'; const catalogSlug = request.userData?.catalogSlug || null; - // Parse response with stac-js - // Note: create(data, migrate, updateVersionNumber) - second param is boolean, not URL + // Validate STAC structure before attempting migration using stac-node-validator + const validation = await validateStacStructure(json, log, indent); + if (!validation.valid) { + log.warning(`${indent}STAC validation failed for collections at ${request.url}`); + log.warning(`${indent}Error: ${validation.error}`); + if (validation.errors && validation.errors.length > 0) { + validation.errors.forEach((err, idx) => { + log.warning(`${indent} [${idx + 1}] ${err}`); + }); + } + results.stats.nonCompliant++; + return; + } + + // Parse and migrate response with stac-js let stacObj; try { stacObj = create(json, true); } catch (parseError) { - log.warning(`${indent}Skipping non-compliant STAC collections at ${request.url}`); + log.warning(`${indent}Migration failed for collections at ${request.url}: ${parseError.message}`); + results.stats.nonCompliant++; return; } From 9bc8a562918185087d0b96fef12eff6188d255d6 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 28 Jan 2026 16:15:15 +0100 Subject: [PATCH 111/134] Fix(crawler): insert query to include correct parameters --- crawler/utils/db.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index e889c3a..258c6fe 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -267,14 +267,14 @@ async function _insertOrUpdateCollectionInternal(collection) { `INSERT INTO collection ( stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, - is_api, is_active, full_json, source_url + is_api, is_active, source_url, full_json ) VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13) RETURNING id`, [ stacId, collection.stac_version || null, - collection.type || null, + collection.type || 'Collection', collectionTitle, collection.description || null, collection.license || null, From dfc0c06a02f1d2f6434a44d24cdb8763c5610813 Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 28 Jan 2026 16:22:30 +0100 Subject: [PATCH 112/134] skip database save for catalogs (#272) * bug fix: extent was written wrong * feat: add source URL extraction for catalogs and collections * feat: update insertOrUpdateCatalog to only process catalogs for traversal, no longer saving to database * bug: the type should now be a catalog or collection * saving catalogs into database, with stac_id, source_url and description * feat(crawler): enhance unique stac_id generation and include stac_version in normalization for catalogs * fix(crawler): source_url for catalogs are now completly saved * revert all latest changes because catalogs dont need to save in the database * Fix(crawler): insert query to include correct parameters --- crawler/utils/db.js | 116 +++--------------------------- crawler/utils/handlers.js | 6 +- crawler/utils/normalization.js | 4 +- db/init/03_tables_collections.sql | 2 +- 4 files changed, 17 insertions(+), 111 deletions(-) diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 00b641f..258c6fe 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -51,119 +51,19 @@ async function initDb() { } /** - * Insert or update a catalog in the database + * Process a catalog for traversal only - catalogs are not saved to database + * Only collections are saved. Catalogs are only used to traverse deeper into the tree. * @param {Object} catalog - STAC catalog object - * @returns {Promise} catalog ID or null if invalid input + * @returns {Promise} always returns null (no catalog saved) */ async function insertOrUpdateCatalog(catalog) { if (!catalog || typeof catalog !== 'object') return null; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - - const catalogTitle = catalog.title || catalog.id || 'Unnamed Catalog'; - - // Check if catalog with same title already exists - const existingCatalog = await client.query( - 'SELECT id FROM catalog WHERE title = $1', - [catalogTitle] - ); - - let catalogId; - if (existingCatalog.rows.length > 0) { - // Update existing catalog - set updated_at when crawled - catalogId = existingCatalog.rows[0].id; - await client.query( - `UPDATE catalog SET - stac_version = $1, - type = $2, - description = $3, - updated_at = now() - WHERE id = $4`, - [ - catalog.stac_version || null, - catalog.type || 'Catalog', - catalog.description || null, - catalogId - ] - ); - console.log(`Updated catalog: ${catalogTitle} (id: ${catalogId})`); - } else { - // Insert new catalog - updated_at defaults to now() (same as created_at) - // since we know the data is current as of this crawl - const catalogResult = await client.query( - `INSERT INTO catalog (stac_version, type, title, description) - VALUES ($1, $2, $3, $4) - RETURNING id`, - [ - catalog.stac_version || null, - catalog.type || 'Catalog', - catalogTitle, - catalog.description || null - ] - ); - catalogId = catalogResult.rows[0].id; - console.log(`Inserted new catalog: ${catalogTitle} (id: ${catalogId})`); - } - - // Insert keywords if present - if (catalog.keywords && Array.isArray(catalog.keywords)) { - await insertKeywords(client, catalogId, catalog.keywords, 'catalog'); - } - - // Insert STAC extensions if present - if (catalog.stac_extensions && Array.isArray(catalog.stac_extensions)) { - await insertStacExtensions(client, catalogId, catalog.stac_extensions, 'catalog'); - } - - // Insert catalog links if present - if (catalog.links && Array.isArray(catalog.links)) { - // Extract source URL from links (prefer 'self', fallback to 'root') - const selfLink = catalog.links.find(link => link.rel === 'self'); - const rootLink = catalog.links.find(link => link.rel === 'root'); - const sourceUrl = selfLink?.href || rootLink?.href || null; - - // Delete existing links for this catalog - await client.query('DELETE FROM catalog_links WHERE catalog_id = $1', [catalogId]); - - // Insert new links - for (const link of catalog.links) { - if (!link.href) continue; - await client.query( - `INSERT INTO catalog_links (catalog_id, source_url, rel, href, type, title) - VALUES ($1, $2, $3, $4, $5, $6)`, - [ - catalogId, - sourceUrl, - link.rel || null, - link.href, - link.type || null, - link.title || null - ] - ); - } - } - - // Update crawl log - await client.query( - 'DELETE FROM crawllog_catalog WHERE catalog_id = $1', - [catalogId] - ); - await client.query( - 'INSERT INTO crawllog_catalog (catalog_id, last_crawled) VALUES ($1, now())', - [catalogId] - ); - - await client.query('COMMIT'); - return catalogId; - } catch (error) { - await client.query('ROLLBACK'); - console.error('Error inserting catalog:', error.message); - throw error; - } finally { - client.release(); - } + // Catalogs are not saved to database - only used for tree traversal + // Only collections will be saved + console.log(`Skipping catalog save (used for traversal only): ${catalog.title || catalog.id}`); + + return null; } /** diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index e989517..163985a 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -219,6 +219,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul const isCollection = typeof stacCatalog.isCollection === 'function' && stacCatalog.isCollection(); if (!isCollection) { try { + + await db.insertOrUpdateCatalog({ id: stacCatalog.id, title: stacCatalog.title || catalogId, @@ -432,6 +434,7 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Get base URL for constructing absolute collection URLs // Remove trailing /collections from the request URL to get the API base const baseUrl = request.url.replace(/\/collections\/?$/, ''); + // Normalize and store collections const collections = collectionsData.map((colObj, index) => { @@ -444,13 +447,14 @@ export async function handleCollections({ request, json, crawler, log, indent, r if (typeof colObj.getAbsoluteUrl === 'function') { try { collection.crawledUrl = colObj.getAbsoluteUrl(); + } catch { // Fallback to constructing URL from base collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; } } else { collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; - } + } return collection; }); diff --git a/crawler/utils/normalization.js b/crawler/utils/normalization.js index 8faa731..c70dbf4 100644 --- a/crawler/utils/normalization.js +++ b/crawler/utils/normalization.js @@ -57,7 +57,7 @@ export function normalizeCatalog(catalog, index) { ...Object.fromEntries( Object.entries(catalog).filter(([key]) => !['id', 'url', 'slug', 'title', 'summary', 'access', 'created', - 'updated', 'isPrivate', 'isApi', 'accessInfo'].includes(key) + 'updated', 'isPrivate', 'isApi', 'accessInfo', 'stac_version'].includes(key) ) ) }; @@ -146,6 +146,8 @@ export function normalizeCollection(colObj, index) { links = rawData.links; } + + return { index, id: colObj.id || rawData?.id || 'Unknown', diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index a443aa8..852aa0d 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -6,7 +6,7 @@ CREATE TABLE collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, - stac_id INTEGER, + stac_id TEXT, type TEXT, title TEXT, description TEXT, From de873b12c7a650e96f6f856bee44a9139dd6a672 Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:40:59 +0100 Subject: [PATCH 113/134] Database: Added source_url in crawllog (#274) * Add .gitignore for environment and dependencies; update docker-compose to include network configuration * Update docker-compose.yml to rename network from 'stac_network' to 'stac-network' for consistency. * db(change) source_url is now in collection and stac_id is now unique * database(feature) Enhance crawllog_collection table: make collection_id optional and add source_url field for identifying collections not in the collection table. Update documentation accordingly and create an index on source_url for improved query performance. --- bid.md | 5 +++-- db/init/03_tables_collections.sql | 1 + db/init/05_indexes.sql | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bid.md b/bid.md index 5771fd9..e96a715 100644 --- a/bid.md +++ b/bid.md @@ -399,10 +399,11 @@ Protokolliert die Zeitpunkte der letzten Crawling-Vorgänge für jeden Katalog. | Spalte | Beschreibung / Inhalt | Datentyp / Format | |---------------|------------------------------------------|-------------------| | **id** | Eindeutige ID des Crawlvorgangs | integer (PK) | -| collection_id | Referenz auf `collection.id` | integer (FK) | +| collection_id | Referenz auf `collection.id` (optional) | integer (FK) | +| source_url | Quell-URL der Collection | text | | last_crawled | Zeitpunkt des letzten Crawls | timestamp | -Analog zur vorherigen Tabelle dient `crawllog_collection` der Nachverfolgung der Crawling-Zyklen für Collections. +Protokolliert die Zeitpunkte der letzten Crawling-Vorgänge für jede Collection. Falls die Collection noch nicht in der `collection`-Tabelle existiert, kann sie über die `source_url` identifiziert werden. --- diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index a33ecc6..2391433 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -64,6 +64,7 @@ CREATE TABLE assets ( CREATE TABLE crawllog_collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, + source_url TEXT, last_crawled TIMESTAMP ); diff --git a/db/init/05_indexes.sql b/db/init/05_indexes.sql index 7c0d74a..76445db 100644 --- a/db/init/05_indexes.sql +++ b/db/init/05_indexes.sql @@ -42,6 +42,7 @@ CREATE INDEX idx_collection_providers_collection ON collection_providers (collec CREATE INDEX idx_collection_assets_collection ON collection_assets (collection_id); CREATE INDEX idx_crawllog_collection_last ON crawllog_collection (last_crawled); +CREATE INDEX idx_crawllog_collection_source_url ON crawllog_collection (source_url); -- ======================================== -- PROVIDER & ASSET INDEXES From 5dded0a01b559ac853c106539bc969b0c079304a Mon Sep 17 00:00:00 2001 From: Lenn Date: Wed, 28 Jan 2026 17:26:19 +0100 Subject: [PATCH 114/134] feat(crawler): implement is_api for collections and add unit tests --- crawler/__tests__/is_api.test.js | 556 +++++++++++++++++++++++++++++++ crawler/apis/api.js | 6 +- crawler/utils/db.js | 6 +- crawler/utils/handlers.js | 10 +- 4 files changed, 572 insertions(+), 6 deletions(-) create mode 100644 crawler/__tests__/is_api.test.js diff --git a/crawler/__tests__/is_api.test.js b/crawler/__tests__/is_api.test.js new file mode 100644 index 0000000..2ce63cf --- /dev/null +++ b/crawler/__tests__/is_api.test.js @@ -0,0 +1,556 @@ +/** + * @fileoverview Unit tests for is_api field functionality + * Tests that collections are correctly marked as API or static catalog collections + */ + +import { jest } from '@jest/globals'; +import create from 'stac-js'; + +// Mock normalizeCollection to return a simple object +jest.unstable_mockModule('../utils/normalization.js', () => ({ + normalizeCollection: jest.fn((stacObj, index) => ({ + id: stacObj.id || `collection-${index}`, + title: stacObj.title || 'Test Collection', + description: stacObj.description || 'Test Description' + })) +})); + +// Mock db module +const mockInsertOrUpdateCollection = jest.fn(); +jest.unstable_mockModule('../utils/db.js', () => ({ + default: { + insertOrUpdateCollection: mockInsertOrUpdateCollection + } +})); + +// Mock endpoints module +jest.unstable_mockModule('../utils/endpoints.js', () => ({ + tryCollectionEndpoints: jest.fn() +})); + +// Import the modules to test +const { handleCatalog, handleCollections } = await import('../utils/handlers.js'); +const { normalizeCollection } = await import('../utils/normalization.js'); + +describe('is_api field - handleCatalog (static catalogs)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should set is_api=false for collections extracted from static catalogs', async () => { + // Real Sentinel-2 collection from static catalog + const collectionJson = { + stac_version: '1.0.0', + type: 'Collection', + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + description: 'Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance', + license: 'proprietary', + keywords: ['sentinel', 'esa', 'copernicus', 'satellite', 'global'], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-27T10:25:31Z', null]] } + }, + links: [ + { rel: 'self', href: './sentinel-2-l2a/collection.json' }, + { rel: 'root', href: '../catalog.json' } + ] + }; + + const mockRequest = { + url: 'https://example.com/catalog/collection.json', + userData: { + depth: 1, + catalogId: 'test-catalog', + catalogSlug: 'test-catalog-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + error: jest.fn() + }; + + const results = { + collections: [], + catalogs: [], + stats: { + stacCompliant: 0, + catalogsProcessed: 0, + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + await handleCatalog({ + request: mockRequest, + json: collectionJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results, + config: {} + }); + + // Verify that a collection was added + expect(results.collections.length).toBe(1); + + // Verify that is_api is set to false for static catalog collection + expect(results.collections[0].is_api).toBe(false); + + // Verify other fields are set correctly + expect(results.collections[0].sourceSlug).toBe('test-catalog-slug'); + expect(results.collections[0].crawledUrl).toBe('https://example.com/catalog/collection.json'); + }); + + test('should set is_api=false for STAC catalog (not collection)', async () => { + // Real static STAC catalog structure + const catalogJson = { + stac_version: '1.0.0', + type: 'Catalog', + id: 'earth-observation-catalog', + title: 'Earth Observation Data Catalog', + description: 'A catalog of Earth observation satellite imagery collections', + links: [ + { rel: 'self', href: './catalog.json' }, + { rel: 'root', href: './catalog.json' }, + { rel: 'child', href: './sentinel-2/catalog.json', title: 'Sentinel-2' }, + { rel: 'child', href: './landsat/catalog.json', title: 'Landsat' } + ] + }; + + const mockRequest = { + url: 'https://example.com/catalog.json', + userData: { + depth: 0, + catalogId: 'root-catalog', + catalogSlug: 'root-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + error: jest.fn() + }; + + const results = { + collections: [], + catalogs: [], + stats: { + stacCompliant: 0, + catalogsProcessed: 0, + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + await handleCatalog({ + request: mockRequest, + json: catalogJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results, + config: {} + }); + + // Verify that no collections were added (it's a catalog, not a collection) + expect(results.collections.length).toBe(0); + + // Verify catalog was processed + expect(results.catalogs.length).toBe(1); + expect(results.stats.catalogsProcessed).toBe(1); + }); +}); + +describe('is_api field - handleCollections', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should set is_api=false when isApi parameter is false (static catalog)', async () => { + // Real static catalog collections response + const collectionsJson = { + collections: [ + { + stac_version: '1.0.0', + type: 'Collection', + id: 'landsat-c2-l2', + title: 'Landsat Collection 2 Level-2', + description: 'Landsat Collection 2 Level-2 Science Products', + license: 'proprietary', + keywords: ['landsat', 'usgs', 'nasa', 'satellite', 'global'], + providers: [ + { name: 'NASA', roles: ['producer'], url: 'https://landsat.gsfc.nasa.gov/' }, + { name: 'USGS', roles: ['processor', 'host'], url: 'https://www.usgs.gov/landsat-missions' } + ], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['1972-07-25T00:00:00Z', null]] } + }, + summaries: { + platform: ['landsat-4', 'landsat-5', 'landsat-7', 'landsat-8', 'landsat-9'], + instruments: ['tm', 'etm+', 'oli', 'tirs'] + }, + links: [ + { rel: 'self', href: './landsat-c2-l2/collection.json' } + ] + }, + { + stac_version: '1.0.0', + type: 'Collection', + id: 'cop-dem-glo-30', + title: 'Copernicus DEM GLO-30', + description: 'Global 30m Digital Elevation Model', + license: 'proprietary', + keywords: ['dem', 'elevation', 'copernicus'], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2021-04-22T00:00:00Z', '2021-04-22T23:59:59Z']] } + }, + links: [ + { rel: 'self', href: './cop-dem-glo-30/collection.json' } + ] + } + ] + }; + + const mockRequest = { + url: 'https://example.com/collections', + userData: { + catalogId: 'test-catalog', + catalogSlug: 'test-catalog-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn() + }; + + const results = { + collections: [], + stats: { + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + await handleCollections({ + request: mockRequest, + json: collectionsJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results, + isApi: false // Static catalog + }); + + // Verify collections were added + expect(results.collections.length).toBe(2); + + // Verify all collections have is_api=false + results.collections.forEach(collection => { + expect(collection.is_api).toBe(false); + }); + }); + + test('should set is_api=true when isApi parameter is true (API)', async () => { + // Real Earth Search API collections response + const collectionsJson = { + collections: [ + { + stac_version: '1.0.0', + type: 'Collection', + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + description: 'Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance', + license: 'proprietary', + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-27T10:25:31Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a' }, + { rel: 'root', href: 'https://earth-search.aws.element84.com/v1' } + ] + }, + { + stac_version: '1.0.0', + type: 'Collection', + id: 'sentinel-2-l1c', + title: 'Sentinel-2 Level-1C', + description: 'Sentinel-2 Level-1C Top-of-Atmosphere reflectance', + license: 'proprietary', + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-27T10:25:31Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections/sentinel-2-l1c' }, + { rel: 'root', href: 'https://earth-search.aws.element84.com/v1' } + ] + } + ], + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections' }, + { rel: 'root', href: 'https://earth-search.aws.element84.com/v1' } + ] + }; + + const mockRequest = { + url: 'https://api.example.com/stac/v1/collections', + userData: { + apiId: 'test-api', + catalogSlug: 'test-api-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn() + }; + + const results = { + collections: [], + stats: { + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + await handleCollections({ + request: mockRequest, + json: collectionsJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results, + isApi: true // API endpoint + }); + + // Verify collections were added + expect(results.collections.length).toBe(2); + + // Verify all collections have is_api=true + results.collections.forEach(collection => { + expect(collection.is_api).toBe(true); + }); + }); + + test('should default to is_api=false when isApi parameter is not provided', async () => { + // Real NASA CMR STAC collection + const collectionsJson = { + collections: [ + { + stac_version: '1.0.0', + type: 'Collection', + id: 'HLSL30.v2.0', + title: 'HLS Landsat Operational Land Imager Surface Reflectance and TOA Brightness Daily Global 30m v2.0', + description: 'The Harmonized Landsat Sentinel-2 (HLS) project provides consistent surface reflectance data from Landsat 8 and Sentinel-2 satellites.', + license: 'not-provided', + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2013-04-11T00:00:00Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD/collections/HLSL30.v2.0' }, + { rel: 'parent', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD' }, + { rel: 'root', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD' } + ] + } + ] + }; + + const mockRequest = { + url: 'https://example.com/collections', + userData: { + catalogId: 'test-catalog', + catalogSlug: 'test-catalog-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn() + }; + + const results = { + collections: [], + stats: { + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + // Call without isApi parameter - should default to false + await handleCollections({ + request: mockRequest, + json: collectionsJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results + // isApi parameter omitted + }); + + // Verify collections were added + expect(results.collections.length).toBe(1); + + // Verify is_api defaults to false + expect(results.collections[0].is_api).toBe(false); + }); +}); + +describe('is_api field - Database integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockInsertOrUpdateCollection.mockResolvedValue(1); + }); + + test('should pass is_api=true to database for API collections', async () => { + const { flushCollectionsToDb } = await import('../utils/handlers.js'); + + const results = { + collections: [ + { + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + description: 'Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance', + license: 'proprietary', + is_api: true, // API collection from Microsoft Planetary Computer + sourceSlug: 'microsoft-planetary-computer', + crawledUrl: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-2-l2a' + } + ] + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn() + }; + + await flushCollectionsToDb(results, mockLog, true); + + // Verify insertOrUpdateCollection was called + expect(mockInsertOrUpdateCollection).toHaveBeenCalledTimes(1); + + // Verify the collection passed has is_api=true + const passedCollection = mockInsertOrUpdateCollection.mock.calls[0][0]; + expect(passedCollection.is_api).toBe(true); + }); + + test('should pass is_api=false to database for static catalog collections', async () => { + const { flushCollectionsToDb } = await import('../utils/handlers.js'); + + const results = { + collections: [ + { + id: 'landsat-c2-l2', + title: 'Landsat Collection 2 Level-2', + description: 'Landsat Collection 2 Level-2 Science Products', + license: 'proprietary', + is_api: false, // Static catalog collection + sourceSlug: 'usgs-landsat-catalog', + crawledUrl: 'https://landsatlook.usgs.gov/stac-browser/landsat-c2-l2/collection.json' + } + ] + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn() + }; + + await flushCollectionsToDb(results, mockLog, true); + + // Verify insertOrUpdateCollection was called + expect(mockInsertOrUpdateCollection).toHaveBeenCalledTimes(1); + + // Verify the collection passed has is_api=false + const passedCollection = mockInsertOrUpdateCollection.mock.calls[0][0]; + expect(passedCollection.is_api).toBe(false); + }); + + test('should handle mixed API and static catalog collections in batch', async () => { + const { flushCollectionsToDb } = await import('../utils/handlers.js'); + + const results = { + collections: [ + { + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + is_api: true, // From Earth Search API + sourceSlug: 'earth-search' + }, + { + id: 'landsat-c2-l2', + title: 'Landsat Collection 2 Level-2', + is_api: false, // From static catalog + sourceSlug: 'usgs-catalog' + }, + { + id: 'naip', + title: 'NAIP: National Agriculture Imagery Program', + is_api: true, // From Microsoft Planetary Computer API + sourceSlug: 'microsoft-pc' + }, + { + id: 'cop-dem-glo-30', + title: 'Copernicus DEM GLO-30', + is_api: false, // From static catalog + sourceSlug: 'copernicus-catalog' + } + ] + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn() + }; + + await flushCollectionsToDb(results, mockLog, true); + + // Verify all collections were processed + expect(mockInsertOrUpdateCollection).toHaveBeenCalledTimes(4); + + // Verify correct is_api values were passed + const calls = mockInsertOrUpdateCollection.mock.calls; + expect(calls[0][0].is_api).toBe(true); // sentinel-2-l2a (API) + expect(calls[1][0].is_api).toBe(false); // landsat-c2-l2 (static) + expect(calls[2][0].is_api).toBe(true); // naip (API) + expect(calls[3][0].is_api).toBe(false); // cop-dem-glo-30 (static) + }); +}); diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 9294cec..9b8beb7 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -133,7 +133,7 @@ async function crawlSingleApiDomain(apis, domain, config = {}) { if (request.label === 'API_ROOT') { await handleApiRoot({ request, json, crawler, log, indent, results, maxDepth }); } else if (request.label === 'API_COLLECTIONS') { - await handleCollections({ request, json, crawler, log, indent, results }); + await handleCollections({ request, json, crawler, log, indent, results, isApi: true }); } else if (request.label === 'API_COLLECTION') { await handleApiCollection({ request, json, crawler, log, indent, results }); } @@ -355,6 +355,8 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max const collection = normalizeCollection(stacObj, results.collections.length); // Add the API slug to the collection for unique stac_id generation collection.sourceSlug = apiSlug; + // Mark as API collection + collection.is_api = true; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -488,6 +490,8 @@ async function handleApiCollection({ request, json, crawler, log, indent, result const collection = normalizeCollection(stacObj, results.collections.length); // Add the API slug to the collection for unique stac_id generation collection.sourceSlug = apiSlug; + // Mark as API collection + collection.is_api = true; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); diff --git a/crawler/utils/db.js b/crawler/utils/db.js index e889c3a..19d9a2c 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -253,7 +253,7 @@ async function _insertOrUpdateCollectionInternal(collection) { spatialExtent, temporalStart, temporalEnd, - false, // is_api - will be determined by crawler + collection.is_api !== undefined ? collection.is_api : false, // is_api - set by crawler true, // is_active sourceUrl, JSON.stringify(fullJsonData), @@ -267,7 +267,7 @@ async function _insertOrUpdateCollectionInternal(collection) { `INSERT INTO collection ( stac_id, stac_version, type, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, - is_api, is_active, full_json, source_url + is_api, is_active, source_url, full_json ) VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13) RETURNING id`, @@ -281,7 +281,7 @@ async function _insertOrUpdateCollectionInternal(collection) { spatialExtent, temporalStart, temporalEnd, - false, // is_api - will be determined by crawler + collection.is_api !== undefined ? collection.is_api : false, // is_api - set by crawler true, // is_active sourceUrl, JSON.stringify(fullJsonData) diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index d1905c5..386e4b7 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -170,6 +170,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul collection.sourceSlug = catalogSlug; // Store the actual crawled URL as the source URL (not relative links from the JSON) collection.crawledUrl = request.url; + // Mark as non-API collection (from static catalog) + collection.is_api = false; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -300,8 +302,9 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul * @param {Object} context.log - Logger instance * @param {string} context.indent - Indentation for logging * @param {Object} context.results - Results object to store data + * @param {boolean} context.isApi - Whether this is an API collections endpoint (default: false) */ -export async function handleCollections({ request, json, crawler, log, indent, results }) { +export async function handleCollections({ request, json, crawler, log, indent, results, isApi = false }) { const catalogId = request.userData?.catalogId || 'unknown'; const catalogSlug = request.userData?.catalogSlug || null; @@ -366,7 +369,10 @@ export async function handleCollections({ request, json, crawler, log, indent, r } } else { collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; - } + } + + // Mark collection as API or static catalog based on context + collection.is_api = isApi; return collection; }); From 1551fbcd949911ea36b6dd33ef26a14f5b87ad64 Mon Sep 17 00:00:00 2001 From: Lenn Date: Sat, 31 Jan 2026 09:44:26 +0100 Subject: [PATCH 115/134] Crawler: is_API=true wenn das gecrawlte eine api ist (#278) * bug fix: extent was written wrong * feat: add source URL extraction for catalogs and collections * feat: update insertOrUpdateCatalog to only process catalogs for traversal, no longer saving to database * bug: the type should now be a catalog or collection * saving catalogs into database, with stac_id, source_url and description * feat(crawler): enhance unique stac_id generation and include stac_version in normalization for catalogs * fix(crawler): source_url for catalogs are now completly saved * revert all latest changes because catalogs dont need to save in the database * Fix(crawler): insert query to include correct parameters * feat(crawler): implement is_api for collections and add unit tests --- crawler/__tests__/is_api.test.js | 556 +++++++++++++++++++++++++++++++ crawler/apis/api.js | 6 +- crawler/utils/db.js | 4 +- crawler/utils/handlers.js | 8 +- 4 files changed, 570 insertions(+), 4 deletions(-) create mode 100644 crawler/__tests__/is_api.test.js diff --git a/crawler/__tests__/is_api.test.js b/crawler/__tests__/is_api.test.js new file mode 100644 index 0000000..2ce63cf --- /dev/null +++ b/crawler/__tests__/is_api.test.js @@ -0,0 +1,556 @@ +/** + * @fileoverview Unit tests for is_api field functionality + * Tests that collections are correctly marked as API or static catalog collections + */ + +import { jest } from '@jest/globals'; +import create from 'stac-js'; + +// Mock normalizeCollection to return a simple object +jest.unstable_mockModule('../utils/normalization.js', () => ({ + normalizeCollection: jest.fn((stacObj, index) => ({ + id: stacObj.id || `collection-${index}`, + title: stacObj.title || 'Test Collection', + description: stacObj.description || 'Test Description' + })) +})); + +// Mock db module +const mockInsertOrUpdateCollection = jest.fn(); +jest.unstable_mockModule('../utils/db.js', () => ({ + default: { + insertOrUpdateCollection: mockInsertOrUpdateCollection + } +})); + +// Mock endpoints module +jest.unstable_mockModule('../utils/endpoints.js', () => ({ + tryCollectionEndpoints: jest.fn() +})); + +// Import the modules to test +const { handleCatalog, handleCollections } = await import('../utils/handlers.js'); +const { normalizeCollection } = await import('../utils/normalization.js'); + +describe('is_api field - handleCatalog (static catalogs)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should set is_api=false for collections extracted from static catalogs', async () => { + // Real Sentinel-2 collection from static catalog + const collectionJson = { + stac_version: '1.0.0', + type: 'Collection', + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + description: 'Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance', + license: 'proprietary', + keywords: ['sentinel', 'esa', 'copernicus', 'satellite', 'global'], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-27T10:25:31Z', null]] } + }, + links: [ + { rel: 'self', href: './sentinel-2-l2a/collection.json' }, + { rel: 'root', href: '../catalog.json' } + ] + }; + + const mockRequest = { + url: 'https://example.com/catalog/collection.json', + userData: { + depth: 1, + catalogId: 'test-catalog', + catalogSlug: 'test-catalog-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + error: jest.fn() + }; + + const results = { + collections: [], + catalogs: [], + stats: { + stacCompliant: 0, + catalogsProcessed: 0, + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + await handleCatalog({ + request: mockRequest, + json: collectionJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results, + config: {} + }); + + // Verify that a collection was added + expect(results.collections.length).toBe(1); + + // Verify that is_api is set to false for static catalog collection + expect(results.collections[0].is_api).toBe(false); + + // Verify other fields are set correctly + expect(results.collections[0].sourceSlug).toBe('test-catalog-slug'); + expect(results.collections[0].crawledUrl).toBe('https://example.com/catalog/collection.json'); + }); + + test('should set is_api=false for STAC catalog (not collection)', async () => { + // Real static STAC catalog structure + const catalogJson = { + stac_version: '1.0.0', + type: 'Catalog', + id: 'earth-observation-catalog', + title: 'Earth Observation Data Catalog', + description: 'A catalog of Earth observation satellite imagery collections', + links: [ + { rel: 'self', href: './catalog.json' }, + { rel: 'root', href: './catalog.json' }, + { rel: 'child', href: './sentinel-2/catalog.json', title: 'Sentinel-2' }, + { rel: 'child', href: './landsat/catalog.json', title: 'Landsat' } + ] + }; + + const mockRequest = { + url: 'https://example.com/catalog.json', + userData: { + depth: 0, + catalogId: 'root-catalog', + catalogSlug: 'root-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + error: jest.fn() + }; + + const results = { + collections: [], + catalogs: [], + stats: { + stacCompliant: 0, + catalogsProcessed: 0, + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + await handleCatalog({ + request: mockRequest, + json: catalogJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results, + config: {} + }); + + // Verify that no collections were added (it's a catalog, not a collection) + expect(results.collections.length).toBe(0); + + // Verify catalog was processed + expect(results.catalogs.length).toBe(1); + expect(results.stats.catalogsProcessed).toBe(1); + }); +}); + +describe('is_api field - handleCollections', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should set is_api=false when isApi parameter is false (static catalog)', async () => { + // Real static catalog collections response + const collectionsJson = { + collections: [ + { + stac_version: '1.0.0', + type: 'Collection', + id: 'landsat-c2-l2', + title: 'Landsat Collection 2 Level-2', + description: 'Landsat Collection 2 Level-2 Science Products', + license: 'proprietary', + keywords: ['landsat', 'usgs', 'nasa', 'satellite', 'global'], + providers: [ + { name: 'NASA', roles: ['producer'], url: 'https://landsat.gsfc.nasa.gov/' }, + { name: 'USGS', roles: ['processor', 'host'], url: 'https://www.usgs.gov/landsat-missions' } + ], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['1972-07-25T00:00:00Z', null]] } + }, + summaries: { + platform: ['landsat-4', 'landsat-5', 'landsat-7', 'landsat-8', 'landsat-9'], + instruments: ['tm', 'etm+', 'oli', 'tirs'] + }, + links: [ + { rel: 'self', href: './landsat-c2-l2/collection.json' } + ] + }, + { + stac_version: '1.0.0', + type: 'Collection', + id: 'cop-dem-glo-30', + title: 'Copernicus DEM GLO-30', + description: 'Global 30m Digital Elevation Model', + license: 'proprietary', + keywords: ['dem', 'elevation', 'copernicus'], + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2021-04-22T00:00:00Z', '2021-04-22T23:59:59Z']] } + }, + links: [ + { rel: 'self', href: './cop-dem-glo-30/collection.json' } + ] + } + ] + }; + + const mockRequest = { + url: 'https://example.com/collections', + userData: { + catalogId: 'test-catalog', + catalogSlug: 'test-catalog-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn() + }; + + const results = { + collections: [], + stats: { + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + await handleCollections({ + request: mockRequest, + json: collectionsJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results, + isApi: false // Static catalog + }); + + // Verify collections were added + expect(results.collections.length).toBe(2); + + // Verify all collections have is_api=false + results.collections.forEach(collection => { + expect(collection.is_api).toBe(false); + }); + }); + + test('should set is_api=true when isApi parameter is true (API)', async () => { + // Real Earth Search API collections response + const collectionsJson = { + collections: [ + { + stac_version: '1.0.0', + type: 'Collection', + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + description: 'Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance', + license: 'proprietary', + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-27T10:25:31Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a' }, + { rel: 'root', href: 'https://earth-search.aws.element84.com/v1' } + ] + }, + { + stac_version: '1.0.0', + type: 'Collection', + id: 'sentinel-2-l1c', + title: 'Sentinel-2 Level-1C', + description: 'Sentinel-2 Level-1C Top-of-Atmosphere reflectance', + license: 'proprietary', + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2015-06-27T10:25:31Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections/sentinel-2-l1c' }, + { rel: 'root', href: 'https://earth-search.aws.element84.com/v1' } + ] + } + ], + links: [ + { rel: 'self', href: 'https://earth-search.aws.element84.com/v1/collections' }, + { rel: 'root', href: 'https://earth-search.aws.element84.com/v1' } + ] + }; + + const mockRequest = { + url: 'https://api.example.com/stac/v1/collections', + userData: { + apiId: 'test-api', + catalogSlug: 'test-api-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn() + }; + + const results = { + collections: [], + stats: { + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + await handleCollections({ + request: mockRequest, + json: collectionsJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results, + isApi: true // API endpoint + }); + + // Verify collections were added + expect(results.collections.length).toBe(2); + + // Verify all collections have is_api=true + results.collections.forEach(collection => { + expect(collection.is_api).toBe(true); + }); + }); + + test('should default to is_api=false when isApi parameter is not provided', async () => { + // Real NASA CMR STAC collection + const collectionsJson = { + collections: [ + { + stac_version: '1.0.0', + type: 'Collection', + id: 'HLSL30.v2.0', + title: 'HLS Landsat Operational Land Imager Surface Reflectance and TOA Brightness Daily Global 30m v2.0', + description: 'The Harmonized Landsat Sentinel-2 (HLS) project provides consistent surface reflectance data from Landsat 8 and Sentinel-2 satellites.', + license: 'not-provided', + extent: { + spatial: { bbox: [[-180, -90, 180, 90]] }, + temporal: { interval: [['2013-04-11T00:00:00Z', null]] } + }, + links: [ + { rel: 'self', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD/collections/HLSL30.v2.0' }, + { rel: 'parent', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD' }, + { rel: 'root', href: 'https://cmr.earthdata.nasa.gov/stac/LPCLOUD' } + ] + } + ] + }; + + const mockRequest = { + url: 'https://example.com/collections', + userData: { + catalogId: 'test-catalog', + catalogSlug: 'test-catalog-slug' + } + }; + + const mockCrawler = { + addRequests: jest.fn() + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn() + }; + + const results = { + collections: [], + stats: { + collectionsFound: 0, + collectionsSaved: 0, + collectionsFailed: 0 + } + }; + + // Call without isApi parameter - should default to false + await handleCollections({ + request: mockRequest, + json: collectionsJson, + crawler: mockCrawler, + log: mockLog, + indent: '', + results + // isApi parameter omitted + }); + + // Verify collections were added + expect(results.collections.length).toBe(1); + + // Verify is_api defaults to false + expect(results.collections[0].is_api).toBe(false); + }); +}); + +describe('is_api field - Database integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockInsertOrUpdateCollection.mockResolvedValue(1); + }); + + test('should pass is_api=true to database for API collections', async () => { + const { flushCollectionsToDb } = await import('../utils/handlers.js'); + + const results = { + collections: [ + { + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + description: 'Sentinel-2 Level-2A, orthorectified atmosphere-corrected surface reflectance', + license: 'proprietary', + is_api: true, // API collection from Microsoft Planetary Computer + sourceSlug: 'microsoft-planetary-computer', + crawledUrl: 'https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-2-l2a' + } + ] + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn() + }; + + await flushCollectionsToDb(results, mockLog, true); + + // Verify insertOrUpdateCollection was called + expect(mockInsertOrUpdateCollection).toHaveBeenCalledTimes(1); + + // Verify the collection passed has is_api=true + const passedCollection = mockInsertOrUpdateCollection.mock.calls[0][0]; + expect(passedCollection.is_api).toBe(true); + }); + + test('should pass is_api=false to database for static catalog collections', async () => { + const { flushCollectionsToDb } = await import('../utils/handlers.js'); + + const results = { + collections: [ + { + id: 'landsat-c2-l2', + title: 'Landsat Collection 2 Level-2', + description: 'Landsat Collection 2 Level-2 Science Products', + license: 'proprietary', + is_api: false, // Static catalog collection + sourceSlug: 'usgs-landsat-catalog', + crawledUrl: 'https://landsatlook.usgs.gov/stac-browser/landsat-c2-l2/collection.json' + } + ] + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn() + }; + + await flushCollectionsToDb(results, mockLog, true); + + // Verify insertOrUpdateCollection was called + expect(mockInsertOrUpdateCollection).toHaveBeenCalledTimes(1); + + // Verify the collection passed has is_api=false + const passedCollection = mockInsertOrUpdateCollection.mock.calls[0][0]; + expect(passedCollection.is_api).toBe(false); + }); + + test('should handle mixed API and static catalog collections in batch', async () => { + const { flushCollectionsToDb } = await import('../utils/handlers.js'); + + const results = { + collections: [ + { + id: 'sentinel-2-l2a', + title: 'Sentinel-2 Level-2A', + is_api: true, // From Earth Search API + sourceSlug: 'earth-search' + }, + { + id: 'landsat-c2-l2', + title: 'Landsat Collection 2 Level-2', + is_api: false, // From static catalog + sourceSlug: 'usgs-catalog' + }, + { + id: 'naip', + title: 'NAIP: National Agriculture Imagery Program', + is_api: true, // From Microsoft Planetary Computer API + sourceSlug: 'microsoft-pc' + }, + { + id: 'cop-dem-glo-30', + title: 'Copernicus DEM GLO-30', + is_api: false, // From static catalog + sourceSlug: 'copernicus-catalog' + } + ] + }; + + const mockLog = { + info: jest.fn(), + warning: jest.fn() + }; + + await flushCollectionsToDb(results, mockLog, true); + + // Verify all collections were processed + expect(mockInsertOrUpdateCollection).toHaveBeenCalledTimes(4); + + // Verify correct is_api values were passed + const calls = mockInsertOrUpdateCollection.mock.calls; + expect(calls[0][0].is_api).toBe(true); // sentinel-2-l2a (API) + expect(calls[1][0].is_api).toBe(false); // landsat-c2-l2 (static) + expect(calls[2][0].is_api).toBe(true); // naip (API) + expect(calls[3][0].is_api).toBe(false); // cop-dem-glo-30 (static) + }); +}); diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 9294cec..9b8beb7 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -133,7 +133,7 @@ async function crawlSingleApiDomain(apis, domain, config = {}) { if (request.label === 'API_ROOT') { await handleApiRoot({ request, json, crawler, log, indent, results, maxDepth }); } else if (request.label === 'API_COLLECTIONS') { - await handleCollections({ request, json, crawler, log, indent, results }); + await handleCollections({ request, json, crawler, log, indent, results, isApi: true }); } else if (request.label === 'API_COLLECTION') { await handleApiCollection({ request, json, crawler, log, indent, results }); } @@ -355,6 +355,8 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max const collection = normalizeCollection(stacObj, results.collections.length); // Add the API slug to the collection for unique stac_id generation collection.sourceSlug = apiSlug; + // Mark as API collection + collection.is_api = true; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -488,6 +490,8 @@ async function handleApiCollection({ request, json, crawler, log, indent, result const collection = normalizeCollection(stacObj, results.collections.length); // Add the API slug to the collection for unique stac_id generation collection.sourceSlug = apiSlug; + // Mark as API collection + collection.is_api = true; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 258c6fe..f88db53 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -253,7 +253,7 @@ async function _insertOrUpdateCollectionInternal(collection) { spatialExtent, temporalStart, temporalEnd, - false, // is_api - will be determined by crawler + collection.is_api !== undefined ? collection.is_api : false, // is_api - set by crawler true, // is_active sourceUrl, JSON.stringify(fullJsonData), @@ -281,7 +281,7 @@ async function _insertOrUpdateCollectionInternal(collection) { spatialExtent, temporalStart, temporalEnd, - false, // is_api - will be determined by crawler + collection.is_api !== undefined ? collection.is_api : false, // is_api - set by crawler true, // is_active sourceUrl, JSON.stringify(fullJsonData) diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 163985a..0384503 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -244,6 +244,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul collection.sourceSlug = catalogSlug; // Store the actual crawled URL as the source URL (not relative links from the JSON) collection.crawledUrl = request.url; + // Mark as non-API collection (from static catalog) + collection.is_api = false; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -374,8 +376,9 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul * @param {Object} context.log - Logger instance * @param {string} context.indent - Indentation for logging * @param {Object} context.results - Results object to store data + * @param {boolean} context.isApi - Whether this is an API collections endpoint (default: false) */ -export async function handleCollections({ request, json, crawler, log, indent, results }) { +export async function handleCollections({ request, json, crawler, log, indent, results, isApi = false }) { const catalogId = request.userData?.catalogId || 'unknown'; const catalogSlug = request.userData?.catalogSlug || null; @@ -456,6 +459,9 @@ export async function handleCollections({ request, json, crawler, log, indent, r collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; } + // Mark collection as API or static catalog based on context + collection.is_api = isApi; + return collection; }); From 7a25e89ff2649daa55ada230d3c98466709af587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 31 Jan 2026 09:46:02 +0100 Subject: [PATCH 116/134] changed the whole structure of the catalogs. Now the catalogs can be seen as a crawlog for teh crawler. The crawllog can be now used in case the crawling gets cancelled and the crawler can use the crawllog for its own --- db/init/02_tables_catalog.sql | 112 +++--------------------------- db/init/03_tables_collections.sql | 9 ++- db/init/04_relation_tables.sql | 16 +---- db/init/05_indexes.sql | 20 ------ db/init/06_triggers.sql | 6 -- 5 files changed, 14 insertions(+), 149 deletions(-) diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index dca17c4..93755ec 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -1,107 +1,13 @@ --- creates every table related to catalogs --- Main catalog table: Stores STAC catalog metadata including version, type, title, and description --- Each catalog represents a STAC catalog endpoint that has been discovered and indexed -CREATE TABLE catalog ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - stac_version TEXT, - type TEXT, - title TEXT, - description TEXT, - created_at TIMESTAMP DEFAULT now(), - updated_at TIMESTAMP DEFAULT now(), - search_vector tsvector -); - --- Catalog links table: Stores related links for catalogs (e.g., self, root, child, item links) --- Links define the navigation structure between STAC resources -CREATE TABLE catalog_links ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, - source_url TEXT, - rel TEXT, - href TEXT, - type TEXT, - title TEXT -); - --- Keywords lookup table: Stores unique searchable keywords --- Used by both catalogs and collections for categorization and search -CREATE TABLE keywords ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - keyword TEXT UNIQUE -); - --- STAC extensions lookup table: Stores unique STAC extension identifiers --- Extensions provide additional standardized fields beyond core STAC spec -CREATE TABLE stac_extensions ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - stac_extension TEXT UNIQUE -); +-- The crawllog_catalog is required to save the crawler's current location. +-- If the crawler crashes, for example because the server goes down, it can +-- now restart at the correct location and does not have to crawl everything again. --- Crawl log for catalogs: Tracks when each catalog was last crawled for updates --- Used to schedule re-crawling and maintain freshness of catalog data CREATE TABLE crawllog_catalog ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, - last_crawled TIMESTAMP -); - --- ======================================== --- FULL-TEXT SEARCH TRIGGERS --- ======================================== - --- Trigger function to auto-update search_vector when catalog is inserted or updated --- Includes title, description, and all associated keywords for comprehensive search -CREATE OR REPLACE FUNCTION update_catalog_search_vector() -RETURNS TRIGGER AS $$ -BEGIN - NEW.search_vector := to_tsvector('simple', - coalesce(NEW.title, '') || ' ' || - coalesce(NEW.description, '') || ' ' || - coalesce( - ( - SELECT string_agg(k.keyword, ' ') - FROM catalog_keywords ck - JOIN keywords k ON k.id = ck.keyword_id - WHERE ck.catalog_id = NEW.id - ), - '' - ) - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER catalog_search_vector_update -BEFORE INSERT OR UPDATE ON catalog -FOR EACH ROW -EXECUTE FUNCTION update_catalog_search_vector(); - --- Trigger function to update search_vector when keywords are added/removed --- Ensures search index stays in sync with keyword changes -CREATE OR REPLACE FUNCTION update_catalog_search_vector_on_keyword_change() -RETURNS TRIGGER AS $$ -BEGIN - UPDATE catalog - SET search_vector = to_tsvector('simple', - coalesce(title, '') || ' ' || - coalesce(description, '') || ' ' || - coalesce( - ( - SELECT string_agg(k.keyword, ' ') - FROM catalog_keywords ck - JOIN keywords k ON k.id = ck.keyword_id - WHERE ck.catalog_id = catalog.id - ), - '' - ) - ) - WHERE id = COALESCE(NEW.catalog_id, OLD.catalog_id); - - RETURN COALESCE(NEW, OLD); -END; -$$ LANGUAGE plpgsql; - --- NOTE: The trigger for catalog_keywords is defined in 06_triggers.sql --- because it depends on the catalog_keywords table which is created there + slug TEXT, + source_url TEXT UNIQUE NOT NULL, + is_api BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now(), +); \ No newline at end of file diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 2391433..3b2a8ac 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -58,14 +58,13 @@ CREATE TABLE assets ( metadata JSONB ); --- Crawl log for collections: Tracks when each collection was last crawled for updates +-- Crawl log for collections: Tracks the last crawled state of each collection and references the matching catalog -- Used to schedule re-crawling and maintain freshness of collection data --- (same usecase as the crawllog for catalogs) CREATE TABLE crawllog_collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, - source_url TEXT, - last_crawled TIMESTAMP + FOREIGN KEY (collection_id) INTEGER REFERENCES collection(id) ON DELETE CASCADE, + source_url TEXT UNIQUE NOT NULL, + FOREIGN KEY (catalog_id) REFERENCES catalog_id(id) ON DELETE CASCADE, ); -- ======================================== diff --git a/db/init/04_relation_tables.sql b/db/init/04_relation_tables.sql index d95f31b..f132d7d 100644 --- a/db/init/04_relation_tables.sql +++ b/db/init/04_relation_tables.sql @@ -1,18 +1,4 @@ --- creates every table needed for relations between tables for catalogs and collections - --- Junction table: Links catalogs to their associated keywords (many-to-many) -CREATE TABLE catalog_keywords ( - catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, - keyword_id INTEGER REFERENCES keywords(id) ON DELETE CASCADE, - PRIMARY KEY (catalog_id, keyword_id) -); - --- Junction table: Links catalogs to STAC extensions they implement (many-to-many) -CREATE TABLE catalog_stac_extension ( - catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, - stac_extension_id INTEGER REFERENCES stac_extensions(id) ON DELETE CASCADE, - PRIMARY KEY (catalog_id, stac_extension_id) -); +-- creates every table needed for relations between tables for collections -- Junction table: Links collections to their associated keywords (many-to-many) CREATE TABLE collection_keywords ( diff --git a/db/init/05_indexes.sql b/db/init/05_indexes.sql index 76445db..6d4cbb0 100644 --- a/db/init/05_indexes.sql +++ b/db/init/05_indexes.sql @@ -1,23 +1,6 @@ -- Performance indexes for all tables -- These indexes optimize common query patterns and improve search performance --- ======================================== --- CATALOG INDEXES --- ======================================== - --- Basic catalog lookups -CREATE INDEX idx_catalog_title ON catalog (title); -CREATE INDEX idx_catalog_updated_at ON catalog (updated_at); - --- Full-text search index on computed search_vector column (includes title, description, and keywords) -CREATE INDEX idx_catalog_search_vector ON catalog USING GIN (search_vector); - -CREATE INDEX idx_catalog_links_catalog_id ON catalog_links (catalog_id); -CREATE INDEX idx_catalog_keywords_catalog ON catalog_keywords (catalog_id); -CREATE INDEX idx_catalog_stac_ext_catalog ON catalog_stac_extension (catalog_id); - -CREATE INDEX idx_crawllog_catalog_last ON crawllog_catalog (last_crawled); - -- ======================================== -- COLLECTION INDEXES -- ======================================== @@ -41,9 +24,6 @@ CREATE INDEX idx_collection_stac_ext_collection ON collection_stac_extension (co CREATE INDEX idx_collection_providers_collection ON collection_providers (collection_id); CREATE INDEX idx_collection_assets_collection ON collection_assets (collection_id); -CREATE INDEX idx_crawllog_collection_last ON crawllog_collection (last_crawled); -CREATE INDEX idx_crawllog_collection_source_url ON crawllog_collection (source_url); - -- ======================================== -- PROVIDER & ASSET INDEXES -- ======================================== diff --git a/db/init/06_triggers.sql b/db/init/06_triggers.sql index 5e167a0..720e780 100644 --- a/db/init/06_triggers.sql +++ b/db/init/06_triggers.sql @@ -3,12 +3,6 @@ -- ======================================== -- These triggers must be created here (after junction tables exist) --- Trigger to update catalog search_vector when keywords change -CREATE TRIGGER catalog_keywords_update_vector -AFTER INSERT OR DELETE ON catalog_keywords -FOR EACH ROW -EXECUTE FUNCTION update_catalog_search_vector_on_keyword_change(); - -- Trigger to update collection search_vector when keywords change CREATE TRIGGER collection_keywords_update_vector AFTER INSERT OR DELETE ON collection_keywords From 724634ee8ed655e147ece36ca6cdfd4feada9118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 31 Jan 2026 09:48:21 +0100 Subject: [PATCH 117/134] We asked Mohr on the handling with valid and unvalid collection. We don't need to save the unvalid collections, so also don't need the collumn that points that out --- db/init/03_tables_collections.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 3b2a8ac..e40af3d 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -20,7 +20,6 @@ CREATE TABLE collection ( is_api BOOLEAN DEFAULT FALSE, is_active BOOLEAN DEFAULT TRUE, - is_valid BOOLEAN DEFAULT TRUE, full_json JSONB, search_vector tsvector From 0802b00f7be65c91eb8f1d971679f0ba3d865b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 31 Jan 2026 10:05:51 +0100 Subject: [PATCH 118/134] the reference for the crawllog_collections weren't right --- db/init/03_tables_collections.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index e40af3d..14385bf 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -61,9 +61,9 @@ CREATE TABLE assets ( -- Used to schedule re-crawling and maintain freshness of collection data CREATE TABLE crawllog_collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - FOREIGN KEY (collection_id) INTEGER REFERENCES collection(id) ON DELETE CASCADE, + collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, source_url TEXT UNIQUE NOT NULL, - FOREIGN KEY (catalog_id) REFERENCES catalog_id(id) ON DELETE CASCADE, + crawllog_catalog_id INTEGER REFERENCES crawllog_catalog(id) ON DELETE CASCADE ); -- ======================================== From 731f9d9ba71b9da05b62b71dc92689f29739ccc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 31 Jan 2026 10:12:49 +0100 Subject: [PATCH 119/134] Dev database: changed catalog handling (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added `.env` * added environment for docker-compose.yml now every connection-details are inside an `.env`. There is an `example.env` for better understanding which need to be set as connection details * added description of how to use the `.env` and `example.env` in the `README.md` * changed a few things e.g. DB_PORT --> ${DB_PORT} * now, everthing should be done. my god, help. sorry * layout issues fixed * Fixed Typo/incomplete Sentence in README.md * added `stac_id` for collections * all IDs are now written in the newer PostgrSQL standart: ```SQL id SERIAL PRIMARY KEY, ``` changed to ```SQL id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, ``` * changed `extend` to `extent`. * Updated Query-Builder to get all necessary fields from all db_tables for collections. Added some tests and fixed some already existing tests, becuase now the tablenames start with the alias `c.`. * Updated Query-Builder to get all necessary fields from all db_tables for collections. Added some tests and fixed some already existing tests, becuase now the tablenames start with the alias `c.`. * Added `openapi.yaml` (now http://localhost:3000/api-docs/ is working). - needed to do some modifying to the app.js * Added discription on how to use `stac-api-validator`. Currently we are onyl valid to `core`. * Changed API-Version name to 1.1.0 instead of 1.0.0 * Changed language used in `./api/README.md` from german to english. I wanted to thsi anyway at some point, but this is now more like a Test-commit to see if the CI/CD Pipeline triggers... * Revert "API is now responding with all necessary fields for each collection" (#195) Reverts #185 @SonkeHoffmann accidentally didn't squash correctly. * Revert "Revert "API is now responding with all necessary fields for each collection"" (#185) (#195) (#196) dev-api: prepare v1.1.0 + API docs + query builder fixes - Change API version to 1.1.0 - Add OpenAPI spec so /api-docs works locally - Document stac-api-validator usage - Update api/.env.example - Query builder: select required fields for collections across db_tables; adjust tests (alias `c.`) Commits included: - 34bf962 Changed API-Version name to 1.1.0 instead of 1.0.0 - b047389 Added description on how to use `stac-api-validator` (currently only valid for `core`) - b811288 Added `openapi.yaml` (so http://localhost:3000/api-docs/ works); modified app.js accordingly - 6e5ab3e Merge branch 'dev-api-robin' of github.com:SpatioCore/STAC-Atlas into dev-api-robin - 70dc043 Updated Query-Builder to get all necessary fields from all db_tables for collections. Added tests and fixed existing tests (table names now start with alias `c.`) - d83eeb4 Update api/.env.example - 5a7af5b Updated Query-Builder to get all necessary fields from all db_tables for collections. Added tests and fixed existing tests (table names now start with alias `c.`) Co-authored-by: Robin Tammo Gummels * Implement more Queryable-Fields and add the keywords-field to q fulltext search (#200) * Add provider and license filters to collection search API - Updated buildCollectionSearchQuery to include provider and license parameters for filtering collections. - Enhanced validateCollectionSearchParams middleware to validate provider and license query parameters. - Modified collections route to handle new provider and license filters in search queries. - Implemented validation functions for provider and license parameters in collectionSearchParams. * Add validation tests for provider and license * Enhance full-text search by including keywords in the tsvector expression and update related tests * Add provider and license to query parameter extraction in collection search validation * Revert "Enhance full-text search by including keywords in the tsvector expression and update related tests" This reverts commit 872443d8e83c55834ef5f0d275c54fefb4b74e2d. * Implement GET /collections/{id} endpoint with validation and QueryBuilder integration (#186) * added SQLQuery-builder with these parameters: q,bbox,datetime,sortby,limit and token * finalised bbox and datetime * adapted to DB, QueryBuilder and added helperfunction runQuery * added question-TODOs * added bbox+datetime to the Query-Builder from Jonas * added tests for Query-Builder from Jonas * added tests from George * added falsely deleted TODOs again * fixed collumn names to match our DB and adjusted full text search to match 05_indexes.sql correctly * Used a formatter and linter on `buildCollectionSearchQuery.js * Did some major and minor fixes to the collection search. - Updated `buildCollectionSearchQuery` to support pagination and improved text search with English language settings. - Modified tests in `buildCollectionsSearchQuery.basic.test.js`, `collections-pagination.test.js`, and `collections-sort.test.js` to reflect new query behavior and validation logic. - Enhanced sort validation in `validators.test.js` and `collectionSearchParams.js` to map API fields to database column names. - Implemented total count retrieval for matched results in `collections.js`. * Added a internal .env creation in the CI/CD Pipeline. It utilzes GitHub Repository Secrets to not publish any private Logins and stuff. * Forgot that the second Job of the CI/CD pipeline runs seperatly and needs a internal .env file too. * Enhance documentation for buildCollectionSearchQuery Updated the documentation for: - the buildCollectionSearchQuery function - the fulltextsearch * Refactor buildCollectionSearchQuery and updated SELECT part Changed the SELECT part to match our bid and the database shema. Updated comments and for clarity. Changed full-text search to use 'simple' configuration instead of 'english'. * Update api/routes/collections.js small typo Co-authored-by: Robin Tammo Gummels * Remove sorting TODO from collections route Removed TODO comment about sorting based on sortby parameter. * Explicitly return undefined for normalized in validateSortby Update validateSortby function to explicitly return undefined for normalized when sortby is not provided. * small fix in buildCollectionSearch.fulltext.test.js Change plainto_tsquery language from 'english' to 'simple' * Fix duplicate SELECT keyword in query Remove duplicate 'SELECT' keyword in SQL query. * Fix missing newline at end of collectionSearchParams.js * Fixed missing bracket in collectionSearchParams.js * Refactor validateSortby for optional parameter handling Refactor validateSortby function to handle optional sortby parameter and improve validation logic. * Stabilize API test pipeline by running Jest in-band with extended timeout Run Jest in CI with --runInBand and a higher default --testTimeout to stabilize database-backed integration tests. Multiple Jest workers were competing for the same PostgreSQL connection pool and some long-running /collections queries exceeded the default 5s timeout, causing failures in existing test suites (e.g. collectionSearch and DBconnection). * Fixed leaking tests that blocked CI/CD-Pipeline. - Added a global Teardown for jest and force-exited the tests to prevent leaking. - Made a change to db_APIconnection to only log the pool-(dis)connection if it isn't run in a test enviroment. * Did a minimum amount of Formatting to the discription * Used `npm audit fix --force` to fix all vulnerabilties in our used packages. * Fixed curious doublechecking for empty Strings for the sortby-Parameter. - Now we only check once for a empty sortby - And added a test which distinguish between `sortby=""` and `sortby="+"` * Update api/routes/collections.js Removed the TODO about switching from mock-data to the real db * Removed globalTeardown as i brought up some problems corresponding to long db-queries (for example BBOX). Instead i increased the maximal testTimeout. * added validator for collections{id} and correctly implemented collections{id} * added test for collections{id} * removed unnecessary parameter * added id parameter to the Query (temporary fix) * test-fixes to match our current tests and a fix to the baseURL for collection{id} * test fix * fixed problem with tests in api.test.js and adjusted the "invalid-id-test" in the validator. * Update api/routes/collections.js - Renamed `collection.id` to `c.collection.id` * added test for negative ids * deleted the whole "existing links" part and build base Links * fixed bug in validateCollectionId.js * Refactor negative ID test i encoded the "-1" value in the negative ID test instead of directly putting it into the path. * Removed a german comment in `api/routes/collections.js` --------- Co-authored-by: Robin Tammo Gummels * added triggering function for an auto-update search_vector, both for collections and catalogs. The search_vector includes title, description and keywords * changed the CI-Pipeline. Now also Changes in the /db will be acceped by the Pipeline * feat(api): Implement complete CQL2 filtering for Collection Search (#208) This commit implements comprehensive CQL2 (Common Query Language 2) filtering support for the STAC Atlas Collection Search API, enabling advanced queries on collection metadata. ## New Features ### CQL2 Parser Integration - Integrated cql2-wasm (Rust compiled to WebAssembly) for parsing CQL2 - Support for both CQL2-Text and CQL2-JSON encodings - Dynamic ESM import to maintain Jest compatibility with CommonJS ### Basic CQL2 Operators - Comparison operators: =, <, >, <=, >=, <> - Logical operators: AND, OR, NOT - Advanced comparison: BETWEEN, IN, IS NULL ### Spatial Operators (PostGIS) - S_INTERSECTS: Find collections whose geometry intersects with GeoJSON - S_WITHIN: Find collections completely within a geometry - S_CONTAINS: Find collections containing a geometry - Uses ST_GeomFromGeoJSON for geometry parsing ### Temporal Operators - T_INTERSECTS: Find collections with overlapping temporal extents - T_BEFORE: Find collections before a timestamp - T_AFTER: Find collections after a timestamp - Support for open-ended intervals (..) ### Column Mappings - Maps CQL2 properties to database columns with table aliases - Core fields: id, title, description, license, type, etc. - Aggregated fields: keywords, stac_extensions, providers, assets, summaries - Fallback to JSONB full_json column for custom properties ## Files Added or Modified - utils/cql2.js: WASM initialization and CQL2 parsing wrapper - utils/cql2ToSql.js: CQL2 JSON AST to PostgreSQL WHERE clause converter - middleware/validateCollectionSearch.js: Request validation with filter support - docs/cql2-filtering.md: Comprehensive CQL2 documentation - routes/collections.js: Integrated CQL2 filter processing - utils/buildCollectionSearchQuery.js: Added cqlWhere parameter support - config/conformanceURIS.js: Added all CQL2 conformance class URIs - README.md: Added CQL2 section and updated implementation status ## Tests Added - __tests__/cql2ToSql.test.js: Unit tests for SQL conversion (17 tests) - __tests__/cql2.integration.test.js: Integration tests with database (18 tests) - __tests__/buildCollectionSearchQuery_cql.test.js: Query builder CQL2 tests ## Technical Notes ### ESM Compatibility The cql2-wasm package is an ES Module. To maintain compatibility with Jest (CommonJS), the module is loaded via dynamic import() instead of require(). This allows the WASM to be initialized lazily when first needed. ### SQL Injection Prevention All CQL2 filters are converted to parameterized queries with $1, $2, etc. placeholders. Values are passed separately to pg-pool, preventing injection. ## Conformance Classes Implemented - http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2 - http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators - http://www.opengis.net/spec/cql2/1.0/conf/cql2-json - http://www.opengis.net/spec/cql2/1.0/conf/cql2-text - http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-functions - http://www.opengis.net/spec/cql2/1.0/conf/spatial-functions - http://www.opengis.net/spec/cql2/1.0/conf/temporal-functions ## Dependencies Added - cql2-wasm@0.4.2: WASM-based CQL2 parser from cql2-rs * added different users for the api and crawler groups. The api has read-only acces and the crawler user full acces to the database. The acces to the database is now possible by using the users `stac_api`or `stac_crawler`. The admin user (`postgres_user`) is still available but shouldn't be used * Refactor(database): SQL trigger definitions for catalog and collection keywords. Moved triggers to 06_triggers.sql for better organization, as they depend on the respective tables created in earlier scripts. * added source_url for collections and catalogs. Now the full_json doesn`t has to be used for getting the url * resolved a Problem I had with git by hand cause I didn't found the function * to be stac conform the stac_id is not allowed to throw an error when asking for a string. So the stac_id in the database is saved as a TEXT and no longer as a INTEGER * Implemented structured Error-Handling, Error-Messaging and Error-Logging incl. request tracking - all according to RFC 7807 (#213) * feat(api): implement RFC 7807 error handling with request tracking Implement standardized error responses and global error handler to improve API error reporting and debugging capabilities. Resolves API 7.1 (Implement Error Response Format) #119 Resolves API 7.3 (Implement Global Error Handler) #121 Changes: - Add RFC 7807 Problem Details error response format * Standard fields: type, title, status, detail, instance, requestId * Backwards compatibility: maintained code/description fields * Error type URIs: https://stacspec.org/errors/{code} - Implement request ID tracking system * UUID v4 generation for request tracing * Support for client-provided X-Request-ID header * Request ID included in all error responses - Add global error handler with intelligent logging * Severity-based logging (500+: full details, 400+: basic info) * Error message sanitization (removes passwords, tokens, secrets) * Production-safe error messages - Update error responses across codebase * validateCollectionSearch: InvalidParameterValue errors * validateCollectionId: InvalidParameter errors * collections route: NotFound errors * 404 handler: throw errors instead of direct response - Add comprehensive error handler test suite * RFC 7807 compliance validation * Request ID generation and propagation * Error code consistency checks * Message sanitization verification * Removed old mock-data `/api/data/collections.js` as it is no longer used * Added Docker-Setup for API-Component (#246) * database connection in implementated. The parameters for the connection have to added in the .env-file. Also there is test-file for testing and console messages (installed `pg`) * support for spatial queries via postgis + error handling for datatbase operations changed language to english * error handling * added DATABASE_URL There is an issue with the distance query. Changed the error handling and testing, the console messages are now way better structured * found the Problem with the distance query. The layer are so big, that they reach over the 180° long (PostgGIS can't handel that). Now the calc is done by degree and not meters. * The two files `test-data-retrieval.js` and `verify-schema.js` have been added. `test-data-retrieval` (theoretical, checks against the spezification): ``` Discovers all tables and columns and validates against expected schema. ``` The second files `verify-schema.js` (practical, checks against the real data): ``` Discovers all tables and columns, validates against expected schema ``` * pooling error hanling and log imporoved. renamed tests files to actual test-files * standalone node tests were convertad into JEST * write file `validateRequest.js`. Validates every incoming API request, whether the request is valid and logical. * commented `stac_id` from the tests, it is not in both databases, so the tests for `stac_id` will always fail Added explanation to the `.env.example`, which port is which database * added example pattern for API - database connection. * deleted `validateRequest` cause it's already implemented by @robinGummels * added everything related to containerisation for the API-component. The docker compose starts the whole API folder and starts the whole API funcunality * Deleted `./api/example/README.md` because the same file already exists under a different name in `./api/docs/` --------- Co-authored-by: Sönke Hoffmann Co-authored-by: RobinGummels * Implement rate limiting middleware and update README with rate limit details (#253) * Enhance full-text search by including keywords in the tsvector expression and update related tests * Revert "Enhance full-text search by including keywords in the tsvector expression and update related tests" This reverts commit 872443d8e83c55834ef5f0d275c54fefb4b74e2d. * Enhance full-text search by including keywords in the tsvector expression and update related tests * Revert "Enhance full-text search by including keywords in the tsvector expression and update related tests" This reverts commit 872443d8e83c55834ef5f0d275c54fefb4b74e2d. * Enhance full-text search by including keywords in the tsvector expression and update related tests * Revert "Enhance full-text search by including keywords in the tsvector expression and update related tests" This reverts commit 872443d8e83c55834ef5f0d275c54fefb4b74e2d. * Enhance full-text search by including keywords in the tsvector expression and update related tests * Revert "Enhance full-text search by including keywords in the tsvector expression and update related tests" This reverts commit 872443d8e83c55834ef5f0d275c54fefb4b74e2d. * Implement rate limiting middleware and update README with rate limit details * Fixed wrong Errorhandling. Now Ratelimit-Errors will be handled the same, as all other Errors according to RFC7807 * Removed sample Errorresponse in `README.md`. --------- Co-authored-by: RobinGummels * STAC API Validator Compliance, Tests Alignment, and CI Integration (#216) * Add STAC API Validator workflow and enhance collection retrieval logic - Introduced a new CI job for STAC API validation in the GitHub Actions workflow. - Updated collection retrieval endpoints to support both numeric and string IDs. - Improved validation middleware for collection IDs to ensure proper formatting and length. - Enhanced test cases for collection endpoints to reflect new validation rules and response structures. - Added documentation for STAC API Validator results. * refactor(api): Remove unused cqlFilter parameter from buildCollectionSearchQuery function * feat(api): Add falsely removed cqlFilter parameter back to buildCollectionSearchQuery function * feat(api): Add queryables schema for STAC Atlas collections * Add 'parent' link check in collections test Add test to check for 'parent' link in collections response * Update api/__tests__/collections-id.test.js removed (non-numeric) as its outdated Co-authored-by: Robin Tammo Gummels * Remove test case for negative id 404 response Removed test for non-existing negative id. * Update api/middleware/validateCollectionId.js Co-authored-by: Robin Tammo Gummels * Update api/middleware/validateCollectionId.js Co-authored-by: Robin Tammo Gummels * Update api/middleware/validateCollectionId.js Co-authored-by: Robin Tammo Gummels * Add validation tests for collection ID format Added tests for validation of collection IDs including length, invalid characters, and empty/whitespace cases. * Add TODOs for full_json and extent handling Added TODO comments for future database schema updates. * Change error code from 'InvalidParameter' to 'NotFound' * Fix error messages for id parameter validation * Correct error response in validateCollectionId Fix error response structure in validateCollectionId middleware. * move helper function from collections.js out of block * Disable parent link test in collections response Comment out the test for 'parent' link in collections response. * bugfix Change error response from 400 to 404 for invalid parameter * Update validation to return error response Return a 400 status with an error response instead of calling next() when validation fails. * Fix duplicate error response in validateCollectionId * Fixed: Incorrect middelware handling (missed `return next()`) and added `parent` link into the response of `GET /collections` and brought back helperfunction inside of the old base-code-block. --------- Co-authored-by: Robin Tammo Gummels * Dev docker: container to start every component container at once (#259) * added docker-compose for the whole project. Via the docker-comand include every docker-sompose from the under-foldrs can be started. * changed structure of the docker-compose. Now the dontainer is more resistant against issues * Database: Docker config for using the same network, STAC_ID now unique and stac_url is now in collections (#262) * Add .gitignore for environment and dependencies; update docker-compose to include network configuration * Update docker-compose.yml to rename network from 'stac_network' to 'stac-network' for consistency. * db(change) source_url is now in collection and stac_id is now unique * type is no longer needes, since we only need to save collections * added is_valid. So we can save every collection be can also be stac_conform since we can easily sort by valid collections * Database: Added source_url in crawllog (#274) * Add .gitignore for environment and dependencies; update docker-compose to include network configuration * Update docker-compose.yml to rename network from 'stac_network' to 'stac-network' for consistency. * db(change) source_url is now in collection and stac_id is now unique * database(feature) Enhance crawllog_collection table: make collection_id optional and add source_url field for identifying collections not in the collection table. Update documentation accordingly and create an index on source_url for improved query performance. * changed the whole structure of the catalogs. Now the catalogs can be seen as a crawlog for teh crawler. The crawllog can be now used in case the crawling gets cancelled and the crawler can use the crawllog for its own * We asked Mohr on the handling with valid and unvalid collection. We don't need to save the unvalid collections, so also don't need the collumn that points that out * the reference for the crawllog_collections weren't right --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Robin Tammo Gummels Co-authored-by: JonasK <156602337+BrokeJ@users.noreply.github.com> Co-authored-by: Vincent Kühn Co-authored-by: mammutor Co-authored-by: Humam <44206081+Mammutor@users.noreply.github.com> --- .github/workflows/api-ci.yml | 120 +- api/.dockerignore | 4 + api/Dockerfile | 20 + api/README.md | 123 +- api/__tests__/api.test.js | 20 +- .../buildCollectionSearchQuery_cql.test.js | 55 + api/__tests__/collectionSearch.test.js | 58 +- api/__tests__/collections-id.test.js | 41 +- api/__tests__/collections-pagination.test.js | 31 +- api/__tests__/cql2.integration.test.js | 288 +++ api/__tests__/cql2ToSql.test.js | 221 ++ api/__tests__/errorHandler.test.js | 127 ++ api/__tests__/rateLimit.test.js | 41 + api/app.js | 35 +- api/config/conformanceURIS.js | 22 +- api/config/queryablesSchema.js | 172 ++ api/data/collections.js | 105 - api/db/buildCollectionSearchQuery.js | 37 +- api/docker-compose.yml | 13 + api/docs/cql2-filtering.md | 382 ++++ .../how-to-database-integration.md} | 0 api/docs/stac-api-validator.md | 21 + api/middleware/errorHandler.js | 107 + api/middleware/rateLimit.js | 31 + api/middleware/requestId.js | 35 + api/middleware/validateCollectionId.js | 64 +- api/middleware/validateCollectionSearch.js | 38 +- api/package-lock.json | 2016 ++++++++++++++++- api/package.json | 5 + api/routes/collections.js | 198 +- api/utils/cql2.js | 65 + api/utils/cql2ToSql.js | 204 ++ api/utils/errorResponse.js | 226 ++ api/validators/collectionSearchParams.js | 31 +- bid.md | 5 +- crawler/.gitignore | 2 +- db/docker-compose.yml | 3 + db/init/02_tables_catalog.sql | 112 +- db/init/03_tables_collections.sql | 11 +- db/init/04_relation_tables.sql | 16 +- db/init/05_indexes.sql | 19 - db/init/06_triggers.sql | 6 - db/package-lock.json | 6 + docker-compose.yml | 34 + 44 files changed, 4614 insertions(+), 556 deletions(-) create mode 100644 api/.dockerignore create mode 100644 api/Dockerfile create mode 100644 api/__tests__/buildCollectionSearchQuery_cql.test.js create mode 100644 api/__tests__/cql2.integration.test.js create mode 100644 api/__tests__/cql2ToSql.test.js create mode 100644 api/__tests__/errorHandler.test.js create mode 100644 api/__tests__/rateLimit.test.js create mode 100644 api/config/queryablesSchema.js delete mode 100644 api/data/collections.js create mode 100644 api/docker-compose.yml create mode 100644 api/docs/cql2-filtering.md rename api/{examples/README.md => docs/how-to-database-integration.md} (100%) create mode 100644 api/docs/stac-api-validator.md create mode 100644 api/middleware/errorHandler.js create mode 100644 api/middleware/rateLimit.js create mode 100644 api/middleware/requestId.js create mode 100644 api/utils/cql2.js create mode 100644 api/utils/cql2ToSql.js create mode 100644 api/utils/errorResponse.js create mode 100644 db/package-lock.json create mode 100644 docker-compose.yml diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index 1b49ad6..60ecc95 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -179,8 +179,118 @@ jobs: cd api timeout 10s npm start || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; fi continue-on-error: false - - # Job 3: Security Audit + + # Job 3: STAC API Validator + stac_validator: + name: STAC API Validator (core + collections) + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: 'npm' + cache-dependency-path: api/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Create .env file + working-directory: api + run: | + cat > .env << EOF + # Server Configuration + PORT=3000 + NODE_ENV=test + + # Database Configuration + DB_HOST=${{ secrets.DB_HOST }} + DB_PORT=${{ secrets.DB_PORT }} + DB_NAME=${{ secrets.DB_NAME }} + DB_USER=${{ secrets.DB_USER }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_SSL=false + + # Connection Pool Configuration + DB_POOL_MAX=20 + DB_POOL_MIN=2 + DB_IDLE_TIMEOUT=30000 + DB_CONNECTION_TIMEOUT=10000 + + # CORS Configuration + CORS_ORIGIN=* + + # API Configuration + API_TITLE=STAC Atlas + API_DESCRIPTION=A centralized platform for managing, indexing, and providing STAC Collection metadata + API_VERSION=1.1.0 + EOF + + - name: Install Node dependencies + working-directory: api + run: npm ci + + - name: Install STAC API Validator + run: | + python -m pip install --upgrade pip + python -m pip install stac-api-validator + + - name: Start API server + working-directory: api + run: | + # Start server in background + npm start > server.log 2>&1 & + echo $! > server.pid + + # Wait until landing page responds + for i in {1..30}; do + if curl -fsS http://localhost:3000/ > /dev/null; then + echo "API is up" + exit 0 + fi + sleep 1 + done + + echo "API did not start in time" + echo "---- server.log ----" + tail -n 200 server.log || true + exit 1 + + - name: Run STAC API Validator (core + collections) + run: | + python -m stac_api_validator \ + --root-url "http://localhost:3000/" \ + --conformance core \ + --conformance collections \ + --collection 1 \ + --verbose | tee stac-validator-output.txt + + - name: Upload validator output + uses: actions/upload-artifact@v4 + if: always() + with: + name: stac-validator-output + path: stac-validator-output.txt + retention-days: 30 + + - name: Stop API server + if: always() + working-directory: api + run: | + if [ -f server.pid ]; then + kill "$(cat server.pid)" || true + fi + echo "---- server.log (tail) ----" + tail -n 200 server.log || true + + # Job 4: Security Audit security: name: Security Audit runs-on: ubuntu-latest @@ -202,17 +312,17 @@ jobs: npm audit --audit-level=moderate continue-on-error: true - # Job 4: Status-Check for Branch Protection + # Job 5: Status-Check for Branch Protection ci-success: name: CI Success runs-on: ubuntu-latest - needs: [test, build] + needs: [test, build, stac_validator, security] if: always() steps: - name: Check all jobs succeeded run: | - if [ "${{ needs.test.result }}" != "success" ] || [ "${{ needs.build.result }}" != "success" ]; then + if [ "${{ needs.test.result }}" != "success" ] || [ "${{ needs.build.result }}" != "success" ] || [ "${{ needs.stac_validator.result }}" != "success" ] || [ "${{ needs.security.result }}" != "success" ]; then echo "CI Pipeline failed!" echo "Test status: ${{ needs.test.result }}" echo "Build status: ${{ needs.build.result }}" diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..5ce57aa --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +.DS_Store +.env diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..dcac0bd --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,20 @@ +# Use Node.js 22 based on package.json engines +FROM node:22-alpine + +# Set working directory inside the container +WORKDIR /app + +# Copy package.json and package-lock.json first (for caching dependencies) +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy the rest of the application code +COPY . . + +# Expose the API port +EXPOSE 3000 + +# Start the application +CMD ["npm", "start"] diff --git a/api/README.md b/api/README.md index c337643..f4b0bea 100644 --- a/api/README.md +++ b/api/README.md @@ -67,6 +67,14 @@ This project uses GitHub Actions for Continuous Integration: **Status:** ![CI Status](https://github.com/SpatioCore/STAC-Atlas/workflows/API%20CI%2FCD%20Pipeline/badge.svg?branch=dev-api) +## 🚦 Rate Limiting + +All API endpoints are protected by rate limiting: + +- **Limit:** 1000 requests per 15 minutes per IP address +- If the limit is exceeded, HTTP status **429 Too Many Requests** is returned +- The headers `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` are set + ## 📋 API Endpoints ### Core Endpoints @@ -107,6 +115,40 @@ GET /collections?limit=20&sortby=-created&token=2 📖 **Detailed documentation:** See [docs/collection-search-parameters.md](docs/collection-search-parameters.md) +### CQL2 Filtering (GET /collections) + +The API supports advanced filtering using the Common Query Language 2 (CQL2) standard. Both CQL2-Text and CQL2-JSON encodings are supported. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `filter` | String | CQL2 filter expression | +| `filter-lang` | String | Filter language: `cql2-text` (default) or `cql2-json` | + +**Supported Operators:** +- **Comparison:** `=`, `<`, `>`, `<=`, `>=`, `<>`, `BETWEEN`, `IN`, `IS NULL` +- **Logical:** `AND`, `OR`, `NOT` +- **Spatial:** `S_INTERSECTS`, `S_WITHIN`, `S_CONTAINS` +- **Temporal:** `T_INTERSECTS`, `T_BEFORE`, `T_AFTER` + +**Examples:** +```bash +# Filter by license (note: string literals require single quotes) +GET /collections?filter=license = 'MIT' + +# Combined filters +GET /collections?filter=license = 'CC-BY-4.0' AND title LIKE '%Sentinel%' + +# Spatial filter with GeoJSON +GET /collections?filter-lang=cql2-json&filter={"op":"s_intersects","args":[{"property":"spatial_extend"},{"type":"Polygon","coordinates":[[[7,51],[8,51],[8,52],[7,52],[7,51]]]}]} + +# Temporal filter +GET /collections?filter-lang=cql2-json&filter={"op":"t_intersects","args":[{"property":"datetime"},{"interval":["2020-01-01","2025-12-31"]}]} +``` + +⚠️ **Important:** In CQL2-Text, string literals must be enclosed in single quotes (`'MIT'`), not bare words (`MIT`) as they will be interpreted as propertys. + +📖 **Detailed documentation:** See [docs/cql2-filtering.md](docs/cql2-filtering.md) + ### API Documentation - **Swagger UI**: `http://localhost:3000/api-docs` (if `docs/openapi.yaml` exists) @@ -160,8 +202,57 @@ This API implements: - ✅ OGC API Features Core - ✅ STAC Collections - ✅ Collection Search Extension -- 🚧 CQL2 Basic Filtering (in development) -- 🚧 CQL2 Advanced Operators (in development) +- ✅ CQL2 Basic Filtering (comparison, logical operators) +- ✅ CQL2 Advanced Comparison Operators (between, in, isNull) +- ✅ CQL2 Spatial Functions (s_intersects, s_within, s_contains) +- ✅ CQL2 Temporal Functions (t_intersects, t_before, t_after) +- ✅ CQL2-Text and CQL2-JSON encodings + +### STAC API Validator + +The API can be tested using the official [STAC API Validator](https://github.com/stac-utils/stac-api-validator): + +#### Installation + +```bash +# Python 3.11 required +pip install stac-api-validator +``` + +#### Usage + +```bash +# Validate Core Conformance Class +python -m stac_api_validator --root-url http://localhost:3000 --conformance core + +# Validate Collections Extension (requires collection ID) +python -m stac_api_validator \ + --root-url http://localhost:3000 \ + --conformance core \ + --conformance collections \ + --collection + +# With spatial filtering (requires geometry in dataset) +python -m stac_api_validator \ + --root-url http://localhost:3000 \ + --conformance core \ + --conformance collections \ + --collection \ + --geometry '{"type": "Polygon", "coordinates": [[[7.0, 51.0], [8.0, 51.0], [8.0, 52.0], [7.0, 52.0], [7.0, 51.0]]]}' +``` + +#### Validation Status + +| Conformance Class | Status | Date | Errors | Warnings | +|-------------------|--------|------|--------|----------| +| **STAC API - Core** | ✅ Passed | 2025-12-10 | 0 | 0 | +| STAC API - Collections | ⏳ Pending | - | - | - | +| STAC API - Features | ⏳ Pending | - | - | - | +| STAC API - Item Search | ⏳ Pending | - | - | - | +| CQL2 - Basic | ⏳ Pending | - | - | - | +| CQL2 - Advanced | ⏳ Pending | - | - | - | + +**Note:** The Collection Search Extension is not currently validated automatically by the validator and is instead validated through custom Jest integration tests (see `__tests__/`). ### STAC API Validator @@ -213,28 +304,28 @@ python -m stac_api_validator \ ### TODO -- [ ] Database integration (PostgreSQL + PostGIS) - - [ ] Implement q (full-text search with TSVector) - - [ ] Implement bbox (PostGIS spatial queries) - - [ ] Implement datetime (temporal overlap queries) - - [ ] Implement sortby (ORDER BY in SQL) -- [ ] CQL2 parser integration (cql2-rs via WASM) +- [x] Database integration (PostgreSQL + PostGIS) + - [x] Implement q (full-text search with TSVector) + - [x] Implement bbox (PostGIS spatial queries) + - [x] Implement datetime (temporal overlap queries) + - [x] Implement sortby (ORDER BY in SQL) +- [x] CQL2 parser integration (cql2-rs via WASM) - [ ] Implement controller layer - [ ] Service layer for business logic - [ ] Complete OpenAPI documentation -- [ ] Advanced tests (integration, E2E) - - [ ] Unit tests for validators - - [ ] Integration tests for filtered queries +- [x] Advanced tests (integration, E2E) + - [x] Unit tests for validators + - [x] Integration tests for filtered queries - [ ] Docker setup -- [ ] CI/CD pipeline +- [x] CI/CD pipeline ### Implementation Plan (see bid.md) 1. ✅ **AP-01**: Project skeleton & infrastructure 2. ✅ **AP-02**: Query parameter validation (q, bbox, datetime, limit, sortby, token) -3. 🚧 **AP-03**: STAC core endpoints (baseline implemented) -4. 🚧 **AP-04**: Collection search – filter implementation (DB integration pending) -5. ⏳ **AP-05**: CQL2 filtering integration +3. ✅ **AP-03**: STAC core endpoints (implemented) +4. ✅ **AP-04**: Collection search – filter implementation (DB integration complete) +5. ✅ **AP-05**: CQL2 filtering integration (Basic, Advanced, Spatial, Temporal) ## 📄 License @@ -242,4 +333,4 @@ Apache-2.0 ## 👥 Team -STAC Atlas API Team — Robin (Team lead), Jonas, George, Vincent +STAC Atlas API Team — Robin (Team lead), Jonas, Vincent diff --git a/api/__tests__/api.test.js b/api/__tests__/api.test.js index ad4b867..9cc0058 100644 --- a/api/__tests__/api.test.js +++ b/api/__tests__/api.test.js @@ -71,23 +71,23 @@ describe('STAC API Core Endpoints', () => { }); describe('GET /collections', () => { - it('should return a FeatureCollection structure', async () => { + it('should return a STAC Collections response', async () => { const response = await request(app).get('/collections').expect(200); - expect(response.body).toHaveProperty('type', 'FeatureCollection'); + expect(response.body).toHaveProperty('collections'); expect(response.body).toHaveProperty('links'); - expect(response.body).toHaveProperty('context'); expect(Array.isArray(response.body.collections)).toBe(true); + expect(Array.isArray(response.body.links)).toBe(true); }); - it('should include pagination context', async () => { - const response = await request(app).get('/collections').expect(200); - - expect(response.body.context).toHaveProperty('returned'); - expect(response.body.context).toHaveProperty('limit'); - expect(response.body.context).toHaveProperty('matched'); - }); + it('should include required link relations', async () => { + const response = await request(app).get('/collections').expect(200); + const rels = response.body.links.map(l => l.rel); + expect(rels).toContain('self'); + expect(rels).toContain('root'); + expect(rels).toContain('parent'); + }); }); describe('GET /queryables', () => { diff --git a/api/__tests__/buildCollectionSearchQuery_cql.test.js b/api/__tests__/buildCollectionSearchQuery_cql.test.js new file mode 100644 index 0000000..14bac99 --- /dev/null +++ b/api/__tests__/buildCollectionSearchQuery_cql.test.js @@ -0,0 +1,55 @@ +const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); + +describe('buildCollectionSearchQuery with CQL2', () => { + test('integrates CQL2 filter for license correctly', () => { + const params = { + cqlFilter: { + sql: "c.license = $1", + values: ['MIT'] + } + }; + + const { sql, values } = buildCollectionSearchQuery(params); + + // Check if WHERE clause contains the CQL SQL + expect(sql).toContain("WHERE"); + expect(sql).toContain("(c.license = $1)"); + + // Check values + expect(values).toEqual(['MIT']); + }); + + test('integrates CQL2 filter with title and license', () => { + const params = { + cqlFilter: { + sql: "(c.title = $1 AND c.license = $2)", + values: ['Sentinel-2', 'CC-BY-4.0'] + } + }; + + const { sql, values } = buildCollectionSearchQuery(params); + + expect(sql).toContain("WHERE"); + expect(sql).toContain("(c.title = $1 AND c.license = $2)"); + expect(values).toEqual(['Sentinel-2', 'CC-BY-4.0']); + }); + + test('integrates CQL2 filter with other params and re-indexes placeholders', () => { + const params = { + license: 'proprietary', + cqlFilter: { + sql: "c.title = $1", + values: ['My Collection'] + } + }; + + const { sql, values } = buildCollectionSearchQuery(params); + + // License is processed first, so it takes $1 + // CQL filter should be re-indexed to $2 + expect(sql).toContain("c.license = $1"); + expect(sql).toContain("(c.title = $2)"); + + expect(values).toEqual(['proprietary', 'My Collection']); + }); +}); diff --git a/api/__tests__/collectionSearch.test.js b/api/__tests__/collectionSearch.test.js index 25c16c5..3637d47 100644 --- a/api/__tests__/collectionSearch.test.js +++ b/api/__tests__/collectionSearch.test.js @@ -14,10 +14,10 @@ describe('Collection Search API - Query Parameters', () => { .get('/collections') .expect(200); - expect(response.body).toHaveProperty('type', 'FeatureCollection'); + expect(response.body).toHaveProperty('collections'); - expect(response.body).toHaveProperty('context'); - expect(response.body.context.limit).toBe(10); // default limit + expect(response.body).toHaveProperty('links'); + }); it('should accept valid limit parameter', async () => { @@ -25,8 +25,12 @@ describe('Collection Search API - Query Parameters', () => { .get('/collections?limit=5') .expect(200); - expect(response.body.context.limit).toBe(5); + expect(response.body.collections.length).toBeLessThanOrEqual(5); + const self = response.body.links.find(l => l.rel === 'self'); + expect(self).toBeDefined(); + const url = new URL(self.href); + expect(url.searchParams.get('limit')).toBe('5'); }); it('should accept valid token parameter', async () => { @@ -42,8 +46,12 @@ describe('Collection Search API - Query Parameters', () => { .get('/collections?limit=3&token=0') .expect(200); - expect(response.body.context.limit).toBe(3); + expect(response.body.collections.length).toBeLessThanOrEqual(3); + const self = response.body.links.find(l => l.rel === 'self'); + expect(self).toBeDefined(); + const url = new URL(self.href); + expect(url.searchParams.get('limit')).toBe('3'); }); it('should accept valid q parameter', async () => { @@ -83,8 +91,12 @@ describe('Collection Search API - Query Parameters', () => { .get('/collections?q=test&limit=5&sortby=%2Btitle') .expect(200); - expect(response.body.context.limit).toBe(5); + expect(response.body).toHaveProperty('collections'); + const self = response.body.links.find(l => l.rel === 'self'); + expect(self).toBeDefined(); + const url = new URL(self.href); + expect(url.searchParams.get('limit')).toBe('5'); }); // ========== Limit Parameter Validation ========== @@ -292,7 +304,10 @@ describe('Collection Search API - Query Parameters', () => { .expect(200); expect(response.body.collections.length).toBeLessThanOrEqual(2); - expect(response.body.context.limit).toBe(2); + const self = response.body.links.find(l => l.rel === 'self'); + expect(self).toBeDefined(); + const url = new URL(self.href); + expect(url.searchParams.get('limit')).toBe('2'); }); it('should include next link when more results available', async () => { @@ -303,10 +318,9 @@ describe('Collection Search API - Query Parameters', () => { const links = response.body.links; const nextLink = links.find(link => link.rel === 'next'); - // Only check for next link if there are more items than limit - if (response.body.context.matched > response.body.context.limit) { - expect(nextLink).toBeDefined(); - expect(nextLink.href).toContain('token='); + if (nextLink) { + const url = new URL(nextLink.href); + expect(url.searchParams.get('token')).not.toBeNull(); } }); @@ -335,18 +349,7 @@ describe('Collection Search API - Query Parameters', () => { expect(selfLink.href).toContain('token=10'); }); - it('should return context with correct counts', async () => { - const response = await request(app) - .get('/collections?limit=3') - .expect(200); - - const context = response.body.context; - expect(context).toHaveProperty('returned'); - expect(context).toHaveProperty('limit', 3); - expect(context).toHaveProperty('matched'); - expect(context.returned).toBeLessThanOrEqual(context.limit); - expect(context.returned).toBeLessThanOrEqual(context.matched); - }); + it('should handle token beyond available results', async () => { const response = await request(app) @@ -354,7 +357,8 @@ describe('Collection Search API - Query Parameters', () => { .expect(200); expect(response.body.collections).toHaveLength(0); - expect(response.body.context.returned).toBe(0); + const returned = response.body.collections.length; + expect(returned).toBe(0); }); }); @@ -366,14 +370,8 @@ describe('Collection Search API - Query Parameters', () => { .expect(200); expect(response.body).toMatchObject({ - type: 'FeatureCollection', collections: expect.any(Array), links: expect.any(Array), - context: { - returned: expect.any(Number), - limit: expect.any(Number), - matched: expect.any(Number) - } }); }); diff --git a/api/__tests__/collections-id.test.js b/api/__tests__/collections-id.test.js index 63f89c8..7b37a2f 100644 --- a/api/__tests__/collections-id.test.js +++ b/api/__tests__/collections-id.test.js @@ -17,7 +17,29 @@ describe('GET /collections/:id - Single collection retrieval', () => { return res.body.collections[0].id; } + + test('should return 400 for too long collection id', async () => { + const tooLong = 'a'.repeat(257); + const res = await request(app).get(`/collections/${tooLong}`).expect(400); + + expect(res.body.code).toBeDefined(); + expect(res.body.description).toMatch(/too long/i); + }); + test('should return 400 for collection id with invalid characters', async () => { + const res = await request(app).get('/collections/vege!tation').expect(400); + + expect(res.body.code).toBeDefined(); + expect(res.body.description).toMatch(/invalid characters/i); + }); + + test('should return 400 for empty/whitespace collection id', async () => { + const res = await request(app).get('/collections/%20').expect(400); + + expect(res.body.code).toBeDefined(); + expect(res.body.description).toMatch(/required/i); + }); + test('should return a single collection with matching id and STAC-style links', async () => { const existingId = await getAnyExistingCollectionId(); @@ -51,25 +73,14 @@ describe('GET /collections/:id - Single collection retrieval', () => { expect(selfLink.href).toContain(`/collections/${existingId}`); }); - test('should return 400 for an invalid (non-numeric) id', async () => { + test('should return 404 for non-existing collection id', async () => { const res = await request(app) .get('/collections/not-a-number') - .expect(400); + .expect(404); - expect(res.body).toHaveProperty('code', 'InvalidParameter'); - expect(res.body.description).toMatch(/id/i); + expect(res.body).toHaveProperty('code', 'NotFound'); + expect(res.body).toHaveProperty('id', 'not-a-number'); }); - - test('should return 400 for a negative id', async () => { - const negativeId = '-1'; - - const res = await request(app) - .get(`/collections/${encodeURIComponent(negativeId)}`) - .expect(400); - - expect(res.body).toHaveProperty('code', 'InvalidParameter'); - expect(res.body.description).toMatch(/id/i); -}) test('should return 404 for a non-existing numeric id', async () => { // use a very large id that is unlikely to exist diff --git a/api/__tests__/collections-pagination.test.js b/api/__tests__/collections-pagination.test.js index 4133a97..36dc919 100644 --- a/api/__tests__/collections-pagination.test.js +++ b/api/__tests__/collections-pagination.test.js @@ -24,8 +24,8 @@ describe('Collection Search - Pagination behavior (4.5 Implement Pagination)', ( .expect(200); expect(response.body.collections.length).toBe(2); - expect(response.body.context.returned).toBe(2); - expect(response.body.context.matched).toBeGreaterThanOrEqual(2); + // returned == collections.length + expect(response.body.collections.length).toBeLessThanOrEqual(2); }); /** @@ -70,19 +70,23 @@ describe('Collection Search - Pagination behavior (4.5 Implement Pagination)', ( }); /** - * Test 4: matched remains constant regardless of limit/token + * Test 4: Self-Link should inhabit parameters used */ it('matched should reflect total results, not paginated results', async () => { - const full = await request(app) - .get('/collections') - .expect(200); + const full = await request(app) + .get('/collections') + .expect(200); - const paginated = await request(app) - .get('/collections?limit=1&token=0') - .expect(200); + const paginated = await request(app) + .get('/collections?limit=1&token=0') + .expect(200); + + const self = paginated.body.links.find(l => l.rel === 'self'); + expect(self).toBeDefined(); - expect(paginated.body.context.matched).toBe(full.body.context.matched); - expect(paginated.body.context.returned).toBe(1); + const url = new URL(self.href); + expect(url.searchParams.get('limit')).toBe('1'); + expect(url.searchParams.get('token')).toBe('0'); }); /** @@ -93,7 +97,8 @@ describe('Collection Search - Pagination behavior (4.5 Implement Pagination)', ( .get('/collections') .expect(200); - const tooHighToken = full.body.context.matched + 50; + const total = full.body.collections.length; + const tooHighToken = total + 10; const response = await request(app) .get(`/collections?limit=5&token=${tooHighToken}`) @@ -101,7 +106,7 @@ describe('Collection Search - Pagination behavior (4.5 Implement Pagination)', ( // Should return empty or very few results expect(response.body.collections.length).toBeLessThanOrEqual(5); - expect(response.body.context.returned).toBe(response.body.collections.length); + expect(response.body.collections.length).toBe(response.body.collections.length); }); /** diff --git a/api/__tests__/cql2.integration.test.js b/api/__tests__/cql2.integration.test.js new file mode 100644 index 0000000..688ae33 --- /dev/null +++ b/api/__tests__/cql2.integration.test.js @@ -0,0 +1,288 @@ +// __tests__/cql2.integration.test.js + +/** + * Integration tests for CQL2 filtering with database queries. + * Uses query() directly like buildCollectionSearchQuery.integration.test.js + */ + +const { query, closePool } = require('../db/db_APIconnection'); +const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); +const { cql2ToSql } = require('../utils/cql2ToSql'); + +describe('CQL2 Filter Integration Tests', () => { + afterAll(async () => { + await closePool(); + }); + + describe('CQL2 to SQL Conversion', () => { + + test('should convert license filter with string literal', () => { + // This is what cql2-wasm produces for: license = 'MIT' + const cql = { op: '=', args: [{ property: 'license' }, 'MIT'] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.license = $1'); + expect(values).toEqual(['MIT']); + }); + + test('should convert title filter', () => { + const cql = { op: '=', args: [{ property: 'title' }, 'Sentinel Data'] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.title = $1'); + expect(values).toEqual(['Sentinel Data']); + }); + + test('should convert numeric id filter', () => { + const cql = { op: '=', args: [{ property: 'id' }, 1] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.id = $1'); + expect(values).toEqual([1]); + }); + + test('should convert AND operator', () => { + const cql = { + op: 'and', + args: [ + { op: '=', args: [{ property: 'license' }, 'MIT'] }, + { op: '=', args: [{ property: 'title' }, 'Test'] } + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('(c.license = $1 AND c.title = $2)'); + expect(values).toEqual(['MIT', 'Test']); + }); + + test('should convert OR operator', () => { + const cql = { + op: 'or', + args: [ + { op: '=', args: [{ property: 'license' }, 'MIT'] }, + { op: '=', args: [{ property: 'license' }, 'Apache-2.0'] } + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('(c.license = $1 OR c.license = $2)'); + expect(values).toEqual(['MIT', 'Apache-2.0']); + }); + }); + + describe('Extended Column Mappings', () => { + + test('should map all core collection fields', () => { + const fields = ['id', 'stac_version', 'type', 'title', 'description', + 'license', 'created_at', 'updated_at', 'is_api', 'is_active']; + + fields.forEach(field => { + const cql = { op: '=', args: [{ property: field }, 'test'] }; + const values = []; + const sql = cql2ToSql(cql, values); + expect(sql).toContain(`c.${field}`); + }); + }); + + test('should map aggregated fields to correct aliases', () => { + const mappings = { + 'keywords': 'kw.keywords', + 'stac_extensions': 'ext.stac_extensions', + 'providers': 'prov.providers', + 'assets': 'a.assets', + 'summaries': 's.summaries', + 'last_crawled': 'cl.last_crawled' + }; + + Object.entries(mappings).forEach(([prop, expected]) => { + const cql = { op: '=', args: [{ property: prop }, 'test'] }; + const values = []; + const sql = cql2ToSql(cql, values); + expect(sql).toContain(expected); + }); + }); + + test('should map aliases correctly', () => { + expect(cql2ToSql({ op: '=', args: [{ property: 'created' }, 'x'] }, [])) + .toBe('c.created_at = $1'); + expect(cql2ToSql({ op: '=', args: [{ property: 'updated' }, 'x'] }, [])) + .toBe('c.updated_at = $1'); + expect(cql2ToSql({ op: '=', args: [{ property: 'collection' }, 'x'] }, [])) + .toBe('c.id = $1'); + }); + }); + + describe('Spatial Operators', () => { + + test('should convert s_intersects with GeoJSON', () => { + const geojson = { type: 'Polygon', coordinates: [[[0,0],[1,0],[1,1],[0,1],[0,0]]] }; + const cql = { op: 's_intersects', args: [{ property: 'spatial_extend' }, geojson] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toContain('ST_Intersects'); + expect(sql).toContain('ST_GeomFromGeoJSON'); + expect(values[0]).toBe(JSON.stringify(geojson)); + }); + + test('should convert s_within with GeoJSON', () => { + const geojson = { type: 'Polygon', coordinates: [[[0,0],[1,0],[1,1],[0,1],[0,0]]] }; + const cql = { op: 's_within', args: [{ property: 'spatial_extend' }, geojson] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toContain('ST_Within'); + }); + + test('should convert s_contains with GeoJSON', () => { + const geojson = { type: 'Point', coordinates: [10, 50] }; + const cql = { op: 's_contains', args: [{ property: 'spatial_extend' }, geojson] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toContain('ST_Contains'); + }); + }); + + describe('Temporal Operators', () => { + + test('should convert t_intersects with interval', () => { + const cql = { + op: 't_intersects', + args: [ + { property: 'datetime' }, + { interval: ['2020-01-01', '2025-12-31'] } + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toContain('temporal_extend_start'); + expect(sql).toContain('temporal_extend_end'); + expect(values).toContain('2020-01-01'); + expect(values).toContain('2025-12-31'); + }); + + test('should convert t_before', () => { + const cql = { op: 't_before', args: [{ property: 'created_at' }, '2025-01-01'] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.created_at < $1'); + expect(values).toEqual(['2025-01-01']); + }); + + test('should convert t_after', () => { + const cql = { op: 't_after', args: [{ property: 'updated_at' }, '2024-01-01'] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.updated_at > $1'); + expect(values).toEqual(['2024-01-01']); + }); + }); + + describe('Database Query Execution with CQL2', () => { + + test('should execute license filter query successfully', async () => { + const cqlFilter = { + sql: 'c.license = $1', + values: ['CC-BY-4.0'] + }; + + const { sql, values } = buildCollectionSearchQuery({ + cqlFilter, + limit: 10, + token: 0 + }); + + const result = await query(sql, values); + expect(result.rows).toBeDefined(); + expect(Array.isArray(result.rows)).toBe(true); + + // All returned collections should have the filtered license + result.rows.forEach(row => { + expect(row.license).toBe('CC-BY-4.0'); + }); + }); + + test('should execute combined CQL2 and standard filters', async () => { + const cqlFilter = { + sql: 'c.is_active = $1', + values: [true] + }; + + const { sql, values } = buildCollectionSearchQuery({ + cqlFilter, + limit: 5, + token: 0 + }); + + const result = await query(sql, values); + expect(result.rows).toBeDefined(); + + result.rows.forEach(row => { + expect(row.is_active).toBe(true); + }); + }); + + test('should execute OR filter query', async () => { + // Build CQL2 filter for: license = 'MIT' OR license = 'Apache-2.0' + const cql = { + op: 'or', + args: [ + { op: '=', args: [{ property: 'license' }, 'MIT'] }, + { op: '=', args: [{ property: 'license' }, 'Apache-2.0'] } + ] + }; + const filterValues = []; + const filterSql = cql2ToSql(cql, filterValues); + + const { sql, values } = buildCollectionSearchQuery({ + cqlFilter: { sql: filterSql, values: filterValues }, + limit: 10, + token: 0 + }); + + const result = await query(sql, values); + expect(result.rows).toBeDefined(); + + result.rows.forEach(row => { + expect(['MIT', 'Apache-2.0']).toContain(row.license); + }); + }); + + test('should return correct structure with CQL2 filter', async () => { + const cqlFilter = { + sql: 'c.id > $1', + values: [0] + }; + + const { sql, values } = buildCollectionSearchQuery({ + cqlFilter, + limit: 3, + token: 0 + }); + + const result = await query(sql, values); + + if (result.rows.length > 0) { + const row = result.rows[0]; + // Core fields + expect(row).toHaveProperty('id'); + expect(row).toHaveProperty('title'); + expect(row).toHaveProperty('license'); + // Aggregated fields + expect(row).toHaveProperty('keywords'); + expect(row).toHaveProperty('providers'); + expect(row).toHaveProperty('assets'); + } + }); + }); +}); + diff --git a/api/__tests__/cql2ToSql.test.js b/api/__tests__/cql2ToSql.test.js new file mode 100644 index 0000000..8a3fb0e --- /dev/null +++ b/api/__tests__/cql2ToSql.test.js @@ -0,0 +1,221 @@ +const { cql2ToSql } = require('../utils/cql2ToSql'); + +describe('cql2ToSql', () => { + describe('Basic Operators', () => { + test('converts simple equality for title', () => { + const cql = { op: '=', args: [{ property: 'title' }, 'My Collection'] }; + const values = []; + const sql = cql2ToSql(cql, values); + expect(sql).toBe("c.title = $1"); + expect(values).toEqual(['My Collection']); + }); + + test('converts simple equality for license', () => { + const cql = { op: '=', args: [{ property: 'license' }, 'MIT'] }; + const values = []; + const sql = cql2ToSql(cql, values); + expect(sql).toBe("c.license = $1"); + expect(values).toEqual(['MIT']); + }); + + test('converts logical AND with title and license', () => { + const cql = { + op: 'and', + args: [ + { op: '=', args: [{ property: 'license' }, 'CC-BY-4.0'] }, + { op: '=', args: [{ property: 'title' }, 'Sentinel Data'] } + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + expect(sql).toBe("(c.license = $1 AND c.title = $2)"); + expect(values).toEqual(['CC-BY-4.0', 'Sentinel Data']); + }); + + test('converts logical OR for multiple IDs', () => { + const cql = { + op: 'or', + args: [ + { op: '=', args: [{ property: 'id' }, 'sentinel-2-l2a'] }, + { op: '=', args: [{ property: 'id' }, 'landsat-8-c2-l2'] } + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + expect(sql).toBe("(c.id = $1 OR c.id = $2)"); + expect(values).toEqual(['sentinel-2-l2a', 'landsat-8-c2-l2']); + }); + + test('converts IN operator for license values', () => { + const cql = { + op: 'in', + args: [ + { property: 'license' }, + ['MIT', 'Apache-2.0', 'CC-BY-4.0'] + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + expect(sql).toBe("c.license IN ($1, $2, $3)"); + expect(values).toEqual(['MIT', 'Apache-2.0', 'CC-BY-4.0']); + }); + + test('maps unknown properties to full_json JSONB column', () => { + const cql = { op: '=', args: [{ property: 'custom_field' }, 'some_value'] }; + const values = []; + const sql = cql2ToSql(cql, values); + expect(sql).toBe("c.full_json ->> 'custom_field' = $1"); + expect(values).toEqual(['some_value']); + }); + }); + + describe('Extended Column Mappings', () => { + test('maps all core collection fields', () => { + const mappings = { + 'id': 'c.id', + 'stac_version': 'c.stac_version', + 'type': 'c.type', + 'title': 'c.title', + 'description': 'c.description', + 'license': 'c.license', + 'spatial_extend': 'c.spatial_extend', + 'temporal_extend_start': 'c.temporal_extend_start', + 'temporal_extend_end': 'c.temporal_extend_end', + 'created_at': 'c.created_at', + 'updated_at': 'c.updated_at', + 'is_api': 'c.is_api', + 'is_active': 'c.is_active' + }; + + Object.entries(mappings).forEach(([prop, expected]) => { + const values = []; + const sql = cql2ToSql({ op: '=', args: [{ property: prop }, 'x'] }, values); + expect(sql).toBe(`${expected} = $1`); + }); + }); + + test('maps aggregated fields to LATERAL JOIN aliases', () => { + const mappings = { + 'keywords': 'kw.keywords', + 'stac_extensions': 'ext.stac_extensions', + 'providers': 'prov.providers', + 'assets': 'a.assets', + 'summaries': 's.summaries', + 'last_crawled': 'cl.last_crawled' + }; + + Object.entries(mappings).forEach(([prop, expected]) => { + const values = []; + const sql = cql2ToSql({ op: '=', args: [{ property: prop }, 'x'] }, values); + expect(sql).toBe(`${expected} = $1`); + }); + }); + + test('maps common aliases', () => { + expect(cql2ToSql({ op: '=', args: [{ property: 'created' }, 'x'] }, [])) + .toBe('c.created_at = $1'); + expect(cql2ToSql({ op: '=', args: [{ property: 'updated' }, 'x'] }, [])) + .toBe('c.updated_at = $1'); + expect(cql2ToSql({ op: '=', args: [{ property: 'collection' }, 'x'] }, [])) + .toBe('c.id = $1'); + }); + }); + + describe('Spatial Operators', () => { + test('converts s_intersects with GeoJSON polygon', () => { + const geojson = { type: 'Polygon', coordinates: [[[0,0],[1,0],[1,1],[0,1],[0,0]]] }; + const cql = { op: 's_intersects', args: [{ property: 'spatial_extend' }, geojson] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe("ST_Intersects(c.spatial_extend, ST_GeomFromGeoJSON($1))"); + expect(values).toEqual([JSON.stringify(geojson)]); + }); + + test('converts s_within with GeoJSON polygon', () => { + const geojson = { type: 'Polygon', coordinates: [[[-10,-10],[10,-10],[10,10],[-10,10],[-10,-10]]] }; + const cql = { op: 's_within', args: [{ property: 'spatial_extend' }, geojson] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe("ST_Within(c.spatial_extend, ST_GeomFromGeoJSON($1))"); + expect(values).toEqual([JSON.stringify(geojson)]); + }); + + test('converts s_contains with GeoJSON point', () => { + const geojson = { type: 'Point', coordinates: [10, 50] }; + const cql = { op: 's_contains', args: [{ property: 'spatial_extend' }, geojson] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe("ST_Contains(c.spatial_extend, ST_GeomFromGeoJSON($1))"); + expect(values).toEqual([JSON.stringify(geojson)]); + }); + }); + + describe('Temporal Operators', () => { + test('converts t_intersects with closed interval', () => { + const cql = { + op: 't_intersects', + args: [ + { property: 'datetime' }, + { interval: ['2020-01-01', '2025-12-31'] } + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toContain('temporal_extend_start'); + expect(sql).toContain('temporal_extend_end'); + expect(values).toEqual(['2020-01-01', '2025-12-31']); + }); + + test('converts t_intersects with open start interval', () => { + const cql = { + op: 't_intersects', + args: [ + { property: 'temporal_extend' }, + { interval: ['..', '2025-12-31'] } + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.temporal_extend_start <= $1'); + expect(values).toEqual(['2025-12-31']); + }); + + test('converts t_intersects with open end interval', () => { + const cql = { + op: 't_intersects', + args: [ + { property: 'datetime' }, + { interval: ['2020-01-01', '..'] } + ] + }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.temporal_extend_end >= $1'); + expect(values).toEqual(['2020-01-01']); + }); + + test('converts t_before', () => { + const cql = { op: 't_before', args: [{ property: 'created_at' }, '2025-01-01'] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.created_at < $1'); + expect(values).toEqual(['2025-01-01']); + }); + + test('converts t_after', () => { + const cql = { op: 't_after', args: [{ property: 'updated_at' }, '2024-01-01'] }; + const values = []; + const sql = cql2ToSql(cql, values); + + expect(sql).toBe('c.updated_at > $1'); + expect(values).toEqual(['2024-01-01']); + }); + }); +}); diff --git a/api/__tests__/errorHandler.test.js b/api/__tests__/errorHandler.test.js new file mode 100644 index 0000000..2e151ca --- /dev/null +++ b/api/__tests__/errorHandler.test.js @@ -0,0 +1,127 @@ +const request = require('supertest'); +const app = require('../app'); + +describe('Error Handler Integration Tests', () => { + describe('RFC 7807 Error Response Format', () => { + test('400 errors should include RFC 7807 fields', async () => { + const response = await request(app) + .get('/collections?limit=-1') + .expect(400); + + // RFC 7807 standard fields + expect(response.body).toHaveProperty('type'); + expect(response.body).toHaveProperty('title'); + expect(response.body).toHaveProperty('status', 400); + expect(response.body).toHaveProperty('detail'); + expect(response.body).toHaveProperty('instance'); + expect(response.body).toHaveProperty('requestId'); + + // Backwards compatibility fields + expect(response.body).toHaveProperty('code'); + expect(response.body).toHaveProperty('description'); + }); + + test('404 errors should include RFC 7807 fields', async () => { + const response = await request(app) + .get('/nonexistent') + .expect(404); + + expect(response.body).toHaveProperty('type'); + expect(response.body).toHaveProperty('title'); + expect(response.body).toHaveProperty('status', 404); + expect(response.body).toHaveProperty('detail'); + expect(response.body).toHaveProperty('requestId'); + expect(response.body.code).toBe('NotFound'); + }); + }); + + describe('Request ID Tracking', () => { + test('should generate request ID if not provided', async () => { + const response = await request(app) + .get('/collections?limit=1') + .expect(200); + + expect(response.headers['x-request-id']).toBeDefined(); + expect(response.headers['x-request-id']).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + }); + + test('should use client-provided request ID', async () => { + const clientRequestId = 'test-request-123'; + + const response = await request(app) + .get('/collections?limit=1') + .set('X-Request-ID', clientRequestId) + .expect(200); + + expect(response.headers['x-request-id']).toBe(clientRequestId); + }); + + test('should include request ID in error responses', async () => { + const clientRequestId = 'error-test-456'; + + const response = await request(app) + .get('/collections?limit=-1') + .set('X-Request-ID', clientRequestId) + .expect(400); + + expect(response.body.requestId).toBe(clientRequestId); + }); + }); + + describe('Error Code Consistency', () => { + test('InvalidParameterValue for validation errors', async () => { + const response = await request(app) + .get('/collections?limit=0') + .expect(400); + + expect(response.body.code).toBe('InvalidParameterValue'); + }); + + test('InvalidParameter for malformed parameters', async () => { + const response = await request(app) + .get('/collections/not-a-number') + .expect(404); + + expect(response.body.code).toBe('NotFound'); + }); + + test('NotFound for missing resources', async () => { + const response = await request(app) + .get('/collections/999999999') + .expect(404); + + expect(response.body.code).toBe('NotFound'); + }); + }); + + describe('Error Message Sanitization', () => { + test('should include descriptive error messages', async () => { + const response = await request(app) + .get('/collections?limit=-5') + .expect(400); + + expect(response.body.description).toContain('limit'); + expect(response.body.description).toContain('at least 1'); + }); + + test('should combine multiple validation errors', async () => { + const response = await request(app) + .get('/collections?limit=0&token=-5') + .expect(400); + + expect(response.body.description).toContain('limit'); + expect(response.body.description).toContain('token'); + }); + }); + + describe('Instance Path', () => { + test('should include request path in error response', async () => { + const response = await request(app) + .get('/collections?limit=-1') + .expect(400); + + expect(response.body.instance).toContain('/collections'); + expect(response.body.instance).toContain('limit=-1'); + }); + }); +}); diff --git a/api/__tests__/rateLimit.test.js b/api/__tests__/rateLimit.test.js new file mode 100644 index 0000000..af6f778 --- /dev/null +++ b/api/__tests__/rateLimit.test.js @@ -0,0 +1,41 @@ +const request = require('supertest'); +const app = require('../app'); + +describe('Rate Limiting Middleware', () => { + it('should allow requests under the limit', async () => { + // Send a single request, should not be rate limited + const res = await request(app).get('/'); + expect(res.status).not.toBe(429); + }); + + it('should return 429 after exceeding the rate limit', async () => { + // Use a unique IP for isolation + const agent = request.agent(app); + let lastRes; + // Send requests up to the limit + for (let i = 0; i < 1000; i++) { + lastRes = await agent.get('/').set('X-Forwarded-For', '1.2.3.4'); + } + // The next request should be rate limited + const res = await agent.get('/').set('X-Forwarded-For', '1.2.3.4'); + expect(res.status).toBe(429); + expect(res.body).toHaveProperty('status', 429); + expect(res.body.title).toMatch(/too many requests/i); + }); + + it('should reset the limit after the window', async () => { + jest.useFakeTimers(); + const agent = request.agent(app); + for (let i = 0; i < 1000; i++) { + await agent.get('/').set('X-Forwarded-For', '5.6.7.8'); + } + let res = await agent.get('/').set('X-Forwarded-For', '5.6.7.8'); + expect(res.status).toBe(429); + // Advance time by 15 minutes + jest.advanceTimersByTime(15 * 60 * 1000); + res = await agent.get('/').set('X-Forwarded-For', '5.6.7.8'); + // Should be allowed again + expect(res.status).not.toBe(429); + jest.useRealTimers(); + }); +}); diff --git a/api/app.js b/api/app.js index a5b0393..be1c1d7 100644 --- a/api/app.js +++ b/api/app.js @@ -6,6 +6,11 @@ const swaggerUi = require('swagger-ui-express'); const YAML = require('yamljs'); const path = require('path'); +// Import middleware +const { requestIdMiddleware } = require('./middleware/requestId'); +const { globalErrorHandler } = require('./middleware/errorHandler'); +const { rateLimitMiddleware } = require('./middleware/rateLimit'); + // Import routes const indexRouter = require('./routes/index'); const conformanceRouter = require('./routes/conformance'); @@ -14,6 +19,13 @@ const queryablesRouter = require('./routes/queryables'); const app = express(); +// Request ID middleware (must be first) +app.use(requestIdMiddleware); + +// Global rate limiting middleware +// Limits each IP to 1000 requests per 15 minutes +app.use(rateLimitMiddleware); + // Middleware app.use(logger('dev')); app.use(express.json()); @@ -58,24 +70,15 @@ app.use('/conformance', conformanceRouter); app.use('/collections', collectionsRouter); app.use('/queryables', queryablesRouter); -// 404 handler +// 404 handler - must be after all routes app.use((req, res, next) => { - res.status(404).json({ - code: 'NotFound', - description: `The requested resource '${req.url}' was not found on this server.` - }); + const error = new Error(`The requested resource '${req.originalUrl}' was not found on this server.`); + error.status = 404; + error.code = 'NotFound'; + next(error); }); -// Error handler -app.use((err, req, res, next) => { - // Set locals, only providing error in development - const isDev = req.app.get('env') === 'development'; - - res.status(err.status || 500).json({ - code: err.code || 'InternalServerError', - description: err.message || 'An internal server error occurred', - ...(isDev && { stack: err.stack }) - }); -}); +// Global error handler - must be last +app.use(globalErrorHandler); module.exports = app; \ No newline at end of file diff --git a/api/config/conformanceURIS.js b/api/config/conformanceURIS.js index 2154168..5065505 100644 --- a/api/config/conformanceURIS.js +++ b/api/config/conformanceURIS.js @@ -5,19 +5,29 @@ // - GET /conformance const CONFORMANCE_URIS = [ + // STAC API Core 'https://api.stacspec.org/v1.0.0/core', 'https://api.stacspec.org/v1.0.0/collections', + // Collection Search conformance classes 'https://api.stacspec.org/v1.0.0/collection-search', 'http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/simple-query', // Simple Query (bbox, datetime, limit) 'https://api.stacspec.org/v1.0.0-rc.1/collection-search#free-text', // Free-text search - 'https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter', // CQL2 Filter' + 'https://api.stacspec.org/v1.0.0-rc.1/collection-search#filter', // CQL2 Filter 'https://api.stacspec.org/v1.1.0/collection-search#sort', // Sorting - // CQL2 conformance classes - "http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2", // Basic CQL2 - "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json", // CQL2 JSON-Querys - "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text", // CQL2 Text-Querys - "http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-functions" // Basic Spatial Functions + + // CQL2 Basic conformance classes + 'http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2', // Basic CQL2 (=, <, >, <=, >=, <>, and, or, not) + 'http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators', // between, in, isNull + 'http://www.opengis.net/spec/cql2/1.0/conf/cql2-json', // CQL2 JSON encoding + 'http://www.opengis.net/spec/cql2/1.0/conf/cql2-text', // CQL2 Text encoding + + // CQL2 Spatial conformance classes + 'http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-functions', // s_intersects + 'http://www.opengis.net/spec/cql2/1.0/conf/spatial-functions', // s_within, s_contains, etc. + + // CQL2 Temporal conformance classes + 'http://www.opengis.net/spec/cql2/1.0/conf/temporal-functions' // t_intersects, t_before, t_after ]; module.exports = { diff --git a/api/config/queryablesSchema.js b/api/config/queryablesSchema.js new file mode 100644 index 0000000..014fe1b --- /dev/null +++ b/api/config/queryablesSchema.js @@ -0,0 +1,172 @@ +// config/queryablesSchema.js +/** + * Queryables Schema for STAC Atlas (Collections) + * + * - Defined from bid.md (required search facets) + actual CQL2→SQL support + * - Explicitly documents supported operators per field (vendor extension) + * + * Notes: + * - Operator lists are expressed via `x-ogc-operators` (explicit contract; non-standard, but clear). + * - Some fields (keywords/providers/stac_extensions) are required by bid, but need explicit SQL semantics + * to be fully filterable via CQL2. We keep them queryable but mark limitations honestly. + */ + +function buildCollectionsQueryablesSchema(baseUrl) { + const cleanBase = String(baseUrl || '').replace(/\/+$/, ''); + const schemaId = `${cleanBase}/collections-queryables`; + + // Operator sets based on utils/cql2ToSql.js + const OPS_STRING_BASIC = ['=', '<>', 'isNull']; + const OPS_STRING_ADV = ['=', '<>', 'in', 'between', 'isNull']; // only if you implement semantics correctly per field + const OPS_TEMPORAL = ['t_intersects', 't_before', 't_after', 'between', 'isNull']; + const OPS_SPATIAL = ['s_intersects', 's_within', 's_contains', 'isNull']; + + // For keywords/providers/extensions we include them because bid expects them, + // but filtering semantics must be implemented explicitly (e.g., EXISTS / jsonb operators). + // Until implemented, we only claim `isNull` as truly safe. + const OPS_ARRAY_PLANNED = ['isNull']; // upgrade later to ['in','isNull'] once semantics are implemented + + // Minimal GeoJSON Geometry schema (enough for queryables docs) + const GEOJSON_GEOMETRY = { + type: 'object', + required: ['type', 'coordinates'], + properties: { + type: { + type: 'string', + enum: [ + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', + 'GeometryCollection' + ] + }, + coordinates: {}, + geometries: { type: 'array', items: {} } + }, + additionalProperties: true + }; + + return { + $schema: 'https://json-schema.org/draft/2019-09/schema', + $id: schemaId, + type: 'object', + title: 'STAC Atlas Collections Queryables', + description: + 'Queryable properties for STAC Atlas Collection Search. This JSON Schema defines the properties that can be referenced in CQL2 filters and documents supported operators per field.', + additionalProperties: true, + + properties: { + /** + * Core fields mentioned/implicitly required for collection search + */ + id: { + title: 'Collection ID', + description: 'STAC Collection identifier.', + type: 'string', + 'x-ogc-operators': OPS_STRING_BASIC + }, + title: { + title: 'Title', + description: 'Human-readable title of the collection.', + type: 'string', + 'x-ogc-operators': OPS_STRING_BASIC + }, + description: { + title: 'Description', + description: 'Human-readable description of the collection.', + type: 'string', + 'x-ogc-operators': OPS_STRING_BASIC + }, + license: { + title: 'License', + description: 'License identifier/name of the collection.', + type: 'string', + 'x-ogc-operators': OPS_STRING_BASIC + }, + + /** + * keywords, providers + * TODO: explicit SQL semantics for full CQL2 filtering beyond isNull. + */ + keywords: { + title: 'Keywords', + description: + 'Keywords/tags of the collection. Planned semantics: keyword membership filtering (e.g., keywords IN (...)).', + type: 'array', + items: { type: 'string' }, + 'x-ogc-operators': OPS_ARRAY_PLANNED, + 'x-implementation-status': + 'Filtering semantics beyond isNull require explicit SQL (array/jsonb membership).' + }, + providers: { + title: 'Providers', + description: + 'Providers associated with the collection. Planned semantics: provider name membership filtering (e.g., providers IN (...), matching provider.name).', + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: true + }, + 'x-ogc-operators': OPS_ARRAY_PLANNED, + 'x-implementation-status': + 'Filtering semantics beyond isNull require explicit SQL (jsonb array elements / join).' + }, + + /** + * Spatial / Temporal constraints (bbox, datetime, spatial_extend, temporal operators) + */ + 'extent.spatial.bbox': { + title: 'Extent Spatial BBox', + description: + 'STAC extent spatial bbox (informational). For CQL2 spatial operators, prefer spatial_extend with GeoJSON geometry literals.', + type: 'array', + items: { type: 'number' }, + 'x-ogc-operators': OPS_SPATIAL + }, + spatial_extend: { + title: 'Spatial Extent Geometry', + description: + 'Collection spatial extent geometry (used for CQL2 spatial operators s_intersects/s_within/s_contains). Provide GeoJSON geometry literals in the filter.', + ...GEOJSON_GEOMETRY, + 'x-ogc-operators': OPS_SPATIAL + }, + 'extent.temporal.interval': { + title: 'Extent Temporal Interval', + description: + 'STAC temporal interval (informational). For CQL2 temporal operators, use datetime/temporal filtering.', + type: 'array', + 'x-ogc-operators': OPS_TEMPORAL + }, + datetime: { + title: 'Datetime', + description: + 'Datetime or interval used for temporal filtering. Supports CQL2 temporal operators (t_intersects/t_before/t_after).', + type: 'string', + 'x-ogc-operators': OPS_TEMPORAL, + 'x-format-hint': 'ISO8601 timestamp or interval (start/end)' + }, + + /** + * Include stac_extensions as queryable. + */ + stac_extensions: { + title: 'STAC Extensions', + description: + 'List of STAC extensions used by the collection (e.g., EO, SAR, Point Cloud). Planned semantics: membership filtering.', + type: 'array', + items: { type: 'string' }, + 'x-ogc-operators': OPS_ARRAY_PLANNED, + 'x-implementation-status': + 'Filtering semantics beyond isNull require explicit SQL (array/jsonb membership).' + } + } + }; +} + +module.exports = { buildCollectionsQueryablesSchema }; \ No newline at end of file diff --git a/api/data/collections.js b/api/data/collections.js deleted file mode 100644 index e58bcbf..0000000 --- a/api/data/collections.js +++ /dev/null @@ -1,105 +0,0 @@ -// Small in-memory sample of collections for basic GET /collections implementation -// -// This file is intentionally simple and used only for local testing and -// unit-tests. Each entry represents a minimal STAC Collection-like object -// containing common STAC fields (id, title, description, keywords, extent, etc). -// In a production deployment this should be replaced by a database query -// that returns fully validated STAC Collection objects. -module.exports = [ - { - id: 'sentinel-2-l2a', - stac_version: '1.0.0', - type: 'Collection', - title: 'Sentinel-2 L2A Collection', - description: 'Sentinel-2 Level-2A processed imagery from Copernicus', - keywords: ['sentinel-2', 'optical', 'multispectral'], - license: 'CC-BY-4.0', - providers: [ - { - name: 'ESA', - roles: ['producer', 'licensor'], - url: 'https://www.esa.int/' - } - ], - extent: { - spatial: { bbox: [[-180, -90, 180, 90]] }, - temporal: { interval: [['2015-06-23T00:00:00Z', null]] } - }, - links: [ - { - rel: 'self', - href: 'https://example.com/collections/sentinel-2-l2a', - type: 'application/json' - }, - { - rel: 'parent', - href: 'https://example.com/', - type: 'application/json' - } - ] - }, - { - id: 'landsat-8-l1', - stac_version: '1.0.0', - type: 'Collection', - title: 'Landsat 8 Level-1', - description: 'Landsat 8 Collection 1 Level 1 data', - keywords: ['landsat', 'optical', 'multispectral'], - license: 'CC0-1.0', - providers: [ - { - name: 'USGS', - roles: ['producer'], - url: 'https://www.usgs.gov/' - } - ], - extent: { - spatial: { bbox: [[-180, -90, 180, 90]] }, - temporal: { interval: [['2013-02-11T00:00:00Z', null]] } - }, - links: [ - { - rel: 'self', - href: 'https://example.com/collections/landsat-8-l1', - type: 'application/json' - }, - { - rel: 'parent', - href: 'https://example.com/', - type: 'application/json' - } - ] - }, - { - id: 'modis', - stac_version: '1.0.0', - type: 'Collection', - title: 'MODIS Daily', - description: 'MODIS daily composites from NASA Earth Observatories', - keywords: ['modis', 'daily', 'thermal', 'visible'], - license: 'CC0-1.0', - providers: [ - { - name: 'NASA', - roles: ['producer', 'licensor'], - url: 'https://www.nasa.gov/' - } - ], - extent: { - spatial: { bbox: [[-180, -90, 180, 90]] }, - temporal: { interval: [['2000-02-24T00:00:00Z', null]] } - }, - links: [ - { - rel: 'self', - href: 'https://example.com/collections/modis', - type: 'application/json' - }, - { - rel: 'parent', - href: 'https://example.com/', - type: 'application/json' - } - ] - } -]; diff --git a/api/db/buildCollectionSearchQuery.js b/api/db/buildCollectionSearchQuery.js index 064a7d1..c76e538 100644 --- a/api/db/buildCollectionSearchQuery.js +++ b/api/db/buildCollectionSearchQuery.js @@ -66,6 +66,11 @@ * * @param {string|undefined} params.license * License identifier to filter collections by `collection.license`. + * + * @param {{sql: string, values: any[]}|undefined} params.cqlFilter + * Pre-parsed CQL2 filter SQL fragment and values. + * The SQL fragment uses 1-based placeholders ($1, $2...) relative to its own values. + * This function will re-index them to match the main query's parameter sequence. * * @returns {{ sql: string, values: any[] }} * sql – complete parameterized SQL string @@ -82,7 +87,9 @@ function buildCollectionSearchQuery(params) { license, sortby, limit, - token + token, + collectionId, + cqlFilter } = params; // Base SELECT columns. We may append a relevance `rank` column below when `q` is present. @@ -104,6 +111,10 @@ function buildCollectionSearchQuery(params) { c.description, c.license, c.spatial_extend, + ST_XMin(c.spatial_extend) AS minx, + ST_YMin(c.spatial_extend) AS miny, + ST_XMax(c.spatial_extend) AS maxx, + ST_YMax(c.spatial_extend) AS maxy, c.temporal_extend_start, c.temporal_extend_end, c.created_at, @@ -124,11 +135,17 @@ function buildCollectionSearchQuery(params) { let i = 1; if (id !== undefined && id !== null) { - where.push(`id = $${i}`); + where.push(`c.id = $${i}`); values.push(id); i++; } + if (collectionId !== undefined && collectionId !== null && collectionId !== '') { + where.push(`c.full_json->>'id' = $${i}`); + values.push(collectionId); + i += 1; + } + // Full-text search using weighted tsvector across title (weight A) and description (weight B). // // Notes: @@ -226,6 +243,22 @@ function buildCollectionSearchQuery(params) { i++; } + // CQL2 Filter + if (cqlFilter && cqlFilter.sql) { + // Re-index placeholders in cqlFilter.sql + // Current index is i. + // cqlFilter.sql has $1, $2... + // We need to replace $1 with $i, $2 with $(i+1)... + + const reindexedSql = cqlFilter.sql.replace(/\$(\d+)/g, (match, num) => { + return '$' + (parseInt(num) + i - 1); + }); + + where.push(`(${reindexedSql})`); + values.push(...cqlFilter.values); + i += cqlFilter.values.length; + } + // Build final SQL from selectPart and add FROM clause with LATERAL JOINs. // We delayed adding `FROM collection` to allow conditional additions to the // selected columns above (notably `rank`). The final `sql` string includes the diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 0000000..cc9b9bb --- /dev/null +++ b/api/docker-compose.yml @@ -0,0 +1,13 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + volumes: + - .:/app + - /app/node_modules + env_file: .env + environment: + - PORT=3000 diff --git a/api/docs/cql2-filtering.md b/api/docs/cql2-filtering.md new file mode 100644 index 0000000..6a7a727 --- /dev/null +++ b/api/docs/cql2-filtering.md @@ -0,0 +1,382 @@ +# CQL2 Filtering + +This document describes the Common Query Language 2 (CQL2) filtering capabilities supported by the STAC Atlas Collection Search API (`GET /collections`). + +## Overview + +CQL2 is an OGC standard for expressing filter expressions. The STAC Atlas API supports both CQL2-Text (human-readable) and CQL2-JSON (machine-readable) encodings for filtering collections based on their properties. + +## Query Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `filter` | String | - | CQL2 filter expression | +| `filter-lang` | String | `cql2-text` | Filter language: `cql2-text` or `cql2-json` | + +--- + +## CQL2-Text Syntax + +CQL2-Text is a human-readable format for expressing filter conditions. + +### Basic Syntax Rules + +1. **String literals** must be enclosed in **single quotes**: `'value'` +2. **Property names** are written without quotes: `license`, `title` +3. **Operators** are case-insensitive: `AND`, `and`, `And` are equivalent +4. **Parentheses** can be used to group expressions + +**Common Mistake:** Forgetting single quotes around string literals. + +``` +Correct: license = 'MIT' +Wrong: license = MIT (MIT is interpreted as a property reference) +``` + +--- + +## Supported Operators + +### Comparison Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `=` | Equal to | `license = 'MIT'` | +| `<>` | Not equal to | `license <> 'proprietary'` | +| `<` | Less than | `id < 100` | +| `>` | Greater than | `id > 50` | +| `<=` | Less than or equal | `id <= 100` | +| `>=` | Greater than or equal | `id >= 1` | + +**Examples:** +``` +GET /collections?filter=license = 'MIT' +GET /collections?filter=id >= 10 +GET /collections?filter=title = 'Sentinel-2 L2A' +``` + +--- + +### Logical Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `AND` | Both conditions must be true | `license = 'MIT' AND id < 100` | +| `OR` | At least one condition must be true | `license = 'MIT' OR license = 'Apache-2.0'` | +| `NOT` | Negates a condition | `NOT license = 'proprietary'` | + +**Examples:** +``` +GET /collections?filter=license = 'CC-BY-4.0' AND title LIKE '%Sentinel%' +GET /collections?filter=id = 1 OR id = 2 OR id = 3 +GET /collections?filter=NOT is_active = false +``` + +--- + +### Advanced Comparison Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `BETWEEN` | Value is within range (inclusive) | `id BETWEEN 10 AND 50` | +| `IN` | Value is in a list | `license IN ('MIT', 'Apache-2.0', 'CC-BY-4.0')` | +| `IS NULL` | Value is null | `description IS NULL` | + +**Examples:** +``` +GET /collections?filter=id BETWEEN 1 AND 100 +GET /collections?filter=license IN ('MIT', 'CC0-1.0', 'CC-BY-4.0') +GET /collections?filter=title IS NULL +``` + +--- + +### Spatial Operators + +Spatial operators compare geometry properties against GeoJSON geometries. These use PostGIS functions internally. + +| Operator | PostGIS Function | Description | +|----------|------------------|-------------| +| `S_INTERSECTS` | `ST_Intersects` | Geometries share any space | +| `S_WITHIN` | `ST_Within` | First geometry is completely within second | +| `S_CONTAINS` | `ST_Contains` | First geometry completely contains second | + +**CQL2-JSON Examples:** + +```json +// S_INTERSECTS: Find collections intersecting a bounding box +{ + "op": "s_intersects", + "args": [ + { "property": "spatial_extend" }, + { + "type": "Polygon", + "coordinates": [[[7, 51], [8, 51], [8, 52], [7, 52], [7, 51]]] + } + ] +} +``` + +**HTTP Request:** +```bash +GET /collections?filter-lang=cql2-json&filter={"op":"s_intersects","args":[{"property":"spatial_extend"},{"type":"Polygon","coordinates":[[[7,51],[8,51],[8,52],[7,52],[7,51]]]}]} +``` + +**Note:** Spatial operators are primarily used with CQL2-JSON encoding due to the complexity of GeoJSON geometry literals. + +--- + +### Temporal Operators + +Temporal operators compare datetime properties against timestamps or intervals. + +| Operator | Description | +|----------|-------------| +| `T_INTERSECTS` | Temporal extents overlap | +| `T_BEFORE` | Property value is before the given timestamp | +| `T_AFTER` | Property value is after the given timestamp | + +**Interval Syntax:** + +- Closed interval: `["2020-01-01", "2025-12-31"]` +- Open start: `["..", "2025-12-31"]` (all times up to end) +- Open end: `["2020-01-01", ".."]` (all times from start) + +**CQL2-JSON Examples:** + +```json +// T_INTERSECTS: Collections overlapping 2020-2025 +{ + "op": "t_intersects", + "args": [ + { "property": "datetime" }, + { "interval": ["2020-01-01", "2025-12-31"] } + ] +} + +// T_BEFORE: Collections created before 2024 +{ + "op": "t_before", + "args": [ + { "property": "created_at" }, + "2024-01-01T00:00:00Z" + ] +} + +// T_AFTER: Collections updated after 2023 +{ + "op": "t_after", + "args": [ + { "property": "updated_at" }, + "2023-01-01T00:00:00Z" + ] +} +``` + +--- + +## Queryable Properties + +The following properties can be used in CQL2 filter expressions: + +### Core Collection Properties + +| Property | Type | Description | +|----------|------|-------------| +| `id` | Integer | Collection database ID | +| `stac_version` | String | STAC specification version | +| `type` | String | Always "Collection" | +| `title` | String | Collection title | +| `description` | String | Collection description | +| `license` | String | License identifier (e.g., "MIT", "CC-BY-4.0") | +| `spatial_extend` | Geometry | Spatial bounding box (for spatial operators) | +| `temporal_extend_start` | Timestamp | Start of temporal extent | +| `temporal_extend_end` | Timestamp | End of temporal extent | +| `created_at` | Timestamp | Creation timestamp | +| `updated_at` | Timestamp | Last update timestamp | +| `is_api` | Boolean | Whether collection has an API | +| `is_active` | Boolean | Whether collection is active | + +### Aggregated Properties + +| Property | Type | Description | +|----------|------|-------------| +| `keywords` | Array | Collection keywords | +| `stac_extensions` | Array | STAC extensions used | +| `providers` | Array | Data providers | +| `assets` | Array | Collection assets | +| `summaries` | Object | Property summaries | +| `last_crawled` | Timestamp | Last crawler update | + +### Aliases + +| Alias | Maps To | +|-------|---------| +| `datetime` | `temporal_extend_start` / `temporal_extend_end` | +| `temporal_extend` | `temporal_extend_start` / `temporal_extend_end` | +| `created` | `created_at` | +| `updated` | `updated_at` | +| `collection` | `id` | + +### Custom Properties + +Properties not in the above lists are queried from the `full_json` JSONB column if possible: + +``` +GET /collections?filter=custom_property = 'some_value' +``` + +This translates to: `c.full_json ->> 'custom_property' = 'some_value'` + +--- + +## CQL2-JSON Format + +CQL2-JSON is a structured JSON format for filter expressions. + +### Structure + +```json +{ + "op": "", + "args": [, , ...] +} +``` + +### Property References + +```json +{ "property": "license" } +``` + +### Literal Values + +- Strings: `"MIT"` +- Numbers: `42`, `3.14` +- Booleans: `true`, `false` +- Null: `null` + +### Examples + +**Simple equality:** +```json +{ + "op": "=", + "args": [{ "property": "license" }, "MIT"] +} +``` + +**Logical AND:** +```json +{ + "op": "and", + "args": [ + { "op": "=", "args": [{ "property": "license" }, "CC-BY-4.0"] }, + { "op": "=", "args": [{ "property": "type" }, "Collection"] } + ] +} +``` + +**IN operator:** +```json +{ + "op": "in", + "args": [ + { "property": "license" }, + ["MIT", "Apache-2.0", "CC-BY-4.0"] + ] +} +``` + +--- + +## Combining CQL2 with Other Parameters + +CQL2 filters can be combined with standard query parameters: + +```bash +# CQL2 filter + bbox + limit + sorting +GET /collections?filter=license = 'MIT'&bbox=-10,40,10,50&limit=20&sortby=-created +``` + +The filters are combined with AND logic internally. + +--- + +## Error Handling + +### Invalid CQL2 Syntax + +```json +{ + "code": "InvalidParameterValue", + "description": "Invalid CQL2 Text: Expected operator at position 15" +} +``` + +### Unsupported Operator + +```json +{ + "code": "InvalidParameterValue", + "description": "CQL2 filter error: Unsupported CQL2 operator: like_regex" +} +``` + +--- + +## Implementation Details + +### WASM Parser + +The API uses [cql2-wasm](https://github.com/stac-utils/cql2-rs) (Rust compiled to WebAssembly) to parse CQL2 expressions: + +1. CQL2-Text is parsed to CQL2-JSON using `parseText()` +2. CQL2-JSON is validated using `parseJson()` +3. The JSON AST is converted to PostgreSQL WHERE clauses using `cql2ToSql()` + +### SQL Translation + +CQL2 expressions are translated to parameterized PostgreSQL queries for security: + +```javascript +// CQL2-JSON input +{ "op": "=", "args": [{ "property": "license" }, "MIT"] } + +// SQL output +WHERE c.license = $1 +// Values: ['MIT'] +``` + +### PostGIS Integration + +Spatial operators use PostGIS functions with ST_GeomFromGeoJSON for geometry parsing: + +```sql +ST_Intersects(c.spatial_extend, ST_GeomFromGeoJSON($1)) +``` + +--- + +## Conformance Classes + +This implementation conforms to: + +| Conformance Class | URI | +|-------------------|-----| +| Basic CQL2 | `http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2` | +| Advanced Comparison | `http://www.opengis.net/spec/cql2/1.0/conf/advanced-comparison-operators` | +| CQL2-JSON | `http://www.opengis.net/spec/cql2/1.0/conf/cql2-json` | +| CQL2-Text | `http://www.opengis.net/spec/cql2/1.0/conf/cql2-text` | +| Basic Spatial Functions | `http://www.opengis.net/spec/cql2/1.0/conf/basic-spatial-functions` | +| Spatial Functions | `http://www.opengis.net/spec/cql2/1.0/conf/spatial-functions` | +| Temporal Functions | `http://www.opengis.net/spec/cql2/1.0/conf/temporal-functions` | + +--- + +## See Also + +- [OGC CQL2 Standard](https://docs.ogc.org/is/21-065r2/21-065r2.html) +- [STAC API Filter Extension](https://github.com/stac-api-extensions/filter) +- [cql2-rs (WASM Parser)](https://github.com/stac-utils/cql2-rs) +- [Collection Search Parameters](collection-search-parameters.md) diff --git a/api/examples/README.md b/api/docs/how-to-database-integration.md similarity index 100% rename from api/examples/README.md rename to api/docs/how-to-database-integration.md diff --git a/api/docs/stac-api-validator.md b/api/docs/stac-api-validator.md new file mode 100644 index 0000000..56d3881 --- /dev/null +++ b/api/docs/stac-api-validator.md @@ -0,0 +1,21 @@ +# STAC API Validator Results + +## Validator +- Tool: stac_api_validator (official) +- Execution: Python module (`py -m stac_api_validator`) +- STAC API Version: 1.1.0 +- Collection STAC version: 1.0.0 +- API Base URL: http://localhost:3000 + +## Command Used + +```powershell +py -m stac_api_validator ` + --root-url "http://localhost:3000/" ` + --conformance core ` + --conformance collections ` + --collection 1 +Result +The validator completed successfully without any errors or warnings. + +No validation errors were reported. \ No newline at end of file diff --git a/api/middleware/errorHandler.js b/api/middleware/errorHandler.js new file mode 100644 index 0000000..7d0e1a6 --- /dev/null +++ b/api/middleware/errorHandler.js @@ -0,0 +1,107 @@ +const { ErrorResponses, sanitizeErrorMessage } = require('../utils/errorResponse'); + +/** + * Global error handler middleware + * + * This middleware: + * 1. Catches all unhandled errors from routes and middleware + * 2. Logs errors appropriately based on severity + * 3. Returns RFC 7807 compliant error responses + * 4. Sanitizes error messages to prevent sensitive data leakage + * 5. Includes request ID for error tracing + * + * @param {Error} err - Error object + * @param {Request} req - Express request + * @param {Response} res - Express response + * @param {Function} next - Next middleware + */ +function globalErrorHandler(err, req, res, next) { + const isDevelopment = process.env.NODE_ENV === 'development'; + const requestId = req.requestId || 'unknown'; + const instance = req.originalUrl || req.url; + + // Determine status code + const status = err.status || err.statusCode || 500; + + // Log error based on severity + if (status >= 500) { + // Server errors - log full details + console.error('='.repeat(80)); + console.error('INTERNAL SERVER ERROR'); + console.error('Request ID:', requestId); + console.error('Timestamp:', new Date().toISOString()); + console.error('Method:', req.method); + console.error('URL:', instance); + console.error('User-Agent:', req.get('user-agent')); + console.error('Error:', err); + console.error('Stack:', err.stack); + console.error('='.repeat(80)); + } else if (status >= 400) { + // Client errors - log basic info + console.warn('Client Error:', { + requestId, + status, + method: req.method, + url: instance, + error: err.message, + code: err.code + }); + } + + // Sanitize error message + const sanitizedMessage = sanitizeErrorMessage(err, isDevelopment); + + // Create error response based on status code + let errorResponse; + + if (status === 404) { + errorResponse = ErrorResponses.notFound( + sanitizedMessage, + requestId, + instance + ); + } else if (status >= 400 && status < 500) { + // Client errors + errorResponse = ErrorResponses.badRequest( + sanitizedMessage, + requestId, + instance, + { + // Include error code if available + ...(err.code && { code: err.code }) + } + ); + errorResponse.status = status; // Override with specific status + } else if (status === 501) { + errorResponse = ErrorResponses.notImplemented( + sanitizedMessage, + requestId, + instance + ); + } else if (status === 503) { + errorResponse = ErrorResponses.serviceUnavailable( + sanitizedMessage, + requestId, + instance + ); + } else { + // 500 or other server errors + errorResponse = ErrorResponses.internalError( + isDevelopment ? sanitizedMessage : undefined, // Hide details in production + requestId, + instance + ); + } + + // In development, include stack trace + if (isDevelopment && status >= 500) { + errorResponse.stack = err.stack; + } + + // Send error response + res.status(status).json(errorResponse); +} + +module.exports = { + globalErrorHandler +}; diff --git a/api/middleware/rateLimit.js b/api/middleware/rateLimit.js new file mode 100644 index 0000000..303ff9b --- /dev/null +++ b/api/middleware/rateLimit.js @@ -0,0 +1,31 @@ +const expressRateLimit = require('express-rate-limit'); +const { ErrorResponses } = require('../utils/errorResponse'); + +/** + * Rate limiting middleware + * + * This middleware: + * 1. Limits each IP address to a maximum number of requests per time window (default: 1000 requests per 15 minutes) + * 2. Returns HTTP 429 Too Many Requests if the limit is exceeded + * 3. Returns RFC 7807 compliant error response + * 4. Sets standard RateLimit headers for client awareness + * 5. Can be configured for different limits or strategies if needed + * + * @see https://www.npmjs.com/package/express-rate-limit + */ +const rateLimitMiddleware = expressRateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 1000, // max 1000 requests per IP + handler: (req, res) => { + const errorResponse = ErrorResponses.tooManyRequests( + undefined, + req.requestId, + req.originalUrl + ); + res.status(429).json(errorResponse); + }, + standardHeaders: true, // Set RateLimit headers + legacyHeaders: false, // Disable X-RateLimit headers +}); + +module.exports = { rateLimitMiddleware }; diff --git a/api/middleware/requestId.js b/api/middleware/requestId.js new file mode 100644 index 0000000..3398afe --- /dev/null +++ b/api/middleware/requestId.js @@ -0,0 +1,35 @@ +const { generateRequestId } = require('../utils/errorResponse'); + +/** + * Request ID middleware + * + * Attaches a unique request ID to each request for tracing and logging. + * The request ID can be: + * 1. Provided by the client via X-Request-ID header + * 2. Auto-generated if not provided + * + * The request ID is: + * - Attached to req.requestId for use in routes and middleware + * - Included in the X-Request-ID response header + * - Included in error responses for debugging + * + * @param {Request} req - Express request + * @param {Response} res - Express response + * @param {Function} next - Next middleware + */ +function requestIdMiddleware(req, res, next) { + // Use client-provided request ID or generate new one + const requestId = req.get('X-Request-ID') || generateRequestId(); + + // Attach to request object + req.requestId = requestId; + + // Include in response headers + res.setHeader('X-Request-ID', requestId); + + next(); +} + +module.exports = { + requestIdMiddleware +}; diff --git a/api/middleware/validateCollectionId.js b/api/middleware/validateCollectionId.js index 2cb1bb7..b1ec823 100644 --- a/api/middleware/validateCollectionId.js +++ b/api/middleware/validateCollectionId.js @@ -1,25 +1,63 @@ +const { ErrorResponses } = require('../utils/errorResponse'); + /** * Middleware to validate the :id route parameter for /collections/:id. * - * - Ensures the id looks like a positive integer (all digits). + * - Ensures the id exists and is not empty or exceeds a certain length limit. + * - Ensures the id only contains allowed characters (letters, digits, ".", "_", "-"). + * - The database only uses digits as ids, but the API should accept common STAC + * - collection id formats. * - Prevents obviously malformed input reaching the database layer. - * - On error, responds with a 404 JSON body that matches the "NotFound" error - * format used elsewhere in the API tests. + * - On error, responds with a 400 JSON body using RFC 7807 format. */ function validateCollectionId(req, res, next) { const { id } = req.params; - // id must be present and must be a sequence of digits (no minus, no spaces, no letters) - if (!id || !/^\d+$/u.test(id)) { - return res.status(400).json({ - code: 'InvalidParameter', - description: 'The "id" parameter must be a non-negative integer (digits only).', - parameter: 'id', - value: id - }); + // not empty - id must be present and be a non-empty string + if (typeof id !== 'string' || id.trim().length === 0) { + const errorResponse = ErrorResponses.invalidParameter( + 'The "id" parameter is required. It cannot be empty.', + req.requestId, + req.originalUrl, + { + parameter: 'id', + value: id + } + ); + return res.status(400).json(errorResponse); + } + + // length limit (STAC IDs are usually short; 256 is generous) + if (id.length > 256) { + const errorResponse = ErrorResponses.invalidParameter( + 'The "id" parameter is too long. It is not allowed to exceed 256 characters.', + req.requestId, + req.originalUrl, + { + parameter: 'id', + value: id + } + ); + return res.status(400).json(errorResponse); } - next(); + // whitelist allowed characters + // - allows typical STAC-style ids like: sentinel-2-l2a, my.collection_01, abc123 + // - disallows slashes, spaces, quotes, etc. + const allowed = /^[A-Za-z0-9._-]+$/u; + if (!allowed.test(id)) { + const errorResponse = ErrorResponses.invalidParameter( + 'The "id" parameter contains invalid characters. Allowed: letters, digits, ".", "_", "-".', + req.requestId, + req.originalUrl, + { + parameter: 'id', + value: id + } + ); + return res.status(400).json(errorResponse); + } + return next(); } -module.exports = { validateCollectionId }; \ No newline at end of file +module.exports = { validateCollectionId }; diff --git a/api/middleware/validateCollectionSearch.js b/api/middleware/validateCollectionSearch.js index 0aa2c74..1c7b8ec 100644 --- a/api/middleware/validateCollectionSearch.js +++ b/api/middleware/validateCollectionSearch.js @@ -8,8 +8,11 @@ const { validateSortby, validateToken, validateProvider, - validateLicense + validateLicense, + validateFilter, + validateFilterLang } = require('../validators/collectionSearchParams'); +const { ErrorResponses } = require('../utils/errorResponse'); /** * Express middleware to validate Collection Search query parameters @@ -27,6 +30,8 @@ const { * - token: Pagination continuation token * - provider: Provider name — filter by data provider * - license: License identifier — filter by collection license + * - filter: CQL2 filter expression + * - filter-lang: Language of the filter (cql2-text, cql2-json) * * @param {Request} req - Express request object * @param {Response} res - Express response object @@ -37,7 +42,8 @@ function validateCollectionSearchParams(req, res, next) { const normalized = {}; // Extract query parameters - const { q, bbox, datetime, limit, sortby, token, provider, license } = req.query; + const { q, bbox, datetime, limit, sortby, token, provider, license, filter } = req.query; + const filterLang = req.query['filter-lang']; // separate extraction due to hyphen in name // Validate q (free-text search) const qResult = validateQ(q); @@ -102,13 +108,31 @@ function validateCollectionSearchParams(req, res, next) { } else if (licenseResult.normalized !== undefined) { normalized.license = licenseResult.normalized; } + + // Validate filter + const filterResult = validateFilter(filter); + if (!filterResult.valid) { + errors.push(filterResult.error); + } else if (filterResult.normalized !== undefined) { + normalized.filter = filterResult.normalized; + } + + // Validate filter-lang + const filterLangResult = validateFilterLang(filterLang); + if (!filterLangResult.valid) { + errors.push(filterLangResult.error); + } else if (filterLangResult.normalized !== undefined) { + normalized['filter-lang'] = filterLangResult.normalized; + } - // If any validation errors occurred, return 400 with details + // If any validation errors occurred, return 400 with RFC 7807 format if (errors.length > 0) { - return res.status(400).json({ - code: 'InvalidParameterValue', - description: errors.join('; ') - }); + const errorResponse = ErrorResponses.badRequest( + errors.join('; '), + req.requestId, + req.originalUrl + ); + return res.status(400).json(errorResponse); } // Attach normalized params to request for use in route handler diff --git a/api/package-lock.json b/api/package-lock.json index a12b09e..de530d8 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -10,15 +10,20 @@ "license": "Apache-2.0", "dependencies": { "cors": "^2.8.5", + "cql2-wasm": "^0.4.2", "debug": "~2.6.9", "dotenv": "^17.2.3", "express": "^4.22.1", + "express-rate-limit": "^8.2.1", "morgan": "^1.10.1", "pg": "^8.16.3", "swagger-ui-express": "^5.0.1", "yamljs": "^0.3.0" }, "devDependencies": { + "@babel/core": "^7.28.5", + "@babel/preset-env": "^7.28.5", + "babel-jest": "^30.2.0", "eslint": "^8.57.1", "jest": "^29.7.0", "nodemon": "^3.1.11", @@ -127,6 +132,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", @@ -144,6 +162,88 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -154,6 +254,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", @@ -186,6 +300,19 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", @@ -196,6 +323,56 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -226,6 +403,21 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", + "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helpers": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", @@ -256,6 +448,103 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", + "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -311,10 +600,886 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz", + "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", + "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", + "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz", + "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", + "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz", + "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", + "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", "dev": true, "license": "MIT", "dependencies": { @@ -327,36 +1492,43 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-jsx": { + "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "dev": true, "license": "MIT", "dependencies": { @@ -369,92 +1541,113 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -463,30 +1656,100 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "node_modules/@babel/preset-env": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz", + "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/compat-data": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.5", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.28.5", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.5", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.4", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.4", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -495,6 +1758,21 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -976,6 +2254,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -1506,64 +2808,292 @@ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, - "license": "ISC", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-jest/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-jest/node_modules/@jest/transform": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-jest/node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-jest/node_modules/@sinclair/typebox": { + "version": "0.34.45", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.45.tgz", + "integrity": "sha512-qJcFVfCa5jxBFSuv7S5WYbA8XdeCPmhnaVVfX/2Y6L8WYg8sk3XY2+6W0zH+3mq1Cz+YC7Ki66HfqX6IHAwnkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest/node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-jest/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-jest/node_modules/jest-haste-map": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/babel-jest/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-jest/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": ">= 8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/babel-jest/node_modules/jest-worker": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "dev": true, "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "node_modules/babel-jest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "node_modules/babel-jest/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "node_modules/babel-jest/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10" }, - "peerDependencies": { - "@babel/core": "^7.8.0" + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/babel-jest/node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/babel-plugin-istanbul": { @@ -1601,19 +3131,58 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" + "@types/babel__core": "^7.20.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-preset-current-node-syntax": { @@ -1644,20 +3213,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "node_modules/balanced-match": { @@ -2130,6 +3699,20 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js-compat": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2143,6 +3726,12 @@ "node": ">= 0.10" } }, + "node_modules/cql2-wasm": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cql2-wasm/-/cql2-wasm-0.4.2.tgz", + "integrity": "sha512-0vnQZJYk2R52hyXO6CpHXKPtbjykNlU7n+cukz2JXfbNQsXZtOeCX2X7xwo23CqdcbPlHY7RrNnJuCGBPja03Q==", + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2742,6 +4331,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3336,6 +4943,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3727,6 +5343,61 @@ } } }, + "node_modules/jest-config/node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/jest-config/node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-config/node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -4317,6 +5988,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5282,6 +6960,64 @@ "node": ">=8.10.0" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6129,6 +7865,50 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/api/package.json b/api/package.json index 4f1ea4c..310fcd2 100644 --- a/api/package.json +++ b/api/package.json @@ -22,15 +22,20 @@ "license": "Apache-2.0", "dependencies": { "cors": "^2.8.5", + "cql2-wasm": "^0.4.2", "debug": "~2.6.9", "dotenv": "^17.2.3", "express": "^4.22.1", + "express-rate-limit": "^8.2.1", "morgan": "^1.10.1", "pg": "^8.16.3", "swagger-ui-express": "^5.0.1", "yamljs": "^0.3.0" }, "devDependencies": { + "@babel/core": "^7.28.5", + "@babel/preset-env": "^7.28.5", + "babel-jest": "^30.2.0", "eslint": "^8.57.1", "jest": "^29.7.0", "nodemon": "^3.1.11", diff --git a/api/routes/collections.js b/api/routes/collections.js index 5e81101..eada39b 100644 --- a/api/routes/collections.js +++ b/api/routes/collections.js @@ -4,6 +4,78 @@ const { validateCollectionId } = require('../middleware/validateCollectionId'); const { validateCollectionSearchParams } = require('../middleware/validateCollectionSearch'); const { query } = require('../db/db_APIconnection'); const { buildCollectionSearchQuery } = require('../db/buildCollectionSearchQuery'); +const { parseCql2Text, parseCql2Json } = require('../utils/cql2'); +const { cql2ToSql } = require('../utils/cql2ToSql'); +const { ErrorResponses } = require('../utils/errorResponse'); + +// helper to map DB row to STAC Collection object +// the full_json column contains the original STAC Collection Json as crawled but it is needed to set some fields/links correctly +// TODO(DB-final): full_json is currently used as primary source for STAC fields. +// Once the DB schema is finalized, replace full_json-based mapping with normalized columns and only use full_json as fallback/debug +function toStacCollection(row, baseHost) { + const base = + row.full_json && + typeof row.full_json === 'object' && + !Array.isArray(row.full_json) + ? row.full_json + : {}; + + const id = base.id ?? String(row.id); + + const collection = { + // merge full_json first to override/normalize below + ...base, + type: 'Collection', + stac_version: base.stac_version ?? row.stac_version ?? '1.1.0', + id, + title: base.title ?? row.title ?? id, + description: base.description ?? row.description ?? '', + license: base.license ?? row.license ?? 'proprietary', + }; + + // assets must be an object/dict if present + if (collection.assets === null || collection.assets === undefined) { + delete collection.assets; + } else if (Array.isArray(collection.assets)) { + delete collection.assets; + } else if (typeof collection.assets !== 'object') { + delete collection.assets; + } + + if (collection.summaries === null || collection.summaries === undefined) { + delete collection.summaries; + } else if (Array.isArray(collection.summaries) || typeof collection.summaries !== 'object') { + delete collection.summaries; + } + + // TODO(DB-final): extent should come from normalized spatial/temporal columns once finalized. + // For now, fallback to DB-derived bbox/interval if full_json does not contain extent. + if (!collection.extent) { + const hasBbox = + row.minx !== null && row.miny !== null && row.maxx !== null && row.maxy !== null; + + collection.extent = { + spatial: { + bbox: hasBbox ? [[row.minx, row.miny, row.maxx, row.maxy]] : [[-180, -90, 180, 90]], + }, + temporal: { + interval: [[ + row.temporal_extend_start ? new Date(row.temporal_extend_start).toISOString() : null, + row.temporal_extend_end ? new Date(row.temporal_extend_end).toISOString() : null, + ]], + }, + }; + } + + // ensure links exist + collection.links = [ + { rel: 'self', href: `${baseHost}/collections/${encodeURIComponent(id)}`, type: 'application/json' }, + { rel: 'parent', href: `${baseHost}`, type: 'application/json' }, + { rel: 'root', href: `${baseHost}`, type: 'application/json' } + ]; + + return collection; +} // helper to run the built query (from documentation) async function runQuery(sql, params = []) { @@ -33,12 +105,36 @@ async function runQuery(sql, params = []) { * All parameters are validated by validateCollectionSearchParams middleware. * Validated/normalized values are available in req.validatedParams. */ + router.get('/', validateCollectionSearchParams, async (req, res, next) => { // TODO: Think about the parameters `provider` and `license` - They are mentioned in the bid, but not in the STAC spec - // TODO: Implement CQL2 filtering (GET endpoint) and add validator for `filter`, filter-lang` parameters try { // validated parameters from middleware - const { q, bbox, datetime, limit, sortby, token, provider, license } = req.validatedParams; + const { q, bbox, datetime, limit, sortby, token, provider, license, filter } = req.validatedParams; + const filterLang = req.validatedParams['filter-lang'] || 'cql2-text'; // seperate extraction due to hyphen and default value + + let cqlFilter = undefined; + if (filter) { + try { + let cqlJson; + if (filterLang === 'cql2-text') { + cqlJson = await parseCql2Text(filter); + } else if (filterLang === 'cql2-json') { + cqlJson = await parseCql2Json(filter); + } + + if (cqlJson) { + const values = []; + const sql = cql2ToSql(cqlJson, values); + cqlFilter = { sql, values }; + } + } catch (err) { + return res.status(400).json({ + code: 'InvalidParameterValue', + description: `Invalid filter expression: ${err.message}` + }); + } + } // build SQL querry and parameters const { sql, values } = buildCollectionSearchQuery({ @@ -49,12 +145,16 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { license, limit, sortby, - token + token, + cqlFilter }); // execute Query against database - const collections = await runQuery(sql, values); - const returned = collections.length; + const baseHost = `${req.protocol}://${req.get('host')}`; + const rows = await runQuery(sql, values); + const returned = rows.length; + const collections = rows.map(r => toStacCollection(r, baseHost)); + // Get total count for matched field // Build count query using same WHERE conditions @@ -78,45 +178,38 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { const countResult = await runQuery(countQuery, countValues); const matched = parseInt(countResult[0]?.total || 0); - // Base URL for links - const baseHost = `${req.protocol}://${req.get('host')}`; - const baseUrl = `${baseHost}${req.baseUrl}`; - const buildLink = (rel, tokenValue) => ({ - rel, - href: `${baseUrl}?limit=${limit}&token=${tokenValue}`, - type: 'application/json' - }); + // self MUST match the requested URL exactly (validator requirement) + const selfHref = `${baseHost}${req.originalUrl}`; + + // helper to create pagination links while keeping existing query params + function withToken(newToken) { + const url = new URL(selfHref); + url.searchParams.set('limit', String(limit)); + url.searchParams.set('token', String(newToken)); + return url.toString(); + } const links = [ - buildLink('self', token), - { - rel: 'root', - href: baseHost, - type: 'application/json' - } + { rel: 'self', href: selfHref, type: 'application/json' }, + { rel: 'root', href: baseHost, type: 'application/json' }, + { rel: 'parent', href: baseHost, type: 'application/json' } ]; // "next": only if returned === limit AND token + limit < matched if (returned === limit && token + limit < matched) { - links.push(buildLink('next', token + limit)); + links.push({ rel: 'next', href: withToken(token + limit), type: 'application/json' }); } // "prev": only if token > 0 if (token > 0) { const prevToken = Math.max(0, token - limit); - links.push(buildLink('prev', prevToken)); + links.push({ rel: 'prev', href: withToken(prevToken), type: 'application/json' }); } res.json({ - type: 'FeatureCollection', collections, links, - context: { - returned, - limit, - matched - } }); } catch (error) { next(error); @@ -139,34 +232,45 @@ router.get('/', validateCollectionSearchParams, async (req, res, next) => { * SELECT part in buildCollectionSearchQuery. This allows the query builder * (and later a mapping layer) to evolve without touching this route. */ + router.get('/:id', validateCollectionId, async (req, res, next) => { + try { const { id } = req.params; - // id is already syntactically validated by validateCollectionId. - // For the database we use a numeric id, matching the c.collection.id column type. - const numericId = parseInt(id, 10); +// Numeric: use numeric filter +const numericId = Number(id); +const isNumericId = Number.isFinite(numericId) && String(numericId) === String(id); - // Reuse the shared query builder with an exact id filter. - // We request a single row (LIMIT 1) and no offset. - const { sql, values } = buildCollectionSearchQuery({ - id: numericId, - limit: 1, - token: 0, - }); +// Build params depending on id type +const queryParams = { + limit: 1, + token: 0, +}; - const rows = await runQuery(sql, values); +if (isNumericId) { + queryParams.id = numericId; +} else { + // STAC Collection IDs are strings use string filter + queryParams.collectionId = id; +} + +const { sql, values } = buildCollectionSearchQuery(queryParams); + +const rows = await runQuery(sql, values); if (!rows || rows.length === 0) { - // Return 404 with standardized error format - return res.status(404).json({ - code: 'NotFound', - description: `Collection with id '${id}' not found`, - id: id - }); + // Return 404 with RFC 7807 format + const errorResponse = ErrorResponses.notFound( + `Collection with id '${id}' not found`, + req.requestId, + req.originalUrl + ); + errorResponse.id = id; // Add collection id for context + return res.status(404).json(errorResponse); } - const collection = rows[0]; + const row = rows[0]; const baseHost = `${req.protocol}://${req.get('host')}`; const selfHref = `${baseHost}${req.originalUrl}`; @@ -182,11 +286,11 @@ router.get('/:id', validateCollectionId, async (req, res, next) => { { rel: 'root', href: rootHref, type: 'application/json' }, { rel: 'parent', href: rootHref, type: 'application/json' } ]; - + const collection_id = toStacCollection(row, baseHost); // Return the collection with a normalized `links` array. // The rest of the attributes (id, title, extent, full_json, …) come directly // from the query builder / database. - res.json(Object.assign({}, collection, { links })); + res.json(Object.assign({}, collection_id, { links })); } catch (error) { next(error); } diff --git a/api/utils/cql2.js b/api/utils/cql2.js new file mode 100644 index 0000000..6820962 --- /dev/null +++ b/api/utils/cql2.js @@ -0,0 +1,65 @@ +const fs = require('fs'); +const path = require('path'); + +let cql2Module = null; +let wasmInitialized = false; + +async function initWasm() { + if (wasmInitialized) return; + + try { + // Dynamic import for ESM module + cql2Module = await import('cql2-wasm'); + + const wasmPath = path.join(__dirname, '..', 'node_modules', 'cql2-wasm', 'cql2_wasm_bg.wasm'); + const wasmBuffer = fs.readFileSync(wasmPath); + await cql2Module.default(wasmBuffer); + wasmInitialized = true; + } catch (error) { + console.error('Failed to initialize cql2-wasm:', error); + throw new Error('CQL2 parser initialization failed'); + } +} + +/** + * Parses CQL2 Text to CQL2 JSON object + * @param {string} text - CQL2 Text + * @returns {Promise} CQL2 JSON object + */ +async function parseCql2Text(text) { + await initWasm(); + try { + const result = cql2Module.parseText(text); + // result.to_json() returns a JSON string, so we parse it + return JSON.parse(result.to_json()); + } catch (error) { + throw new Error(`Invalid CQL2 Text: ${error.message || error}`); + } +} + +/** + * Validates/Parses CQL2 JSON + * @param {Object|string} json - CQL2 JSON object or string + * @returns {Promise} CQL2 JSON object + */ +async function parseCql2Json(json) { + await initWasm(); + try { + let jsonStr; + if (typeof json === 'string') { + jsonStr = json; + } else { + jsonStr = JSON.stringify(json); + } + + const result = cql2Module.parseJson(jsonStr); + return JSON.parse(result.to_json()); + } catch (error) { + throw new Error(`Invalid CQL2 JSON: ${error.message || error}`); + } +} + +module.exports = { + parseCql2Text, + parseCql2Json +}; diff --git a/api/utils/cql2ToSql.js b/api/utils/cql2ToSql.js new file mode 100644 index 0000000..77ddf85 --- /dev/null +++ b/api/utils/cql2ToSql.js @@ -0,0 +1,204 @@ +/** + * Converts CQL2 JSON to SQL WHERE clause with parameterized values. + * + * This function translates CQL2 filter expressions to PostgreSQL WHERE clauses. + * Property names are mapped to database columns (e.g., 'title' -> 'c.title'). + * + * NOTE: String literals in CQL2-Text must be enclosed in single quotes! + * Example: license = 'MIT' (correct) + * license = MIT (WRONG - MIT is interpreted as a property reference) + * + * @param {Object} cql - CQL2 JSON object + * @param {Array} values - Array to append SQL parameters to + * @returns {string} SQL fragment + */ +function cql2ToSql(cql, values) { + if (!cql) return 'TRUE'; + + // Handle logical operators + if (cql.op === 'and') { + const args = cql.args.map(arg => cql2ToSql(arg, values)); + return `(${args.join(' AND ')})`; + } + if (cql.op === 'or') { + const args = cql.args.map(arg => cql2ToSql(arg, values)); + return `(${args.join(' OR ')})`; + } + if (cql.op === 'not') { + return `(NOT ${cql2ToSql(cql.args[0], values)})`; + } + + // Handle comparison operators + const opMap = { + '=': '=', + '<': '<', + '>': '>', + '<=': '<=', + '>=': '>=', + '<>': '<>' + }; + + if (opMap[cql.op]) { + const leftArg = cql.args[0]; + const rightArg = cql.args[1]; + + const left = processArg(leftArg, values); + const right = processArg(rightArg, values); + return `${left} ${opMap[cql.op]} ${right}`; + } + + if (cql.op === 'between') { + const val = processArg(cql.args[0], values); + const min = processArg(cql.args[1], values); + const max = processArg(cql.args[2], values); + return `${val} BETWEEN ${min} AND ${max}`; + } + + if (cql.op === 'in') { + const val = processArg(cql.args[0], values); + const list = cql.args[1].map(item => processArg(item, values)).join(', '); + return `${val} IN (${list})`; + } + + if (cql.op === 'isNull') { + const val = processArg(cql.args[0], values); + return `${val} IS NULL`; + } + + // Spatial operators (CQL2 Advanced) + if (cql.op === 's_intersects') { + const geomProp = processArg(cql.args[0], values); + const geomLiteral = cql.args[1]; + // GeoJSON geometry literal + values.push(JSON.stringify(geomLiteral)); + return `ST_Intersects(${geomProp}, ST_GeomFromGeoJSON($${values.length}))`; + } + + if (cql.op === 's_within') { + const geomProp = processArg(cql.args[0], values); + const geomLiteral = cql.args[1]; + values.push(JSON.stringify(geomLiteral)); + return `ST_Within(${geomProp}, ST_GeomFromGeoJSON($${values.length}))`; + } + + if (cql.op === 's_contains') { + const geomProp = processArg(cql.args[0], values); + const geomLiteral = cql.args[1]; + values.push(JSON.stringify(geomLiteral)); + return `ST_Contains(${geomProp}, ST_GeomFromGeoJSON($${values.length}))`; + } + + // Temporal operators (CQL2 Advanced) + if (cql.op === 't_intersects') { + // t_intersects(property, interval) + // For collections: check if collection's temporal extent overlaps with given interval + const prop = cql.args[0]; + const interval = cql.args[1]; + + if (prop.property === 'datetime' || prop.property === 'temporal_extend') { + // interval can be: { interval: [start, end] } or a single timestamp + if (interval.interval) { + const [start, end] = interval.interval; + if (start !== '..' && end !== '..') { + values.push(start, end); + return `(c.temporal_extend_start <= $${values.length} AND c.temporal_extend_end >= $${values.length - 1})`; + } else if (start === '..') { + values.push(end); + return `c.temporal_extend_start <= $${values.length}`; + } else if (end === '..') { + values.push(start); + return `c.temporal_extend_end >= $${values.length}`; + } + } else { + // Single timestamp + values.push(interval); + return `(c.temporal_extend_start <= $${values.length} AND c.temporal_extend_end >= $${values.length})`; + } + } + throw new Error(`t_intersects only supported for datetime/temporal_extend property`); + } + + if (cql.op === 't_before') { + const prop = processArg(cql.args[0], values); + values.push(cql.args[1]); + return `${prop} < $${values.length}`; + } + + if (cql.op === 't_after') { + const prop = processArg(cql.args[0], values); + values.push(cql.args[1]); + return `${prop} > $${values.length}`; + } + + // Incase cql.op hasn't matched yet it's an unsupported operator + throw new Error(`Unsupported CQL2 operator: ${cql.op}`); +} + +function processArg(arg, values) { + if (arg === null || arg === undefined) { + return 'NULL'; + } + + // Property reference + if (arg.property) { + return mapProperty(arg.property); + } + + // Function call (not fully supported yet, but structure exists) + if (arg.function) { + throw new Error(`CQL2 functions not supported yet: ${arg.function.name}`); + } + + // Literal value + values.push(arg); + return `$${values.length}`; +} + +function mapProperty(propName) { + // Map CQL2 property names to database columns + // Based on SELECT columns from buildCollectionSearchQuery.js + const columnMap = { + // Core collection fields + 'id': 'c.id', + 'stac_version': 'c.stac_version', + 'type': 'c.type', + 'title': 'c.title', + 'description': 'c.description', + 'license': 'c.license', + 'spatial_extend': 'c.spatial_extend', + 'temporal_extend_start': 'c.temporal_extend_start', + 'temporal_extend_end': 'c.temporal_extend_end', + 'created_at': 'c.created_at', + 'updated_at': 'c.updated_at', + 'is_api': 'c.is_api', + 'is_active': 'c.is_active', + + // Common aliases + 'created': 'c.created_at', + 'updated': 'c.updated_at', + 'collection': 'c.id', + + // Aggregated fields (from LATERAL JOINs) + 'keywords': 'kw.keywords', + 'stac_extensions': 'ext.stac_extensions', + 'providers': 'prov.providers', + 'assets': 'a.assets', + 'summaries': 's.summaries', + 'last_crawled': 'cl.last_crawled' + }; + + if (columnMap[propName]) { + return columnMap[propName]; + } + + // Fallback: query inside full_json JSONB column + // Ensure propName is safe (alphanumeric + underscores) + if (!/^[a-zA-Z0-9_]+$/.test(propName)) { + throw new Error(`Invalid property name: ${propName}`); + } + + // Use JSONB operator ->> for text extraction + return `c.full_json ->> '${propName}'`; +} + +module.exports = { cql2ToSql }; diff --git a/api/utils/errorResponse.js b/api/utils/errorResponse.js new file mode 100644 index 0000000..9215ded --- /dev/null +++ b/api/utils/errorResponse.js @@ -0,0 +1,226 @@ +const crypto = require('crypto'); + +/** + * RFC 7807 Problem Details for HTTP APIs + * https://datatracker.ietf.org/doc/html/rfc7807 + * + * Standard error response format that includes: + * - type: URI reference identifying the problem type + * - title: Short, human-readable summary + * - status: HTTP status code + * - detail: Human-readable explanation specific to this occurrence + * - instance: URI reference identifying the specific occurrence + * - requestId: Unique identifier for tracing this request + */ + +/** + * Generates a unique request ID for tracing + * @returns {string} UUID v4 + */ +function generateRequestId() { + return crypto.randomUUID(); +} + +/** + * Creates a standardized RFC 7807 error response + * @param {Object} options - Error options + * @param {number} options.status - HTTP status code + * @param {string} options.code - Error code (e.g., 'InvalidParameterValue') + * @param {string} options.title - Short error title + * @param {string} options.detail - Detailed error description + * @param {string} [options.requestId] - Request ID for tracing + * @param {string} [options.instance] - Request path + * @param {Object} [options.extensions] - Additional custom fields + * @returns {Object} RFC 7807 compliant error response + */ +function createErrorResponse({ status, code, title, detail, requestId, instance, extensions = {} }) { + const errorResponse = { + // RFC 7807 standard fields + type: `https://stacspec.org/errors/${code}`, + title: title || getDefaultTitle(status), + status, + detail: detail || title || getDefaultTitle(status), + ...(instance && { instance }), + ...(requestId && { requestId }), + // Backwards compatibility fields + code, // Keep for existing tests + description: detail || title || getDefaultTitle(status), // Alias for detail + ...extensions + }; + + return errorResponse; +} + +/** + * Gets default error title for status code + * @param {number} status - HTTP status code + * @returns {string} Default title + */ +function getDefaultTitle(status) { + const titles = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 409: 'Conflict', + 422: 'Unprocessable Entity', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable' + }; + return titles[status] || 'Error'; +} + +/** + * Common error creators for consistent responses + */ +const ErrorResponses = { + /** + * 400 - Bad Request (invalid parameter - malformed/wrong type) + */ + invalidParameter(detail, requestId, instance, extensions) { + return createErrorResponse({ + status: 400, + code: 'InvalidParameter', + title: 'Invalid Parameter', + detail, + requestId, + instance, + extensions + }); + }, + + /** + * 400 - Bad Request (invalid parameter value - wrong value range/format) + */ + badRequest(detail, requestId, instance, extensions) { + return createErrorResponse({ + status: 400, + code: 'InvalidParameterValue', + title: 'Invalid Parameter Value', + detail, + requestId, + instance, + extensions + }); + }, + + /** + * 404 - Not Found + */ + notFound(detail, requestId, instance) { + return createErrorResponse({ + status: 404, + code: 'NotFound', + title: 'Resource Not Found', + detail, + requestId, + instance + }); + }, + + /** + * 500 - Internal Server Error + */ + internalError(detail, requestId, instance) { + return createErrorResponse({ + status: 500, + code: 'InternalServerError', + title: 'Internal Server Error', + detail: detail || 'An unexpected error occurred while processing the request', + requestId, + instance + }); + }, + + /** + * 501 - Not Implemented + */ + notImplemented(detail, requestId, instance) { + return createErrorResponse({ + status: 501, + code: 'NotImplemented', + title: 'Not Implemented', + detail, + requestId, + instance + }); + }, + + /** + * 503 - Service Unavailable + */ + serviceUnavailable(detail, requestId, instance) { + return createErrorResponse({ + status: 503, + code: 'ServiceUnavailable', + title: 'Service Unavailable', + detail, + requestId, + instance + }); + }, + + /** + * 429 - Too Many Requests (Rate Limit Exceeded) + */ + tooManyRequests(detail, requestId, instance) { + return createErrorResponse({ + status: 429, + code: 'TooManyRequests', + title: 'Too Many Requests', + detail: detail || 'Too many requests from this IP address, please try again later.', + requestId, + instance + }); + } +}; + +/** + * Sanitizes error messages to prevent sensitive data leakage + * @param {Error} error - Original error + * @param {boolean} isDevelopment - Whether in development mode + * @returns {string} Sanitized error message + */ +function sanitizeErrorMessage(error, isDevelopment = false) { + // In development, show detailed errors + if (isDevelopment) { + return error.message || 'Unknown error'; + } + + // In production, hide sensitive details + const safePatterns = [ + /invalid parameter/i, + /not found/i, + /unauthorized/i, + /forbidden/i, + /validation error/i, + /invalid format/i, + /missing required/i + ]; + + const message = error.message || ''; + + // If message matches safe patterns, return it + if (safePatterns.some(pattern => pattern.test(message))) { + // Remove any database-specific details + return message + .replace(/\bpassword\b/gi, '***') + .replace(/\btoken\b/gi, '***') + .replace(/\bsecret\b/gi, '***') + .replace(/postgresql:\/\/[^\s]+/gi, '***') + .replace(/error: /gi, ''); + } + + // For unknown errors, return generic message + return 'An unexpected error occurred while processing the request'; +} + +module.exports = { + generateRequestId, + createErrorResponse, + ErrorResponses, + sanitizeErrorMessage +}; diff --git a/api/validators/collectionSearchParams.js b/api/validators/collectionSearchParams.js index b15c263..d8a2b3c 100644 --- a/api/validators/collectionSearchParams.js +++ b/api/validators/collectionSearchParams.js @@ -312,6 +312,33 @@ function validateLicense(license) { return { valid: true, normalized: trimmed }; } +/** + * Validates filter parameter (CQL2) + * @param {string|Object} filter - CQL2 filter + * @returns {Object} { valid: boolean, error?: string, normalized?: string|Object } + */ +function validateFilter(filter) { + if (!filter) return { valid: true }; + // Basic validation, deep validation happens in the route handler via cql2-wasm + return { valid: true, normalized: filter }; +} + +/** + * Validates filter-lang parameter + * @param {string} lang - Filter language + * @returns {Object} { valid: boolean, error?: string, normalized?: string } + */ +function validateFilterLang(lang) { + if (!lang) return { valid: true }; + + const validLangs = ['cql2-text', 'cql2-json']; + if (!validLangs.includes(lang)) { + return { valid: false, error: `Invalid filter-lang. Supported: ${validLangs.join(', ')}` }; + } + + return { valid: true, normalized: lang }; +} + module.exports = { validateQ, validateBbox, @@ -320,6 +347,8 @@ module.exports = { validateSortby, validateToken, validateProvider, - validateLicense + validateLicense, + validateFilter, + validateFilterLang }; diff --git a/bid.md b/bid.md index 5771fd9..e96a715 100644 --- a/bid.md +++ b/bid.md @@ -399,10 +399,11 @@ Protokolliert die Zeitpunkte der letzten Crawling-Vorgänge für jeden Katalog. | Spalte | Beschreibung / Inhalt | Datentyp / Format | |---------------|------------------------------------------|-------------------| | **id** | Eindeutige ID des Crawlvorgangs | integer (PK) | -| collection_id | Referenz auf `collection.id` | integer (FK) | +| collection_id | Referenz auf `collection.id` (optional) | integer (FK) | +| source_url | Quell-URL der Collection | text | | last_crawled | Zeitpunkt des letzten Crawls | timestamp | -Analog zur vorherigen Tabelle dient `crawllog_collection` der Nachverfolgung der Crawling-Zyklen für Collections. +Protokolliert die Zeitpunkte der letzten Crawling-Vorgänge für jede Collection. Falls die Collection noch nicht in der `collection`-Tabelle existiert, kann sie über die `source_url` identifiziert werden. --- diff --git a/crawler/.gitignore b/crawler/.gitignore index 5daf102..02f3661 100644 --- a/crawler/.gitignore +++ b/crawler/.gitignore @@ -1,3 +1,3 @@ node_modules storage -.env \ No newline at end of file +.env diff --git a/db/docker-compose.yml b/db/docker-compose.yml index 94848fb..9524111 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -23,6 +23,9 @@ services: - ./init:/docker-entrypoint-initdb.d networks: [stac-network] + networks: + - stac-network + volumes: stac_data: diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index dca17c4..93755ec 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -1,107 +1,13 @@ --- creates every table related to catalogs --- Main catalog table: Stores STAC catalog metadata including version, type, title, and description --- Each catalog represents a STAC catalog endpoint that has been discovered and indexed -CREATE TABLE catalog ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - stac_version TEXT, - type TEXT, - title TEXT, - description TEXT, - created_at TIMESTAMP DEFAULT now(), - updated_at TIMESTAMP DEFAULT now(), - search_vector tsvector -); - --- Catalog links table: Stores related links for catalogs (e.g., self, root, child, item links) --- Links define the navigation structure between STAC resources -CREATE TABLE catalog_links ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, - source_url TEXT, - rel TEXT, - href TEXT, - type TEXT, - title TEXT -); - --- Keywords lookup table: Stores unique searchable keywords --- Used by both catalogs and collections for categorization and search -CREATE TABLE keywords ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - keyword TEXT UNIQUE -); - --- STAC extensions lookup table: Stores unique STAC extension identifiers --- Extensions provide additional standardized fields beyond core STAC spec -CREATE TABLE stac_extensions ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - stac_extension TEXT UNIQUE -); +-- The crawllog_catalog is required to save the crawler's current location. +-- If the crawler crashes, for example because the server goes down, it can +-- now restart at the correct location and does not have to crawl everything again. --- Crawl log for catalogs: Tracks when each catalog was last crawled for updates --- Used to schedule re-crawling and maintain freshness of catalog data CREATE TABLE crawllog_catalog ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, - last_crawled TIMESTAMP -); - --- ======================================== --- FULL-TEXT SEARCH TRIGGERS --- ======================================== - --- Trigger function to auto-update search_vector when catalog is inserted or updated --- Includes title, description, and all associated keywords for comprehensive search -CREATE OR REPLACE FUNCTION update_catalog_search_vector() -RETURNS TRIGGER AS $$ -BEGIN - NEW.search_vector := to_tsvector('simple', - coalesce(NEW.title, '') || ' ' || - coalesce(NEW.description, '') || ' ' || - coalesce( - ( - SELECT string_agg(k.keyword, ' ') - FROM catalog_keywords ck - JOIN keywords k ON k.id = ck.keyword_id - WHERE ck.catalog_id = NEW.id - ), - '' - ) - ); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER catalog_search_vector_update -BEFORE INSERT OR UPDATE ON catalog -FOR EACH ROW -EXECUTE FUNCTION update_catalog_search_vector(); - --- Trigger function to update search_vector when keywords are added/removed --- Ensures search index stays in sync with keyword changes -CREATE OR REPLACE FUNCTION update_catalog_search_vector_on_keyword_change() -RETURNS TRIGGER AS $$ -BEGIN - UPDATE catalog - SET search_vector = to_tsvector('simple', - coalesce(title, '') || ' ' || - coalesce(description, '') || ' ' || - coalesce( - ( - SELECT string_agg(k.keyword, ' ') - FROM catalog_keywords ck - JOIN keywords k ON k.id = ck.keyword_id - WHERE ck.catalog_id = catalog.id - ), - '' - ) - ) - WHERE id = COALESCE(NEW.catalog_id, OLD.catalog_id); - - RETURN COALESCE(NEW, OLD); -END; -$$ LANGUAGE plpgsql; - --- NOTE: The trigger for catalog_keywords is defined in 06_triggers.sql --- because it depends on the catalog_keywords table which is created there + slug TEXT, + source_url TEXT UNIQUE NOT NULL, + is_api BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now(), +); \ No newline at end of file diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 852aa0d..14385bf 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -6,11 +6,11 @@ CREATE TABLE collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, stac_version TEXT, - stac_id TEXT, - type TEXT, + stac_id TEXT UNIQUE, title TEXT, description TEXT, license TEXT, + source_url TEXT, created_at TIMESTAMP DEFAULT now(), updated_at TIMESTAMP DEFAULT now(), @@ -33,7 +33,6 @@ CREATE TABLE collection_summaries ( collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, name TEXT, kind TEXT, - source_url TEXT, range_min NUMERIC, range_max NUMERIC, set_value TEXT, @@ -58,13 +57,13 @@ CREATE TABLE assets ( metadata JSONB ); --- Crawl log for collections: Tracks when each collection was last crawled for updates +-- Crawl log for collections: Tracks the last crawled state of each collection and references the matching catalog -- Used to schedule re-crawling and maintain freshness of collection data --- (same usecase as the crawllog for catalogs) CREATE TABLE crawllog_collection ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, collection_id INTEGER REFERENCES collection(id) ON DELETE CASCADE, - last_crawled TIMESTAMP + source_url TEXT UNIQUE NOT NULL, + crawllog_catalog_id INTEGER REFERENCES crawllog_catalog(id) ON DELETE CASCADE ); -- ======================================== diff --git a/db/init/04_relation_tables.sql b/db/init/04_relation_tables.sql index d95f31b..f132d7d 100644 --- a/db/init/04_relation_tables.sql +++ b/db/init/04_relation_tables.sql @@ -1,18 +1,4 @@ --- creates every table needed for relations between tables for catalogs and collections - --- Junction table: Links catalogs to their associated keywords (many-to-many) -CREATE TABLE catalog_keywords ( - catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, - keyword_id INTEGER REFERENCES keywords(id) ON DELETE CASCADE, - PRIMARY KEY (catalog_id, keyword_id) -); - --- Junction table: Links catalogs to STAC extensions they implement (many-to-many) -CREATE TABLE catalog_stac_extension ( - catalog_id INTEGER REFERENCES catalog(id) ON DELETE CASCADE, - stac_extension_id INTEGER REFERENCES stac_extensions(id) ON DELETE CASCADE, - PRIMARY KEY (catalog_id, stac_extension_id) -); +-- creates every table needed for relations between tables for collections -- Junction table: Links collections to their associated keywords (many-to-many) CREATE TABLE collection_keywords ( diff --git a/db/init/05_indexes.sql b/db/init/05_indexes.sql index 7c0d74a..6d4cbb0 100644 --- a/db/init/05_indexes.sql +++ b/db/init/05_indexes.sql @@ -1,23 +1,6 @@ -- Performance indexes for all tables -- These indexes optimize common query patterns and improve search performance --- ======================================== --- CATALOG INDEXES --- ======================================== - --- Basic catalog lookups -CREATE INDEX idx_catalog_title ON catalog (title); -CREATE INDEX idx_catalog_updated_at ON catalog (updated_at); - --- Full-text search index on computed search_vector column (includes title, description, and keywords) -CREATE INDEX idx_catalog_search_vector ON catalog USING GIN (search_vector); - -CREATE INDEX idx_catalog_links_catalog_id ON catalog_links (catalog_id); -CREATE INDEX idx_catalog_keywords_catalog ON catalog_keywords (catalog_id); -CREATE INDEX idx_catalog_stac_ext_catalog ON catalog_stac_extension (catalog_id); - -CREATE INDEX idx_crawllog_catalog_last ON crawllog_catalog (last_crawled); - -- ======================================== -- COLLECTION INDEXES -- ======================================== @@ -41,8 +24,6 @@ CREATE INDEX idx_collection_stac_ext_collection ON collection_stac_extension (co CREATE INDEX idx_collection_providers_collection ON collection_providers (collection_id); CREATE INDEX idx_collection_assets_collection ON collection_assets (collection_id); -CREATE INDEX idx_crawllog_collection_last ON crawllog_collection (last_crawled); - -- ======================================== -- PROVIDER & ASSET INDEXES -- ======================================== diff --git a/db/init/06_triggers.sql b/db/init/06_triggers.sql index 5e167a0..720e780 100644 --- a/db/init/06_triggers.sql +++ b/db/init/06_triggers.sql @@ -3,12 +3,6 @@ -- ======================================== -- These triggers must be created here (after junction tables exist) --- Trigger to update catalog search_vector when keywords change -CREATE TRIGGER catalog_keywords_update_vector -AFTER INSERT OR DELETE ON catalog_keywords -FOR EACH ROW -EXECUTE FUNCTION update_catalog_search_vector_on_keyword_change(); - -- Trigger to update collection search_vector when keywords change CREATE TRIGGER collection_keywords_update_vector AFTER INSERT OR DELETE ON collection_keywords diff --git a/db/package-lock.json b/db/package-lock.json new file mode 100644 index 0000000..f517125 --- /dev/null +++ b/db/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "db", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2526f8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# This docker-compose file orchestrates the startup of the entire STAC-Atlas project. +# It includes the docker-compose configurations from the individual components. + +#TODO: add docker-compose files for UI and Crawler components + +version: "3.9" + +networks: + stac_net: + +services: + db: + extends: + file: db/docker-compose.yml + service: database + networks: [stac_net] + + #crawler: + # extends: + # file: crawler/docker-compose.yml + # service: crawler + # networks: [stac_net] + + api: + extends: + file: api/docker-compose.yml + service: api + networks: [stac_net] + + #ui: + # extends: + # file: ui/docker-compose.yml + # service: ui + # networks: [stac_net] \ No newline at end of file From 4730c4ce49d5ea5a1782710e3a023ec0fc973e32 Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Sat, 31 Jan 2026 10:15:07 +0100 Subject: [PATCH 120/134] removing duplicate networks Removed redundant networks declaration for the database service. --- db/docker-compose.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/db/docker-compose.yml b/db/docker-compose.yml index 9524111..db77b95 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -23,13 +23,10 @@ services: - ./init:/docker-entrypoint-initdb.d networks: [stac-network] - networks: - - stac-network - volumes: stac_data: networks: stac-network: name: stac-network - driver: bridge \ No newline at end of file + driver: bridge From 6ae39420e6612b94d9d60093b3bf74592db09fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 31 Jan 2026 10:46:43 +0100 Subject: [PATCH 121/134] I hate SQL --- db/init/02_tables_catalog.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/init/02_tables_catalog.sql b/db/init/02_tables_catalog.sql index 93755ec..93a692e 100644 --- a/db/init/02_tables_catalog.sql +++ b/db/init/02_tables_catalog.sql @@ -9,5 +9,5 @@ CREATE TABLE crawllog_catalog ( source_url TEXT UNIQUE NOT NULL, is_api BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT now(), - updated_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() ); \ No newline at end of file From c96d17e88c466b217924a8d7d7eb8f283a11ca67 Mon Sep 17 00:00:00 2001 From: Lenn Date: Sat, 31 Jan 2026 10:47:13 +0100 Subject: [PATCH 122/134] feat(scheduler): enforce time window for crawler execution and add related checks --- crawler/scheduler.js | 183 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 169 insertions(+), 14 deletions(-) diff --git a/crawler/scheduler.js b/crawler/scheduler.js index 8e7a92f..55b2540 100644 --- a/crawler/scheduler.js +++ b/crawler/scheduler.js @@ -1,6 +1,6 @@ /** * @fileoverview Scheduler for running STAC crawler every 7 days - * Waits for the actual crawl time to complete before scheduling next run + * Runs only during allowed time window (22:00 - 07:00) * Skips scheduling if database errors occur * @module scheduler */ @@ -16,8 +16,85 @@ const RUN_ON_STARTUP = true; // Set to false to wait 7 days before first run const RETRY_ON_CRAWL_ERROR = true; // Retry if crawl fails but DB is ok const RETRY_DELAY_HOURS = 2; // Hours to wait before retry on crawl error +// Time window configuration (crawler only starts between these hours) +const ALLOWED_START_HOUR = 22; // 22:00 (10 PM) +const ALLOWED_END_HOUR = 7; // 07:00 (7 AM) +const ENFORCE_TIME_WINDOW = true; // Set to false to disable time window check +const GRACE_PERIOD_MINUTES = 30; // Minutes to allow crawler to finish gracefully after end hour + +/** + * Check if current time is within allowed time window + * @returns {boolean} True if within allowed window + */ +const isWithinAllowedTimeWindow = () => { + if (!ENFORCE_TIME_WINDOW) return true; + + const now = new Date(); + const currentHour = now.getHours(); + + // Handle time window that spans midnight (e.g., 22:00 - 07:00) + if (ALLOWED_START_HOUR > ALLOWED_END_HOUR) { + return currentHour >= ALLOWED_START_HOUR || currentHour < ALLOWED_END_HOUR; + } else { + // Normal time window (e.g., 09:00 - 17:00) + return currentHour >= ALLOWED_START_HOUR && currentHour < ALLOWED_END_HOUR; + } +}; + +/** + * Calculate milliseconds until next allowed start time + * @returns {number} Milliseconds to wait + */ +const getMillisecondsUntilAllowedTime = () => { + if (!ENFORCE_TIME_WINDOW) return 0; + + const now = new Date(); + const currentHour = now.getHours(); + + // Already in allowed window + if (isWithinAllowedTimeWindow()) { + return 0; + } + + // Calculate next allowed start time + const nextAllowedTime = new Date(now); + nextAllowedTime.setHours(ALLOWED_START_HOUR, 0, 0, 0); + + // If allowed start hour is later today + if (currentHour < ALLOWED_START_HOUR && ALLOWED_START_HOUR < ALLOWED_END_HOUR) { + // Same day, later + } else if (currentHour >= ALLOWED_END_HOUR && currentHour < ALLOWED_START_HOUR) { + // Same day, wait until ALLOWED_START_HOUR + } else { + // Next day + nextAllowedTime.setDate(nextAllowedTime.getDate() + 1); + } + + const msToWait = nextAllowedTime.getTime() - now.getTime(); + return msToWait > 0 ? msToWait : 0; +}; + +/** + * Calculate milliseconds until the end of allowed time window + * @returns {number} Milliseconds until end hour + */ +const getMillisecondsUntilEndTime = () => { + const now = new Date(); + const endTime = new Date(now); + endTime.setHours(ALLOWED_END_HOUR, 0, 0, 0); + + // If end hour is earlier than current hour, it's tomorrow + if (now.getHours() >= ALLOWED_END_HOUR && ALLOWED_START_HOUR > ALLOWED_END_HOUR) { + endTime.setDate(endTime.getDate() + 1); + } + + const msUntilEnd = endTime.getTime() - now.getTime(); + return msUntilEnd > 0 ? msUntilEnd : 0; +}; + /** * Runs the crawler and returns statistics + * Monitors time and warns if approaching end of allowed window * @async * @function runCrawler * @returns {Promise} Crawler statistics @@ -28,11 +105,54 @@ const runCrawler = async () => { console.log(`[${timestamp}] Starting crawler run...`); console.log('='.repeat(60)); + // Set up shutdown timer if we're approaching end time + let shutdownTimer = null; + let gracePeriodTimer = null; + + if (ENFORCE_TIME_WINDOW) { + const msUntilEnd = getMillisecondsUntilEndTime(); + const msUntilGraceEnd = msUntilEnd + (GRACE_PERIOD_MINUTES * 60 * 1000); + + if (msUntilEnd > 0 && msUntilEnd < 12 * 60 * 60 * 1000) { // Less than 12 hours + const endTime = new Date(Date.now() + msUntilEnd); + console.log(`Note: Crawl should complete before ${endTime.toLocaleTimeString()} (${formatDuration(msUntilEnd)} remaining)`); + console.log(`Grace period: ${GRACE_PERIOD_MINUTES} minutes after end time\n`); + + // Set warning timer for end time + shutdownTimer = setTimeout(() => { + console.warn(`\n${'!'.repeat(60)}`); + console.warn(`WARNING: End time (${ALLOWED_END_HOUR}:00) reached!`); + console.warn(`Crawler is still running. Grace period: ${GRACE_PERIOD_MINUTES} minutes`); + console.warn(`The crawler will continue to finish current operations.`); + console.warn('!'.repeat(60) + '\n'); + }, msUntilEnd); + + // Set forced shutdown timer (end time + grace period) + gracePeriodTimer = setTimeout(() => { + console.error(`\n${'!'.repeat(60)}`); + console.error(`CRITICAL: Grace period expired! (${ALLOWED_END_HOUR}:00 + ${GRACE_PERIOD_MINUTES}min)`); + console.error(`Crawler is being stopped to respect time window.`); + console.error(`Next run will be scheduled for ${ALLOWED_START_HOUR}:00`); + console.error('!'.repeat(60) + '\n'); + process.exit(0); // Graceful exit + }, msUntilGraceEnd); + } + } + try { const stats = await crawler(); + + // Clear timers if crawler finished in time + if (shutdownTimer) clearTimeout(shutdownTimer); + if (gracePeriodTimer) clearTimeout(gracePeriodTimer); + console.log(`\n[${new Date().toISOString()}] Crawler finished`); return stats; } catch (error) { + // Clear timers on error + if (shutdownTimer) clearTimeout(shutdownTimer); + if (gracePeriodTimer) clearTimeout(gracePeriodTimer); + console.error(`\n[${new Date().toISOString()}] Crawler encountered an error:`, error.message); return { success: false, @@ -45,10 +165,11 @@ const runCrawler = async () => { }; /** - * Schedule next run based on the crawl duration - * @param {number} crawlDuration - Duration of the last crawl in milliseconds + * Schedule next run exactly 7 days after the START of the last crawl + * Adjusts timing to fit within allowed time window if configured + * @param {boolean} isRetry - Whether this is a retry after an error */ -const scheduleNextRun = (crawlDuration = 0, isRetry = false) => { +const scheduleNextRun = (isRetry = false) => { let delayMs; let intervalDescription; @@ -56,19 +177,37 @@ const scheduleNextRun = (crawlDuration = 0, isRetry = false) => { delayMs = RETRY_DELAY_HOURS * 60 * 60 * 1000; intervalDescription = `${RETRY_DELAY_HOURS} hour(s) (retry)`; } else { - // Schedule next run: 7 days minus the time the crawl took + // Schedule next run: exactly 7 days from now (start of last crawl) const intervalMs = DAYS_INTERVAL * 24 * 60 * 60 * 1000; - delayMs = Math.max(intervalMs - crawlDuration, 0); + delayMs = intervalMs; intervalDescription = `${DAYS_INTERVAL} days`; } + // Check if scheduled time falls within allowed window + if (ENFORCE_TIME_WINDOW) { + const scheduledTime = new Date(Date.now() + delayMs); + const scheduledHour = scheduledTime.getHours(); + + // Check if the scheduled time is outside the window + const isScheduledTimeAllowed = ALLOWED_START_HOUR > ALLOWED_END_HOUR + ? (scheduledHour >= ALLOWED_START_HOUR || scheduledHour < ALLOWED_END_HOUR) + : (scheduledHour >= ALLOWED_START_HOUR && scheduledHour < ALLOWED_END_HOUR); + + if (!isScheduledTimeAllowed) { + // Calculate how much to add to reach the next allowed window + const hoursUntilAllowed = ALLOWED_START_HOUR > scheduledHour + ? ALLOWED_START_HOUR - scheduledHour + : (24 - scheduledHour) + ALLOWED_START_HOUR; + const additionalMs = hoursUntilAllowed * 60 * 60 * 1000; + delayMs += additionalMs; + console.log(`\nTime window enforcement: Next run moved to allowed window (${ALLOWED_START_HOUR}:00 - ${ALLOWED_END_HOUR}:00)`); + } + } + const nextRun = new Date(Date.now() + delayMs); console.log(`\nNext crawl scheduled for: ${nextRun.toLocaleString()}`); console.log(` Interval: ${intervalDescription}`); - if (!isRetry && crawlDuration > 0) { - console.log(` Adjusted for crawl time: -${formatDuration(crawlDuration)}`); - } console.log(` Wait time: ${formatDuration(delayMs)}\n`); setTimeout(async () => { @@ -80,10 +219,10 @@ const scheduleNextRun = (crawlDuration = 0, isRetry = false) => { process.exit(1); } else if (stats.crawlError && RETRY_ON_CRAWL_ERROR) { console.warn('\nCrawl error detected but database is OK - scheduling retry...'); - scheduleNextRun(0, true); // Retry without adjusting for crawl time + scheduleNextRun(true); // Retry after 2 hours } else if (stats.success) { console.log('\nCrawl completed successfully - scheduling next run...'); - scheduleNextRun(stats.elapsedTime, false); // Schedule next run with time adjustment + scheduleNextRun(false); // Schedule next run in exactly 7 days } else { console.error('\nCrawl failed - scheduler stopped.\n'); process.exit(1); @@ -104,10 +243,26 @@ const startScheduler = async () => { if (RETRY_ON_CRAWL_ERROR) { console.log(` Retry delay: ${RETRY_DELAY_HOURS} hour(s)`); } + console.log(`Time window enforcement: ${ENFORCE_TIME_WINDOW ? 'ENABLED' : 'DISABLED'}`); + if (ENFORCE_TIME_WINDOW) { + console.log(` Allowed start hours: ${ALLOWED_START_HOUR}:00 - ${ALLOWED_END_HOUR}:00`); + console.log(` Currently in window: ${isWithinAllowedTimeWindow() ? 'YES' : 'NO'}`); + } console.log(`Current time: ${new Date().toLocaleString()}\n`); // Run immediately if configured if (RUN_ON_STARTUP) { + // Check if we need to wait for allowed time window + if (ENFORCE_TIME_WINDOW && !isWithinAllowedTimeWindow()) { + const waitMs = getMillisecondsUntilAllowedTime(); + const waitUntil = new Date(Date.now() + waitMs); + console.log(`Current time is outside allowed window (${ALLOWED_START_HOUR}:00 - ${ALLOWED_END_HOUR}:00)`); + console.log(` Waiting until: ${waitUntil.toLocaleString()}`); + console.log(` Wait time: ${formatDuration(waitMs)}\n`); + + await new Promise(resolve => setTimeout(resolve, waitMs)); + } + console.log('Running initial crawl on startup...'); const stats = await runCrawler(); @@ -117,17 +272,17 @@ const startScheduler = async () => { process.exit(1); } else if (stats.crawlError && RETRY_ON_CRAWL_ERROR) { console.warn('\nInitial crawl had errors but database is OK - scheduling retry...'); - scheduleNextRun(0, true); + scheduleNextRun(true); } else if (stats.success) { console.log('\nInitial crawl completed - scheduling next run...'); - scheduleNextRun(stats.elapsedTime, false); + scheduleNextRun(false); } else { console.error('\nInitial crawl failed - exiting.\n'); process.exit(1); } } else { // Schedule first run without running now - scheduleNextRun(0, false); + scheduleNextRun(false); } console.log('Scheduler is running. Press Ctrl+C to stop.\n'); From de6d9746ec36e60d70b74b779d20bb644bb4a926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Hoffmann?= Date: Sat, 31 Jan 2026 10:52:34 +0100 Subject: [PATCH 123/134] I acciddently deleted keywords and extentions, now they are back --- db/init/03_tables_collections.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/db/init/03_tables_collections.sql b/db/init/03_tables_collections.sql index 14385bf..7e256d0 100644 --- a/db/init/03_tables_collections.sql +++ b/db/init/03_tables_collections.sql @@ -25,6 +25,20 @@ CREATE TABLE collection ( search_vector tsvector ); +-- Keywords lookup table: Stores unique searchable keywords +-- Used by both catalogs and collections for categorization and search +CREATE TABLE keywords ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + keyword TEXT UNIQUE +); + +-- STAC extensions lookup table: Stores unique STAC extension identifiers +-- Extensions provide additional standardized fields beyond core STAC spec +CREATE TABLE stac_extensions ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + stac_extension TEXT UNIQUE +); + -- Collection summaries: Stores summaries for collection properties -- represent ranges (min/max), sets of values, or JSON schemas -- Used to describe the range of values found in collection items From a56d4e8e8c3cad87061b0038b12ca63bc0296296 Mon Sep 17 00:00:00 2001 From: Lenn Date: Sat, 31 Jan 2026 12:05:52 +0100 Subject: [PATCH 124/134] feat(crawler): add scheduler and time window configuration to .env and update scheduler logic --- crawler/.env.example | 23 ++++++++++++++++++++++- crawler/scheduler.js | 19 +++++++++++-------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/crawler/.env.example b/crawler/.env.example index d257021..3d9f3fb 100644 --- a/crawler/.env.example +++ b/crawler/.env.example @@ -19,4 +19,25 @@ MAX_CATALOGS=0 # Maximum API Catalogs that are crawled (0 ist unlimited) MAX_APIS=0 # Timeout of connection of crawler -TIMEOUT_MS=30000 \ No newline at end of file +TIMEOUT_MS=30000 + +# Scheduler Configuration +# How many days between crawl runs (default: 7) +CRAWL_DAYS_INTERVAL=7 +# Run crawler immediately on startup (true/false, default: true) +CRAWL_RUN_ON_STARTUP=true +# Retry if crawl fails but DB is ok (true/false, default: true) +CRAWL_RETRY_ON_ERROR=true +# Hours to wait before retry on crawl error (default: 2) +CRAWL_RETRY_DELAY_HOURS=2 + +# Time Window Configuration (only active when CRAWL_ENFORCE_TIME_WINDOW=true) +# By default, crawler runs anytime. Set CRAWL_ENFORCE_TIME_WINDOW=true to restrict crawling to specific hours +# Enforce time window (true/false, default: false - crawler runs anytime) +CRAWL_ENFORCE_TIME_WINDOW=false +# Hour when crawler is allowed to start (0-23, e.g. 22 for 10 PM) - only used when time window is enforced +CRAWL_ALLOWED_START_HOUR=22 +# Hour when crawler should stop (0-23, e.g. 7 for 7 AM) - only used when time window is enforced +CRAWL_ALLOWED_END_HOUR=7 +# Grace period in minutes after end hour (default: 30) - only used when time window is enforced +CRAWL_GRACE_PERIOD_MINUTES=30 \ No newline at end of file diff --git a/crawler/scheduler.js b/crawler/scheduler.js index 55b2540..68c84b4 100644 --- a/crawler/scheduler.js +++ b/crawler/scheduler.js @@ -5,22 +5,25 @@ * @module scheduler */ +import dotenv from 'dotenv'; import { crawler } from './index.js'; import { formatDuration } from './utils/time.js'; +dotenv.config(); + /** * Configuration */ -const DAYS_INTERVAL = 7; // Run every 7 days -const RUN_ON_STARTUP = true; // Set to false to wait 7 days before first run -const RETRY_ON_CRAWL_ERROR = true; // Retry if crawl fails but DB is ok -const RETRY_DELAY_HOURS = 2; // Hours to wait before retry on crawl error +const DAYS_INTERVAL = parseInt(process.env.CRAWL_DAYS_INTERVAL, 10) || 7; // Run every N days +const RUN_ON_STARTUP = process.env.CRAWL_RUN_ON_STARTUP !== 'false'; // Set to false to wait N days before first run +const RETRY_ON_CRAWL_ERROR = process.env.CRAWL_RETRY_ON_ERROR !== 'false'; // Retry if crawl fails but DB is ok +const RETRY_DELAY_HOURS = parseInt(process.env.CRAWL_RETRY_DELAY_HOURS, 10) || 2; // Hours to wait before retry on crawl error // Time window configuration (crawler only starts between these hours) -const ALLOWED_START_HOUR = 22; // 22:00 (10 PM) -const ALLOWED_END_HOUR = 7; // 07:00 (7 AM) -const ENFORCE_TIME_WINDOW = true; // Set to false to disable time window check -const GRACE_PERIOD_MINUTES = 30; // Minutes to allow crawler to finish gracefully after end hour +const ALLOWED_START_HOUR = parseInt(process.env.CRAWL_ALLOWED_START_HOUR, 10) || 0; // Default: 00:00 (Midnight) +const ALLOWED_END_HOUR = parseInt(process.env.CRAWL_ALLOWED_END_HOUR, 10) || 23; // Default: 23:00 (11 PM) +const ENFORCE_TIME_WINDOW = process.env.CRAWL_ENFORCE_TIME_WINDOW === 'true'; // Set to true to enable time window check +const GRACE_PERIOD_MINUTES = parseInt(process.env.CRAWL_GRACE_PERIOD_MINUTES, 10) || 30; // Minutes to allow crawler to finish gracefully after end hour /** * Check if current time is within allowed time window From 78c12ee1943c173a038fcdcd13e93248d15c08fb Mon Sep 17 00:00:00 2001 From: Lenn Date: Sat, 31 Jan 2026 12:13:54 +0100 Subject: [PATCH 125/134] refactor(scheduler, db): update file overviews and enhance documentation for clarity --- crawler/scheduler.js | 4 +-- crawler/utils/db.js | 75 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/crawler/scheduler.js b/crawler/scheduler.js index 68c84b4..1fd50be 100644 --- a/crawler/scheduler.js +++ b/crawler/scheduler.js @@ -1,6 +1,6 @@ /** - * @fileoverview Scheduler for running STAC crawler every 7 days - * Runs only during allowed time window (22:00 - 07:00) + * @fileoverview Scheduler for running STAC crawler at configurable intervals + * Optionally restricts crawling to specified time windows * Skips scheduling if database errors occur * @module scheduler */ diff --git a/crawler/utils/db.js b/crawler/utils/db.js index f88db53..3b3c330 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -1,6 +1,14 @@ /** - * DB helper for crawler using `pg` Pool - * Exposes: initDb(), insertOrUpdateCatalog(), close() + * @fileoverview Database helper module for STAC crawler using PostgreSQL connection pool + * Provides functions for database initialization, collection/catalog management, and connection handling + * @module utils/db + * + * Exports: + * - initDb() - Initialize and test database connection + * - insertOrUpdateCatalog() - Process catalog (currently skips saving) + * - insertOrUpdateCollection() - Insert or update STAC collection with retry logic + * - close() - Close database connection pool + * - pool - PostgreSQL connection pool instance */ import pkg from 'pg'; const { Pool } = pkg; @@ -16,6 +24,14 @@ const pool = new Pool({ max: 10, }); +/** + * Initialize and test database connection + * Tests the connection by executing a simple query and logs the result + * @async + * @function initDb + * @returns {Promise} + * @throws {Error} If database connection fails + */ async function initDb() { const host = process.env.PGHOST; const port = parseInt(process.env.PGPORT, 10); @@ -345,7 +361,15 @@ async function _insertOrUpdateCollectionInternal(collection) { /** - * Helper function to insert keywords + * Insert or update keywords for a collection + * Deletes existing keywords for the parent and inserts new ones + * @async + * @function insertKeywords + * @param {Object} client - PostgreSQL client from connection pool + * @param {number} parentId - Parent entity ID (collection ID) + * @param {string[]} keywords - Array of keyword strings + * @param {string} type - Entity type ('collection') + * @returns {Promise} */ async function insertKeywords(client, parentId, keywords, type) { await client.query( @@ -372,7 +396,15 @@ async function insertKeywords(client, parentId, keywords, type) { } /** - * Helper function to insert STAC extensions + * Insert or update STAC extensions for a collection + * Deletes existing extensions for the parent and inserts new ones + * @async + * @function insertStacExtensions + * @param {Object} client - PostgreSQL client from connection pool + * @param {number} parentId - Parent entity ID (collection ID) + * @param {string[]} extensions - Array of STAC extension URLs + * @param {string} type - Entity type ('collection') + * @returns {Promise} */ async function insertStacExtensions(client, parentId, extensions, type) { await client.query( @@ -400,7 +432,15 @@ async function insertStacExtensions(client, parentId, extensions, type) { /** - * Helper function to insert collection summaries + * Insert a single collection summary entry + * Automatically determines the summary type (range, set, schema, or value) based on the value + * @async + * @function insertSummary + * @param {Object} client - PostgreSQL client from connection pool + * @param {number} collectionId - Collection ID + * @param {string} name - Summary property name + * @param {*} value - Summary value (can be array, object, or primitive) + * @returns {Promise} */ async function insertSummary(client, collectionId, name, value) { let kind = 'unknown'; @@ -433,7 +473,14 @@ async function insertSummary(client, collectionId, name, value) { } /** - * Helper function to insert providers + * Insert or update providers for a collection + * Deletes existing provider links and creates new ones + * @async + * @function insertProviders + * @param {Object} client - PostgreSQL client from connection pool + * @param {number} collectionId - Collection ID + * @param {Object[]} providers - Array of provider objects with name and roles + * @returns {Promise} */ async function insertProviders(client, collectionId, providers) { await client.query('DELETE FROM collection_providers WHERE collection_id = $1', [collectionId]); @@ -458,7 +505,14 @@ async function insertProviders(client, collectionId, providers) { } /** - * Helper function to insert assets + * Insert or update assets for a collection + * Deletes existing asset links and creates new ones + * @async + * @function insertAssets + * @param {Object} client - PostgreSQL client from connection pool + * @param {number} collectionId - Collection ID + * @param {Object} assets - Object mapping asset names to asset data (href, type, roles, metadata) + * @returns {Promise} */ async function insertAssets(client, collectionId, assets) { await client.query('DELETE FROM collection_assets WHERE collection_id = $1', [collectionId]); @@ -491,6 +545,13 @@ async function insertAssets(client, collectionId, assets) { +/** + * Close the database connection pool + * Should be called when the application shuts down + * @async + * @function close + * @returns {Promise} + */ async function close() { await pool.end(); } From babae45228a1bca3cf93c9875caf22e3c828cd0a Mon Sep 17 00:00:00 2001 From: Lenn Date: Sat, 31 Jan 2026 13:06:47 +0100 Subject: [PATCH 126/134] docs(crawler): enhance README and .env.example with detailed configuration options and usage examples --- crawler/.env.example | 1 + crawler/README.md | 385 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 367 insertions(+), 19 deletions(-) diff --git a/crawler/.env.example b/crawler/.env.example index 3d9f3fb..0cffe5e 100644 --- a/crawler/.env.example +++ b/crawler/.env.example @@ -12,6 +12,7 @@ PGPASSWORD=example_password # Postgres/PostGis databse PGDATABASE=example_db +# For single Crawler run: # 'catalogs', 'apis', or 'both' CRAWL_MODE=both # Maximum Static Catalogs that are crawled (0 ist unlimited) diff --git a/crawler/README.md b/crawler/README.md index b63fb8a..2b68d33 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -1,6 +1,46 @@ # STAC Crawler -A Node.js crawler for STAC Index API that fetches and processes catalog and collection data with configurable options. +A Node.js crawler for STAC Index API that fetches and processes catalog and collection data with configurable options. Includes an automated scheduler for periodic crawling. + +## Features + +- **Single-run Mode**: Execute crawler once and exit +- **Scheduled Mode**: Automated periodic crawling with configurable intervals +- **Time Window Control**: Optional restriction to specific hours (e.g., night-time crawling) +- **Retry Logic**: Automatic retry on crawl errors with configurable delay +- **Environment-based Configuration**: All settings configurable via `.env` file +- **CLI Arguments**: Override settings with command-line flags +- **Database Integration**: PostgreSQL storage with deadlock handling +- **Parallel Execution**: Efficient domain-based parallel processing + +## Quick Start + +```bash +# Install dependencies +npm install + +# Copy and configure environment file +cp .env.example .env +``` +### Single Crawl Run + +```bash +# Run crawler once +npm start +``` + +### Scheduled Crawling + +```bash +# Run scheduler for automatic periodic crawling +node scheduler.js +``` + +The scheduler will: +- Run the crawler immediately on startup (configurable) +- Schedule next runs based on configured interval (default: 7 days) +- Respect time window restrictions if enabled +- Automatically retry on errors ## Configuration @@ -8,6 +48,8 @@ The crawler can be configured using environment variables, CLI arguments, or a c ### Configuration Options +#### Crawler Configuration + | Option | CLI Flag | Environment Variable | Default | Description | |--------|----------|---------------------|---------|-------------| | Mode | `-m, --mode` | `CRAWL_MODE` | `both` | Crawl mode: `catalogs`, `apis`, or `both` | @@ -16,6 +58,29 @@ The crawler can be configured using environment variables, CLI arguments, or a c | Timeout | `-t, --timeout` | `TIMEOUT_MS` | `30000` | Timeout per operation in milliseconds | | Max Depth | `-d, --max-depth` | `MAX_DEPTH` | `3` | Maximum recursion depth for nested catalogs | +#### Scheduler Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `CRAWL_DAYS_INTERVAL` | `7` | Days between crawl runs | +| `CRAWL_RUN_ON_STARTUP` | `true` | Run crawler immediately on startup | +| `CRAWL_RETRY_ON_ERROR` | `true` | Retry if crawl fails but DB is ok | +| `CRAWL_RETRY_DELAY_HOURS` | `2` | Hours to wait before retry on error | +| `CRAWL_ENFORCE_TIME_WINDOW` | `false` | Enable time window restrictions | +| `CRAWL_ALLOWED_START_HOUR` | `22` | Start hour (0-23) when time window is enforced | +| `CRAWL_ALLOWED_END_HOUR` | `7` | End hour (0-23) when time window is enforced | +| `CRAWL_GRACE_PERIOD_MINUTES` | `30` | Grace period in minutes after end hour | + +#### Database Configuration + +| Environment Variable | Description | +|---------------------|-------------| +| `PGHOST` | PostgreSQL host | +| `PGPORT` | PostgreSQL port (default: 5432) | +| `PGUSER` | PostgreSQL username | +| `PGPASSWORD` | PostgreSQL password | +| `PGDATABASE` | PostgreSQL database name | + ### Using Environment Variables 1. Copy the example environment file: @@ -25,16 +90,41 @@ cp .env.example .env 2. Edit `.env` to customize settings: ```bash +# Database Configuration +PGHOST=localhost +PGPORT=5432 +PGUSER=postgres +PGPASSWORD=yourpassword +PGDATABASE=stac_db + +# Crawler Configuration CRAWL_MODE=both -MAX_CATALOGS=20 -MAX_APIS=10 -TIMEOUT_MS=60000 -MAX_DEPTH=5 +MAX_CATALOGS=0 # 0 = unlimited +MAX_APIS=0 # 0 = unlimited +TIMEOUT_MS=30000 +MAX_DEPTH=3 + +# Scheduler Configuration +CRAWL_DAYS_INTERVAL=7 +CRAWL_RUN_ON_STARTUP=true +CRAWL_RETRY_ON_ERROR=true +CRAWL_RETRY_DELAY_HOURS=2 + +# Time Window Configuration (optional) +# Set CRAWL_ENFORCE_TIME_WINDOW=true to restrict crawling to specific hours +CRAWL_ENFORCE_TIME_WINDOW=false +CRAWL_ALLOWED_START_HOUR=22 # 10 PM +CRAWL_ALLOWED_END_HOUR=7 # 7 AM +CRAWL_GRACE_PERIOD_MINUTES=30 ``` -3. Run the crawler: +3. Run the crawler or scheduler: ```bash +# Single run npm start + +# Scheduled runs +node scheduler.js ``` ### Using CLI Arguments @@ -62,6 +152,8 @@ node index.js --help ## Running Locally +### Single Crawl Run + ```bash # Install dependencies npm install @@ -69,10 +161,45 @@ npm install # Run with default configuration npm start -# Run with custom configuration +# Run with custom configuration via CLI node index.js --mode catalogs --max-catalogs 15 ``` +### Scheduled Crawling + +```bash +# Start the scheduler (runs in foreground) +node scheduler.js + +# The scheduler will: +# - Run crawler immediately on startup (if CRAWL_RUN_ON_STARTUP=true) +# - Schedule next run based on CRAWL_DAYS_INTERVAL +# - Wait for allowed time window (if CRAWL_ENFORCE_TIME_WINDOW=true) +# - Automatically retry on errors (if CRAWL_RETRY_ON_ERROR=true) +# - Stop gracefully with Ctrl+C +``` + +### Time Window Examples + +**Example 1: Night-time only crawling (22:00 - 07:00)** +```bash +CRAWL_ENFORCE_TIME_WINDOW=true +CRAWL_ALLOWED_START_HOUR=22 +CRAWL_ALLOWED_END_HOUR=7 +``` + +**Example 2: Business hours crawling (09:00 - 17:00)** +```bash +CRAWL_ENFORCE_TIME_WINDOW=true +CRAWL_ALLOWED_START_HOUR=9 +CRAWL_ALLOWED_END_HOUR=17 +``` + +**Example 3: No restrictions (default)** +```bash +CRAWL_ENFORCE_TIME_WINDOW=false +``` + ## Docker ### Build and run with Docker @@ -81,14 +208,29 @@ node index.js --mode catalogs --max-catalogs 15 # Build the image docker build -t stac-crawler . -# Run with default configuration +# Run single crawl with default configuration docker run --rm stac-crawler # Run with environment variables -docker run --rm -e CRAWL_MODE=apis -e MAX_APIS=10 stac-crawler +docker run --rm \ + -e PGHOST=host.docker.internal \ + -e PGPORT=5432 \ + -e PGUSER=postgres \ + -e PGPASSWORD=yourpassword \ + -e PGDATABASE=stac_db \ + -e CRAWL_MODE=apis \ + -e MAX_APIS=10 \ + stac-crawler # Run with CLI arguments docker run --rm stac-crawler --mode catalogs --max-catalogs 20 + +# Run scheduler in Docker (detached) +docker run -d \ + --name stac-scheduler \ + -e PGHOST=host.docker.internal \ + -e CRAWL_DAYS_INTERVAL=7 \ + stac-crawler node scheduler.js ``` Or use npm scripts: @@ -103,7 +245,7 @@ npm run docker:run Create a `.env` file or modify `docker-compose.yml` to set environment variables: ```bash -# Start the crawler +# Start the crawler (single run) docker-compose up -d # View logs @@ -113,6 +255,16 @@ docker-compose logs -f docker-compose down ``` +For scheduled crawling with Docker Compose, modify `docker-compose.yml`: +```yaml +services: + crawler: + build: . + command: node scheduler.js # Use scheduler instead of single run + env_file: .env + restart: unless-stopped # Auto-restart on failure +``` + Or use npm scripts: ```bash @@ -157,37 +309,232 @@ The test suite covers utility functions with **110 tests** across three modules: All tests use **real STAC domain names and collection IDs** from production STAC APIs for realistic testing. +## Architecture + +### Core Components + +- **`index.js`** - Main crawler entry point for single runs +- **`scheduler.js`** - Scheduler for periodic automated crawling +- **`utils/db.js`** - Database helper with PostgreSQL connection pool + - `initDb()` - Initialize and test database connection + - `insertOrUpdateCollection()` - Insert/update collections with deadlock retry logic + - `insertOrUpdateCatalog()` - Process catalogs (currently skips saving) + - Helper functions for keywords, extensions, providers, assets, summaries +- **`utils/normalization.js`** - Data normalization and processing +- **`utils/parallel.js`** - Parallel execution utilities with domain-based batching +- **`utils/config.js`** - Configuration management (env vars + CLI) +- **`utils/time.js`** - Time formatting utilities +- **`catalogs/catalog.js`** - Static catalog crawling logic +- **`apis/api.js`** - STAC API crawling logic + +### Database Schema + +The crawler stores collections in PostgreSQL with the following key tables: +- `collection` - Main collection data with spatial/temporal extents +- `collection_summaries` - Collection summary metadata +- `collection_keywords` - Keywords linked to collections +- `collection_stac_extension` - STAC extensions used +- `collection_providers` - Data providers +- `collection_assets` - Collection assets +- `crawllog_collection` - Crawl history and timestamps + +### Scheduler Workflow + +1. **Initialization**: Load configuration from `.env` +2. **Startup Check**: Verify if within allowed time window +3. **Initial Run**: Execute crawler immediately (if enabled) +4. **Schedule Next**: Calculate next run time based on interval +5. **Time Window Adjustment**: Shift schedule to fit time window if enforced +6. **Wait**: Sleep until next scheduled time +7. **Execute**: Run crawler and collect statistics +8. **Error Handling**: + - DB errors → Stop scheduler + - Crawl errors → Retry after delay (if enabled) + - Success → Schedule next run +9. **Repeat**: Loop back to step 6 + +### Error Handling + +- **Database Errors**: Scheduler stops to prevent data corruption +- **Crawl Errors**: Automatic retry with configurable delay (default: 2 hours) +- **Deadlock Handling**: Automatic retry with exponential backoff for database deadlocks +- **Time Window Violations**: Grace period allows crawler to finish current operations +- **Connection Errors**: Detailed error logging with connection details + +## Troubleshooting + +### Scheduler Not Running + +Check that: +1. Database connection is configured correctly in `.env` +2. Database is accessible and running +3. Time window settings allow execution (if `CRAWL_ENFORCE_TIME_WINDOW=true`) + +View scheduler status: +```bash +node scheduler.js +# Output shows current configuration and time window status +``` + +### Crawler Runs Too Frequently + +Increase `CRAWL_DAYS_INTERVAL`: +```bash +CRAWL_DAYS_INTERVAL=14 # Run every 2 weeks +``` + +### Crawler Only Runs at Specific Times + +This is controlled by time window enforcement. To allow crawling anytime: +```bash +CRAWL_ENFORCE_TIME_WINDOW=false +``` + +### Database Connection Errors + +Verify database configuration: +```bash +# Test connection manually +psql -h $PGHOST -p $PGPORT -U $PGUSER -d $PGDATABASE +``` + +Check environment variables are loaded: +```bash +node -e "require('dotenv').config(); console.log(process.env.PGHOST)" +``` + +### Deadlock Errors + +The crawler has automatic deadlock retry logic with exponential backoff. If deadlocks persist: +- Reduce parallel execution settings +- Increase database connection pool size +- Check database load and indexing + +## Performance Tuning + +### Parallel Execution Settings + +Control parallel processing (in `.env.example` or code): +- `PARALLEL_DOMAINS` - Number of domains to process simultaneously +- `MAX_REQUESTS_PER_MINUTE_PER_DOMAIN` - Rate limit per domain +- `MAX_CONCURRENCY_PER_DOMAIN` - Max concurrent requests per domain +- `MAX_CONCURRENCY` - Overall max concurrency + +### Database Connection Pool + +Adjust pool size in `utils/db.js`: +```javascript +const pool = new Pool({ + // ... other settings + max: 10, // Increase for higher parallelism +}); +``` + +### Timeout Configuration + +Increase timeouts for slow endpoints: +```bash +TIMEOUT_MS=120000 # 2 minutes +``` + +## npm Scripts + +```bash +npm start # Run crawler once +npm test # Run all tests +npm run test:watch # Run tests in watch mode +npm run docker:build # Build Docker image +npm run docker:run # Run Docker container +npm run docker:compose:up # Start with docker-compose +npm run docker:compose:down # Stop docker-compose +``` + +## License + +See LICENSE file in the project root. + ## Examples -### Example 1: Quick API Test +### Single-Run Examples + +#### Example 1: Quick API Test Crawl only the first 3 APIs with a short timeout: ```bash node index.js -m apis -a 3 -t 15000 ``` -### Example 2: Deep Catalog Exploration +#### Example 2: Deep Catalog Exploration Crawl 100 catalogs with maximum depth and extended timeout: ```bash node index.js -m catalogs -c 100 -d 10 -t 120000 ``` -### Example 3: Balanced Crawl +#### Example 3: Balanced Crawl Crawl both catalogs and APIs with moderate settings: ```bash node index.js -m both -c 25 -a 15 -t 45000 -d 4 ``` -### Example 4: Production Environment -Set up `.env` for production: +### Scheduler Examples + +#### Example 1: Weekly Full Crawl (Default) +Run complete crawl every 7 days, anytime: ```bash +CRAWL_DAYS_INTERVAL=7 +CRAWL_RUN_ON_STARTUP=true +CRAWL_ENFORCE_TIME_WINDOW=false +``` + +#### Example 2: Night-time Weekly Crawl +Run every 7 days, only between 22:00 and 07:00: +```bash +CRAWL_DAYS_INTERVAL=7 +CRAWL_ENFORCE_TIME_WINDOW=true +CRAWL_ALLOWED_START_HOUR=22 +CRAWL_ALLOWED_END_HOUR=7 +CRAWL_GRACE_PERIOD_MINUTES=30 +``` + +#### Example 3: Daily Updates +Run every day with retry on errors: +```bash +CRAWL_DAYS_INTERVAL=1 +CRAWL_RUN_ON_STARTUP=true +CRAWL_RETRY_ON_ERROR=true +CRAWL_RETRY_DELAY_HOURS=2 +``` + +#### Example 4: Production Setup +Full production configuration in `.env`: +```bash +# Database +PGHOST=db.production.com +PGPORT=5432 +PGUSER=crawler_user +PGPASSWORD=secure_password +PGDATABASE=stac_production + +# Crawler - Full scan CRAWL_MODE=both -MAX_CATALOGS=1000 -MAX_APIS=500 +MAX_CATALOGS=0 # Unlimited +MAX_APIS=0 # Unlimited TIMEOUT_MS=60000 MAX_DEPTH=5 + +# Scheduler - Weekly night crawls +CRAWL_DAYS_INTERVAL=7 +CRAWL_RUN_ON_STARTUP=false # Wait for scheduled time +CRAWL_RETRY_ON_ERROR=true +CRAWL_RETRY_DELAY_HOURS=2 + +# Time Window - Night time only +CRAWL_ENFORCE_TIME_WINDOW=true +CRAWL_ALLOWED_START_HOUR=22 +CRAWL_ALLOWED_END_HOUR=7 +CRAWL_GRACE_PERIOD_MINUTES=30 ``` -Then run: +Then run the scheduler: ```bash -npm start +node scheduler.js ``` From 5c348f15bfdfb1942cd22ac943c8ec751ff8c8e1 Mon Sep 17 00:00:00 2001 From: Jakob <70800454+vertrox78@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:13:33 +0100 Subject: [PATCH 127/134] Added Resume and Pause capabilty with the new db layout, edited the test accordingly. Closes #270 (#285) * #241, Add unit tests for normalization and parallel execution utilities. * #270, added stac node validation for static catalogs from stac indedx to reduce errors with not stac compliant catalogs * #270, added stac node validation for static catalogs from stac indedx to reduce errors with not stac compliant catalogs * Implement pause/resume functionality and enhance crawllog integration for collections and catalogs * Remove unused variable from clearAllCrawllogs function in db.js --- STAC-Atlas | 1 + crawler/__tests__/api.test.js | 13 ++ crawler/__tests__/is_api.test.js | 8 +- crawler/apis/api.js | 49 ++++++- crawler/catalogs/catalog.js | 22 ++- crawler/index.js | 129 +++++++++++++--- crawler/utils/cli.js | 6 + crawler/utils/config.js | 3 + crawler/utils/db.js | 245 +++++++++++++++++++++++++++---- crawler/utils/endpoints.js | 4 +- crawler/utils/handlers.js | 48 +++++- 11 files changed, 459 insertions(+), 69 deletions(-) create mode 160000 STAC-Atlas diff --git a/STAC-Atlas b/STAC-Atlas new file mode 160000 index 0000000..dc25775 --- /dev/null +++ b/STAC-Atlas @@ -0,0 +1 @@ +Subproject commit dc257754f6d6c5d19b522bbd79f863fe5e28ae0f diff --git a/crawler/__tests__/api.test.js b/crawler/__tests__/api.test.js index 0ab9233..3ee8c9c 100644 --- a/crawler/__tests__/api.test.js +++ b/crawler/__tests__/api.test.js @@ -13,6 +13,19 @@ jest.unstable_mockModule('../utils/handlers.js', () => ({ handleCollections: jest.fn() })); +// Mock db module +jest.unstable_mockModule('../utils/db.js', () => ({ + default: { + isCollectionUrlCrawled: jest.fn().mockResolvedValue(false), + getCrawledCollectionUrls: jest.fn().mockResolvedValue(new Set()) + } +})); + +// Mock index.js to avoid side effects from main module +jest.unstable_mockModule('../index.js', () => ({ + isShutdownRequested: jest.fn().mockReturnValue(false) +})); + // Import the actual module to test const { checkAndFlushApi, BATCH_SIZE, API_CLEAR_BATCH_SIZE } = await import('../apis/api.js'); diff --git a/crawler/__tests__/is_api.test.js b/crawler/__tests__/is_api.test.js index 2ce63cf..4853204 100644 --- a/crawler/__tests__/is_api.test.js +++ b/crawler/__tests__/is_api.test.js @@ -17,9 +17,15 @@ jest.unstable_mockModule('../utils/normalization.js', () => ({ // Mock db module const mockInsertOrUpdateCollection = jest.fn(); +const mockInsertOrUpdateCatalog = jest.fn(); +const mockIsCollectionUrlCrawled = jest.fn().mockResolvedValue(false); +const mockGetCrawledCollectionUrls = jest.fn().mockResolvedValue(new Set()); jest.unstable_mockModule('../utils/db.js', () => ({ default: { - insertOrUpdateCollection: mockInsertOrUpdateCollection + insertOrUpdateCollection: mockInsertOrUpdateCollection, + insertOrUpdateCatalog: mockInsertOrUpdateCatalog, + isCollectionUrlCrawled: mockIsCollectionUrlCrawled, + getCrawledCollectionUrls: mockGetCrawledCollectionUrls } })); diff --git a/crawler/apis/api.js b/crawler/apis/api.js index 9b8beb7..bc4fa0f 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -16,6 +16,8 @@ import { logDomainStats } from '../utils/parallel.js'; import globalStats from '../utils/globalStats.js'; +import db from '../utils/db.js'; +import { isShutdownRequested } from '../index.js'; /** * Batch size for saving collections to database during API crawling @@ -181,6 +183,7 @@ async function crawlSingleApiDomain(apis, domain, config = {}) { apiId: `${domain}-api-${index}`, apiUrl: api.url, apiSlug: api.slug, + crawllogCatalogId: api.crawllogCatalogId, // Link to crawllog_catalog for collections depth: 0 } })); @@ -257,12 +260,20 @@ async function crawlApis(apis, isApi, config = {}) { console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); console.log(`============================================\n`); - // Create tasks for each domain (pass full API objects including slug) + // Create tasks for each domain (pass full API objects including slug), with shutdown check const domainTasks = Array.from(domainMap.entries()).map(([domain, domainApis]) => { - return () => crawlSingleApiDomain(domainApis, domain, config); + return async () => { + // Check if shutdown was requested before starting this domain + if (isShutdownRequested()) { + console.log(` [${domain}] Skipped (shutdown requested)`); + return { stats: { totalRequests: 0, successfulRequests: 0, failedRequests: 0, collectionsFound: 0, collectionsSaved: 0, collectionsFailed: 0, apisProcessed: 0, stacCompliant: 0, nonCompliant: 0 } }; + } + return crawlSingleApiDomain(domainApis, domain, config); + }; }); console.log(`Starting parallel API crawl of ${domainMap.size} domains (${parallelDomains} at a time)...\n`); + console.log(`Press Ctrl+C to pause (will stop after current batch and resume on next run)\n`); // Track total runtime for throughput calculation const crawlStartTime = Date.now(); @@ -272,7 +283,11 @@ async function crawlApis(apis, isApi, config = {}) { domainTasks, parallelDomains, (completed, total) => { - console.log(`\n>>> Domain progress: ${completed}/${total} domains completed <<<\n`); + if (isShutdownRequested()) { + console.log(`\n>>> Shutdown requested. Stopping after current domains complete... <<<\n`); + } else { + console.log(`\n>>> Domain progress: ${completed}/${total} domains completed <<<\n`); + } } ); @@ -319,6 +334,7 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max const apiId = request.userData?.apiId || 'unknown'; const apiUrl = request.userData?.apiUrl || request.url; const apiSlug = request.userData?.apiSlug || null; + const crawllogCatalogId = request.userData?.crawllogCatalogId || null; const depth = request.userData?.depth || 0; log.info(`${indent}Processing API: ${apiId} at ${apiUrl} (depth: ${depth})`); @@ -352,11 +368,22 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max // If this is a STAC Collection directly, extract and store it if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { + // Check if this collection URL was already crawled (pause/resume support) + const alreadyCrawled = await db.isCollectionUrlCrawled(request.url); + if (alreadyCrawled) { + log.info(`${indent}Skipping already-crawled collection: ${stacObj.id} (resume mode)`); + return; + } + const collection = normalizeCollection(stacObj, results.collections.length); // Add the API slug to the collection for unique stac_id generation collection.sourceSlug = apiSlug; // Mark as API collection collection.is_api = true; + // Link to crawllog_catalog + collection.crawllogCatalogId = crawllogCatalogId; + // Store the crawled URL + collection.crawledUrl = request.url; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -390,7 +417,8 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max userData: { apiId: apiId, apiUrl: apiUrl, - catalogSlug: apiSlug // Use catalogSlug for compatibility with handleCollections + catalogSlug: apiSlug, // Use catalogSlug for compatibility with handleCollections + crawllogCatalogId: crawllogCatalogId // Link to crawllog_catalog for collections } }]); @@ -456,6 +484,7 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max apiUrl: apiUrl, apiSlug: apiSlug, catalogSlug: apiSlug, // Use catalogSlug for compatibility with handleCollections + crawllogCatalogId: crawllogCatalogId, // Link to crawllog_catalog for collections parentId: apiId, depth: nextDepth } @@ -481,6 +510,14 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max async function handleApiCollection({ request, json, crawler, log, indent, results }) { const apiId = request.userData?.apiId || 'unknown'; const apiSlug = request.userData?.apiSlug || request.userData?.catalogSlug || null; + const crawllogCatalogId = request.userData?.crawllogCatalogId || null; + + // Check if this collection URL was already crawled (pause/resume support) + const alreadyCrawled = await db.isCollectionUrlCrawled(request.url); + if (alreadyCrawled) { + log.info(`${indent}Skipping already-crawled collection at ${request.url} (resume mode)`); + return; + } let stacObj; try { @@ -492,6 +529,10 @@ async function handleApiCollection({ request, json, crawler, log, indent, result collection.sourceSlug = apiSlug; // Mark as API collection collection.is_api = true; + // Link to crawllog_catalog + collection.crawllogCatalogId = crawllogCatalogId; + // Store the crawled URL + collection.crawledUrl = request.url; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 9b75e15..128d6b2 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -14,6 +14,7 @@ import { logDomainStats } from '../utils/parallel.js'; import globalStats from '../utils/globalStats.js'; +import { isShutdownRequested } from '../index.js'; /** * Creates and runs a single Crawlee HttpCrawler for a specific domain @@ -143,7 +144,8 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { depth: 0, catalogId: catalog.id, catalogTitle: catalog.title, - catalogSlug: catalog.slug + catalogSlug: catalog.slug, + crawllogCatalogId: catalog.crawllogCatalogId // Pass for linking collections to crawllog_catalog } })); @@ -203,12 +205,20 @@ async function crawlCatalogs(initialCatalogs, config = {}) { console.log(`Theoretical max throughput: ${parallelDomains * maxRequestsPerMinutePerDomain} req/min across all domains`); console.log(`===============================================\n`); - // Create tasks for each domain + // Create tasks for each domain, with shutdown check const domainTasks = Array.from(domainMap.entries()).map(([domain, catalogs]) => { - return () => crawlSingleDomain(catalogs, domain, config); + return async () => { + // Check if shutdown was requested before starting this domain + if (isShutdownRequested()) { + console.log(` [${domain}] Skipped (shutdown requested)`); + return { stats: { totalRequests: 0, successfulRequests: 0, failedRequests: 0, collectionsFound: 0, collectionsSaved: 0, collectionsFailed: 0, catalogsProcessed: 0, stacCompliant: 0, nonCompliant: 0 } }; + } + return crawlSingleDomain(catalogs, domain, config); + }; }); console.log(`Starting parallel crawl of ${domainMap.size} domains (${parallelDomains} at a time)...\n`); + console.log(`Press Ctrl+C to pause (will stop after current batch and resume on next run)\n`); // Track total runtime for throughput calculation const crawlStartTime = Date.now(); @@ -218,7 +228,11 @@ async function crawlCatalogs(initialCatalogs, config = {}) { domainTasks, parallelDomains, (completed, total) => { - console.log(`\n>>> Domain progress: ${completed}/${total} domains completed <<<\n`); + if (isShutdownRequested()) { + console.log(`\n>>> Shutdown requested. Stopping after current domains complete... <<<\n`); + } else { + console.log(`\n>>> Domain progress: ${completed}/${total} domains completed <<<\n`); + } } ); diff --git a/crawler/index.js b/crawler/index.js index 8088850..adfe60a 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -18,6 +18,18 @@ import globalStats from './utils/globalStats.js'; */ const targetUrl = 'https://www.stacindex.org/api/catalogs'; +/** + * Flag to track if shutdown was requested + */ +let shutdownRequested = false; + +/** + * Check if shutdown was requested (can be used by crawlers to stop early) + */ +export function isShutdownRequested() { + return shutdownRequested; +} + /** * Fetches catalog data from the STAC Index API and processes it * @async @@ -30,6 +42,28 @@ export const crawler = async () => { let dbError = false; let crawlError = false; + // Setup graceful shutdown handler + const shutdownHandler = async (signal) => { + if (shutdownRequested) { + console.log('\nForce shutdown requested. Exiting immediately...'); + process.exit(1); + } + + console.log(`\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); + console.log(`PAUSE REQUESTED (${signal})`); + console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); + console.log(`The crawler will stop after the current batch completes.`); + console.log(`Already-crawled collections are saved in crawllog_collection.`); + console.log(`Re-run the crawler to resume from where it left off.`); + console.log(`Press Ctrl+C again to force immediate exit.`); + console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`); + + shutdownRequested = true; + }; + + process.on('SIGINT', shutdownHandler); + process.on('SIGTERM', shutdownHandler); + try { // Load configuration const config = getConfig(); @@ -43,9 +77,21 @@ export const crawler = async () => { throw err; } + // Clear crawllog if fresh mode is enabled (allows re-crawling everything) + if (config.fresh) { + console.log('\n=== Fresh mode enabled - Clearing crawllog ==='); + try { + await db.clearCrawllogCollection(); + console.log('Crawllog collection entries cleared. All URLs will be re-crawled.'); + } catch (err) { + console.error(`Warning: Failed to clear crawllog: ${err.message}`); + } + } + // Display configuration console.log('\n=== STAC Crawler Configuration ==='); console.log(`Mode: ${config.mode}`); + console.log(`Fresh Mode: ${config.fresh ? 'enabled (re-crawl everything)' : 'disabled (resume/skip crawled URLs)'}`); console.log(`Max Catalogs: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs} (debugging limit)`); console.log(`Max APIs: ${config.maxApis === 0 ? 'unlimited' : config.maxApis} (debugging limit)`); console.log(`Timeout: ${config.timeout === Infinity ? 'unlimited' : config.timeout + 'ms'}`); @@ -60,44 +106,77 @@ export const crawler = async () => { const response = await axios.get(targetUrl); const catalogs = processCatalogs(response.data); - // Save catalogs from STAC Index to database - console.log('\n=== Saving catalogs to database ==='); + // Save catalogs from STAC Index to crawllog_catalog table + // This creates the URL queue for re-crawling and stores the slug for stac_id generation + console.log('\n=== Saving catalogs to crawllog_catalog ==='); let catalogsSaved = 0; let catalogsFailed = 0; - for (const catalog of response.data) { + for (const catalog of catalogs) { try { - const catalogId = await db.insertOrUpdateCatalog(catalog); - console.log(`Saved catalog: ${catalog.title || catalog.id} (DB ID: ${catalogId})`); + const isApi = catalog.isApi === true && !isStaticCatalogUrl(catalog.url); + const crawllogId = await db.saveCrawllogCatalog({ + slug: catalog.slug, + url: catalog.url, + isApi: isApi + }); + console.log(`Saved: ${catalog.title || catalog.slug} (crawllog_id: ${crawllogId}, isApi: ${isApi})`); catalogsSaved++; } catch (err) { - console.error(`Failed catalog: ${catalog.title || catalog.id} - ${err.message}`); + console.error(`Failed: ${catalog.title || catalog.slug} - ${err.message}`); catalogsFailed++; } } - console.log(`\nCatalogs: ${catalogsSaved} saved, ${catalogsFailed} failed\n`); + console.log(`\nCrawllog Catalogs: ${catalogsSaved} saved, ${catalogsFailed} failed\n`); + + // Now fetch the URL queue from crawllog_catalog for re-crawling + // This allows us to re-crawl existing catalogs without fetching from STAC Index again + console.log('\n=== Loading catalogs from crawllog_catalog for crawling ==='); + const crawllogCatalogs = await db.getCrawllogCatalogs({ isApi: false }); + const crawllogApis = await db.getCrawllogCatalogs({ isApi: true }); + + console.log(`Loaded ${crawllogCatalogs.length} catalogs and ${crawllogApis.length} APIs from crawllog_catalog\n`); + + // Merge original catalog metadata with crawllog entries for crawling + // We need the full catalog info (title, etc.) for processing + const catalogUrlMap = new Map(catalogs.map(c => [c.url, c])); + + const regularCatalogs = crawllogCatalogs.map(cl => { + const original = catalogUrlMap.get(cl.url) || {}; + return { + ...original, + id: cl.id, + slug: cl.slug, + url: cl.url, + crawllogCatalogId: cl.id // Pass the crawllog_catalog id for linking + }; + }); - // Separate static catalogs from real APIs - const staticCatalogs = catalogs.filter(cat => cat.isApi === true && isStaticCatalogUrl(cat.url)); - const regularCatalogs = catalogs.filter(cat => cat.isApi !== true); - const realApis = catalogs.filter(cat => cat.isApi === true && !isStaticCatalogUrl(cat.url)); + const realApis = crawllogApis.map(cl => { + const original = catalogUrlMap.get(cl.url) || {}; + return { + ...original, + id: cl.id, + slug: cl.slug, + url: cl.url, + crawllogCatalogId: cl.id // Pass the crawllog_catalog id for linking + }; + }); - console.log(`\nCatalog Classification:`); - console.log(` Regular Catalogs: ${regularCatalogs.length}`); - console.log(` Static Catalogs (mismarked as APIs): ${staticCatalogs.length}`); - console.log(` Real APIs: ${realApis.length}\n`); + console.log(`\nCatalog Classification (from crawllog_catalog):`); + console.log(` Catalogs: ${regularCatalogs.length}`); + console.log(` APIs: ${realApis.length}\n`); // Start global statistics tracking (no periodic logging, only final stats) - const totalItems = [...regularCatalogs, ...staticCatalogs, ...realApis].length; + const totalItems = [...regularCatalogs, ...realApis].length; globalStats.start(totalItems); // Crawl catalogs if mode is 'catalogs' or 'both' if (config.mode === 'catalogs' || config.mode === 'both') { console.log('\nCrawling collections and nested catalogs with Crawlee...\n'); - // Combine regular catalogs with static catalogs (mismarked as APIs) - const allCatalogsToProcess = [...regularCatalogs, ...staticCatalogs]; + const allCatalogsToProcess = regularCatalogs; // Note: MAX_CATALOGS limit is for debugging purposes only // Set maxCatalogs to 0 or use --max-catalogs 0 for unlimited catalog crawling @@ -105,8 +184,7 @@ export const crawler = async () => { ? allCatalogsToProcess : allCatalogsToProcess.slice(0, config.maxCatalogs); - console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs})`); - console.log(` (includes ${staticCatalogs.length} static catalogs mismarked as APIs)\n`); + console.log(`Processing ${catalogsToProcess.length} catalogs (max: ${config.maxCatalogs === 0 ? 'unlimited' : config.maxCatalogs})\n`); try { const results = await crawlCatalogs(catalogsToProcess, config); @@ -121,14 +199,19 @@ export const crawler = async () => { // Crawl APIs if mode is 'apis' or 'both' if (config.mode === 'apis' || config.mode === 'both') { console.log('\nCrawling APIs...'); - // Pass full API objects (including slug) instead of just URLs - const apiObjects = realApis.map(cat => ({ url: cat.url, slug: cat.slug, title: cat.title })); + // Pass full API objects (including slug and crawllogCatalogId) instead of just URLs + const apiObjects = realApis.map(api => ({ + url: api.url, + slug: api.slug, + title: api.title, + crawllogCatalogId: api.crawllogCatalogId // Link to crawllog_catalog for collections + })); if (apiObjects.length > 0) { // Note: MAX_APIS limit is for debugging purposes only // Set maxApis to 0 or use --max-apis 0 for unlimited API crawling const apisToProcess = config.maxApis === 0 ? apiObjects : apiObjects.slice(0, config.maxApis); - console.log(`Found ${realApis.length} real APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); + console.log(`Found ${realApis.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); try { await crawlApis(apisToProcess, true, config); diff --git a/crawler/utils/cli.js b/crawler/utils/cli.js index 2cf0d2c..cf98e54 100644 --- a/crawler/utils/cli.js +++ b/crawler/utils/cli.js @@ -40,6 +40,8 @@ export function parseCliArgs() { config.maxRequestsPerMinutePerDomain = parseInt(args[++i], 10); } else if (arg === '--concurrency-per-domain') { config.maxConcurrencyPerDomain = parseInt(args[++i], 10); + } else if (arg === '--fresh' || arg === '-f') { + config.fresh = true; } else if (arg === '--help' || arg === '-h') { printHelp(); process.exit(0); @@ -70,6 +72,10 @@ STAC Crawler Configuration Options: Total throughput = parallel-domains × rpm-per-domain --concurrency-per-domain Max concurrent requests per domain (default: 10) + Resume/Fresh Options: + -f, --fresh Clear crawl log and start fresh (ignore previous progress) + Without this flag, crawler resumes from where it left off + Legacy Rate Limiting Options (still supported): --max-concurrency Maximum concurrent requests (default: 5) --requests-per-minute, --rpm Maximum requests per minute (default: 60) diff --git a/crawler/utils/config.js b/crawler/utils/config.js index e0ebdcf..3e83563 100644 --- a/crawler/utils/config.js +++ b/crawler/utils/config.js @@ -76,6 +76,9 @@ function getConfig() { maxConcurrencyPerDomain: cliArgs.maxConcurrencyPerDomain !== undefined ? cliArgs.maxConcurrencyPerDomain : (process.env.MAX_CONCURRENCY_PER_DOMAIN ? parseInt(process.env.MAX_CONCURRENCY_PER_DOMAIN, 10) : defaults.maxConcurrencyPerDomain), + // Fresh start option - clear crawl log and recrawl everything + fresh: cliArgs.fresh || process.env.FRESH_CRAWL === 'true' || false, + // Legacy rate limiting options maxConcurrency: cliArgs.maxConcurrency !== undefined ? cliArgs.maxConcurrency : (process.env.MAX_CONCURRENCY ? parseInt(process.env.MAX_CONCURRENCY, 10) : defaults.maxConcurrency), diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 3b3c330..53fb9fc 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -82,6 +82,168 @@ async function insertOrUpdateCatalog(catalog) { return null; } +/** + * Insert or update a catalog/API entry in crawllog_catalog table + * This stores the URL queue for re-crawling and the slug for stac_id generation + * @param {Object} catalogInfo - Catalog info object + * @param {string} catalogInfo.slug - The STAC Index slug for this catalog + * @param {string} catalogInfo.url - The source URL of the catalog/API + * @param {boolean} catalogInfo.isApi - Whether this is an API (true) or static catalog (false) + * @returns {Promise} The crawllog_catalog id + */ +async function saveCrawllogCatalog(catalogInfo) { + if (!catalogInfo || !catalogInfo.url) { + throw new Error('Catalog info with url is required'); + } + + const { slug, url, isApi = false } = catalogInfo; + + const result = await pool.query( + `INSERT INTO crawllog_catalog (slug, source_url, is_api, updated_at) + VALUES ($1, $2, $3, now()) + ON CONFLICT (source_url) DO UPDATE SET + slug = COALESCE(EXCLUDED.slug, crawllog_catalog.slug), + is_api = EXCLUDED.is_api, + updated_at = now() + RETURNING id`, + [slug || null, url, isApi] + ); + + return result.rows[0].id; +} + +/** + * Get all catalogs from crawllog_catalog for re-crawling + * @param {Object} options - Query options + * @param {boolean} options.isApi - If provided, filter by API status + * @returns {Promise} Array of catalog objects with id, slug, source_url, is_api + */ +async function getCrawllogCatalogs(options = {}) { + let query = 'SELECT id, slug, source_url, is_api FROM crawllog_catalog'; + const params = []; + + if (options.isApi !== undefined) { + query += ' WHERE is_api = $1'; + params.push(options.isApi); + } + + query += ' ORDER BY id'; + + const result = await pool.query(query, params); + return result.rows.map(row => ({ + id: row.id, + slug: row.slug, + url: row.source_url, + isApi: row.is_api + })); +} + +/** + * Get the crawllog_catalog id for a given source URL + * @param {string} sourceUrl - The source URL to look up + * @returns {Promise} The crawllog_catalog id or null if not found + */ +async function getCrawllogCatalogIdByUrl(sourceUrl) { + if (!sourceUrl) return null; + + const result = await pool.query( + 'SELECT id FROM crawllog_catalog WHERE source_url = $1', + [sourceUrl] + ); + + return result.rows.length > 0 ? result.rows[0].id : null; +} + +/** + * Get the slug for a given crawllog_catalog id + * Used to generate stac_id for collections + * @param {number} crawllogCatalogId - The crawllog_catalog id + * @returns {Promise} The slug or null if not found + */ +async function getSlugByCrawllogCatalogId(crawllogCatalogId) { + if (!crawllogCatalogId) return null; + + const result = await pool.query( + 'SELECT slug FROM crawllog_catalog WHERE id = $1', + [crawllogCatalogId] + ); + + return result.rows.length > 0 ? result.rows[0].slug : null; +} + +/** + * Get all already-crawled collection URLs for a given crawllog_catalog + * Used for pause/resume functionality - skip URLs that have already been processed + * @param {number} crawllogCatalogId - The crawllog_catalog id + * @returns {Promise>} Set of source URLs already in crawllog_collection + */ +async function getCrawledCollectionUrls(crawllogCatalogId) { + if (!crawllogCatalogId) return new Set(); + + const result = await pool.query( + 'SELECT source_url FROM crawllog_collection WHERE crawllog_catalog_id = $1 AND source_url IS NOT NULL', + [crawllogCatalogId] + ); + + return new Set(result.rows.map(row => row.source_url)); +} + +/** + * Check if a specific collection URL has already been crawled + * @param {string} sourceUrl - The source URL to check + * @returns {Promise} True if URL exists in crawllog_collection + */ +async function isCollectionUrlCrawled(sourceUrl) { + if (!sourceUrl) return false; + + const result = await pool.query( + 'SELECT 1 FROM crawllog_collection WHERE source_url = $1 LIMIT 1', + [sourceUrl] + ); + + return result.rows.length > 0; +} + +/** + * Update the updated_at timestamp for a crawllog_catalog entry + * Called when a catalog has been fully processed + * @param {number} crawllogCatalogId - The crawllog_catalog id + */ +async function markCatalogCrawled(crawllogCatalogId) { + if (!crawllogCatalogId) return; + + await pool.query( + 'UPDATE crawllog_catalog SET updated_at = now() WHERE id = $1', + [crawllogCatalogId] + ); +} + +/** + * Clear all entries from crawllog_collection table + * Used for fresh crawl - forces re-crawling of all collections + * @returns {Promise} Number of rows deleted + */ +async function clearCrawllogCollection() { + const result = await pool.query('DELETE FROM crawllog_collection'); + return result.rowCount; +} + +/** + * Clear all entries from both crawllog tables + * Used for complete fresh start + * @returns {Promise<{catalogs: number, collections: number}>} Number of rows deleted from each table + */ +async function clearAllCrawllogs() { + // Delete collections first (foreign key constraint) + const collectionsResult = await pool.query('DELETE FROM crawllog_collection'); + const catalogsResult = await pool.query('DELETE FROM crawllog_catalog'); + + return { + catalogs: catalogsResult.rowCount, + collections: collectionsResult.rowCount + }; +} + /** * Check if an error is a PostgreSQL deadlock error * @param {Error} error - The error to check @@ -208,32 +370,21 @@ async function _insertOrUpdateCollectionInternal(collection) { sourceUrl = selfLink?.href || rootLink?.href || null; } - // Check if collection with same stac_id from same source already exists - // This allows collections with the same title from different sources to coexist + // Check if collection with same stac_id already exists - stac_id is the unique key for upsert + // stac_id format: {sourceSlug}_{collection.id} ensures uniqueness across sources let existingCollection; - if (stacId && sourceUrl) { - // Best case: match by stac_id + source_url (most precise) + if (stacId) { + // Primary matching: stac_id is unique, so match by stac_id alone existingCollection = await client.query( - 'SELECT id FROM collection WHERE stac_id = $1 AND full_json->>\'links\' LIKE $2', - [stacId, `%${sourceUrl}%`] + 'SELECT id FROM collection WHERE stac_id = $1', + [stacId] + ); + } else { + // Fallback for collections without stac_id: match by title + source_url + existingCollection = await client.query( + 'SELECT id FROM collection WHERE stac_id IS NULL AND title = $1 AND source_url = $2', + [collectionTitle, sourceUrl] ); - } - - // Fallback: if no stac_id or source_url, check by title only (legacy behavior) - if (!existingCollection || existingCollection.rows.length === 0) { - if (stacId) { - // Try matching by stac_id + title combination - existingCollection = await client.query( - 'SELECT id FROM collection WHERE stac_id = $1 AND title = $2', - [stacId, collectionTitle] - ); - } else { - // Last resort: match by title only (for collections without stac_id) - existingCollection = await client.query( - 'SELECT id FROM collection WHERE title = $1 AND stac_id IS NULL', - [collectionTitle] - ); - } } // Use originalJson if available (from normalizeCollection), otherwise use the collection object @@ -334,15 +485,42 @@ async function _insertOrUpdateCollectionInternal(collection) { await insertAssets(client, collectionId, collection.assets); } - // Update crawl log + // Update crawl log with source_url and link to crawllog_catalog + // First, find the crawllog_catalog_id based on the source URL or parent catalog URL + let crawllogCatalogId = null; + if (collection.crawllogCatalogId) { + // Use the directly provided crawllog_catalog_id from the crawler + crawllogCatalogId = collection.crawllogCatalogId; + } else if (sourceUrl) { + // Try to find the crawllog_catalog_id by matching parent URL + // Extract the base URL (remove /collections/X if present) + const baseUrl = sourceUrl.replace(/\/collections\/[^/]+$/, '').replace(/\/collections\/?$/, ''); + const catalogLookup = await client.query( + 'SELECT id FROM crawllog_catalog WHERE source_url = $1 OR source_url = $2', + [sourceUrl, baseUrl] + ); + if (catalogLookup.rows.length > 0) { + crawllogCatalogId = catalogLookup.rows[0].id; + } + } + + // Delete existing crawllog entry and insert new one with source_url await client.query( 'DELETE FROM crawllog_collection WHERE collection_id = $1', [collectionId] ); - await client.query( - 'INSERT INTO crawllog_collection (collection_id, last_crawled) VALUES ($1, now())', - [collectionId] - ); + + // Only insert into crawllog_collection if we have a source_url + if (sourceUrl) { + await client.query( + `INSERT INTO crawllog_collection (collection_id, source_url, crawllog_catalog_id) + VALUES ($1, $2, $3) + ON CONFLICT (source_url) DO UPDATE SET + collection_id = EXCLUDED.collection_id, + crawllog_catalog_id = EXCLUDED.crawllog_catalog_id`, + [collectionId, sourceUrl, crawllogCatalogId] + ); + } await client.query('COMMIT'); return collectionId; @@ -560,8 +738,15 @@ export default { initDb, insertOrUpdateCatalog, insertOrUpdateCollection, + saveCrawllogCatalog, + getCrawllogCatalogs, + getCrawllogCatalogIdByUrl, + getSlugByCrawllogCatalogId, + getCrawledCollectionUrls, + isCollectionUrlCrawled, + markCatalogCrawled, + clearCrawllogCollection, + clearAllCrawllogs, close, pool }; - - diff --git a/crawler/utils/endpoints.js b/crawler/utils/endpoints.js index 4a6c631..6ef6217 100644 --- a/crawler/utils/endpoints.js +++ b/crawler/utils/endpoints.js @@ -16,8 +16,9 @@ * @param {Object} log - Logger * @param {string} indent - Indentation for logging * @param {string} catalogSlug - Slug of the source catalog for unique ID generation + * @param {number} crawllogCatalogId - ID from crawllog_catalog for linking collections */ -export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, depth, crawler, log, indent, catalogSlug = null) { +export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, depth, crawler, log, indent, catalogSlug = null, crawllogCatalogId = null) { let collectionUrl = null; // Try to find collection endpoint from STAC links (proper STAC discovery) @@ -80,6 +81,7 @@ export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, de catalogUrl: baseUrl, catalogId, catalogSlug, + crawllogCatalogId, depth } }]); diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 0384503..f99d25e 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -172,13 +172,13 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul const depth = request.userData?.depth || 0; const catalogId = request.userData?.catalogId || 'unknown'; const catalogSlug = request.userData?.catalogSlug || null; + const crawllogCatalogId = request.userData?.crawllogCatalogId || null; const maxDepth = config.maxDepth || 0; // 0 = unlimited log.info(`${indent}Processing catalog: ${catalogId} (depth: ${depth}${maxDepth > 0 ? `/${maxDepth}` : ''})`); - // Validate STAC structure before attempting migration - const validation = validateStacStructure(json); + const validation = await validateStacStructure(json, log, indent); if (!validation.valid) { log.warning(`${indent}Pre-validation failed for catalog ${catalogId} at ${request.url}`); log.warning(`${indent}Validation error: ${validation.error}`); @@ -239,6 +239,13 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // If this is a STAC Collection (not a catalog), extract and store it // Collections don't have /collections endpoints, so we skip tryCollectionEndpoints for them if (isCollection) { + // Check if this collection URL was already crawled (pause/resume support) + const alreadyCrawled = await db.isCollectionUrlCrawled(request.url); + if (alreadyCrawled) { + log.info(`${indent}Skipping already-crawled collection: ${stacCatalog.id} (resume mode)`); + return; + } + const collection = normalizeCollection(stacCatalog, results.collections.length); // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; @@ -246,6 +253,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul collection.crawledUrl = request.url; // Mark as non-API collection (from static catalog) collection.is_api = false; + // Link to the crawllog_catalog for the parent catalog + collection.crawllogCatalogId = crawllogCatalogId; results.collections.push(collection); results.stats.collectionsFound++; log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); @@ -256,7 +265,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // Only try /collections endpoint for Catalogs, not Collections // Static STAC catalogs don't have /collections endpoints - they use rel="child" links // STAC APIs have /collections endpoints and advertise them via rel="data" or rel="collections" - await tryCollectionEndpoints(stacCatalog, request.url, catalogId, depth, crawler, log, indent, catalogSlug); + await tryCollectionEndpoints(stacCatalog, request.url, catalogId, depth, crawler, log, indent, catalogSlug, crawllogCatalogId); } // Extract and enqueue child catalog links using stac-js @@ -335,7 +344,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul depth: depth + 1, catalogId: linkTitle, parentId: catalogId, - catalogSlug: catalogSlug + catalogSlug: catalogSlug, + crawllogCatalogId: crawllogCatalogId // Pass through for linking collections } }; }) @@ -381,6 +391,7 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul export async function handleCollections({ request, json, crawler, log, indent, results, isApi = false }) { const catalogId = request.userData?.catalogId || 'unknown'; const catalogSlug = request.userData?.catalogSlug || null; + const crawllogCatalogId = request.userData?.crawllogCatalogId || null; // Validate STAC structure before attempting migration using stac-node-validator const validation = await validateStacStructure(json, log, indent); @@ -438,12 +449,27 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Remove trailing /collections from the request URL to get the API base const baseUrl = request.url.replace(/\/collections\/?$/, ''); + // Get already-crawled URLs for this catalog to enable pause/resume + let crawledUrls = new Set(); + if (crawllogCatalogId) { + try { + crawledUrls = await db.getCrawledCollectionUrls(crawllogCatalogId); + if (crawledUrls.size > 0) { + log.info(`${indent}Found ${crawledUrls.size} already-crawled collection URLs for this catalog`); + } + } catch (err) { + log.warning(`${indent}Failed to get crawled URLs: ${err.message}`); + } + } - // Normalize and store collections + // Normalize and store collections, skipping already-crawled ones + let skippedCount = 0; const collections = collectionsData.map((colObj, index) => { const collection = normalizeCollection(colObj, index); // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; + // Link to the crawllog_catalog for the parent catalog + collection.crawllogCatalogId = crawllogCatalogId; // Store the absolute URL as crawledUrl // Use stac-js getAbsoluteUrl() if available, otherwise construct from base + id @@ -459,11 +485,21 @@ export async function handleCollections({ request, json, crawler, log, indent, r collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; } + // Skip if this URL was already crawled (pause/resume support) + if (crawledUrls.has(collection.crawledUrl)) { + skippedCount++; + return null; + } + // Mark collection as API or static catalog based on context collection.is_api = isApi; return collection; - }); + }).filter(Boolean); // Remove nulls (skipped collections) + + if (skippedCount > 0) { + log.info(`${indent}Skipped ${skippedCount} already-crawled collections (resume mode)`); + } results.collections.push(...collections); results.stats.collectionsFound += collections.length; From 29bd9bba98bc0280e2c76e831c05b5672313a436 Mon Sep 17 00:00:00 2001 From: Lenn Date: Sun, 1 Feb 2026 13:56:16 +0100 Subject: [PATCH 128/134] Crawler: set is_api in collections table (#294) * feat(crawler): implement graceful shutdown functionality and reset flag for scheduler * fix(db): rename 'type' to 'title' in collection insert/update logic * feat(crawler): add FRESH_CRAWL option to .env.example for clearing crawl log * feat(crawler): add syncIsApiFromCatalog function to update is_api values in collections * fix(crawler): remove is_api from collection insert/update logic * feat(crawler): determine is_api based on source_url and remove syncIsApiFromCatalog function * feat(crawler): add functionality to deactivate stale collections not updated in 7 days --- crawler/.env.example | 2 ++ crawler/index.js | 34 +++++++++++++++++++++++++ crawler/scheduler.js | 10 +++++--- crawler/utils/db.js | 59 ++++++++++++++++++++++++++++++++++---------- 4 files changed, 89 insertions(+), 16 deletions(-) diff --git a/crawler/.env.example b/crawler/.env.example index 0cffe5e..b783f59 100644 --- a/crawler/.env.example +++ b/crawler/.env.example @@ -15,6 +15,8 @@ PGDATABASE=example_db # For single Crawler run: # 'catalogs', 'apis', or 'both' CRAWL_MODE=both +# Fresh crawl - clear crawl log and re-crawl all collections (true/false, default: false) +FRESH_CRAWL=false # Maximum Static Catalogs that are crawled (0 ist unlimited) MAX_CATALOGS=0 # Maximum API Catalogs that are crawled (0 ist unlimited) diff --git a/crawler/index.js b/crawler/index.js index adfe60a..96ddae7 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -25,11 +25,35 @@ let shutdownRequested = false; /** * Check if shutdown was requested (can be used by crawlers to stop early) + * @returns {boolean} True if shutdown was requested */ export function isShutdownRequested() { return shutdownRequested; } +/** + * Request a graceful shutdown of the crawler + * The crawler will stop after completing the current batch + */ +export function requestShutdown() { + if (!shutdownRequested) { + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('GRACEFUL SHUTDOWN REQUESTED'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log('The crawler will stop after the current batch completes.'); + console.log('Already-crawled collections are saved in crawllog_collection.'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); + shutdownRequested = true; + } +} + +/** + * Reset the shutdown flag (for scheduler to start a new crawl) + */ +export function resetShutdownFlag() { + shutdownRequested = false; +} + /** * Fetches catalog data from the STAC Index API and processes it * @async @@ -234,6 +258,16 @@ export const crawler = async () => { // Stop global statistics tracking and log final stats globalStats.stop(); + // Deactivate collections that haven't been updated in the last 7 days + if (!dbError) { + try { + console.log('\nChecking for stale collections...'); + await db.deactivateStaleCollections(); + } catch (err) { + console.error(`Error deactivating stale collections: ${err.message}`); + } + } + // Close database connection if (!dbError) { try { diff --git a/crawler/scheduler.js b/crawler/scheduler.js index 1fd50be..19ce9a0 100644 --- a/crawler/scheduler.js +++ b/crawler/scheduler.js @@ -6,7 +6,7 @@ */ import dotenv from 'dotenv'; -import { crawler } from './index.js'; +import { crawler, requestShutdown, resetShutdownFlag } from './index.js'; import { formatDuration } from './utils/time.js'; dotenv.config(); @@ -103,6 +103,9 @@ const getMillisecondsUntilEndTime = () => { * @returns {Promise} Crawler statistics */ const runCrawler = async () => { + // Reset shutdown flag before starting a new crawl + resetShutdownFlag(); + const timestamp = new Date().toISOString(); console.log(`\n${'='.repeat(60)}`); console.log(`[${timestamp}] Starting crawler run...`); @@ -131,13 +134,14 @@ const runCrawler = async () => { }, msUntilEnd); // Set forced shutdown timer (end time + grace period) + // Instead of process.exit(), we request graceful shutdown gracePeriodTimer = setTimeout(() => { console.error(`\n${'!'.repeat(60)}`); console.error(`CRITICAL: Grace period expired! (${ALLOWED_END_HOUR}:00 + ${GRACE_PERIOD_MINUTES}min)`); - console.error(`Crawler is being stopped to respect time window.`); + console.error(`Requesting graceful shutdown to respect time window.`); console.error(`Next run will be scheduled for ${ALLOWED_START_HOUR}:00`); console.error('!'.repeat(60) + '\n'); - process.exit(0); // Graceful exit + requestShutdown(); // Request graceful shutdown instead of hard exit }, msUntilGraceEnd); } } diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 53fb9fc..56d8570 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -391,6 +391,14 @@ async function _insertOrUpdateCollectionInternal(collection) { // This ensures the full original STAC JSON is stored, not the normalized version const fullJsonData = collection.originalJson || collection; + // Determine is_api based on source_url + // If source_url ends with .json, it's NOT an API (static file) + // Otherwise, it's an API endpoint + let isApi = false; + if (sourceUrl) { + isApi = !sourceUrl.toLowerCase().endsWith('.json'); + } + let collectionId; if (existingCollection.rows.length > 0) { // Update existing collection @@ -399,31 +407,31 @@ async function _insertOrUpdateCollectionInternal(collection) { `UPDATE collection SET stac_id = $1, stac_version = $2, - type = $3, + title = $3, description = $4, license = $5, spatial_extent = ST_GeomFromEWKT($6), temporal_extent_start = $7, temporal_extent_end = $8, - is_api = $9, - is_active = $10, - source_url = $11, - full_json = $12, + is_active = $9, + source_url = $10, + full_json = $11, + is_api = $12, updated_at = now() WHERE id = $13`, [ stacId, collection.stac_version || null, - collection.type || 'Collection', + collectionTitle, collection.description || null, collection.license || null, spatialExtent, temporalStart, temporalEnd, - collection.is_api !== undefined ? collection.is_api : false, // is_api - set by crawler true, // is_active sourceUrl, JSON.stringify(fullJsonData), + isApi, collectionId ] ); @@ -432,26 +440,25 @@ async function _insertOrUpdateCollectionInternal(collection) { // since we know the data is current as of this crawl const collectionResult = await client.query( `INSERT INTO collection ( - stac_id, stac_version, type, title, description, license, + stac_id, stac_version, title, description, license, spatial_extent, temporal_extent_start, temporal_extent_end, - is_api, is_active, source_url, full_json + is_active, source_url, full_json, is_api ) - VALUES ($1, $2, $3, $4, $5, $6, ST_GeomFromEWKT($7), $8, $9, $10, $11, $12, $13) + VALUES ($1, $2, $3, $4, $5, ST_GeomFromEWKT($6), $7, $8, $9, $10, $11, $12) RETURNING id`, [ stacId, collection.stac_version || null, - collection.type || 'Collection', collectionTitle, collection.description || null, collection.license || null, spatialExtent, temporalStart, temporalEnd, - collection.is_api !== undefined ? collection.is_api : false, // is_api - set by crawler true, // is_active sourceUrl, - JSON.stringify(fullJsonData) + JSON.stringify(fullJsonData), + isApi ] ); collectionId = collectionResult.rows[0].id; @@ -520,9 +527,11 @@ async function _insertOrUpdateCollectionInternal(collection) { crawllog_catalog_id = EXCLUDED.crawllog_catalog_id`, [collectionId, sourceUrl, crawllogCatalogId] ); + } await client.query('COMMIT'); + return collectionId; } catch (error) { await client.query('ROLLBACK'); @@ -723,6 +732,29 @@ async function insertAssets(client, collectionId, assets) { +/** + * Mark collections as inactive if they haven't been updated in the last 7 days + * Should be called after a crawl completes to deactivate stale collections + * @async + * @function deactivateStaleCollections + * @returns {Promise} Number of collections marked as inactive + */ +async function deactivateStaleCollections() { + const result = await pool.query(` + UPDATE collection + SET is_active = false + WHERE updated_at < NOW() - INTERVAL '7 days' + AND is_active = true + `); + + const count = result.rowCount; + if (count > 0) { + console.log(`Marked ${count} collection(s) as inactive (not updated in last 7 days)`); + } + + return count; +} + /** * Close the database connection pool * Should be called when the application shuts down @@ -747,6 +779,7 @@ export default { markCatalogCrawled, clearCrawllogCollection, clearAllCrawllogs, + deactivateStaleCollections, close, pool }; From 344fa5b4dbba0df923af4bce82403829cc7e0f1d Mon Sep 17 00:00:00 2001 From: Jakob <70800454+vertrox78@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:27:32 +0100 Subject: [PATCH 129/134] Updated README (#299) * #241, Add unit tests for normalization and parallel execution utilities. * #270, added stac node validation for static catalogs from stac indedx to reduce errors with not stac compliant catalogs * #270, added stac node validation for static catalogs from stac indedx to reduce errors with not stac compliant catalogs * Implement pause/resume functionality and enhance crawllog integration for collections and catalogs * Remove unused variable from clearAllCrawllogs function in db.js * Update README.md to enhance documentation on crawler features, configuration options, and usage examples * Enhance README.md with additional features, configuration options, and usage examples for improved clarity and usability --- crawler/README.md | 171 +++++++++++++++++++++++++++++++--------------- 1 file changed, 117 insertions(+), 54 deletions(-) diff --git a/crawler/README.md b/crawler/README.md index 2b68d33..ac9b927 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -4,14 +4,19 @@ A Node.js crawler for STAC Index API that fetches and processes catalog and coll ## Features -- **Single-run Mode**: Execute crawler once and exit -- **Scheduled Mode**: Automated periodic crawling with configurable intervals -- **Time Window Control**: Optional restriction to specific hours (e.g., night-time crawling) -- **Retry Logic**: Automatic retry on crawl errors with configurable delay -- **Environment-based Configuration**: All settings configurable via `.env` file -- **CLI Arguments**: Override settings with command-line flags -- **Database Integration**: PostgreSQL storage with deadlock handling -- **Parallel Execution**: Efficient domain-based parallel processing +- Single-run Mode: Execute crawler once and exit +- Scheduled Mode: Automated periodic crawling with configurable intervals +- Time Window Control: Optional restriction to specific hours (e.g., night-time crawling) +- Retry Logic: Automatic retry on crawl errors with configurable delay +- Environment-based Configuration: All settings configurable via `.env` file +- CLI Arguments: Override settings with command-line flags +- Database Integration: PostgreSQL storage with deadlock handling +- Parallel Execution: Efficient domain-based parallel processing with configurable rate limiting +- Graceful Shutdown: Stop after current batch with Ctrl+C, resume later +- Pause/Resume Support: Already-crawled collections are tracked and skipped on re-run +- Fresh Mode: Clear crawl log with `--fresh` flag to re-crawl everything +- STAC Validation: Validates collections using stac-node-validator +- Automatic Cleanup: Marks stale collections as inactive after 7 days without updates ## Quick Start @@ -53,10 +58,28 @@ The crawler can be configured using environment variables, CLI arguments, or a c | Option | CLI Flag | Environment Variable | Default | Description | |--------|----------|---------------------|---------|-------------| | Mode | `-m, --mode` | `CRAWL_MODE` | `both` | Crawl mode: `catalogs`, `apis`, or `both` | -| Max Catalogs | `-c, --max-catalogs` | `MAX_CATALOGS` | `10` | Maximum number of catalogs to process | -| Max APIs | `-a, --max-apis` | `MAX_APIS` | `5` | Maximum number of APIs to process | +| Max Catalogs | `-c, --max-catalogs` | `MAX_CATALOGS` | `10` | Maximum number of catalogs to process (0 = unlimited) | +| Max APIs | `-a, --max-apis` | `MAX_APIS` | `5` | Maximum number of APIs to process (0 = unlimited) | | Timeout | `-t, --timeout` | `TIMEOUT_MS` | `30000` | Timeout per operation in milliseconds | -| Max Depth | `-d, --max-depth` | `MAX_DEPTH` | `3` | Maximum recursion depth for nested catalogs | +| Max Depth | `-d, --max-depth` | `MAX_DEPTH` | `10` | Maximum recursion depth for nested catalogs (0 = unlimited) | +| Fresh | `-f, --fresh` | `FRESH_CRAWL` | `false` | Clear crawl log and re-crawl all collections | + +#### Parallel Crawling Configuration + +| Option | CLI Flag | Environment Variable | Default | Description | +|--------|----------|---------------------|---------|-------------| +| Parallel Domains | `-p, --parallel-domains` | `PARALLEL_DOMAINS` | `2` | Number of domains to crawl in parallel | +| RPM per Domain | `--rpm-per-domain` | `MAX_REQUESTS_PER_MINUTE_PER_DOMAIN` | `60` | Max requests per minute per domain | +| Concurrency per Domain | `--concurrency-per-domain` | `MAX_CONCURRENCY_PER_DOMAIN` | `5` | Max concurrent requests per domain | + +#### Legacy Rate Limiting (still supported) + +| Option | CLI Flag | Environment Variable | Default | Description | +|--------|----------|---------------------|---------|-------------| +| Max Concurrency | `--max-concurrency` | `MAX_CONCURRENCY` | `5` | Maximum concurrent requests (global) | +| Requests per Minute | `--rpm, --requests-per-minute` | `MAX_REQUESTS_PER_MINUTE` | `60` | Maximum requests per minute (global) | +| Domain Delay | `--domain-delay` | `SAME_DOMAIN_DELAY_SECS` | `1` | Delay between requests to same domain (seconds) | +| Max Retries | `--max-retries` | `MAX_REQUEST_RETRIES` | `3` | Maximum retries for failed requests | #### Scheduler Configuration @@ -140,6 +163,18 @@ node index.js -m apis -a 10 -t 60000 # Crawl both with all custom settings node index.js -m both -c 50 -a 20 -t 45000 -d 5 + +# Start fresh - clear crawl log and re-crawl everything +node index.js --fresh + +# Combine fresh mode with other options +node index.js -f -m apis -a 10 + +# Configure parallel crawling for high-performance servers +node index.js -p 5 --rpm-per-domain 120 --concurrency-per-domain 10 + +# Full unlimited crawl with fresh start +node index.js -f -m both -c 0 -a 0 -d 0 ``` ### Show Help @@ -181,21 +216,21 @@ node scheduler.js ### Time Window Examples -**Example 1: Night-time only crawling (22:00 - 07:00)** +Example 1: Night-time only crawling (22:00 - 07:00) ```bash CRAWL_ENFORCE_TIME_WINDOW=true CRAWL_ALLOWED_START_HOUR=22 CRAWL_ALLOWED_END_HOUR=7 ``` -**Example 2: Business hours crawling (09:00 - 17:00)** +Example 2: Business hours crawling (09:00 - 17:00) ```bash CRAWL_ENFORCE_TIME_WINDOW=true CRAWL_ALLOWED_START_HOUR=9 CRAWL_ALLOWED_END_HOUR=17 ``` -**Example 3: No restrictions (default)** +Example 3: No restrictions (default) ```bash CRAWL_ENFORCE_TIME_WINDOW=false ``` @@ -293,39 +328,46 @@ npm test -- --coverage ### Test Structure -The test suite covers utility functions with **110 tests** across three modules: +The test suite covers utility functions across four test modules: -- **`normalization.test.js`** (46 tests) - Tests for catalog and collection normalization - - Coverage: 91% statements, 92% branches +- `normalization.test.js` - Tests for catalog and collection normalization - Tests `deriveCategories()`, `normalizeCatalog()`, `normalizeCollection()`, `processCatalogs()` -- **`parallel.test.js`** (53 tests) - Tests for parallel execution utilities - - Coverage: 100% statements, 100% branches +- `parallel.test.js` - Tests for parallel execution utilities - Tests `getDomain()`, `groupByDomain()`, `createDomainBatches()`, `aggregateStats()`, `executeWithConcurrency()`, `calculateRateLimits()`, `logDomainStats()` -- **`api.test.js`** (29 tests) - Tests for API crawling utilities +- `api.test.js` - Tests for API crawling utilities - Tests batch management, URL validation, STAC API response structures - Uses real STAC API endpoints (Microsoft Planetary Computer, Element 84, USGS, NASA CMR) -All tests use **real STAC domain names and collection IDs** from production STAC APIs for realistic testing. +- `is_api.test.js` - Tests for is_api field functionality + - Verifies collections are correctly marked as API or static catalog collections + - Tests `handleCatalog()` and `handleCollections()` from handlers.js + +All tests use real STAC domain names and collection IDs from production STAC APIs for realistic testing. ## Architecture ### Core Components -- **`index.js`** - Main crawler entry point for single runs -- **`scheduler.js`** - Scheduler for periodic automated crawling -- **`utils/db.js`** - Database helper with PostgreSQL connection pool +- `index.js` - Main crawler entry point for single runs +- `scheduler.js` - Scheduler for periodic automated crawling +- `utils/db.js` - Database helper with PostgreSQL connection pool - `initDb()` - Initialize and test database connection - `insertOrUpdateCollection()` - Insert/update collections with deadlock retry logic - - `insertOrUpdateCatalog()` - Process catalogs (currently skips saving) + - `saveCrawllogCatalog()` - Store catalog/API URLs for re-crawling + - `getCrawledCollectionUrls()` - Get already-crawled URLs for pause/resume - Helper functions for keywords, extensions, providers, assets, summaries -- **`utils/normalization.js`** - Data normalization and processing -- **`utils/parallel.js`** - Parallel execution utilities with domain-based batching -- **`utils/config.js`** - Configuration management (env vars + CLI) -- **`utils/time.js`** - Time formatting utilities -- **`catalogs/catalog.js`** - Static catalog crawling logic -- **`apis/api.js`** - STAC API crawling logic +- `utils/normalization.js` - Data normalization and processing +- `utils/parallel.js` - Parallel execution utilities with domain-based batching +- `utils/config.js` - Configuration management (env vars + CLI) +- `utils/cli.js` - Command-line argument parsing +- `utils/time.js` - Time formatting utilities +- `utils/handlers.js` - Request handlers for catalog and collection processing +- `utils/endpoints.js` - STAC endpoint discovery and validation +- `utils/globalStats.js` - Global statistics tracking +- `catalogs/catalog.js` - Static catalog crawling logic +- `apis/api.js` - STAC API crawling logic ### Database Schema @@ -336,30 +378,38 @@ The crawler stores collections in PostgreSQL with the following key tables: - `collection_stac_extension` - STAC extensions used - `collection_providers` - Data providers - `collection_assets` - Collection assets -- `crawllog_collection` - Crawl history and timestamps +- `crawllog_catalog` - Catalog/API URL queue for re-crawling with slug for stac_id generation +- `crawllog_collection` - Crawl history, timestamps, and source URLs for pause/resume ### Scheduler Workflow -1. **Initialization**: Load configuration from `.env` -2. **Startup Check**: Verify if within allowed time window -3. **Initial Run**: Execute crawler immediately (if enabled) -4. **Schedule Next**: Calculate next run time based on interval -5. **Time Window Adjustment**: Shift schedule to fit time window if enforced -6. **Wait**: Sleep until next scheduled time -7. **Execute**: Run crawler and collect statistics -8. **Error Handling**: - - DB errors → Stop scheduler - - Crawl errors → Retry after delay (if enabled) - - Success → Schedule next run -9. **Repeat**: Loop back to step 6 +1. Initialization: Load configuration from `.env` +2. Startup Check: Verify if within allowed time window +3. Initial Run: Execute crawler immediately (if enabled) +4. Schedule Next: Calculate next run time based on interval +5. Time Window Adjustment: Shift schedule to fit time window if enforced +6. Wait: Sleep until next scheduled time +7. Execute: Run crawler and collect statistics +8. Error Handling: + - DB errors: Stop scheduler + - Crawl errors: Retry after delay (if enabled) + - Success: Schedule next run +9. Repeat: Loop back to step 6 ### Error Handling -- **Database Errors**: Scheduler stops to prevent data corruption -- **Crawl Errors**: Automatic retry with configurable delay (default: 2 hours) -- **Deadlock Handling**: Automatic retry with exponential backoff for database deadlocks -- **Time Window Violations**: Grace period allows crawler to finish current operations -- **Connection Errors**: Detailed error logging with connection details +- Database Errors: Scheduler stops to prevent data corruption +- Crawl Errors: Automatic retry with configurable delay (default: 2 hours) +- Deadlock Handling: Automatic retry with exponential backoff for database deadlocks +- Time Window Violations: Grace period allows crawler to finish current operations +- Connection Errors: Detailed error logging with connection details +- Graceful Shutdown: Press Ctrl+C to stop after current batch; re-run to resume + +### Automatic Cleanup + +After each crawl completes, the crawler automatically: +- Marks collections as inactive if they haven't been updated in the last 7 days +- This helps identify stale or removed collections that are no longer available ## Troubleshooting @@ -414,11 +464,24 @@ The crawler has automatic deadlock retry logic with exponential backoff. If dead ### Parallel Execution Settings -Control parallel processing (in `.env.example` or code): -- `PARALLEL_DOMAINS` - Number of domains to process simultaneously -- `MAX_REQUESTS_PER_MINUTE_PER_DOMAIN` - Rate limit per domain -- `MAX_CONCURRENCY_PER_DOMAIN` - Max concurrent requests per domain -- `MAX_CONCURRENCY` - Overall max concurrency +The defaults are optimized for 2GB RAM servers. Control parallel processing via environment variables or CLI: + +| Setting | Default | Description | +|---------|---------|-------------| +| `PARALLEL_DOMAINS` | `2` | Number of domains to process simultaneously | +| `MAX_REQUESTS_PER_MINUTE_PER_DOMAIN` | `60` | Rate limit per domain | +| `MAX_CONCURRENCY_PER_DOMAIN` | `5` | Max concurrent requests per domain | + +Theoretical max throughput = `PARALLEL_DOMAINS` x `MAX_REQUESTS_PER_MINUTE_PER_DOMAIN` requests/min + +Example for higher-resource servers: +```bash +# High-performance settings (4+ GB RAM) +PARALLEL_DOMAINS=5 +MAX_REQUESTS_PER_MINUTE_PER_DOMAIN=120 +MAX_CONCURRENCY_PER_DOMAIN=10 +# Theoretical throughput: 600 req/min +``` ### Database Connection Pool From e73592bdda4a9465a963d9acb9fd17c10770f86b Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:30:18 +0100 Subject: [PATCH 130/134] Crawler: added logic for crawler to work with DB Queue (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added ER-Diagramm to explain the database structure * updated the README to the newest stand * added explaniation why the migration folder is empty and explained how to use it in the future * fix(crawler): Enhance collection URL handling in crawler - Persist newly discovered collection URLs in the crawllog_collection queue during API crawling. - Implemented error handling for enqueueing collection URLs. - Updated logging to reflect the loading of pending catalog and API collections. - Refactored database queries to improve retrieval of crawled and pending collection URLs. * feat(crawler): Implement batch processing for collection URLs not catalogs that are only for traversing in DB queue * feat(crawler): Add functionality to manage collection URLs in DB queue * feat(crawler): Enhance URL handling and queue management for collections and catalogs * feat(crawler): Add configuration options for parallel crawling and request limits * feat(crawler): Enhance catalog and API crawling with pending queue management and improved error handling * feat(crawler): Add checks for function existence before database operations in handlers --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Sönke Hoffmann --- crawler/.env.example | 10 + .../deactivateStaleCollections.test.js | 33 +++ crawler/apis/api.js | 181 ++++++++++---- crawler/catalogs/catalog.js | 74 +++++- crawler/index.js | 44 +++- crawler/utils/db.js | 229 ++++++++++++++---- crawler/utils/endpoints.js | 25 +- crawler/utils/handlers.js | 132 +++++++--- db/ER-Diagramm_stacDB.png | Bin 0 -> 1475182 bytes db/README.md | 150 +++++++----- 10 files changed, 667 insertions(+), 211 deletions(-) create mode 100644 crawler/__tests__/deactivateStaleCollections.test.js create mode 100644 db/ER-Diagramm_stacDB.png diff --git a/crawler/.env.example b/crawler/.env.example index b783f59..917fa2f 100644 --- a/crawler/.env.example +++ b/crawler/.env.example @@ -23,6 +23,16 @@ MAX_CATALOGS=0 MAX_APIS=0 # Timeout of connection of crawler TIMEOUT_MS=30000 +# Maximum recursion depth for nested catalogs (0 ist unlimited) +MAX_DEPTH=0 + +# Parallel Crawling Configuration +# Number of domains to crawl in parallel +PARALLEL_DOMAINS=5 +# Max requests per minute PER domain +MAX_REQUESTS_PER_MINUTE_PER_DOMAIN=60 +# Max concurrent requests PER domain +MAX_CONCURRENCY_PER_DOMAIN=5 # Scheduler Configuration # How many days between crawl runs (default: 7) diff --git a/crawler/__tests__/deactivateStaleCollections.test.js b/crawler/__tests__/deactivateStaleCollections.test.js new file mode 100644 index 0000000..edfaa8b --- /dev/null +++ b/crawler/__tests__/deactivateStaleCollections.test.js @@ -0,0 +1,33 @@ +import { jest } from '@jest/globals'; +import db from '../utils/db.js'; + +describe('deactivateStaleCollections', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + test('sets is_active=false for collections older than crawl start (7 days window)', async () => { + const querySpy = jest + .spyOn(db.pool, 'query') + .mockResolvedValue({ rowCount: 3 }); + + const count = await db.deactivateStaleCollections(); + + expect(querySpy).toHaveBeenCalledTimes(1); + + const sql = querySpy.mock.calls[0][0]; + expect(sql).toMatch(/UPDATE\s+collection/i); + expect(sql).toMatch(/SET\s+is_active\s*=\s*false/i); + expect(sql).toMatch(/updated_at\s*<\s*NOW\(\)\s*-\s*INTERVAL\s*'7 days'/i); + expect(sql).toMatch(/AND\s+is_active\s*=\s*true/i); + expect(count).toBe(3); + }); + + test('returns 0 when no collections are deactivated', async () => { + jest.spyOn(db.pool, 'query').mockResolvedValue({ rowCount: 0 }); + + const count = await db.deactivateStaleCollections(); + + expect(count).toBe(0); + }); +}); diff --git a/crawler/apis/api.js b/crawler/apis/api.js index bc4fa0f..0eac760 100644 --- a/crawler/apis/api.js +++ b/crawler/apis/api.js @@ -95,6 +95,53 @@ async function crawlSingleApiDomain(apis, domain, config = {}) { const maxDepth = config.maxDepth || 10; const concurrency = config.maxConcurrencyPerDomain || 20; + + const DB_QUEUE_TARGET = 1000; + const DB_QUEUE_LOW_WATERMARK = 100; + const DB_QUEUE_BATCH_SIZE = 900; + const domainApiIds = apis.map(api => api.crawllogCatalogId).filter(Boolean); + + function getApiQueueLabel(url) { + if (typeof url !== 'string') return 'API_ROOT'; + if (/\/collections\/?$/.test(url)) return 'API_COLLECTIONS'; + if (/\/collections\//.test(url)) return 'API_COLLECTION'; + return 'API_ROOT'; + } + + async function ensureDbQueueBuffer(crawler, log) { + if (!crawler?.requestQueue?.getInfo) return; + + const info = await crawler.requestQueue.getInfo(); + const pending = info?.pendingRequestCount ?? 0; + + if (pending > DB_QUEUE_LOW_WATERMARK) return; + + const toFetch = Math.min(DB_QUEUE_BATCH_SIZE, Math.max(DB_QUEUE_TARGET - pending, 0)); + if (toFetch <= 0) return; + + const batch = await db.claimCollectionQueueBatch({ + limit: toFetch, + isApi: true, + crawllogCatalogIds: domainApiIds.length > 0 ? domainApiIds : undefined + }); + if (batch.length === 0) return; + + const requests = batch.map((item, idx) => ({ + url: item.url, + label: getApiQueueLabel(item.url), + userData: { + apiId: `queued-collection-${idx}`, + apiUrl: item.url, + apiSlug: item.slug || null, + catalogSlug: item.slug || null, + crawllogCatalogId: item.crawllogCatalogId || null, + depth: 0 + } + })); + + await crawler.addRequests(requests); + log.info(`[QUEUE] Pulled ${requests.length} API collection URLs from DB queue (pending: ${pending})`); + } const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, @@ -142,6 +189,7 @@ async function crawlSingleApiDomain(apis, domain, config = {}) { results.stats.successfulRequests++; globalStats.increment('successfulRequests'); + await ensureDbQueueBuffer(crawler, log); } catch (error) { log.error(`${indent}Error handling ${request.label} at ${request.url}: ${error.message}`); throw error; @@ -176,19 +224,23 @@ async function crawlSingleApiDomain(apis, domain, config = {}) { }); // Seed the crawler with initial API requests - const initialRequests = apis.map((api, index) => ({ - url: api.url, - label: 'API_ROOT', - userData: { - apiId: `${domain}-api-${index}`, - apiUrl: api.url, - apiSlug: api.slug, - crawllogCatalogId: api.crawllogCatalogId, // Link to crawllog_catalog for collections - depth: 0 - } - })); + const initialRequests = apis + .filter(api => !api.hasPendingQueue) + .map((api, index) => ({ + url: api.url, + label: 'API_ROOT', + userData: { + apiId: `${domain}-api-${index}`, + apiUrl: api.url, + apiSlug: api.slug, + crawllogCatalogId: api.crawllogCatalogId, // Link to crawllog_catalog for collections + depth: 0 + } + })); await crawler.addRequests(initialRequests); + + await ensureDbQueueBuffer(crawler, crawleeLog); // Register domain as active in global stats globalStats.domainStarted(domain); @@ -368,10 +420,25 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max // If this is a STAC Collection directly, extract and store it if (typeof stacObj.isCollection === 'function' && stacObj.isCollection()) { + // Persist collection URL in crawllog_collection queue + try { + await db.enqueueCollectionUrl({ + sourceUrl: request.url, + crawllogCatalogId: crawllogCatalogId + }); + } catch (err) { + log.warning(`${indent}Failed to enqueue collection URL: ${err.message}`); + } + // Check if this collection URL was already crawled (pause/resume support) const alreadyCrawled = await db.isCollectionUrlCrawled(request.url); if (alreadyCrawled) { log.info(`${indent}Skipping already-crawled collection: ${stacObj.id} (resume mode)`); + try { + await db.markCatalogCrawled(crawllogCatalogId); + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } return; } @@ -389,6 +456,11 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max log.info(`${indent}Extracted collection: ${collection.id} - ${collection.title}`); await checkAndFlushApi(results, log); + try { + await db.markCatalogCrawled(crawllogCatalogId); + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } return; } @@ -410,17 +482,15 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max log.debug(`${indent}No collections link found, using fallback: ${collectionsEndpoint}`); } - // Enqueue single collections request - await crawler.addRequests([{ - url: collectionsEndpoint, - label: 'API_COLLECTIONS', - userData: { - apiId: apiId, - apiUrl: apiUrl, - catalogSlug: apiSlug, // Use catalogSlug for compatibility with handleCollections - crawllogCatalogId: crawllogCatalogId // Link to crawllog_catalog for collections - } - }]); + // Persist collections endpoint in DB queue (batch-loaded into RAM) + try { + await db.enqueueCollectionUrl({ + sourceUrl: collectionsEndpoint, + crawllogCatalogId: crawllogCatalogId + }); + } catch (err) { + log.warning(`${indent}Failed to enqueue collections endpoint: ${err.message}`); + } // Also check for child links (nested catalogs) if (typeof stacObj.getChildLinks === 'function') { @@ -432,10 +502,16 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max const nextDepth = depth + 1; if (maxDepth > 0 && nextDepth > maxDepth) { log.warning(`${indent}Skipping ${childLinks.length} child catalogs - max depth (${maxDepth}) reached`); + try { + await db.markCatalogCrawled(crawllogCatalogId); + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } return; } - const childRequests = childLinks + const enqueuePromises = []; + childLinks .map((link, idx) => { let childUrl; try { @@ -473,34 +549,34 @@ async function handleApiRoot({ request, json, crawler, log, indent, results, max return null; } - const rel = link.rel || ''; - const label = rel === 'child' || rel === 'item' ? 'API_ROOT' : 'API_COLLECTION'; - - return { - url: childUrl, - label: label, - userData: { - apiId: `${apiId}-child-${idx}`, - apiUrl: apiUrl, - apiSlug: apiSlug, - catalogSlug: apiSlug, // Use catalogSlug for compatibility with handleCollections - crawllogCatalogId: crawllogCatalogId, // Link to crawllog_catalog for collections - parentId: apiId, - depth: nextDepth - } - }; + enqueuePromises.push(db.enqueueCollectionUrl({ + sourceUrl: childUrl, + crawllogCatalogId: crawllogCatalogId + })); + + return childUrl; }) .filter(Boolean); - if (childRequests.length > 0) { - await crawler.addRequests(childRequests); - log.info(`${indent}Enqueued ${childRequests.length} child catalogs/collections`); + if (enqueuePromises.length > 0) { + try { + await Promise.all(enqueuePromises); + log.info(`${indent}Queued ${enqueuePromises.length} child catalogs/collections into DB queue`); + } catch (err) { + log.warning(`${indent}Failed to enqueue child catalog/collection URLs: ${err.message}`); + } } } } // Help garbage collector by dereferencing large objects stacObj = null; + + try { + await db.markCatalogCrawled(crawllogCatalogId); + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } } /** @@ -512,10 +588,25 @@ async function handleApiCollection({ request, json, crawler, log, indent, result const apiSlug = request.userData?.apiSlug || request.userData?.catalogSlug || null; const crawllogCatalogId = request.userData?.crawllogCatalogId || null; + // Persist collection URL in crawllog_collection queue + try { + await db.enqueueCollectionUrl({ + sourceUrl: request.url, + crawllogCatalogId: crawllogCatalogId + }); + } catch (err) { + log.warning(`${indent}Failed to enqueue collection URL: ${err.message}`); + } + // Check if this collection URL was already crawled (pause/resume support) const alreadyCrawled = await db.isCollectionUrlCrawled(request.url); if (alreadyCrawled) { log.info(`${indent}Skipping already-crawled collection at ${request.url} (resume mode)`); + try { + await db.markCatalogCrawled(crawllogCatalogId); + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } return; } @@ -547,6 +638,12 @@ async function handleApiCollection({ request, json, crawler, log, indent, result // Help garbage collector stacObj = null; + + try { + await db.markCatalogCrawled(crawllogCatalogId); + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } } export { diff --git a/crawler/catalogs/catalog.js b/crawler/catalogs/catalog.js index 128d6b2..ede62f2 100644 --- a/crawler/catalogs/catalog.js +++ b/crawler/catalogs/catalog.js @@ -15,6 +15,7 @@ import { } from '../utils/parallel.js'; import globalStats from '../utils/globalStats.js'; import { isShutdownRequested } from '../index.js'; +import db from '../utils/db.js'; /** * Creates and runs a single Crawlee HttpCrawler for a specific domain @@ -56,6 +57,51 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { }; const concurrency = config.maxConcurrencyPerDomain || 20; + + const DB_QUEUE_TARGET = 1000; + const DB_QUEUE_LOW_WATERMARK = 100; + const DB_QUEUE_BATCH_SIZE = 900; + const domainCatalogIds = catalogs.map(catalog => catalog.crawllogCatalogId).filter(Boolean); + + function getCatalogQueueLabel(url) { + if (typeof url === 'string' && /\/collections\/?$/.test(url)) { + return 'COLLECTIONS'; + } + return 'CATALOG'; + } + + async function ensureDbQueueBuffer(crawler, log) { + if (!crawler?.requestQueue?.getInfo) return; + + const info = await crawler.requestQueue.getInfo(); + const pending = info?.pendingRequestCount ?? 0; + + if (pending > DB_QUEUE_LOW_WATERMARK) return; + + const toFetch = Math.min(DB_QUEUE_BATCH_SIZE, Math.max(DB_QUEUE_TARGET - pending, 0)); + if (toFetch <= 0) return; + + const batch = await db.claimCollectionQueueBatch({ + limit: toFetch, + isApi: false, + crawllogCatalogIds: domainCatalogIds.length > 0 ? domainCatalogIds : undefined + }); + if (batch.length === 0) return; + + const requests = batch.map((item, idx) => ({ + url: item.url, + label: getCatalogQueueLabel(item.url), + userData: { + depth: 0, + catalogId: `queued-collection-${idx}`, + catalogSlug: item.slug || null, + crawllogCatalogId: item.crawllogCatalogId || null + } + })); + + await crawler.addRequests(requests); + log.info(`[QUEUE] Pulled ${requests.length} collection URLs from DB queue (pending: ${pending})`); + } const crawler = new HttpCrawler({ requestHandlerTimeoutSecs: timeoutSecs, @@ -102,6 +148,7 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { results.stats.successfulRequests++; globalStats.increment('successfulRequests'); + await ensureDbQueueBuffer(crawler, log); } catch (error) { log.error(`${indent}Error handling ${request.label} at ${request.url}: ${error.message}`); throw error; @@ -133,23 +180,28 @@ async function crawlSingleDomain(catalogs, domain, config = {}) { log.warning(`${indent}[FAILED] ${catalogId} at ${request.url}`); log.warning(`${indent} Error: ${error.message}`); } + } }); // Seed the crawler with catalog requests for this domain - const initialRequests = catalogs.map(catalog => ({ - url: catalog.url, - label: 'CATALOG', - userData: { - depth: 0, - catalogId: catalog.id, - catalogTitle: catalog.title, - catalogSlug: catalog.slug, - crawllogCatalogId: catalog.crawllogCatalogId // Pass for linking collections to crawllog_catalog - } - })); + const initialRequests = catalogs + .filter(catalog => !catalog.hasPendingQueue) + .map(catalog => ({ + url: catalog.url, + label: 'CATALOG', + userData: { + depth: 0, + catalogId: catalog.id, + catalogTitle: catalog.title, + catalogSlug: catalog.slug, + crawllogCatalogId: catalog.crawllogCatalogId // Pass for linking collections to crawllog_catalog + } + })); await crawler.addRequests(initialRequests); + + await ensureDbQueueBuffer(crawler, crawleeLog); // Register domain as active in global stats globalStats.domainStarted(domain); diff --git a/crawler/index.js b/crawler/index.js index 96ddae7..f57c279 100644 --- a/crawler/index.js +++ b/crawler/index.js @@ -159,6 +159,8 @@ export const crawler = async () => { console.log('\n=== Loading catalogs from crawllog_catalog for crawling ==='); const crawllogCatalogs = await db.getCrawllogCatalogs({ isApi: false }); const crawllogApis = await db.getCrawllogCatalogs({ isApi: true }); + const pendingCatalogIds = new Set(await db.getCrawllogCatalogIdsWithPendingQueue({ isApi: false })); + const pendingApiIds = new Set(await db.getCrawllogCatalogIdsWithPendingQueue({ isApi: true })); console.log(`Loaded ${crawllogCatalogs.length} catalogs and ${crawllogApis.length} APIs from crawllog_catalog\n`); @@ -173,9 +175,13 @@ export const crawler = async () => { id: cl.id, slug: cl.slug, url: cl.url, - crawllogCatalogId: cl.id // Pass the crawllog_catalog id for linking + crawllogCatalogId: cl.id, // Pass the crawllog_catalog id for linking + createdAt: cl.createdAt, + updatedAt: cl.updatedAt, + hasPendingQueue: pendingCatalogIds.has(cl.id) }; }); + const realApis = crawllogApis.map(cl => { const original = catalogUrlMap.get(cl.url) || {}; @@ -184,9 +190,13 @@ export const crawler = async () => { id: cl.id, slug: cl.slug, url: cl.url, - crawllogCatalogId: cl.id // Pass the crawllog_catalog id for linking + crawllogCatalogId: cl.id, // Pass the crawllog_catalog id for linking + createdAt: cl.createdAt, + updatedAt: cl.updatedAt, + hasPendingQueue: pendingApiIds.has(cl.id) }; }); + console.log(`\nCatalog Classification (from crawllog_catalog):`); console.log(` Catalogs: ${regularCatalogs.length}`); @@ -196,11 +206,24 @@ export const crawler = async () => { const totalItems = [...regularCatalogs, ...realApis].length; globalStats.start(totalItems); + const shouldCrawlSeed = (seed) => { + if (seed.hasPendingQueue) return true; + if (!seed.createdAt || !seed.updatedAt) return true; + const createdAt = new Date(seed.createdAt).getTime(); + const updatedAt = new Date(seed.updatedAt).getTime(); + if (Number.isNaN(createdAt) || Number.isNaN(updatedAt)) return true; + return updatedAt <= createdAt; + }; + // Crawl catalogs if mode is 'catalogs' or 'both' if (config.mode === 'catalogs' || config.mode === 'both') { console.log('\nCrawling collections and nested catalogs with Crawlee...\n'); - - const allCatalogsToProcess = regularCatalogs; + + const allCatalogsToProcess = regularCatalogs.filter(shouldCrawlSeed); + const skippedCatalogs = regularCatalogs.length - allCatalogsToProcess.length; + if (skippedCatalogs > 0) { + console.log(`Skipping ${skippedCatalogs} catalogs already fully crawled (no pending queue)`); + } // Note: MAX_CATALOGS limit is for debugging purposes only // Set maxCatalogs to 0 or use --max-catalogs 0 for unlimited catalog crawling @@ -224,18 +247,25 @@ export const crawler = async () => { if (config.mode === 'apis' || config.mode === 'both') { console.log('\nCrawling APIs...'); // Pass full API objects (including slug and crawllogCatalogId) instead of just URLs - const apiObjects = realApis.map(api => ({ + const apiObjects = realApis + .filter(shouldCrawlSeed) + .map(api => ({ url: api.url, slug: api.slug, title: api.title, - crawllogCatalogId: api.crawllogCatalogId // Link to crawllog_catalog for collections + crawllogCatalogId: api.crawllogCatalogId, // Link to crawllog_catalog for collections + hasPendingQueue: api.hasPendingQueue })); + const skippedApis = realApis.length - apiObjects.length; + if (skippedApis > 0) { + console.log(`Skipping ${skippedApis} APIs already fully crawled (no pending queue)`); + } if (apiObjects.length > 0) { // Note: MAX_APIS limit is for debugging purposes only // Set maxApis to 0 or use --max-apis 0 for unlimited API crawling const apisToProcess = config.maxApis === 0 ? apiObjects : apiObjects.slice(0, config.maxApis); - console.log(`Found ${realApis.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); + console.log(`Found ${apiObjects.length} APIs. Processing ${apisToProcess.length} (max: ${config.maxApis === 0 ? 'unlimited' : config.maxApis})...`); try { await crawlApis(apisToProcess, true, config); diff --git a/crawler/utils/db.js b/crawler/utils/db.js index 56d8570..86f3a14 100644 --- a/crawler/utils/db.js +++ b/crawler/utils/db.js @@ -103,8 +103,7 @@ async function saveCrawllogCatalog(catalogInfo) { VALUES ($1, $2, $3, now()) ON CONFLICT (source_url) DO UPDATE SET slug = COALESCE(EXCLUDED.slug, crawllog_catalog.slug), - is_api = EXCLUDED.is_api, - updated_at = now() + is_api = EXCLUDED.is_api RETURNING id`, [slug || null, url, isApi] ); @@ -119,7 +118,7 @@ async function saveCrawllogCatalog(catalogInfo) { * @returns {Promise} Array of catalog objects with id, slug, source_url, is_api */ async function getCrawllogCatalogs(options = {}) { - let query = 'SELECT id, slug, source_url, is_api FROM crawllog_catalog'; + let query = 'SELECT id, slug, source_url, is_api, created_at, updated_at FROM crawllog_catalog'; const params = []; if (options.isApi !== undefined) { @@ -134,10 +133,37 @@ async function getCrawllogCatalogs(options = {}) { id: row.id, slug: row.slug, url: row.source_url, - isApi: row.is_api + isApi: row.is_api, + createdAt: row.created_at, + updatedAt: row.updated_at })); } +/** + * Get crawllog_catalog ids that still have pending queue entries + * @param {Object} options + * @param {boolean} options.isApi - If provided, filter by API status + * @returns {Promise>} Array of crawllog_catalog ids + */ +async function getCrawllogCatalogIdsWithPendingQueue(options = {}) { + let query = ` + SELECT DISTINCT cc.crawllog_catalog_id + FROM crawllog_collection cc + JOIN crawllog_catalog c ON c.id = cc.crawllog_catalog_id + WHERE cc.source_url IS NOT NULL + AND cc.collection_id IS NULL + `; + const params = []; + + if (options.isApi !== undefined) { + query += ' AND c.is_api = $1'; + params.push(options.isApi); + } + + const result = await pool.query(query, params); + return result.rows.map(row => row.crawllog_catalog_id); +} + /** * Get the crawllog_catalog id for a given source URL * @param {string} sourceUrl - The source URL to look up @@ -172,38 +198,176 @@ async function getSlugByCrawllogCatalogId(crawllogCatalogId) { } /** - * Get all already-crawled collection URLs for a given crawllog_catalog + * Get already-crawled collection URLs for a given catalog (best-effort) * Used for pause/resume functionality - skip URLs that have already been processed + * NOTE: crawllog_collection is used as a queue only; crawled URLs live in collection.source_url * @param {number} crawllogCatalogId - The crawllog_catalog id - * @returns {Promise>} Set of source URLs already in crawllog_collection + * @returns {Promise>} Set of source URLs already in collection */ async function getCrawledCollectionUrls(crawllogCatalogId) { if (!crawllogCatalogId) return new Set(); - - const result = await pool.query( - 'SELECT source_url FROM crawllog_collection WHERE crawllog_catalog_id = $1 AND source_url IS NOT NULL', + + const catalogResult = await pool.query( + 'SELECT source_url FROM crawllog_catalog WHERE id = $1', [crawllogCatalogId] ); - + + if (catalogResult.rows.length === 0) return new Set(); + + const catalogUrl = catalogResult.rows[0].source_url; + const likePattern = `${catalogUrl.replace(/\/$/, '')}/collections/%`; + + const result = await pool.query( + 'SELECT source_url FROM collection WHERE source_url IS NOT NULL AND (source_url = $1 OR source_url LIKE $2)', + [catalogUrl, likePattern] + ); + return new Set(result.rows.map(row => row.source_url)); } /** * Check if a specific collection URL has already been crawled * @param {string} sourceUrl - The source URL to check - * @returns {Promise} True if URL exists in crawllog_collection + * @returns {Promise} True if URL exists in collection table (already crawled) */ async function isCollectionUrlCrawled(sourceUrl) { if (!sourceUrl) return false; const result = await pool.query( - 'SELECT 1 FROM crawllog_collection WHERE source_url = $1 LIMIT 1', + 'SELECT 1 FROM collection WHERE source_url = $1 LIMIT 1', [sourceUrl] ); return result.rows.length > 0; } +/** + * Enqueue a collection URL into crawllog_collection without marking it as crawled + * Used to persist newly discovered collection links for later processing + * @param {Object} params + * @param {string} params.sourceUrl - Collection URL to enqueue + * @param {number|null} params.crawllogCatalogId - Parent crawllog_catalog id + * @returns {Promise} + */ +async function enqueueCollectionUrl({ sourceUrl, crawllogCatalogId = null }) { + if (!sourceUrl) return; + + await pool.query( + `INSERT INTO crawllog_collection (collection_id, source_url, crawllog_catalog_id) + VALUES (NULL, $1, $2) + ON CONFLICT (source_url) DO UPDATE SET + crawllog_catalog_id = COALESCE(EXCLUDED.crawllog_catalog_id, crawllog_collection.crawllog_catalog_id)`, + [sourceUrl, crawllogCatalogId] + ); +} + +/** + * Get pending (not yet crawled) collection URLs from crawllog_collection + * Joined with crawllog_catalog to determine API vs catalog context + * @param {Object} options + * @param {boolean} options.isApi - If provided, filter by API status + * @returns {Promise} Array of pending collection seed objects + */ +async function getPendingCollectionSeeds(options = {}) { + let query = ` + SELECT cc.source_url, cc.crawllog_catalog_id, c.slug, c.is_api + FROM crawllog_collection cc + JOIN crawllog_catalog c ON c.id = cc.crawllog_catalog_id + WHERE cc.collection_id IS NULL + AND cc.source_url IS NOT NULL + `; + const params = []; + + if (options.isApi !== undefined) { + query += ' AND c.is_api = $1'; + params.push(options.isApi); + } + + query += ' ORDER BY cc.id'; + + const result = await pool.query(query, params); + return result.rows.map(row => ({ + url: row.source_url, + crawllogCatalogId: row.crawllog_catalog_id, + slug: row.slug, + isApi: row.is_api + })); +} + +/** + * Claim and remove a batch of pending collection URLs from crawllog_collection + * Used to feed the in-memory queue in controlled batches + * @param {Object} options + * @param {number} options.limit - Maximum number of URLs to claim + * @param {boolean} options.isApi - If provided, filter by API status + * @returns {Promise} Array of claimed queue items + */ +async function claimCollectionQueueBatch({ limit = 900, isApi, crawllogCatalogIds } = {}) { + if (!limit || limit <= 0) return []; + + const params = []; + let apiFilter = ''; + let catalogFilter = ''; + let limitParam = '$1'; + + if (isApi !== undefined) { + apiFilter = 'AND c.is_api = $1'; + params.push(isApi); + limitParam = '$2'; + } + + if (Array.isArray(crawllogCatalogIds) && crawllogCatalogIds.length > 0) { + params.push(crawllogCatalogIds); + catalogFilter = `AND cc.crawllog_catalog_id = ANY($${params.length})`; + limitParam = `$${params.length + 1}`; + } + + params.push(limit); + + const query = ` + WITH cte AS ( + SELECT cc.id + FROM crawllog_collection cc + JOIN crawllog_catalog c ON c.id = cc.crawllog_catalog_id + WHERE cc.source_url IS NOT NULL + ${apiFilter} + ${catalogFilter} + ORDER BY cc.id + LIMIT ${limitParam} + ) + DELETE FROM crawllog_collection cc + USING cte, crawllog_catalog c + WHERE cc.id = cte.id + AND c.id = cc.crawllog_catalog_id + RETURNING cc.source_url, cc.crawllog_catalog_id, c.slug, c.is_api; + `; + + const result = await pool.query(query, params); + return result.rows.map(row => ({ + url: row.source_url, + crawllogCatalogId: row.crawllog_catalog_id, + slug: row.slug, + isApi: row.is_api + })); +} + +/** + * Remove a URL from crawllog_collection queue + * Used when a URL was processed outside of DB batch claiming + * @param {string} sourceUrl - URL to remove + * @returns {Promise} Number of rows deleted + */ +async function removeFromCollectionQueue(sourceUrl) { + if (!sourceUrl) return 0; + + const result = await pool.query( + 'DELETE FROM crawllog_collection WHERE source_url = $1', + [sourceUrl] + ); + + return result.rowCount; +} + /** * Update the updated_at timestamp for a crawllog_catalog entry * Called when a catalog has been fully processed @@ -492,42 +656,12 @@ async function _insertOrUpdateCollectionInternal(collection) { await insertAssets(client, collectionId, collection.assets); } - // Update crawl log with source_url and link to crawllog_catalog - // First, find the crawllog_catalog_id based on the source URL or parent catalog URL - let crawllogCatalogId = null; - if (collection.crawllogCatalogId) { - // Use the directly provided crawllog_catalog_id from the crawler - crawllogCatalogId = collection.crawllogCatalogId; - } else if (sourceUrl) { - // Try to find the crawllog_catalog_id by matching parent URL - // Extract the base URL (remove /collections/X if present) - const baseUrl = sourceUrl.replace(/\/collections\/[^/]+$/, '').replace(/\/collections\/?$/, ''); - const catalogLookup = await client.query( - 'SELECT id FROM crawllog_catalog WHERE source_url = $1 OR source_url = $2', - [sourceUrl, baseUrl] - ); - if (catalogLookup.rows.length > 0) { - crawllogCatalogId = catalogLookup.rows[0].id; - } - } - - // Delete existing crawllog entry and insert new one with source_url - await client.query( - 'DELETE FROM crawllog_collection WHERE collection_id = $1', - [collectionId] - ); - - // Only insert into crawllog_collection if we have a source_url + // Remove from crawllog_collection queue once crawled if (sourceUrl) { await client.query( - `INSERT INTO crawllog_collection (collection_id, source_url, crawllog_catalog_id) - VALUES ($1, $2, $3) - ON CONFLICT (source_url) DO UPDATE SET - collection_id = EXCLUDED.collection_id, - crawllog_catalog_id = EXCLUDED.crawllog_catalog_id`, - [collectionId, sourceUrl, crawllogCatalogId] + 'DELETE FROM crawllog_collection WHERE source_url = $1', + [sourceUrl] ); - } await client.query('COMMIT'); @@ -772,10 +906,15 @@ export default { insertOrUpdateCollection, saveCrawllogCatalog, getCrawllogCatalogs, + getCrawllogCatalogIdsWithPendingQueue, getCrawllogCatalogIdByUrl, getSlugByCrawllogCatalogId, getCrawledCollectionUrls, isCollectionUrlCrawled, + enqueueCollectionUrl, + getPendingCollectionSeeds, + claimCollectionQueueBatch, + removeFromCollectionQueue, markCatalogCrawled, clearCrawllogCollection, clearAllCrawllogs, diff --git a/crawler/utils/endpoints.js b/crawler/utils/endpoints.js index 6ef6217..ea3e6e1 100644 --- a/crawler/utils/endpoints.js +++ b/crawler/utils/endpoints.js @@ -3,6 +3,8 @@ * @module utils/endpoints */ +import db from './db.js'; + /** * Finds the collection endpoint from STAC catalog links * STAC catalogs should advertise their collection endpoint via rel="data" or rel="collections" @@ -73,16 +75,15 @@ export async function tryCollectionEndpoints(stacCatalog, baseUrl, catalogId, de log.debug(`${indent}No collection link found, using fallback: ${collectionUrl}`); } - // Enqueue single collection request - await crawler.addRequests([{ - url: collectionUrl, - label: 'COLLECTIONS', - userData: { - catalogUrl: baseUrl, - catalogId, - catalogSlug, - crawllogCatalogId, - depth - } - }]); + // Persist collection endpoint in DB queue + try { + await db.enqueueCollectionUrl({ + sourceUrl: collectionUrl, + crawllogCatalogId + }); + } catch (err) { + log.warning(`${indent}Failed to enqueue collections endpoint: ${err.message}`); + } + + // Collection request will be pulled from DB queue in batch mode } diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index f99d25e..8f7ed94 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -239,10 +239,29 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul // If this is a STAC Collection (not a catalog), extract and store it // Collections don't have /collections endpoints, so we skip tryCollectionEndpoints for them if (isCollection) { + // Persist collection URL in crawllog_collection queue + try { + if (typeof db.enqueueCollectionUrl === 'function') { + await db.enqueueCollectionUrl({ + sourceUrl: request.url, + crawllogCatalogId: crawllogCatalogId + }); + } + } catch (err) { + log.warning(`${indent}Failed to enqueue collection URL: ${err.message}`); + } + // Check if this collection URL was already crawled (pause/resume support) const alreadyCrawled = await db.isCollectionUrlCrawled(request.url); if (alreadyCrawled) { log.info(`${indent}Skipping already-crawled collection: ${stacCatalog.id} (resume mode)`); + try { + if (typeof db.markCatalogCrawled === 'function') { + await db.markCatalogCrawled(crawllogCatalogId); + } + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } return; } @@ -280,6 +299,13 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul log.info(`${indent}Max depth (${maxDepth}) reached, skipping ${childLinks.length} child catalogs`); // Clear memory and return early - don't enqueue children await checkAndFlush(results, log); + try { + if (typeof db.markCatalogCrawled === 'function') { + await db.markCatalogCrawled(crawllogCatalogId); + } + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } return; } @@ -293,7 +319,8 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul }); } - const childRequests = childLinks + let queuedCount = 0; + childLinks .map((link, idx) => { let childUrl; try { @@ -336,6 +363,19 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul const linkTitle = typeof link.title === 'string' && link.title.length > 0 ? link.title : `child-${idx}`; + + // Persist child catalog URL in DB queue + if (childUrl) { + queuedCount++; + if (typeof db.enqueueCollectionUrl === 'function') { + db.enqueueCollectionUrl({ + sourceUrl: childUrl, + crawllogCatalogId: crawllogCatalogId + }).catch(err => { + log.warning(`${indent}Failed to enqueue child catalog URL: ${err.message}`); + }); + } + } return { url: childUrl, @@ -351,26 +391,29 @@ export async function handleCatalog({ request, json, crawler, log, indent, resul }) .filter(Boolean); // Remove null entries - log.info(`${indent}Valid child requests: ${childRequests.length}/${childLinks.length}`); - - if (childRequests.length > 0) { - try { - await crawler.addRequests(childRequests); - log.info(`${indent}Successfully enqueued ${childRequests.length} child catalogs`); - } catch (error) { - log.error(`${indent}Failed to add child requests: ${error.message}`); - // Log the actual requests that failed - childRequests.forEach((req, idx) => { - log.error(`${indent} [${idx}] url="${req.url}" label="${req.label}" userData=${JSON.stringify(req.userData)}`); - }); - throw error; - } - } + log.info(`${indent}Queued ${queuedCount}/${childLinks.length} child catalogs into DB queue`); } } // Ensure memory is cleared periodically even if no collections were found await checkAndFlush(results, log); + + // Remove processed catalog URL from DB queue (if present) + try { + if (typeof db.removeFromCollectionQueue === 'function') { + await db.removeFromCollectionQueue(request.url); + } + } catch (err) { + log.warning(`${indent}Failed to remove catalog URL from queue: ${err.message}`); + } + + try { + if (typeof db.markCatalogCrawled === 'function') { + await db.markCatalogCrawled(crawllogCatalogId); + } + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } // Help garbage collector by dereferencing large objects stacCatalog = null; @@ -449,22 +492,13 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Remove trailing /collections from the request URL to get the API base const baseUrl = request.url.replace(/\/collections\/?$/, ''); - // Get already-crawled URLs for this catalog to enable pause/resume - let crawledUrls = new Set(); - if (crawllogCatalogId) { - try { - crawledUrls = await db.getCrawledCollectionUrls(crawllogCatalogId); - if (crawledUrls.size > 0) { - log.info(`${indent}Found ${crawledUrls.size} already-crawled collection URLs for this catalog`); - } - } catch (err) { - log.warning(`${indent}Failed to get crawled URLs: ${err.message}`); - } - } + // Note: crawllog_collection is a queue only; already-crawled URLs are stored in collection table // Normalize and store collections, skipping already-crawled ones let skippedCount = 0; - const collections = collectionsData.map((colObj, index) => { + const collections = []; + for (let index = 0; index < collectionsData.length; index++) { + const colObj = collectionsData[index]; const collection = normalizeCollection(colObj, index); // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; @@ -483,19 +517,32 @@ export async function handleCollections({ request, json, crawler, log, indent, r } } else { collection.crawledUrl = `${baseUrl}/collections/${collection.id}`; - } + } + + // Persist discovered collection URL in crawllog_collection queue + try { + if (typeof db.enqueueCollectionUrl === 'function') { + await db.enqueueCollectionUrl({ + sourceUrl: collection.crawledUrl, + crawllogCatalogId: crawllogCatalogId + }); + } + } catch (err) { + log.warning(`${indent}Failed to enqueue collection URL: ${err.message}`); + } // Skip if this URL was already crawled (pause/resume support) - if (crawledUrls.has(collection.crawledUrl)) { + const alreadyCrawled = await db.isCollectionUrlCrawled(collection.crawledUrl); + if (alreadyCrawled) { skippedCount++; - return null; + continue; } // Mark collection as API or static catalog based on context collection.is_api = isApi; - return collection; - }).filter(Boolean); // Remove nulls (skipped collections) + collections.push(collection); + } if (skippedCount > 0) { log.info(`${indent}Skipped ${skippedCount} already-crawled collections (resume mode)`); @@ -512,6 +559,23 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Check if we should flush to database await checkAndFlush(results, log); } + + // Remove processed collections endpoint URL from DB queue (if present) + try { + if (typeof db.removeFromCollectionQueue === 'function') { + await db.removeFromCollectionQueue(request.url); + } + } catch (err) { + log.warning(`${indent}Failed to remove collections URL from queue: ${err.message}`); + } + + try { + if (typeof db.markCatalogCrawled === 'function') { + await db.markCatalogCrawled(crawllogCatalogId); + } + } catch (err) { + log.warning(`${indent}Failed to mark catalog as crawled: ${err.message}`); + } // Help garbage collector by dereferencing large objects stacObj = null; diff --git a/db/ER-Diagramm_stacDB.png b/db/ER-Diagramm_stacDB.png new file mode 100644 index 0000000000000000000000000000000000000000..93ff0428c22c35d4e64be39c3701fbc0f07229a4 GIT binary patch literal 1475182 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

^{(6&f`rIQiyUo)YWF(e{Hfx6GL&&_;LOo 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 z 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

c=D?$dG<#RT@D}=Sil2 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

R|7{&IWrM@dfQy`Z%gP(6ufbR7!56!=GO31lvmA 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ÞW9q*%cw$2@E=D9WQ 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∾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%0AG@~N5P-}boP^4 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 literal 0 HcmV?d00001 diff --git a/db/README.md b/db/README.md index 05e1542..b059448 100644 --- a/db/README.md +++ b/db/README.md @@ -1,97 +1,127 @@ # STAC-Atlas Database -This directory contains the PostgreSQL database setup for STAC-Atlas, a system for managing and searching STAC (SpatioTemporal Asset Catalog) catalogs and collections. +This component contains the PostgreSQL database setup for STAC-Atlas – a system for managing and searching STAC (SpatioTemporal Asset Catalog) Collections. ## Overview -The database is built on **PostgreSQL 16** with **PostGIS 3.4** extensions, providing spatial capabilities for geospatial data management. It stores STAC catalogs, collections, and their associated metadata with full-text search and spatial indexing support. +The database is built on **PostgreSQL 16** with **PostGIS 3.4** for spatial queries. It stores STAC Collections and their metadata with full-text search and spatial indexing. ## Database Structure -### Core Tables +### Entity-Relationship Diagram -#### Catalogs -- **`catalog`**: Main catalog metadata (title, description, STAC version, type) -- **`catalog_links`**: Related links for each catalog -- **`crawllog_catalog`**: Tracks when catalogs were last crawled +![ER-Diagram](ER-Diagramm_stacDB.png) + +### Tables + +#### Crawler Tracking + +| Table | Description | +|-------|-------------| +| `crawllog_catalog` | Tracks crawler progress for catalogs. Enables resume after crash. | +| `crawllog_collection` | Tracks crawl status of individual collections with reference to catalog. | #### Collections -- **`collection`**: Collection metadata with spatial and temporal extents - - Stores spatial extent as PostGIS geometry (POLYGON, EPSG:4326) - - Includes temporal extent (start/end timestamps) - - Full JSON representation of collection stored in `full_json` (JSONB) -- **`collection_summaries`**: Collection summary statistics and ranges -- **`crawllog_collection`**: Tracks when collections were last crawled - -#### Supporting Tables -- **`keywords`**: Searchable keywords for catalogs and collections -- **`stac_extensions`**: STAC extensions used by catalogs/collections -- **`providers`**: Data providers -- **`assets`**: Assets associated with collections - -#### Relation Tables -- **`catalog_keywords`**: Many-to-many relationship between catalogs and keywords -- **`catalog_stac_extension`**: Links catalogs to STAC extensions -- **`collection_keywords`**: Many-to-many relationship between collections and keywords -- **`collection_stac_extension`**: Links collections to STAC extensions -- **`collection_providers`**: Links collections to providers with roles -- **`collection_assets`**: Links collections to assets with roles + +| Table | Description | +|-------|-------------| +| `collection` | Main metadata of STAC Collections (title, description, spatial/temporal extent, license). Stores complete JSON representation in `full_json`. | +| `collection_summaries` | Statistical summaries (value ranges, sets) for collection properties. | + +#### Lookup Tables + +| Table | Description | +|-------|-------------| +| `keywords` | Reusable keywords for search. | +| `stac_extensions` | STAC extensions (e.g., EO, SAR, Point Cloud). | +| `providers` | Data providers and organizations. | +| `assets` | Downloadable resources (files, thumbnails, metadata). | + +#### Junction Tables (n:n) + +| Table | Description | +|-------|-------------| +| `collection_keywords` | Links collections to keywords. | +| `collection_stac_extension` | Links collections to STAC extensions. | +| `collection_providers` | Links collections to providers incl. roles. | +| `collection_assets` | Links collections to assets incl. roles. | ### Extensions -The database uses the following PostgreSQL extensions: -- **PostGIS**: Spatial data types and functions +- **PostGIS**: Spatial data types and functions (geometries, bounding boxes) - **pg_trgm**: Trigram-based text search for fuzzy matching ### Indexes -Comprehensive indexing for optimal query performance: -- **Full-text search** on titles and descriptions -- **Spatial indexes** (GIST) on geographic extents -- **JSONB indexes** (GIN) for flexible JSON queries -- **Temporal indexes** on date ranges -- **Foreign key indexes** for efficient joins +Optimized indexes for fast queries: -## Getting Started +| Type | Usage | +|------|-------| +| **B-Tree** | Title, timestamps, provider names | +| **GIN** | Full-text search (`search_vector`), JSONB fields, asset roles | +| **GIST** | Spatial extent (`spatial_extent`) | -### Starting the Database +### Triggers + +- **`collection_search_vector_update`**: Automatically updates the search vector when collections are modified +- **`collection_keywords_update_vector`**: Updates the search vector when keywords are added/removed + +## Quick Start + +### Start the Database ```bash cd ./db/ +cp example.env .env +# Fill in passwords in .env file docker-compose up ``` ### Connection Details -- **Host**: `atlas.stacindex.org` -- **Port**: `5432` and `5433` +| Parameter | Value | +|-----------|-------| +| Host | choose your server | +| Port | Configurable via `DB_PORT` in `.env` | +| Database | Configurable via `POSTGRES_DB` in `.env` | + +## Configuration -## Port Configuration +### Environment Variables (.env) -This project exposes the database service on a port that can be changed. Update the port in the described place and restart the service. +| Variable | Description | +|----------|-------------| +| `POSTGRES_DB` | Database name | +| `POSTGRES_USER` | Admin user (superuser) | +| `POSTGRES_PASSWORD` | Admin password | +| `DB_PORT` | External port (host side) | +| `STAC_API_PASSWORD` | Password for API user (read-only access) | +| `STAC_CRAWLER_PASSWORD` | Password for crawler user (read-write access) | -The database uses port mapping in the format `HOST:CONTAINER`: -- **`5432:5432`** means: - - Left side (`5432`): Port on your local machine (host) (must be changed in the `.env`) - - Right side (`5432`): Port inside the Docker container +**Important**: Edit the `.env` file, not the `docker-compose.yml`. A template is provided in `example.env`. -How to change the environment parameters in the Docker Compose file -- Open the `docker-compose.yml`. -- Locate e.g. `ports:` and change the host side: -- Format: `":"` -- Example: change `5432:5432` to `5433:5432` to expose the container's 5432 on host port 5433. -- If the compose file references environment variables (e.g. `${DB_PORT}`), change the value in the corresponding `.env` file. +### User Roles -**Important**: Do not modify the `docker-compose.yml` file directly. Instead, update the port configuration in the `.env` file by changing the `${DB_PORT}`, `${POSTGRES_DB}`, `${POSTGRES_USER}` and `${POSTGRES_PASSWORD}` variable, then restart the service with `docker-compose up`. -- The change in the `.env` does not count for the ``, you can change that directly in the `docker-compose.yml` if needed. -- There is an `example.env` provided that can be renamed into `.env` and then modified. +| User | Permissions | +|------|-------------| +| `stac_api` | Read-only access (SELECT) – for the API | +| `stac_crawler` | Full read-write access – for the crawler | ## Initialization Scripts -All SQL scripts in the `./db/init/` folder are automatically executed on the start of the database. The numbering ensures guaranteed execution order: +All scripts in the `./init/` folder are automatically executed on first start in numerical order: + +| Script | Description | +|--------|-------------| +| `00_users.sh` | Creates users (`stac_api`, `stac_crawler`) with appropriate permissions | +| `01_extensions.sql` | Installs PostGIS and pg_trgm extensions | +| `02_tables_catalog.sql` | Creates `crawllog_catalog` for crawler tracking | +| `03_tables_collections.sql` | Creates collection tables and lookup tables | +| `04_relation_tables.sql` | Creates junction tables (n:n relationships) | +| `05_indexes.sql` | Creates performance indexes | +| `06_triggers.sql` | Creates triggers for full-text search | + +## Migrations + +The `./migrations/` folder contains SQL scripts for schema changes after initial setup. Those are not planed yet, but could be used in the future, when chages to the given database are required. -1. **`01_extensions.sql`** - Installs PostGIS and pg_trgm extensions -2. **`02_tables_catalog.sql`** - Creates catalog-related tables -3. **`03_tables_collections.sql`** - Creates collection-related tables -4. **`04_relation_tables.sql`** - Creates relationship n:n tables -5. **`05_indexes.sql`** - Creates the performance indexes From 40363d4da862db9d4a4bb084578a0e8c08238d19 Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:30:40 +0100 Subject: [PATCH 131/134] fix bid. copied from dev (#304) Updated descriptions and formatting in bid.md. --- bid.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bid.md b/bid.md index e96a715..5771fd9 100644 --- a/bid.md +++ b/bid.md @@ -399,11 +399,10 @@ Protokolliert die Zeitpunkte der letzten Crawling-Vorgänge für jeden Katalog. | Spalte | Beschreibung / Inhalt | Datentyp / Format | |---------------|------------------------------------------|-------------------| | **id** | Eindeutige ID des Crawlvorgangs | integer (PK) | -| collection_id | Referenz auf `collection.id` (optional) | integer (FK) | -| source_url | Quell-URL der Collection | text | +| collection_id | Referenz auf `collection.id` | integer (FK) | | last_crawled | Zeitpunkt des letzten Crawls | timestamp | -Protokolliert die Zeitpunkte der letzten Crawling-Vorgänge für jede Collection. Falls die Collection noch nicht in der `collection`-Tabelle existiert, kann sie über die `source_url` identifiziert werden. +Analog zur vorherigen Tabelle dient `crawllog_collection` der Nachverfolgung der Crawling-Zyklen für Collections. --- From f97cd9b0f5844391ea0e9a7729154947191fb604 Mon Sep 17 00:00:00 2001 From: mammutor Date: Tue, 3 Feb 2026 11:35:15 +0100 Subject: [PATCH 132/134] remove STAC-Atlas folder, weird --- STAC-Atlas | 1 - 1 file changed, 1 deletion(-) delete mode 160000 STAC-Atlas diff --git a/STAC-Atlas b/STAC-Atlas deleted file mode 160000 index dc25775..0000000 --- a/STAC-Atlas +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dc257754f6d6c5d19b522bbd79f863fe5e28ae0f From a6e3ef7ae59ca16e9c4caded1ea5f884d9d81bf1 Mon Sep 17 00:00:00 2001 From: Humam <44206081+Mammutor@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:35:42 +0100 Subject: [PATCH 133/134] Crawler: Fix catalogs saved in DB because of wrong metadata (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added ER-Diagramm to explain the database structure * updated the README to the newest stand * added explaniation why the migration folder is empty and explained how to use it in the future * fix(crawler): Enhance collection URL handling in crawler - Persist newly discovered collection URLs in the crawllog_collection queue during API crawling. - Implemented error handling for enqueueing collection URLs. - Updated logging to reflect the loading of pending catalog and API collections. - Refactored database queries to improve retrieval of crawled and pending collection URLs. * feat(crawler): Implement batch processing for collection URLs not catalogs that are only for traversing in DB queue * feat(crawler): Add functionality to manage collection URLs in DB queue * feat(crawler): Enhance URL handling and queue management for collections and catalogs * feat(crawler): Add configuration options for parallel crawling and request limits * feat(crawler): Enhance catalog and API crawling with pending queue management and improved error handling * feat(crawler): Add checks for function existence before database operations in handlers * fix: Enhance collection handling by filtering non-Collection objects and logging warnings for skipped entries --------- Co-authored-by: Sönke Hoffmann Co-authored-by: Sönke Hoffmann --- crawler/utils/handlers.js | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crawler/utils/handlers.js b/crawler/utils/handlers.js index 8f7ed94..b850956 100644 --- a/crawler/utils/handlers.js +++ b/crawler/utils/handlers.js @@ -483,10 +483,40 @@ export async function handleCollections({ request, json, crawler, log, indent, r return null; } }).filter(Boolean); + } else if (typeof stacObj?.isCatalog === 'function' && stacObj.isCatalog()) { + log.warning(`${indent}Collections endpoint returned a Catalog at ${request.url}; skipping`); + results.stats.nonCompliant++; + return; } if (collectionsData.length > 0) { - log.info(`${indent}Found ${collectionsData.length} collections for catalog ${catalogId}`); + const filteredCollections = []; + let nonCollectionCount = 0; + + for (const colObj of collectionsData) { + const raw = typeof colObj?.toJSON === 'function' ? colObj.toJSON() : colObj; + const isCollection = (typeof colObj?.isCollection === 'function' && colObj.isCollection()) + || raw?.type === 'Collection'; + + if (!isCollection) { + nonCollectionCount++; + continue; + } + + filteredCollections.push(colObj); + } + + if (nonCollectionCount > 0) { + log.warning(`${indent}Skipped ${nonCollectionCount} non-Collection object(s) at ${request.url}`); + } + + if (filteredCollections.length === 0) { + log.warning(`${indent}No valid Collection objects found at ${request.url}`); + results.stats.nonCompliant++; + return; + } + + log.info(`${indent}Found ${filteredCollections.length} collections for catalog ${catalogId}`); // Get base URL for constructing absolute collection URLs // Remove trailing /collections from the request URL to get the API base @@ -497,8 +527,8 @@ export async function handleCollections({ request, json, crawler, log, indent, r // Normalize and store collections, skipping already-crawled ones let skippedCount = 0; const collections = []; - for (let index = 0; index < collectionsData.length; index++) { - const colObj = collectionsData[index]; + for (let index = 0; index < filteredCollections.length; index++) { + const colObj = filteredCollections[index]; const collection = normalizeCollection(colObj, index); // Add the catalog slug to the collection for unique stac_id generation collection.sourceSlug = catalogSlug; From a1438c5f326fd4d4f4333f0108eb788995661b60 Mon Sep 17 00:00:00 2001 From: Lenn Date: Tue, 3 Feb 2026 22:05:10 +0100 Subject: [PATCH 134/134] update ReadMe (#308) * feat(crawler): implement graceful shutdown functionality and reset flag for scheduler * fix(db): rename 'type' to 'title' in collection insert/update logic * feat(crawler): add FRESH_CRAWL option to .env.example for clearing crawl log * feat(crawler): add syncIsApiFromCatalog function to update is_api values in collections * fix(crawler): remove is_api from collection insert/update logic * feat(crawler): determine is_api based on source_url and remove syncIsApiFromCatalog function * feat(crawler): add functionality to deactivate stale collections not updated in 7 days * docs: update README with detailed dependencies and technical decisions * docs: update README * docs: expand README table of contents with additional sections --- crawler/README.md | 454 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 393 insertions(+), 61 deletions(-) diff --git a/crawler/README.md b/crawler/README.md index ac9b927..b0935f5 100644 --- a/crawler/README.md +++ b/crawler/README.md @@ -2,6 +2,36 @@ A Node.js crawler for STAC Index API that fetches and processes catalog and collection data with configurable options. Includes an automated scheduler for periodic crawling. +## Table of Contents + +- [Features](#features) +- [Quick Start](#quick-start) +- [Configuration](#configuration) + - [Configuration Options](#configuration-options) + - [Using Environment Variables](#using-environment-variables) + - [Using CLI Arguments](#using-cli-arguments) + - [Show Help](#show-help) +- [Running Locally](#running-locally) +- [Docker](#docker) +- [Testing](#testing) +- [Dependencies](#dependencies) + - [Core Dependencies](#core-dependencies) + - [Development Dependencies](#development-dependencies) + - [Why These Libraries?](#why-these-libraries) +- [Technical Decisions](#technical-decisions) +- [Architecture](#architecture) +- [How It Works](#how-it-works) + - [Crawling Process Overview](#crawling-process-overview) + - [What Gets Stored](#what-gets-stored) + - [Pause and Resume Functionality](#pause-and-resume-functionality) + - [Auto-Recrawling](#auto-recrawling) + - [Data Validation](#data-validation) +- [Troubleshooting](#troubleshooting) +- [Performance Tuning](#performance-tuning) +- [npm Scripts](#npm-scripts) +- [License](#license) +- [Examples](#examples) + ## Features - Single-run Mode: Execute crawler once and exit @@ -346,70 +376,372 @@ The test suite covers utility functions across four test modules: All tests use real STAC domain names and collection IDs from production STAC APIs for realistic testing. +## Dependencies + +The crawler uses carefully selected libraries for specific functionality: + +### Core Dependencies + +#### **Crawlee** (v3.15.3) +- **Purpose**: Advanced web crawling framework with built-in request management +- **Why chosen**: + - Automatic retry logic with exponential backoff + - Built-in rate limiting per domain + - Concurrent request handling with configurable concurrency + - Request queue management for large-scale crawling + - Automatic handling of timeouts and errors +- **Key features used**: + - `HttpCrawler` - For HTTP requests with JSON parsing + - Request/response handlers for custom processing + - Domain-based crawling strategies +- **Alternative considered**: Axios alone - rejected because it lacks built-in queue management and retry logic + +#### **axios** (v1.13.2) +- **Purpose**: HTTP client for direct API calls (non-crawling requests) +- **Why chosen**: + - Simple interface for one-off requests (e.g., fetching catalog list) + - Wide adoption and reliability + - Promise-based async/await support +- **Used for**: Initial STAC Index API calls before crawling starts + +#### **stac-js** (v0.1.9) +- **Purpose**: STAC object manipulation and metadata extraction +- **Why chosen**: + - Official STAC library with spec-compliant parsers + - Type detection (Collection, Catalog, Item) + - Built-in methods for extent extraction (`getBoundingBox()`, `getTemporalExtent()`) + - Link resolution (relative to absolute URLs) +- **Key features used**: + - `create()` - Parse JSON into STAC objects + - `isCollection()`, `isCatalog()` - Type checking + - Extent extraction methods + +#### **stac-node-validator** (v2.0.0-rc.1) +- **Purpose**: Validate STAC JSON against official schemas +- **Why chosen**: + - Uses official STAC JSON schemas + - Validates core spec + extensions (EO, SAT, Projection, etc.) + - Detailed error reporting with field-level messages + - Async validation suitable for high-volume crawling +- **Key features used**: + - Full STAC spec validation (v1.0.0, v1.1.0 support) + - Extension schema validation + - Error message extraction for debugging +- **Critical for**: Data quality - filters out malformed STAC metadata before database insertion + +#### **@databases/pg** (v5.5.0) +- **Purpose**: PostgreSQL database client with modern async/await support +- **Why chosen**: + - Type-safe SQL queries with tagged template literals + - Connection pooling built-in + - Better TypeScript support than `pg` alone + - Cleaner API than raw `pg` +- **Key features used**: + - Connection pool management + - Parameterized queries (SQL injection prevention) + - Transaction support + + +#### **dotenv** (v17.2.3) +- **Purpose**: Environment variable management from `.env` files +- **Why chosen**: + - Standard solution for 12-factor app configuration + - Keeps sensitive credentials out of source code + - Development/production environment separation +- **Used for**: Database credentials, crawler configuration, scheduler settings + +### Development Dependencies + +#### **Jest** (v29.7.0) +- **Purpose**: Testing framework +- **Why chosen**: + - Industry standard for Node.js testing + - Built-in assertion library + - Parallel test execution + - Coverage reporting + - Module mocking support +- **Test coverage**: 110 tests across normalization, parallel execution, and API utilities +- **Configuration**: Uses ES modules (`--experimental-vm-modules`) for modern JavaScript support + +### Implicit Dependencies + +**Node.js built-ins**: +- `pg` (Pool) - Part of `@databases/pg`, PostgreSQL connection pooling +- `process.env` - Environment variable access +- `console` - Logging (no external logger to keep dependencies minimal) + + +## Technical Decisions + +### 1. Why PostgreSQL? + +**Decision**: Use PostgreSQL as the primary database + +**Reason**: +- **PostGIS extension**: Native geospatial support for bounding box queries +- **JSONB type**: Efficient storage of STAC summaries and nested metadata +- **Robust transactions**: ACID compliance prevents data corruption during concurrent crawls +- **Indexing**: B-tree, GiST, and GIN indexes for fast spatial and text searches +- **Scalability**: Handles millions of collections without performance degradation + + +### 2. Why Domain-Based Parallel Processing? + +**Decision**: Group catalogs/APIs by domain and process domains in parallel + +**Reason**: +- **Rate limiting**: Each domain has independent rate limits - prevents throttling +- **Politeness**: Distributes load across servers, avoiding overwhelming single hosts +- **Efficiency**: Processes multiple domains simultaneously while respecting per-domain limits +- **Fairness**: Prevents slow domains from blocking fast domains + + +### 3. Why Separate Crawler and Scheduler? + +**Decision**: Keep single-run crawler (`index.js`) separate from scheduler (`scheduler.js`) + +**Reason**: +- **Flexibility**: Users can run one-off crawls or automated schedules +- **Testing**: Easier to test crawler logic without scheduler complexity +- **Resource efficiency**: Single runs exit immediately, don't hold resources +- **Debugging**: Simpler to debug individual components +- **Docker compatibility**: Can run different commands in containers + + +### 4. Why Batch Flushing to Database? + +**Decision**: Collect 25 collections in memory, then flush to database + +**Reason**: +- **Performance**: Reduces database connection overhead (25x fewer transactions) +- **Memory efficiency**: Prevents unbounded memory growth on large crawls +- **Error recovery**: Smaller batches = less data lost on errors +- **Deadlock mitigation**: Fewer concurrent transactions reduce deadlock risk + +**Batch size selection**: +- Tested on 2GB RAM servers → 25 collections = ~10MB memory footprint +- Larger batches (100+) caused OOM on constrained servers +- Smaller batches (5-10) increased database load significantly + + +### 5. Why Deadlock Retry with Exponential Backoff? + +**Decision**: Retry database deadlocks up to 3 times with exponential backoff + +**Reason**: +- **PostgreSQL behavior**: Concurrent inserts on related tables (keywords, extensions) can deadlock +- **Automatic recovery**: Transient deadlocks resolve after retry +- **Exponential backoff**: Reduces contention by spreading out retry attempts +- **Max retries**: Prevents infinite loops on persistent deadlocks + + + + + + ## Architecture ### Core Components -- `index.js` - Main crawler entry point for single runs -- `scheduler.js` - Scheduler for periodic automated crawling -- `utils/db.js` - Database helper with PostgreSQL connection pool - - `initDb()` - Initialize and test database connection - - `insertOrUpdateCollection()` - Insert/update collections with deadlock retry logic - - `saveCrawllogCatalog()` - Store catalog/API URLs for re-crawling - - `getCrawledCollectionUrls()` - Get already-crawled URLs for pause/resume - - Helper functions for keywords, extensions, providers, assets, summaries -- `utils/normalization.js` - Data normalization and processing -- `utils/parallel.js` - Parallel execution utilities with domain-based batching -- `utils/config.js` - Configuration management (env vars + CLI) -- `utils/cli.js` - Command-line argument parsing -- `utils/time.js` - Time formatting utilities -- `utils/handlers.js` - Request handlers for catalog and collection processing -- `utils/endpoints.js` - STAC endpoint discovery and validation -- `utils/globalStats.js` - Global statistics tracking -- `catalogs/catalog.js` - Static catalog crawling logic -- `apis/api.js` - STAC API crawling logic - -### Database Schema - -The crawler stores collections in PostgreSQL with the following key tables: -- `collection` - Main collection data with spatial/temporal extents -- `collection_summaries` - Collection summary metadata -- `collection_keywords` - Keywords linked to collections -- `collection_stac_extension` - STAC extensions used -- `collection_providers` - Data providers -- `collection_assets` - Collection assets -- `crawllog_catalog` - Catalog/API URL queue for re-crawling with slug for stac_id generation -- `crawllog_collection` - Crawl history, timestamps, and source URLs for pause/resume - -### Scheduler Workflow - -1. Initialization: Load configuration from `.env` -2. Startup Check: Verify if within allowed time window -3. Initial Run: Execute crawler immediately (if enabled) -4. Schedule Next: Calculate next run time based on interval -5. Time Window Adjustment: Shift schedule to fit time window if enforced -6. Wait: Sleep until next scheduled time -7. Execute: Run crawler and collect statistics -8. Error Handling: - - DB errors: Stop scheduler - - Crawl errors: Retry after delay (if enabled) - - Success: Schedule next run -9. Repeat: Loop back to step 6 - -### Error Handling - -- Database Errors: Scheduler stops to prevent data corruption -- Crawl Errors: Automatic retry with configurable delay (default: 2 hours) -- Deadlock Handling: Automatic retry with exponential backoff for database deadlocks -- Time Window Violations: Grace period allows crawler to finish current operations -- Connection Errors: Detailed error logging with connection details -- Graceful Shutdown: Press Ctrl+C to stop after current batch; re-run to resume - -### Automatic Cleanup - -After each crawl completes, the crawler automatically: -- Marks collections as inactive if they haven't been updated in the last 7 days -- This helps identify stale or removed collections that are no longer available +- **`index.js`** - Main crawler entry point for single runs +- **`scheduler.js`** - Scheduler for periodic automated crawling +- **`utils/db.js`** - Database helper with PostgreSQL connection pool +- **`utils/normalization.js`** - Data normalization and processing +- **`utils/parallel.js`** - Parallel execution utilities with domain-based batching +- **`utils/config.js`** - Configuration management (env vars + CLI) +- **`utils/time.js`** - Time formatting utilities +- **`utils/handlers.js`** - Request handlers for catalogs and collections with STAC validation +- **`utils/endpoints.js`** - STAC API endpoint discovery utilities +- **`catalogs/catalog.js`** - Static catalog crawling logic +- **`apis/api.js`** - STAC API crawling logic + +## How It Works + +### Crawling Process Overview + +The crawler operates in two modes: **static catalog crawling** and **STAC API crawling**. Both modes follow a similar workflow but use different strategies to discover and process STAC collections. + +#### Static Catalog Crawling + +1. **Initialization**: Fetch the list of static catalogs from STAC Index API (`https://www.stacindex.org/api/catalogs`) +2. **Domain Grouping**: Group catalogs by domain to enable parallel processing while respecting rate limits +3. **Parallel Execution**: Process multiple domains simultaneously with configurable concurrency +4. **Recursive Traversal**: For each catalog: + - Fetch the catalog JSON from its URL + - Validate STAC structure using `stac-node-validator` + - Migrate to normalized format using `stac-js` + - Extract child links (catalogs and collections) + - Recursively follow catalog links up to `MAX_DEPTH` (default: 3) + - Process collection links to extract metadata +5. **Link Following**: The crawler follows STAC link relations: + - `rel=child` - Navigate to child catalogs/collections + - `rel=item` - Skip (items are not processed, only collections) + - `rel=self` - Used to determine the source URL + +#### STAC API Crawling + +1. **Initialization**: Fetch the list of STAC APIs from STAC Index API +2. **Domain Grouping**: Same as static catalog crawling +3. **API Discovery**: For each API: + - Fetch the API root endpoint + - Validate STAC API compliance + - Discover `/collections` endpoint from API conformance or links + - Try multiple endpoint variations if needed (`/collections`, `/search`, etc.) +4. **Collection Enumeration**: + - Fetch all collections from `/collections` endpoint + - Handle pagination if the API returns paged results + - Process each collection individually +5. **Nested Catalog Support**: If a collection contains child catalog links, recursively crawl them (up to `MAX_DEPTH`) + +#### What Gets Stored + +The crawler stores the following data in PostgreSQL: + +**Collections** (main data): +- **Core metadata**: `stac_id` (generated from slug + collection ID), `title`, `description`, `license` +- **Spatial extent**: Bounding box (`bbox`) stored as PostGIS geometry +- **Temporal extent**: Start and end dates +- **STAC version**: Version of STAC specification used +- **Source tracking**: `source_url` (original collection URL), `crawllog_catalog_id` (reference to source catalog) + +**Related data** (linked tables): +- **Keywords**: Extracted from collection metadata, stored in `collection_keywords` with many-to-many relation +- **STAC Extensions**: List of STAC extensions used (e.g., `eo`, `sat`, `proj`), stored in `collection_stac_extension` +- **Providers**: Data providers with name, description, roles, and URL +- **Assets**: Collection-level assets (thumbnails, documentation, etc.) +- **Summaries**: Statistical summaries of collection properties + +**Crawl tracking** (for pause/resume): +- **`crawllog_catalog`**: Stores the catalog/API URLs and slugs for future re-crawling +- **`crawllog_collection`**: Records which collection URLs have been processed and when + +**What is NOT stored**: +- **Individual items**: The crawler only processes collections, not individual STAC items +- **Catalog metadata**: Static catalogs are only used for traversal, not saved to the database +- **Full link arrays**: Only essential links (self, root) are preserved + +#### Pause and Resume Functionality + +**How Pausing Works**: +1. **Graceful Shutdown**: Press `Ctrl+C` once to trigger graceful shutdown +2. **Batch Completion**: The crawler finishes the current batch of requests before stopping +3. **Progress Saved**: All processed collections are saved to `crawllog_collection` with their source URLs +4. **Safe Exit**: Database connections are properly closed + +**How Resuming Works**: +1. **URL Lookup**: When restarting, the crawler queries `crawllog_collection` for already-processed URLs +2. **Skip Logic**: URLs in the crawl log are skipped during traversal +3. **Continue from Interruption**: Only new/unprocessed collections are fetched +4. **Idempotent**: Running the crawler multiple times is safe - duplicates are handled via `ON CONFLICT` clauses + +**Force Stop**: Press `Ctrl+C` twice for immediate termination (may leave incomplete transactions) + +#### Auto-Recrawling + +The scheduler (`scheduler.js`) provides automated periodic crawling: + +1. **Interval-based**: Runs every `CRAWL_DAYS_INTERVAL` days (default: 7) +2. **Time Window Enforcement**: Optional restriction to specific hours (e.g., night-time only) +3. **Startup Behavior**: Configurable immediate run on startup (`CRAWL_RUN_ON_STARTUP`) +4. **Error Recovery**: Automatic retry on crawl errors with configurable delay +5. **Recrawl Strategy**: Full re-crawl of all catalogs/APIs - `ON CONFLICT` ensures updates rather than duplicates + +**Scheduling Logic**: +``` +Startup → DB Check → Time Window Check → Run Crawler → Success? + ↓ Yes ↓ No (crawl error) + Schedule Next Wait RETRY_DELAY → Retry + ↓ + Wait Until Next → Run Crawler +``` + +### Data Validation + +The crawler implements multi-layer validation to ensure data quality: + +#### 1. STAC Specification Validation + +**Library**: `stac-node-validator` (v2.0.0-rc.1) + +**What it validates**: +- STAC JSON structure compliance with official STAC schemas +- Required fields presence (id, type, stac_version, etc.) +- Field types and formats +- STAC extension schemas (e.g., EO, SAT, Projection) +- Link relation requirements + +**When it runs**: Before processing any catalog or collection + +**Error handling**: +- Non-compliant structures are logged with detailed error messages +- Collections with validation errors are skipped +- Statistics track compliant vs. non-compliant items + + + +#### 2. STAC Migration Validation + +**Library**: `stac-js` (v0.1.9) + +**What it validates**: +- Converts raw JSON to typed STAC objects +- Validates object type (Collection, Catalog, Item) +- Validates link structure and relationships +- Extracts spatial/temporal extents using STAC-aware parsers +- Resolves relative URLs to absolute URLs + +**When it runs**: After STAC spec validation passes + +**Error handling**: +- Migration failures indicate malformed STAC structures +- Failed migrations are logged and skipped +- `stac-js` methods return null for invalid data (e.g., `getBoundingBox()`) + + + +#### 3. Custom Data Normalization + +**Module**: `utils/normalization.js` + +**What it normalizes**: +- **Categories/Keywords**: Derives from multiple possible fields (categories, keywords, tags) +- **Temporal extents**: Handles null values, open-ended intervals +- **Bounding boxes**: Validates array structure, handles missing coordinates +- **URLs**: Extracts self links, resolves relative paths +- **Provider roles**: Normalizes role names (producer, processor, host, licensor) +- **Fallback strategy**: Uses multiple fallback levels to extract data + + +#### 4. URL and HTTP Validation + +**Validation checks**: +- **URL format**: Ensures valid HTTP/HTTPS URLs before making requests +- **Response status**: Checks for 200 OK status codes +- **Content-Type**: Accepts JSON, GeoJSON, and some binary/text types +- **Timeout enforcement**: Requests timeout after configured duration +- **Retry logic**: Automatic retry with exponential backoff for failed requests + +**Rate limiting**: +- Per-domain rate limits prevent overwhelming servers +- Configurable requests per minute per domain +- Crawler respects HTTP 429 (Too Many Requests) responses + +#### Validation Statistics + +The crawler tracks validation results: +- `stacCompliant` - Collections passing STAC validation +- `nonCompliant` - Collections failing STAC validation +- `collectionsSaved` - Successfully saved to database +- `collectionsFailed` - Failed database insertion + +**Example output**: +``` +Validation Results: + STAC Compliant: 450 + Non-compliant: 12 + Saved to DB: 448 + Failed to save: 2 +``` ## Troubleshooting @@ -430,7 +762,7 @@ node scheduler.js Increase `CRAWL_DAYS_INTERVAL`: ```bash -CRAWL_DAYS_INTERVAL=14 # Run every 2 weeks +CRAWL_DAYS_INTERVAL=7 # Run every week ``` ### Crawler Only Runs at Specific Times