diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index b5e8cfd..0000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 1bf0590..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/lib/FredyPipelineExecutioner.js b/lib/FredyPipelineExecutioner.js index 5822106..f7f2c31 100755 --- a/lib/FredyPipelineExecutioner.js +++ b/lib/FredyPipelineExecutioner.js @@ -63,6 +63,7 @@ class FredyPipelineExecutioner { * @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape. * @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings. * @param {(url:string, waitForSelector?:string)=>Promise|Promise} [providerConfig.getListings] Optional override to fetch listings. + * @param {(listing:Listing, browser:any)=>Promise} [providerConfig.fetchDetails] Optional per-listing detail enrichment. Called in parallel for each new listing after deduplication. Receives the shared browser instance. Must always resolve (never reject). * @param {Object} notificationConfig Notification configuration passed to notification adapters. * @param {Object} spatialFilter Optional spatial filter configuration. * @param {string} providerId The ID of the provider currently in use. @@ -92,6 +93,7 @@ class FredyPipelineExecutioner { .then(this._normalize.bind(this)) .then(this._filter.bind(this)) .then(this._findNew.bind(this)) + .then(this._fetchDetails.bind(this)) .then(this._geocode.bind(this)) .then(this._save.bind(this)) .then(this._calculateDistance.bind(this)) @@ -101,6 +103,32 @@ class FredyPipelineExecutioner { .catch(this._handleError.bind(this)); } + /** + * Optionally enrich new listings with data from their detail pages. + * Only called when the provider config defines a `fetchDetails` function. + * Runs all fetches in parallel. Each individual fetch must handle its own errors + * and always resolve (never reject) to avoid aborting other listings. + * + * @param {Listing[]} newListings New listings to enrich. + * @returns {Promise} Resolves with enriched listings. + */ + async _fetchDetails(newListings) { + if (typeof this._providerConfig.fetchDetails !== 'function') { + return newListings; + } + const userId = getJob(this._jobKey)?.userId; + const enabledProviders = getUserSettings(userId)?.provider_details ?? []; + if (!userId || !Array.isArray(enabledProviders) || !enabledProviders.includes(this._providerId)) { + return newListings; + } + const listingsToEnrich = process.env.NODE_ENV === 'test' ? newListings.slice(0, 1) : newListings; + const enriched = []; + for (const listing of listingsToEnrich) { + enriched.push(await this._providerConfig.fetchDetails(listing, this._browser)); + } + return enriched; + } + /** * Geocode new listings. * diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js index 7863126..f1cd040 100644 --- a/lib/api/routes/userSettingsRoute.js +++ b/lib/api/routes/userSettingsRoute.js @@ -97,9 +97,9 @@ userSettingsRouter.post('/news-hash', async (req, res) => { } }); -userSettingsRouter.post('/immoscout-details', async (req, res) => { +userSettingsRouter.post('/provider-details', async (req, res) => { const userId = req.session.currentUser; - const { immoscout_details } = req.body; + const { provider_details } = req.body; const globalSettings = await getSettings(); if (globalSettings.demoMode) { @@ -108,11 +108,17 @@ userSettingsRouter.post('/immoscout-details', async (req, res) => { return; } + if (!Array.isArray(provider_details)) { + res.statusCode = 400; + res.send({ error: 'provider_details must be an array of provider ids.' }); + return; + } + try { - upsertSettings({ immoscout_details: !!immoscout_details }, userId); + upsertSettings({ provider_details }, userId); res.send({ success: true }); } catch (error) { - logger.error('Error updating immoscout details setting', error); + logger.error('Error updating provider details setting', error); res.statusCode = 500; res.send({ error: error.message }); } diff --git a/lib/provider/immobilienDe.js b/lib/provider/immobilienDe.js index f509fad..8076752 100644 --- a/lib/provider/immobilienDe.js +++ b/lib/provider/immobilienDe.js @@ -5,6 +5,9 @@ import { buildHash, isOneOf } from '../utils.js'; import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; +import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js'; +import * as cheerio from 'cheerio'; +import logger from '../services/logger.js'; let appliedBlackList = []; @@ -18,6 +21,51 @@ function parseId(shortenedLink) { return shortenedLink.substring(shortenedLink.lastIndexOf('/') + 1); } +async function fetchDetails(listing, browser) { + try { + const html = await puppeteerExtractor(listing.link, null, { browser }); + if (!html) return listing; + + const $ = cheerio.load(html); + + // Try JSON-LD first + let description = null; + let address = listing.address; + $('script[type="application/ld+json"]').each((_, el) => { + if (description) return; + try { + const data = JSON.parse($(el).text()); + const nodes = Array.isArray(data) ? data : [data]; + for (const node of nodes) { + if (node.description && !description) description = String(node.description).replace(/\s+/g, ' ').trim(); + const addr = node.address || node?.mainEntity?.address; + if (addr && addr.streetAddress && address === listing.address) { + const parts = [addr.streetAddress, addr.postalCode, addr.addressLocality].filter(Boolean); + if (parts.length) address = parts.join(' '); + } + } + } catch { + // ignore malformed JSON-LD + } + }); + + // Fallback: common description selectors used by immobilien.de + if (!description) { + const sel = ['.beschreibung', '.freitext', '.objektbeschreibung', '.description'].find((s) => $(s).length > 0); + if (sel) description = $(sel).text().replace(/\s+/g, ' ').trim(); + } + + return { + ...listing, + address, + description: description || listing.description, + }; + } catch (error) { + logger.warn(`Could not fetch immobilien.de detail page for listing '${listing.id}'.`, error?.message || error); + return listing; + } +} + function normalize(o) { const baseUrl = 'https://www.immobilien.de'; const size = o.size || null; @@ -25,8 +73,8 @@ function normalize(o) { const title = o.title || 'No title available'; const address = o.address || null; const shortLink = shortenLink(o.link); - const link = baseUrl + shortLink; - const image = baseUrl + o.image; + const link = shortLink ? (shortLink.startsWith('http') ? shortLink : baseUrl + shortLink) : baseUrl; + const image = o.image ? (o.image.startsWith('http') ? o.image : baseUrl + o.image) : null; const id = buildHash(parseId(shortLink), o.price); return Object.assign(o, { id, price, size, title, address, link, image }); } @@ -39,21 +87,22 @@ function applyBlacklist(o) { const config = { url: null, - crawlContainer: 'a:has(div.list_entry)', + crawlContainer: 'a.lr-card', sortByDateParam: 'sort_col=*created_ts&sort_dir=desc', - waitForSelector: 'body', + waitForSelector: null, crawlFields: { id: '@href', //will be transformed later - price: '.immo_preis .label_info', - size: '.flaeche .label_info | removeNewline | trim', - title: 'h3 span', + price: '.lr-card__price-amount | trim', + size: '.lr-card__fact:has(.lr-card__fact-label:contains("Fläche")) .lr-card__fact-value | trim', + title: '.lr-card__title | trim', description: '.description | trim', link: '@href', - address: '.place', - image: 'img@src', + address: '.lr-card__address span | trim', + image: 'img.lr-card__gallery-img@src', }, - normalize: normalize, + normalize, filter: applyBlacklist, + fetchDetails, activeTester: checkIfListingIsActive, }; export const init = (sourceConfig, blacklist) => { diff --git a/lib/provider/immoscout.js b/lib/provider/immoscout.js index fcc48ae..2a52967 100644 --- a/lib/provider/immoscout.js +++ b/lib/provider/immoscout.js @@ -46,9 +46,7 @@ import { convertWebToMobile, } from '../services/immoscout/immoscout-web-translator.js'; import logger from '../services/logger.js'; -import { getUserSettings } from '../services/storage/settingsStorage.js'; let appliedBlackList = []; -let currentUserId = null; async function getListings(url) { const response = await fetch(url, { @@ -68,42 +66,40 @@ async function getListings(url) { } const responseBody = await response.json(); - return Promise.all( - responseBody.resultListItems - .filter((item) => item.type === 'EXPOSE_RESULT') - .map(async (expose) => { - const item = expose.item; - const [price, size] = item.attributes; - const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null; - let listing = { - id: item.id, - price: price?.value, - size: size?.value, - title: item.title, - link: `${metaInformation.baseUrl}expose/${item.id}`, - address: item.address?.line, - image, - }; - if (currentUserId) { - const userSettings = getUserSettings(currentUserId); - if (userSettings.immoscout_details) { - return await pushDetails(listing); - } - } - return listing; - }), - ); + return responseBody.resultListItems + .filter((item) => item.type === 'EXPOSE_RESULT') + .map((expose) => { + const item = expose.item; + const [price, size] = item.attributes; + const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null; + return { + id: item.id, + price: price?.value, + size: size?.value, + title: item.title, + link: `${metaInformation.baseUrl}expose/${item.id}`, + address: item.address?.line, + image, + }; + }); +} + +async function fetchDetails(listing) { + return pushDetails(listing); } async function pushDetails(listing) { - const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, { + const exposeId = listing.link?.split('/').pop(); + const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${exposeId}`, { headers: { 'User-Agent': 'ImmoScout_27.3_26.0_._', 'Content-Type': 'application/json', }, }); if (!detailed.ok) { - logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText); + logger.warn( + `Error fetching listing details from ImmoScout Mobile API for id: ${exposeId} Status: ${detailed.statusText}`, + ); return listing; } const detailBody = await detailed.json(); @@ -196,13 +192,13 @@ const config = { normalize: normalize, filter: applyBlacklist, getListings: getListings, + fetchDetails: fetchDetails, activeTester: isListingActive, }; export const init = (sourceConfig, blacklist) => { config.enabled = sourceConfig.enabled; config.url = convertWebToMobile(sourceConfig.url); appliedBlackList = blacklist || []; - currentUserId = sourceConfig.userId || null; }; export const metaInformation = { name: 'Immoscout', diff --git a/lib/provider/immowelt.js b/lib/provider/immowelt.js index df50c4b..ce05c8b 100755 --- a/lib/provider/immowelt.js +++ b/lib/provider/immowelt.js @@ -5,9 +5,49 @@ import { buildHash, isOneOf } from '../utils.js'; import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; +import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js'; +import * as cheerio from 'cheerio'; +import logger from '../services/logger.js'; let appliedBlackList = []; +async function fetchDetails(listing, browser) { + try { + const html = await puppeteerExtractor(listing.link, null, { browser }); + if (!html) return listing; + + const $ = cheerio.load(html); + const nextDataRaw = $('#__NEXT_DATA__').text(); + if (!nextDataRaw) return listing; + + const classified = JSON.parse(nextDataRaw)?.props?.pageProps?.classified; + if (!classified) return listing; + + const description = (classified.Texts || []) + .map((t) => [t.Title, t.Content].filter(Boolean).join('\n')) + .filter(Boolean) + .join('\n\n'); + + const addr = classified.EstateAddress; + let address = listing.address; + if (addr) { + const street = [addr.Street, addr.HouseNumber].filter(Boolean).join(' '); + const cityLine = [addr.ZipCode, addr.District || addr.City].filter(Boolean).join(' '); + const full = [street, cityLine].filter(Boolean).join(', '); + if (full) address = full; + } + + return { + ...listing, + address, + description: description || listing.description, + }; + } catch (error) { + logger.warn(`Could not fetch immowelt detail page for listing '${listing.id}'.`, error?.message || error); + return listing; + } +} + function normalize(o) { const id = buildHash(o.id, o.price); return Object.assign(o, { id }); @@ -37,6 +77,7 @@ const config = { }, normalize: normalize, filter: applyBlacklist, + fetchDetails: fetchDetails, activeTester: checkIfListingIsActive, }; export const init = (sourceConfig, blacklist) => { diff --git a/lib/provider/kleinanzeigen.js b/lib/provider/kleinanzeigen.js index d72c474..cc5cdaa 100755 --- a/lib/provider/kleinanzeigen.js +++ b/lib/provider/kleinanzeigen.js @@ -5,14 +5,151 @@ import { buildHash, isOneOf } from '../utils.js'; import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; +import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js'; +import logger from '../services/logger.js'; +import * as cheerio from 'cheerio'; let appliedBlackList = []; let appliedBlacklistedDistricts = []; +function toAbsoluteLink(link) { + if (!link) return null; + return link.startsWith('http') ? link : `https://www.kleinanzeigen.de${link}`; +} + +function cleanText(value) { + if (value == null) return ''; + return String(value) + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function buildAddressFromJsonLd(address) { + if (!address || typeof address !== 'object') return null; + + const locality = cleanText(address.addressLocality); + const region = cleanText(address.addressRegion); + const postalCode = cleanText(address.postalCode); + const streetAddress = cleanText(address.streetAddress); + + const cityPart = [region, locality].filter(Boolean).join(' - '); + const tail = [postalCode, cityPart || locality || region].filter(Boolean).join(' '); + const fullAddress = [streetAddress, tail].filter(Boolean).join(', '); + + return fullAddress || null; +} + +function flattenJsonLdNodes(node, acc = []) { + if (node == null) return acc; + + if (Array.isArray(node)) { + node.forEach((item) => flattenJsonLdNodes(item, acc)); + return acc; + } + + if (typeof node !== 'object') return acc; + + acc.push(node); + + if (Array.isArray(node['@graph'])) { + node['@graph'].forEach((item) => flattenJsonLdNodes(item, acc)); + } + + if (node.mainEntity) { + flattenJsonLdNodes(node.mainEntity, acc); + } + + if (node.itemOffered) { + flattenJsonLdNodes(node.itemOffered, acc); + } + + return acc; +} + +function extractDetailFromHtml(html) { + const $ = cheerio.load(html); + const nodes = []; + + // Prefer the rendered postal address block from the detail page because + // it contains the street line that is missing from list results. + const streetFromDom = cleanText($('#street-address').first().text()); + const localityFromDom = cleanText($('#viewad-locality').first().text()); + const domAddress = [streetFromDom, localityFromDom].filter(Boolean).join(' '); + + $('script[type="application/ld+json"]').each((_, element) => { + const content = $(element).text(); + if (!content) return; + + try { + const parsed = JSON.parse(content); + flattenJsonLdNodes(parsed, nodes); + } catch { + // Ignore broken JSON-LD blocks from ads/trackers and keep trying others. + } + }); + + let detailAddress = null; + let detailDescription = null; + + if (domAddress) { + detailAddress = domAddress; + } + + for (const node of nodes) { + const candidateAddress = buildAddressFromJsonLd( + node.address || node?.itemOffered?.address || node?.offers?.address, + ); + if (!detailAddress && candidateAddress) { + detailAddress = candidateAddress; + } + + const candidateDescription = cleanText(node.description || node?.itemOffered?.description); + if (!detailDescription && candidateDescription) { + detailDescription = candidateDescription; + } + + if (detailAddress && detailDescription) { + break; + } + } + + return { + detailAddress, + detailDescription, + }; +} + +async function enrichListingFromDetails(listing, browser) { + const absoluteLink = toAbsoluteLink(listing.link); + if (!absoluteLink) return listing; + + try { + const html = await puppeteerExtractor(absoluteLink, null, { browser }); + if (!html) return { ...listing, link: absoluteLink }; + + const { detailAddress, detailDescription } = extractDetailFromHtml(html); + + return { + ...listing, + link: absoluteLink, + address: detailAddress || listing.address, + description: detailDescription || listing.description, + }; + } catch (error) { + logger.warn(`Could not fetch Kleinanzeigen detail page for listing '${listing.id}'.`, error?.message || error); + return { ...listing, link: absoluteLink }; + } +} + +async function fetchDetails(listing, browser) { + return enrichListingFromDetails(listing, browser); +} + function normalize(o) { const size = o.size || '--- m²'; const id = buildHash(o.id, o.price); - const link = `https://www.kleinanzeigen.de${o.link}`; + const link = toAbsoluteLink(o.link) || o.link; return Object.assign(o, { id, size, link }); } @@ -40,12 +177,13 @@ const config = { address: '.aditem-main--top--left | trim | removeNewline', image: 'img@src', }, + fetchDetails, normalize: normalize, filter: applyBlacklist, activeTester: checkIfListingIsActive, }; export const metaInformation = { - name: 'Ebay Kleinanzeigen', + name: 'Kleinanzeigen', baseUrl: 'https://www.kleinanzeigen.de/', id: 'kleinanzeigen', }; diff --git a/lib/provider/sparkasse.js b/lib/provider/sparkasse.js index bc5aa2a..8f4af8c 100755 --- a/lib/provider/sparkasse.js +++ b/lib/provider/sparkasse.js @@ -5,12 +5,60 @@ import { isOneOf, buildHash } from '../utils.js'; import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; +import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js'; +import * as cheerio from 'cheerio'; +import logger from '../services/logger.js'; + let appliedBlackList = []; +async function fetchDetails(listing, browser) { + try { + const html = await puppeteerExtractor(listing.link, 'body', { browser }); + + const $ = cheerio.load(html); + const nextDataRaw = $('#__NEXT_DATA__').text; + if (!nextDataRaw) return listing; + + const estate = JSON.parse(nextDataRaw)?.props?.pageProps?.estate; + if (!estate) return listing; + + const description = (estate.frontendItems || []) + .map((item) => { + const texts = (item.contents || []) + .filter((c) => c.type === 'contentBoxes') + .flatMap((c) => c.data || []) + .filter((d) => d.type === 'text' && d.content) + .map((d) => d.content); + if (!texts.length) return null; + return [item.label, ...texts].filter(Boolean).join('\n'); + }) + .filter(Boolean) + .join('\n\n'); + + const addr = estate.address; + let address = listing.address; + if (addr) { + const street = [addr.street, addr.streetNumber].filter(Boolean).join(' '); + const cityLine = [addr.zip, addr.city].filter(Boolean).join(' '); + const full = [street, cityLine].filter(Boolean).join(', '); + if (full) address = full; + } + + return { + ...listing, + address, + description: description || listing.description, + }; + } catch (error) { + logger.warn(`Could not fetch Sparkasse detail page for listing '${listing.id}'.`, error?.message || error); + return listing; + } +} + function normalize(o) { const originalId = o.id.split('/').pop().replace('.html', ''); const id = buildHash(originalId, o.price); - const size = o.size?.replace(' Wohnfläche', '') ?? null; + const size = o.size?.replace(' Wohnfläche', '').replace(' m²', 'm²') ?? null; const title = o.title || 'No title available'; const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url; return Object.assign(o, { id, size, title, link }); @@ -22,20 +70,21 @@ function applyBlacklist(o) { } const config = { url: null, - crawlContainer: '.estate-list-item-row', + crawlContainer: 'div[data-testid="estate-link"]', sortByDateParam: 'sortBy=date_desc', waitForSelector: 'body', crawlFields: { - id: 'div[data-testid="estate-link"] a@href', + id: 'a@href', title: 'h3 | trim', price: '.estate-list-price | trim', - size: '.estate-mainfact:first-child span | trim', + size: '.estate-mainfact span | trim', address: 'h6 | trim', - image: '.estate-list-item-image-container img@src', - link: 'div[data-testid="estate-link"] a@href', + image: 'img@src', + link: 'a@href', }, normalize: normalize, filter: applyBlacklist, + fetchDetails, activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'), }; export const init = (sourceConfig, blacklist) => { diff --git a/lib/provider/wgGesucht.js b/lib/provider/wgGesucht.js index d0d0551..87e8a37 100755 --- a/lib/provider/wgGesucht.js +++ b/lib/provider/wgGesucht.js @@ -5,9 +5,34 @@ import { isOneOf, buildHash } from '../utils.js'; import checkIfListingIsActive from '../services/listings/listingActiveTester.js'; +import puppeteerExtractor from '../services/extractor/puppeteerExtractor.js'; +import * as cheerio from 'cheerio'; +import logger from '../services/logger.js'; let appliedBlackList = []; +async function fetchDetails(listing, browser) { + try { + const html = await puppeteerExtractor(listing.link, null, { browser }); + if (!html) return listing; + + const $ = cheerio.load(html); + + $('#freitext_0 script').remove(); + const description = $('#freitext_0').text().replace(/\s+/g, ' ').trim(); + const address = $('a[href="#map_container"] .section_panel_detail').text().replace(/\s+/g, ' ').trim(); + + return { + ...listing, + address: address || listing.address, + description: description || listing.description, + }; + } catch (error) { + logger.warn(`Could not fetch wgGesucht detail page for listing '${listing.id}'.`, error?.message || error); + return listing; + } +} + function normalize(o) { const id = buildHash(o.id, o.price); const link = `https://www.wg-gesucht.de${o.link}`; @@ -37,6 +62,7 @@ const config = { }, normalize: normalize, filter: applyBlacklist, + fetchDetails, activeTester: checkIfListingIsActive, }; export const init = (sourceConfig, blacklist) => { diff --git a/lib/services/extractor/botPrevention.js b/lib/services/extractor/botPrevention.js index 1926791..e8f0881 100644 --- a/lib/services/extractor/botPrevention.js +++ b/lib/services/extractor/botPrevention.js @@ -94,12 +94,34 @@ export async function applyBotPreventionToPage(page, cfg) { // webdriver Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); - // chrome runtime + // chrome runtime — expose loadTimes, csi and app like real Chrome // @ts-ignore - if (!window.chrome) { + window.chrome = { + runtime: {}, // @ts-ignore - window.chrome = { runtime: {} }; - } + loadTimes: () => ({ + requestTime: performance.timeOrigin / 1000, + startLoadTime: performance.timeOrigin / 1000, + commitLoadTime: performance.timeOrigin / 1000 + 0.1, + finishDocumentLoadTime: 0, + finishLoadTime: 0, + firstPaintTime: 0, + firstPaintAfterLoadTime: 0, + navigationType: 'Other', + wasFetchedViaSpdy: false, + wasNpnNegotiated: false, + npnNegotiatedProtocol: '', + wasAlternateProtocolAvailable: false, + connectionInfo: 'http/1.1', + }), + // @ts-ignore + csi: () => ({ startE: performance.timeOrigin, onloadT: Date.now(), pageT: performance.now(), tran: 15 }), + app: { + isInstalled: false, + InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, + RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }, + }, + }; // languages // @ts-ignore @@ -107,23 +129,38 @@ export async function applyBotPreventionToPage(page, cfg) { get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','), }); - // plugins + // plugins — mimic real Chrome's built-in PDF plugins + const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => { + const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null }; + const plugin = { name, filename, description, length: 1, 0: mimeObj }; + mimeObj.enabledPlugin = plugin; + return plugin; + }; + const fakePlugins = [ + makePlugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'), + makePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'), + makePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'), + makePlugin( + 'Microsoft Edge PDF Viewer', + 'internal-pdf-viewer', + 'Portable Document Format', + 'application/pdf', + 'pdf', + ), + makePlugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format', 'application/pdf', 'pdf'), + ]; // @ts-ignore - Object.defineProperty(navigator, 'plugins', { - get: () => [{}, {}, {}], - }); + Object.defineProperty(navigator, 'plugins', { get: () => fakePlugins }); + // @ts-ignore + Object.defineProperty(navigator, 'mimeTypes', { get: () => [fakePlugins[0][0]] }); // platform and concurrency hints // @ts-ignore Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }); // @ts-ignore - if (typeof navigator.hardwareConcurrency === 'number' && navigator.hardwareConcurrency < 2) { - Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 }); - } + Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }); // @ts-ignore - if (typeof navigator.deviceMemory === 'number' && navigator.deviceMemory < 2) { - Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }); - } + Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }); // userAgentData (Client Hints) try { @@ -236,6 +273,21 @@ export async function applyBotPreventionToPage(page, cfg) { } catch { //noop } + + // document.hasFocus — headless returns false; real active tabs return true + try { + document.hasFocus = () => true; + } catch { + //noop + } + + // screen color depth — normalise in case headless reports 0 + try { + Object.defineProperty(screen, 'colorDepth', { get: () => 24 }); + Object.defineProperty(screen, 'pixelDepth', { get: () => 24 }); + } catch { + //noop + } } catch { //noop } @@ -273,6 +325,8 @@ export async function applyPostNavigationHumanSignals(page, cfg) { const my = Math.floor(vh * (0.3 + Math.random() * 0.4)); await page.mouse.move(mx, my, { steps: 10 + Math.floor(Math.random() * 10) }); await page.mouse.wheel({ deltaY: 100 + Math.floor(Math.random() * 200) }); + await new Promise((res) => setTimeout(res, 150 + Math.floor(Math.random() * 200))); + await page.mouse.wheel({ deltaY: -(30 + Math.floor(Math.random() * 60)) }); } catch { // ignore if mouse is unavailable } diff --git a/lib/services/extractor/puppeteerExtractor.js b/lib/services/extractor/puppeteerExtractor.js index e76e5c4..e6bdd95 100644 --- a/lib/services/extractor/puppeteerExtractor.js +++ b/lib/services/extractor/puppeteerExtractor.js @@ -110,6 +110,7 @@ export default async function execute(url, waitForSelector, options) { // Navigation const response = await page.goto(url, { waitUntil: options?.waitUntil || 'domcontentloaded', + timeout: options?.puppeteerTimeout || 60000, }); // Optionally wait and add subtle human-like interactions diff --git a/lib/services/storage/migrations/sql/13.provider-details.js b/lib/services/storage/migrations/sql/13.provider-details.js new file mode 100644 index 0000000..e725821 --- /dev/null +++ b/lib/services/storage/migrations/sql/13.provider-details.js @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +// We have moved the previous immoscout_details setting to provider_details and enable this by default +// We also set it to false per default as this is increasing the chance to be detected as a bot by a lot +export function up(db) { + db.exec(` + UPDATE settings + SET name = 'provider_details', value = false + WHERE name = 'immoscout_details' + AND NOT EXISTS ( + SELECT 1 FROM settings WHERE name = 'provider_details' + ); + `); +} diff --git a/lib/services/storage/migrations/sql/14.provider-details-to-array.js b/lib/services/storage/migrations/sql/14.provider-details-to-array.js new file mode 100644 index 0000000..260f5b7 --- /dev/null +++ b/lib/services/storage/migrations/sql/14.provider-details-to-array.js @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +// Convert provider_details from a boolean to an array of provider id strings. +// Users will re-configure which providers they want to fetch details from. +export function up(db) { + const row = db.prepare("SELECT value FROM settings WHERE name = 'provider_details'").get(); + if (row) { + db.prepare("UPDATE settings SET value = ? WHERE name = 'provider_details'").run(JSON.stringify([])); + } else { + db.prepare("INSERT INTO settings (name, value) VALUES ('provider_details', ?)").run(JSON.stringify([])); + } +} diff --git a/package.json b/package.json index 2bd1a64..137bafa 100755 --- a/package.json +++ b/package.json @@ -69,20 +69,19 @@ "@sendgrid/mail": "8.1.6", "@turf/boolean-point-in-polygon": "^7.3.4", "@vitejs/plugin-react": "6.0.1", - "adm-zip": "^0.5.16", + "adm-zip": "^0.5.17", "better-sqlite3": "^12.8.0", "body-parser": "2.2.2", "chart.js": "^4.5.1", "cheerio": "^1.2.0", "cookie-session": "2.1.1", "handlebars": "4.7.9", - "lodash": "4.17.23", - "maplibre-gl": "^5.21.1", + "maplibre-gl": "^5.22.0", "nanoid": "5.1.7", "node-cron": "^4.2.1", "node-fetch": "3.3.2", "node-mailjet": "6.0.11", - "nodemailer": "^8.0.4", + "nodemailer": "^8.0.5", "p-throttle": "^8.1.0", "package-up": "^5.0.0", "puppeteer": "^24.40.0", @@ -92,15 +91,15 @@ "react": "19.2.4", "react-chartjs-2": "^5.3.1", "react-dom": "19.2.4", - "react-range-slider-input": "^3.3.2", - "react-router": "7.13.2", - "react-router-dom": "7.13.2", + "react-range-slider-input": "^3.3.5", + "react-router": "7.14.0", + "react-router-dom": "7.14.0", "resend": "^6.10.0", "restana": "5.1.0", "semver": "^7.7.4", "serve-static": "2.2.1", "slack": "11.0.2", - "vite": "8.0.3", + "vite": "8.0.6", "x-var": "^3.0.1", "zustand": "^5.0.12" }, @@ -111,7 +110,7 @@ "@babel/preset-react": "7.28.5", "@eslint/js": "^10.0.1", "chalk": "^5.6.2", - "eslint": "10.1.0", + "eslint": "10.2.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-react": "7.37.5", "globals": "^17.4.0", @@ -121,6 +120,6 @@ "lint-staged": "16.4.0", "nodemon": "^3.1.14", "prettier": "3.8.1", - "vitest": "^4.1.2" + "vitest": "^4.1.3" } } diff --git a/test/provider/einsAImmobilien.test.js b/test/provider/einsAImmobilien.test.js index 1ee6792..7338803 100644 --- a/test/provider/einsAImmobilien.test.js +++ b/test/provider/einsAImmobilien.test.js @@ -13,7 +13,7 @@ describe('#einsAImmobilien testsuite()', () => { provider.init(providerConfig.einsAImmobilien, [], []); it('should test einsAImmobilien provider', async () => { const Fredy = await mockFredy(); - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { const fredy = new Fredy( provider.config, null, @@ -23,6 +23,10 @@ describe('#einsAImmobilien testsuite()', () => { similarityCache, ); fredy.execute().then((listings) => { + if (listings == null || listings.length === 0) { + reject('Listings is empty!'); + return; + } expect(listings).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); diff --git a/test/provider/immobilienDe.test.js b/test/provider/immobilienDe.test.js index 078377a..f8267f2 100644 --- a/test/provider/immobilienDe.test.js +++ b/test/provider/immobilienDe.test.js @@ -6,36 +6,69 @@ import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import { get } from '../mocks/mockNotification.js'; import { providerConfig, mockFredy } from '../utils.js'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import * as provider from '../../lib/provider/immobilienDe.js'; +import * as mockStore from '../mocks/mockStore.js'; describe('#immobilien.de testsuite()', () => { provider.init(providerConfig.immobilienDe, [], []); it('should test immobilien.de provider', async () => { const Fredy = await mockFredy(); - return await new Promise((resolve) => { - const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache); - fredy.execute().then((listing) => { - expect(listing).toBeInstanceOf(Array); - const notificationObj = get(); - expect(notificationObj).toBeTypeOf('object'); - expect(notificationObj.serviceName).toBe('immobilienDe'); - notificationObj.payload.forEach((notify) => { - /** check the actual structure **/ - expect(notify.id).toBeTypeOf('string'); - expect(notify.price).toBeTypeOf('string'); - expect(notify.size).toBeTypeOf('string'); - expect(notify.title).toBeTypeOf('string'); - expect(notify.link).toBeTypeOf('string'); - expect(notify.address).toBeTypeOf('string'); - /** check the values if possible **/ - expect(notify.price).toContain('€'); - expect(notify.size).toContain('m²'); - expect(notify.title).not.toBe(''); - expect(notify.link).toContain('https://www.immobilien.de'); - expect(notify.address).not.toBe(''); - }); - resolve(); + const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache); + const listing = await fredy.execute(); + + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + + expect(listing).toBeInstanceOf(Array); + const notificationObj = get(); + expect(notificationObj).toBeTypeOf('object'); + expect(notificationObj.serviceName).toBe('immobilienDe'); + notificationObj.payload.forEach((notify) => { + /** check the actual structure **/ + expect(notify.id).toBeTypeOf('string'); + expect(notify.price).toBeTypeOf('string'); + expect(notify.size).toBeTypeOf('string'); + expect(notify.title).toBeTypeOf('string'); + expect(notify.link).toBeTypeOf('string'); + expect(notify.address).toBeTypeOf('string'); + /** check the values if possible **/ + expect(notify.price).toContain('€'); + expect(notify.size).toContain('m²'); + expect(notify.title).not.toBe(''); + expect(notify.link).toContain('https://www.immobilien.de'); + expect(notify.address).not.toBe(''); + }); + }); + + describe('with provider_details enabled', () => { + beforeEach(() => { + vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] }); + vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should enrich listings with details', async () => { + const Fredy = await mockFredy(); + provider.init(providerConfig.immobilienDe, [], []); + const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', { + checkAndAddEntry: () => false, + }); + const listings = await fredy.execute(); + if (listings == null) return; + expect(listings).toBeInstanceOf(Array); + listings.forEach((listing) => { + expect(listing.link).toContain('https://www.immobilien.de'); + expect(listing.address).toBeTypeOf('string'); + expect(listing.address).not.toBe(''); + // description may be null if selectors don't match yet — falls back gracefully + if (listing.description != null) { + expect(listing.description).toBeTypeOf('string'); + } }); }); }); diff --git a/test/provider/immoscout.test.js b/test/provider/immoscout.test.js index 3067c61..0ee4c86 100644 --- a/test/provider/immoscout.test.js +++ b/test/provider/immoscout.test.js @@ -3,19 +3,25 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import { mockFredy, providerConfig } from '../utils.js'; import { get } from '../mocks/mockNotification.js'; import * as provider from '../../lib/provider/immoscout.js'; +import * as mockStore from '../mocks/mockStore.js'; describe('#immoscout provider testsuite()', () => { provider.init(providerConfig.immoscout, [], []); it('should test immoscout provider', async () => { const Fredy = await mockFredy(); - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache); fredy.execute().then((listings) => { + if (listings == null || listings.length === 0) { + reject('Listings is empty!'); + return; + } + expect(listings).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); @@ -37,4 +43,29 @@ describe('#immoscout provider testsuite()', () => { }); }); }); + + describe('with provider_details enabled', () => { + beforeEach(() => { + vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] }); + vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should enrich listings with details', async () => { + const Fredy = await mockFredy(); + provider.init(providerConfig.immoscout, [], []); + const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', { + checkAndAddEntry: () => false, + }); + const listings = await fredy.execute(); + expect(listings).toBeInstanceOf(Array); + listings.forEach((listing) => { + expect(listing.description).toBeTypeOf('string'); + expect(listing.description).not.toBe(''); + }); + }); + }); }); diff --git a/test/provider/immoswp.test.js b/test/provider/immoswp.test.js index dbf2d60..dd19fdb 100644 --- a/test/provider/immoswp.test.js +++ b/test/provider/immoswp.test.js @@ -13,9 +13,14 @@ describe('#immoswp testsuite()', () => { provider.init(providerConfig.immoswp, [], []); it('should test immoswp provider', async () => { const Fredy = await mockFredy(); - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache); fredy.execute().then((listing) => { + if (listing == null || listing.length === 0) { + reject('Listings is empty!'); + return; + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); diff --git a/test/provider/immowelt.test.js b/test/provider/immowelt.test.js index ad2fe13..099397d 100644 --- a/test/provider/immowelt.test.js +++ b/test/provider/immowelt.test.js @@ -6,8 +6,9 @@ import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import { get } from '../mocks/mockNotification.js'; import { mockFredy, providerConfig } from '../utils.js'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import * as provider from '../../lib/provider/immowelt.js'; +import * as mockStore from '../mocks/mockStore.js'; describe('#immowelt testsuite()', () => { it('should test immowelt provider', async () => { @@ -17,6 +18,10 @@ describe('#immowelt testsuite()', () => { const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache); const listing = await fredy.execute(); + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); @@ -37,4 +42,34 @@ describe('#immowelt testsuite()', () => { expect(notify.address).not.toBe(''); }); }); + + describe('with provider_details enabled', () => { + beforeEach(() => { + vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] }); + vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should enrich listings with details', async () => { + const Fredy = await mockFredy(); + provider.init(providerConfig.immowelt, [], []); + const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', { + checkAndAddEntry: () => false, + }); + const listings = await fredy.execute(); + expect(listings).toBeInstanceOf(Array); + listings.forEach((listing) => { + expect(listing.link).toContain('https://www.immowelt.de'); + expect(listing.address).toBeTypeOf('string'); + expect(listing.address).not.toBe(''); + // description is enriched from the detail page; falls back gracefully if blocked + if (listing.description != null) { + expect(listing.description).toBeTypeOf('string'); + } + }); + }); + }); }); diff --git a/test/provider/kleinanzeigen.test.js b/test/provider/kleinanzeigen.test.js index d7dd082..020f1d0 100644 --- a/test/provider/kleinanzeigen.test.js +++ b/test/provider/kleinanzeigen.test.js @@ -6,14 +6,15 @@ import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import { get } from '../mocks/mockNotification.js'; import { mockFredy, providerConfig } from '../utils.js'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import * as provider from '../../lib/provider/kleinanzeigen.js'; +import * as mockStore from '../mocks/mockStore.js'; describe('#kleinanzeigen testsuite()', () => { it('should test kleinanzeigen provider', async () => { const Fredy = await mockFredy(); provider.init(providerConfig.kleinanzeigen, [], []); - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { const fredy = new Fredy( provider.config, null, @@ -23,6 +24,11 @@ describe('#kleinanzeigen testsuite()', () => { similarityCache, ); fredy.execute().then((listing) => { + if (listing == null || listing.length === 0) { + reject('Listings is empty!'); + return; + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); @@ -42,4 +48,32 @@ describe('#kleinanzeigen testsuite()', () => { }); }); }); + + describe('with provider_details enabled', () => { + beforeEach(() => { + vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] }); + vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should enrich listings with details', async () => { + const Fredy = await mockFredy(); + provider.init(providerConfig.kleinanzeigen, [], []); + const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'kleinanzeigen', { + checkAndAddEntry: () => false, + }); + const listings = await fredy.execute(); + expect(listings).toBeInstanceOf(Array); + listings.forEach((listing) => { + expect(listing.link).toContain('https://www.kleinanzeigen.de'); + expect(listing.address).toBeTypeOf('string'); + expect(listing.address).not.toBe(''); + expect(listing.description).toBeTypeOf('string'); + expect(listing.description).not.toBe(''); + }); + }); + }); }); diff --git a/test/provider/mcMakler.test.js b/test/provider/mcMakler.test.js index 21bacdc..3cbaa45 100644 --- a/test/provider/mcMakler.test.js +++ b/test/provider/mcMakler.test.js @@ -17,6 +17,10 @@ describe('#mcMakler testsuite()', () => { const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache); const listing = await fredy.execute(); + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); diff --git a/test/provider/neubauKompass.test.js b/test/provider/neubauKompass.test.js index 08110e1..e83a499 100644 --- a/test/provider/neubauKompass.test.js +++ b/test/provider/neubauKompass.test.js @@ -13,7 +13,7 @@ describe('#neubauKompass testsuite()', () => { provider.init(providerConfig.neubauKompass, [], []); it('should test neubauKompass provider', async () => { const Fredy = await mockFredy(); - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { const fredy = new Fredy( provider.config, null, @@ -23,6 +23,11 @@ describe('#neubauKompass testsuite()', () => { similarityCache, ); fredy.execute().then((listing) => { + if (listing == null || listing.length === 0) { + reject('Listings is empty!'); + return; + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj.serviceName).toBe('neubauKompass'); diff --git a/test/provider/ohneMakler.test.js b/test/provider/ohneMakler.test.js index 060efa6..10c3327 100644 --- a/test/provider/ohneMakler.test.js +++ b/test/provider/ohneMakler.test.js @@ -17,6 +17,10 @@ describe('#ohneMakler testsuite()', () => { const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache); const listing = await fredy.execute(); + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); diff --git a/test/provider/regionalimmobilien24.test.js b/test/provider/regionalimmobilien24.test.js index 61166b3..58b0046 100644 --- a/test/provider/regionalimmobilien24.test.js +++ b/test/provider/regionalimmobilien24.test.js @@ -24,6 +24,10 @@ describe('#regionalimmobilien24 testsuite()', () => { ); const listing = await fredy.execute(); + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); diff --git a/test/provider/sparkasse.test.js b/test/provider/sparkasse.test.js index 1b13904..14095f4 100644 --- a/test/provider/sparkasse.test.js +++ b/test/provider/sparkasse.test.js @@ -6,8 +6,9 @@ import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import { get } from '../mocks/mockNotification.js'; import { mockFredy, providerConfig } from '../utils.js'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import * as provider from '../../lib/provider/sparkasse.js'; +import * as mockStore from '../mocks/mockStore.js'; describe('#sparkasse testsuite()', () => { it('should test sparkasse provider', async () => { @@ -17,6 +18,10 @@ describe('#sparkasse testsuite()', () => { const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache); const listing = await fredy.execute(); + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); @@ -34,4 +39,35 @@ describe('#sparkasse testsuite()', () => { expect(notify.address).not.toBe(''); }); }); + + describe('with provider_details enabled', () => { + beforeEach(() => { + vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] }); + vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should enrich listings with details', async () => { + const Fredy = await mockFredy(); + provider.init(providerConfig.sparkasse, []); + const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', { + checkAndAddEntry: () => false, + }); + const listings = await fredy.execute(); + expect(listings).toBeInstanceOf(Array); + listings.forEach((listing) => { + expect(listing.link).toContain('https://immobilien.sparkasse.de'); + expect(listing.address).toBeTypeOf('string'); + expect(listing.address).not.toBe(''); + // description is enriched from the detail page; falls back gracefully if bot-detected + if (listing.description != null) { + expect(listing.description).toBeTypeOf('string'); + expect(listing.description).not.toBe(''); + } + }); + }); + }); }); diff --git a/test/provider/wgGesucht.test.js b/test/provider/wgGesucht.test.js index 110d669..659e75d 100644 --- a/test/provider/wgGesucht.test.js +++ b/test/provider/wgGesucht.test.js @@ -6,16 +6,22 @@ import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js'; import { get } from '../mocks/mockNotification.js'; import { mockFredy, providerConfig } from '../utils.js'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import * as provider from '../../lib/provider/wgGesucht.js'; +import * as mockStore from '../mocks/mockStore.js'; describe('#wgGesucht testsuite()', () => { provider.init(providerConfig.wgGesucht, [], []); it('should test wgGesucht provider', async () => { const Fredy = await mockFredy(); - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache); fredy.execute().then((listing) => { + if (listing == null || listing.length === 0) { + reject('Listings is empty!'); + return; + } + expect(listing).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj.serviceName).toBe('wgGesucht'); @@ -32,4 +38,30 @@ describe('#wgGesucht testsuite()', () => { }); }); }); + + describe('with provider_details enabled', () => { + beforeEach(() => { + vi.spyOn(mockStore, 'getUserSettings').mockReturnValue({ provider_details: [provider.metaInformation.id] }); + vi.spyOn(mockStore, 'getKnownListingHashesForJobAndProvider').mockReturnValue([]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should enrich listings with details', async () => { + const Fredy = await mockFredy(); + provider.init(providerConfig.wgGesucht, [], []); + const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', { + checkAndAddEntry: () => false, + }); + const listings = await fredy.execute(); + expect(listings).toBeInstanceOf(Array); + listings.forEach((listing) => { + expect(listing.link).toContain('https://www.wg-gesucht.de'); + expect(listing.description).toBeTypeOf('string'); + expect(listing.description).not.toBe(''); + }); + }); + }); }); diff --git a/test/provider/wohnungsboerse.test.js b/test/provider/wohnungsboerse.test.js index 138950b..4d76968 100644 --- a/test/provider/wohnungsboerse.test.js +++ b/test/provider/wohnungsboerse.test.js @@ -13,7 +13,7 @@ describe('#wohnungsboerse testsuite()', () => { provider.init(providerConfig.wohnungsboerse, [], []); it('should test wohnungsboerse provider', async () => { const Fredy = await mockFredy(); - return await new Promise((resolve) => { + return await new Promise((resolve, reject) => { const fredy = new Fredy( provider.config, null, @@ -23,6 +23,11 @@ describe('#wohnungsboerse testsuite()', () => { similarityCache, ); fredy.execute().then((listings) => { + if (listings == null || listings.length === 0) { + reject('Listings is empty!'); + return; + } + expect(listings).toBeInstanceOf(Array); const notificationObj = get(); expect(notificationObj).toBeTypeOf('object'); diff --git a/ui/src/components/grid/jobs/JobGrid.jsx b/ui/src/components/grid/jobs/JobGrid.jsx index 9e1ab61..868db2e 100644 --- a/ui/src/components/grid/jobs/JobGrid.jsx +++ b/ui/src/components/grid/jobs/JobGrid.jsx @@ -41,7 +41,7 @@ import { useNavigate } from 'react-router-dom'; import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import { useActions, useSelector } from '../../../services/state/store.js'; import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js'; -import debounce from 'lodash/debounce'; +import { debounce } from '../../../utils'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import './JobGrid.less'; diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index 2ec65a4..5c974c3 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -47,7 +47,7 @@ import no_image from '../../../assets/no_image.jpg'; import * as timeService from '../../../services/time/timeService.js'; import { xhrDelete, xhrPost } from '../../../services/xhr.js'; import { useActions, useSelector } from '../../../services/state/store.js'; -import debounce from 'lodash/debounce'; +import { debounce } from '../../../utils'; import './ListingsGrid.less'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index 8400254..8a4d174 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -307,17 +307,17 @@ export const useFredyState = create( throw Exception; } }, - async setImmoscoutDetails(enabled) { + async setProviderDetails(providers) { try { - await xhrPost('/api/user/settings/immoscout-details', { immoscout_details: enabled }); + await xhrPost('/api/user/settings/provider-details', { provider_details: providers }); set((state) => ({ userSettings: { ...state.userSettings, - settings: { ...state.userSettings.settings, immoscout_details: enabled }, + settings: { ...state.userSettings.settings, provider_details: providers }, }, })); } catch (Exception) { - console.error('Error while trying to update immoscout details setting. Error:', Exception); + console.error('Error while trying to update provider details setting. Error:', Exception); throw Exception; } }, diff --git a/ui/src/utils.js b/ui/src/utils.js new file mode 100644 index 0000000..d403658 --- /dev/null +++ b/ui/src/utils.js @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +export function debounce(fn, delay) { + let timer; + + function debounced(...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + } + + debounced.cancel = () => clearTimeout(timer); + + return debounced; +} diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx index d0da96a..5e23e66 100644 --- a/ui/src/views/generalSettings/GeneralSettings.jsx +++ b/ui/src/views/generalSettings/GeneralSettings.jsx @@ -15,9 +15,8 @@ import { Checkbox, Input, Modal, - Typography, AutoComplete, - Switch, + Select, Banner, } from '@douyinfe/semi-ui-19'; import { InputNumber } from '@douyinfe/semi-ui-19'; @@ -30,11 +29,9 @@ import { restore as clientRestore, } from '../../services/backupRestoreClient'; import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons'; -import debounce from 'lodash/debounce'; +import { debounce } from '../../utils'; import './GeneralSettings.less'; -const { Text } = Typography; - function formatFromTimestamp(ts) { const date = new Date(ts); return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`; @@ -72,7 +69,8 @@ const GeneralSettings = function GeneralSettings() { // User settings state const homeAddress = useSelector((state) => state.userSettings.settings.home_address); - const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details); + const providerDetails = useSelector((state) => state.userSettings.settings.provider_details); + const allProviders = useSelector((state) => state.provider); const [address, setAddress] = useState(homeAddress?.address || ''); const [coords, setCoords] = useState(homeAddress?.coords || null); const saving = useIsLoading(actions.userSettings.setHomeAddress); @@ -373,39 +371,6 @@ const GeneralSettings = function GeneralSettings() { - - - Backup & Restore - - } - itemKey="backup" - > -
- -
- - - -
-
-
-
- @@ -440,29 +405,30 @@ const GeneralSettings = function GeneralSettings() { -
- { - try { - await actions.userSettings.setImmoscoutDetails(checked); - Toast.success('ImmoScout details setting updated.'); - } catch { - Toast.error('Failed to update setting.'); - } - }} - /> - Fetch detailed ImmoScout listings -
+ + + +
+ +
)} diff --git a/ui/src/views/userSettings/UserSettings.jsx b/ui/src/views/userSettings/UserSettings.jsx deleted file mode 100644 index 3373374..0000000 --- a/ui/src/views/userSettings/UserSettings.jsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) 2026 by Christian Kellner. - * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause - */ - -import { useEffect, useState, useMemo } from 'react'; -import { Divider, Button, AutoComplete, Toast, Banner, Switch } from '@douyinfe/semi-ui-19'; -import { IconSave, IconHome, IconSearch } from '@douyinfe/semi-icons'; -import { useSelector, useActions, useIsLoading } from '../../services/state/store'; -import { xhrGet } from '../../services/xhr'; -import { SegmentPart } from '../../components/segment/SegmentPart'; -import debounce from 'lodash/debounce'; - -const UserSettings = () => { - const actions = useActions(); - const homeAddress = useSelector((state) => state.userSettings.settings.home_address); - const immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details); - const [address, setAddress] = useState(homeAddress?.address || ''); - const [coords, setCoords] = useState(homeAddress?.coords || null); - const saving = useIsLoading(actions.userSettings.setHomeAddress); - const [dataSource, setDataSource] = useState([]); - - useEffect(() => { - setAddress(homeAddress?.address || ''); - setCoords(homeAddress?.coords || null); - }, [homeAddress]); - - const handleSave = async () => { - try { - const responseJson = await actions.userSettings.setHomeAddress(address); - setCoords(responseJson.coords); - await actions.userSettings.getUserSettings(); - Toast.success( - 'Settings saved successfully. We will now start calculating distances for you. This may take a while and runs in the background.', - ); - } catch (error) { - Toast.error(error.json?.error || 'Error while saving settings'); - } - }; - - const debouncedSearch = useMemo( - () => - debounce((value) => { - xhrGet(`/api/user/settings/autocomplete?q=${encodeURIComponent(value)}`) - .then((response) => { - if (response.status === 200) { - setDataSource(response.json); - } - }) - .catch(() => { - // Silently fail for autocomplete - }); - }, 300), - [], - ); - - const searchAddress = (value) => { - if (!value) { - setDataSource([]); - return; - } - debouncedSearch(value); - }; - - return ( -
- -
- setAddress(v)} - onSearch={searchAddress} - placeholder="Enter your home address" - style={{ width: '100%' }} - /> - {coords && coords.lat === -1 && ( - - )} -
-
- - - -
- { - try { - await actions.userSettings.setImmoscoutDetails(checked); - Toast.success('ImmoScout details setting updated.'); - } catch { - Toast.error('Failed to update setting.'); - } - }} - /> - Fetch detailed ImmoScout listings -
-
- -
- -
-
- ); -}; - -export default UserSettings; diff --git a/yarn.lock b/yarn.lock index 96a9e0b..5505551 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1039,6 +1039,14 @@ scroll-into-view-if-needed "^2.2.24" utility-types "^3.10.0" +"@emnapi/core@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd" + integrity sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA== + dependencies: + "@emnapi/wasi-threads" "1.2.0" + tslib "^2.4.0" + "@emnapi/core@^1.7.1": version "1.9.0" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.0.tgz#4a54213b208fcf288cce25076c74e0f7613e6100" @@ -1047,6 +1055,13 @@ "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" +"@emnapi/runtime@1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" + integrity sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@^1.7.1": version "1.9.0" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.0.tgz#91c54a6e77c36154c125e873409472e2b70efd5b" @@ -1073,26 +1088,26 @@ resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/config-array@^0.23.3": - version "0.23.3" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.3.tgz#3f4a93dd546169c09130cbd10f2415b13a20a219" - integrity sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw== +"@eslint/config-array@^0.23.4": + version "0.23.4" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.4.tgz#b4e160e668694011b355bfe9a89eb61a0eb641c4" + integrity sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow== dependencies: - "@eslint/object-schema" "^3.0.3" + "@eslint/object-schema" "^3.0.4" debug "^4.3.1" minimatch "^10.2.4" -"@eslint/config-helpers@^0.5.3": - version "0.5.3" - resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.3.tgz#721fe6bbb90d74b0c80d6ff2428e5bbcb002becb" - integrity sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw== +"@eslint/config-helpers@^0.5.4": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.4.tgz#0b16c091dd16766f27e41f09bd264e3585a45652" + integrity sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg== dependencies: - "@eslint/core" "^1.1.1" + "@eslint/core" "^1.2.0" -"@eslint/core@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.1.tgz#450f3d2be2d463ccd51119544092256b4e88df32" - integrity sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ== +"@eslint/core@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.0.tgz#3f741da0119058ad2a567a6f215677b5557a19e9" + integrity sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A== dependencies: "@types/json-schema" "^7.0.15" @@ -1101,17 +1116,17 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== -"@eslint/object-schema@^3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.3.tgz#5bf671e52e382e4adc47a9906f2699374637db6b" - integrity sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ== +"@eslint/object-schema@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.4.tgz#8ce3aff08f6ca7c3bd9e1cec34530fc7fb44546a" + integrity sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw== -"@eslint/plugin-kit@^0.6.1": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz#eb9e6689b56ce8bc1855bb33090e63f3fc115e8e" - integrity sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ== +"@eslint/plugin-kit@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz#7442f663da4d61091d2af0b30c8a6b50949fb26d" + integrity sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg== dependencies: - "@eslint/core" "^1.1.1" + "@eslint/core" "^1.2.0" levn "^0.4.1" "@floating-ui/core@^1.7.3": @@ -1276,10 +1291,10 @@ dependencies: kdbush "^4.0.2" -"@maplibre/maplibre-gl-style-spec@^24.7.0": - version "24.7.0" - resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz#46e1109303393d15545eb97eb333991c5663f75d" - integrity sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw== +"@maplibre/maplibre-gl-style-spec@^24.8.1": + version "24.8.1" + resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz#8d4d48591750529ce586f9ee5f836ed97870bce5" + integrity sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw== dependencies: "@mapbox/jsonlint-lines-primitives" "~2.0.2" "@mapbox/unitbezier" "^0.0.1" @@ -1372,6 +1387,13 @@ "@emnapi/runtime" "^1.7.1" "@tybys/wasm-util" "^0.10.1" +"@napi-rs/wasm-runtime@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz#e25454b4d44cfabd21d1bc801705359870e33ecc" + integrity sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw== + dependencies: + "@tybys/wasm-util" "^0.10.1" + "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" @@ -1384,6 +1406,11 @@ resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.122.0.tgz#2f4e77a3b183c87b2a326affd703ef71ba836601" integrity sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA== +"@oxc-project/types@=0.123.0": + version "0.123.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.123.0.tgz#a0bbc8f0cec16270df203cbad290bde3ed0289ad" + integrity sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew== + "@puppeteer/browsers@2.13.0": version "2.13.0" resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.13.0.tgz#10f980c6d65efeff77f8a3cac6e1a7ac10604500" @@ -1407,61 +1434,121 @@ resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz#4e6af08b89da02596cc5da4b105082b68673ffec" integrity sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA== +"@rolldown/binding-android-arm64@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz#0862753ca7cad78350240cd68fc79150606b42c4" + integrity sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g== + "@rolldown/binding-darwin-arm64@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz#a06890f4c9b48ff0fc97edbedfc762bef7cffd73" integrity sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg== +"@rolldown/binding-darwin-arm64@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz#01879544538cdfdc35fd6086f21563d29a193ec1" + integrity sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA== + "@rolldown/binding-darwin-x64@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz#eddf6aa3ed3509171fe21711f1e8ec8e0fd7ec49" integrity sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw== +"@rolldown/binding-darwin-x64@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz#da0c47323964b17dfe7997e4e9770837942346b1" + integrity sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug== + "@rolldown/binding-freebsd-x64@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz#2102dfed19fd1f1b53435fcaaf0bc61129a266a3" integrity sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q== +"@rolldown/binding-freebsd-x64@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz#436161bf753b50ecd001dfc0e94fe10794c4c3c6" + integrity sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA== + "@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz#b2c13f40e990fd1e1935492850536c768c961a0f" integrity sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q== +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz#9962f225411bb6bde8efdb330c33cd3d5ae79a8c" + integrity sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw== + "@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz#32ca9f77c1e76b2913b3d53d2029dc171c0532d6" integrity sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg== +"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz#2c707fdc988d225c60704e7ec23ea57258609689" + integrity sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg== + "@rolldown/binding-linux-arm64-musl@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz#f4337ddd52f0ed3ada2105b59ee1b757a2c4858c" integrity sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw== +"@rolldown/binding-linux-arm64-musl@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz#cd96d50ed1556dff541b4dd3845038ad0cf066cd" + integrity sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA== + "@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz#22fdd14cb00ee8208c28a39bab7f28860ec6705d" integrity sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g== +"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz#984ddcdb40345ece2dce0e667350bffe20700e23" + integrity sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ== + "@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz#838215096d1de6d3d509e0410801cb7cda8161ff" integrity sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og== +"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz#76c75572fd3ef01cd0c2d68943e007fc8af6da2d" + integrity sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg== + "@rolldown/binding-linux-x64-gnu@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz#f7d71d97f6bd43198596b26dc2cb364586e12673" integrity sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg== +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz#2e244b9c5bb98054a5d0d8c2df297ffaa86cae07" + integrity sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA== + "@rolldown/binding-linux-x64-musl@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz#a2ca737f01b0ad620c4c404ca176ea3e3ad804c3" integrity sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig== +"@rolldown/binding-linux-x64-musl@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz#a9cd96eb02f83167e33772f0a8dafd3ff67d4283" + integrity sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w== + "@rolldown/binding-openharmony-arm64@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz#f66317e29eafcc300bed7af8dddac26ab3b1bf82" integrity sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA== +"@rolldown/binding-openharmony-arm64@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz#54a471d5620c2d7a84ab989a30a976b2133c5650" + integrity sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w== + "@rolldown/binding-wasm32-wasi@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz#8825523fdffa1f1dc4683be9650ffaa9e4a77f04" @@ -1469,21 +1556,45 @@ dependencies: "@napi-rs/wasm-runtime" "^1.1.1" +"@rolldown/binding-wasm32-wasi@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz#7396c7f5904607e074fe7559a95bea656b5c42e7" + integrity sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g== + dependencies: + "@emnapi/core" "1.9.1" + "@emnapi/runtime" "1.9.1" + "@napi-rs/wasm-runtime" "^1.1.2" + "@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz#4f3a17e3d68a58309c27c0930b0f7986ccabef47" integrity sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q== +"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz#7d4287f66c34cf050f4358a4941c146c4d798e24" + integrity sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ== + "@rolldown/binding-win32-x64-msvc@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz#d762765d5660598a96b570b513f535c151272985" integrity sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw== +"@rolldown/binding-win32-x64-msvc@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz#e5e0d00e4494e7dc51527ba0644ab5a9049e6925" + integrity sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ== + "@rolldown/pluginutils@1.0.0-rc.12": version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz#74163aec62fa51cee18d62709483963dceb3f6dc" integrity sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw== +"@rolldown/pluginutils@1.0.0-rc.13": + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz#093a01af0cde13552f058544fcadf12e9b522c3b" + integrity sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA== + "@rolldown/pluginutils@1.0.0-rc.7": version "1.0.0-rc.7" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022" @@ -1997,63 +2108,63 @@ dependencies: "@rolldown/pluginutils" "1.0.0-rc.7" -"@vitest/expect@4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.2.tgz#2aec02233db4eac14777e6a7d14a535c63ae2d9b" - integrity sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ== +"@vitest/expect@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.3.tgz#2c631d9add8e6696443243ac1a487c6ccdc2d1cc" + integrity sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ== dependencies: "@standard-schema/spec" "^1.1.0" "@types/chai" "^5.2.2" - "@vitest/spy" "4.1.2" - "@vitest/utils" "4.1.2" + "@vitest/spy" "4.1.3" + "@vitest/utils" "4.1.3" chai "^6.2.2" tinyrainbow "^3.1.0" -"@vitest/mocker@4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.2.tgz#3f23523697f9ab9e851b58b2213c4ab6181aa0e6" - integrity sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q== +"@vitest/mocker@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.3.tgz#78ec418d7970c2039ff8bc9f333c126c58d0c7fe" + integrity sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw== dependencies: - "@vitest/spy" "4.1.2" + "@vitest/spy" "4.1.3" estree-walker "^3.0.3" magic-string "^0.30.21" -"@vitest/pretty-format@4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.2.tgz#c2671aa1c931dc8f2759589fc87ea4b2602892c5" - integrity sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA== +"@vitest/pretty-format@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.3.tgz#779626282923040244f7a38584550549c0b19f52" + integrity sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg== dependencies: tinyrainbow "^3.1.0" -"@vitest/runner@4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.2.tgz#6f744fa0d92d31f4c8c255b64bbe073cb75fd96e" - integrity sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ== +"@vitest/runner@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.3.tgz#e083b6de9f4251d7e1440522981c88fc015342a3" + integrity sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA== dependencies: - "@vitest/utils" "4.1.2" + "@vitest/utils" "4.1.3" pathe "^2.0.3" -"@vitest/snapshot@4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.2.tgz#3972b8ed7a311133e12cb833bf86463d26cdd455" - integrity sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A== +"@vitest/snapshot@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.3.tgz#0c7090aaa2e5b443ede3e7cb1b8381d83dc8da82" + integrity sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ== dependencies: - "@vitest/pretty-format" "4.1.2" - "@vitest/utils" "4.1.2" + "@vitest/pretty-format" "4.1.3" + "@vitest/utils" "4.1.3" magic-string "^0.30.21" pathe "^2.0.3" -"@vitest/spy@4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.2.tgz#1f312cef5756256639b4c0614f74c8ad9a036ef9" - integrity sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA== +"@vitest/spy@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.3.tgz#f1537c5be2a1682ff47b3a1fea09ad73539fab53" + integrity sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw== -"@vitest/utils@4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.2.tgz#32be8f42eb6683a598b1c61d7ec9f55596c60ecb" - integrity sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ== +"@vitest/utils@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.3.tgz#f0ef911ce7a41ccb84229d51f2a6ccd148141ddf" + integrity sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw== dependencies: - "@vitest/pretty-format" "4.1.2" + "@vitest/pretty-format" "4.1.3" convert-source-map "^2.0.0" tinyrainbow "^3.1.0" @@ -2080,10 +2191,10 @@ acorn@^8.16.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== -adm-zip@^0.5.16: - version "0.5.16" - resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz" - integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ== +adm-zip@^0.5.17: + version "0.5.17" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.17.tgz#5c0b65f37aeec5c2a94995c024f931f62e4bbc5a" + integrity sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ== agent-base@^7.1.0, agent-base@^7.1.2: version "7.1.4" @@ -3403,17 +3514,17 @@ eslint-visitor-keys@^5.0.1: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== -eslint@10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.1.0.tgz#9ca98e654e642ab2e1af6d1e9d8613857ac341b4" - integrity sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA== +eslint@10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.2.0.tgz#711c80d32fc3fdd3a575bb93977df43887c3ec8e" + integrity sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA== dependencies: "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.2" - "@eslint/config-array" "^0.23.3" - "@eslint/config-helpers" "^0.5.3" - "@eslint/core" "^1.1.1" - "@eslint/plugin-kit" "^0.6.1" + "@eslint/config-array" "^0.23.4" + "@eslint/config-helpers" "^0.5.4" + "@eslint/core" "^1.2.0" + "@eslint/plugin-kit" "^0.7.0" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" @@ -4824,7 +4935,7 @@ lodash.debounce@^4.0.8: resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== -lodash@4.17.23, lodash@^4.17.21: +lodash@^4.17.21: version "4.17.23" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== @@ -4889,10 +5000,10 @@ make-dir@^2.1.0: pify "^4.0.1" semver "^5.6.0" -maplibre-gl@^5.21.1: - version "5.21.1" - resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.21.1.tgz#ae1f09fdae657e7c1a4565f9b2d8ff746d5e21ef" - integrity sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg== +maplibre-gl@^5.22.0: + version "5.22.0" + resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.22.0.tgz#b61a7f3add4e8e85077a5b585009dc7868b1c6fe" + integrity sha512-nc8YA+YSEioMZg5W0cb6Cf3wQ8aJge66dsttyBgpOArOnlmFJO1Kc5G32kYVPeUYhLpBja83T99uanmJvYAIyQ== dependencies: "@mapbox/jsonlint-lines-primitives" "^2.0.2" "@mapbox/point-geometry" "^1.1.0" @@ -4901,7 +5012,7 @@ maplibre-gl@^5.21.1: "@mapbox/vector-tile" "^2.0.4" "@mapbox/whoots-js" "^3.1.0" "@maplibre/geojson-vt" "^6.0.4" - "@maplibre/maplibre-gl-style-spec" "^24.7.0" + "@maplibre/maplibre-gl-style-spec" "^24.8.1" "@maplibre/mlt" "^1.1.8" "@maplibre/vt-pbf" "^4.3.0" "@types/geojson" "^7946.0.16" @@ -5709,10 +5820,10 @@ node-releases@^2.0.27: resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz" integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== -nodemailer@^8.0.4: - version "8.0.4" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.4.tgz#b63626585693f37a390ddaecde273da991c76010" - integrity sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ== +nodemailer@^8.0.5: + version "8.0.5" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.5.tgz#2076fb2b5c1ccfe1c88f6e1aa47c0229ea642e0c" + integrity sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w== nodemon@^3.1.14: version "3.1.14" @@ -6485,10 +6596,10 @@ react-is@^16.13.1: resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-range-slider-input@^3.3.2: - version "3.3.2" - resolved "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.2.tgz" - integrity sha512-CGyD/6Vlc7qakSW+92WAKrp333Xo9W+udW62xvf6dSwqEj7LFSY75udcbNRtCQhuXW1O7o71yC4AC/CC0etqSg== +react-range-slider-input@^3.3.5: + version "3.3.5" + resolved "https://registry.yarnpkg.com/react-range-slider-input/-/react-range-slider-input-3.3.5.tgz#3eebab9b249f7de9eb61510151cc383739c9f284" + integrity sha512-HkGjaq+q7u42K5WzFPD67duugPyD2m7EWVCJm139EqfR9AgSdEUxSFgRj6SW+nzGB5y8/6Jw75euxbeiuTT0cQ== dependencies: clsx "^1.1.1" core-js "^3.22.4" @@ -6501,17 +6612,17 @@ react-resizable@^3.0.5: prop-types "15.x" react-draggable "^4.0.3" -react-router-dom@7.13.2: - version "7.13.2" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.2.tgz#6582ab2e2f096d19486e854898b719b4efc52524" - integrity sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA== +react-router-dom@7.14.0: + version "7.14.0" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.14.0.tgz#9d2df92ec9ce47e696808dc2a0e0a0c794ab278a" + integrity sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ== dependencies: - react-router "7.13.2" + react-router "7.14.0" -react-router@7.13.2: - version "7.13.2" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.2.tgz#bab22c9f96f81759e060a34c04e7527e5f6dbbe1" - integrity sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA== +react-router@7.14.0: + version "7.14.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.14.0.tgz#33169c9ac03b298bb51aad13e038ba548c79a862" + integrity sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ== dependencies: cookie "^1.0.1" set-cookie-parser "^2.6.0" @@ -6822,6 +6933,30 @@ rolldown@1.0.0-rc.12: "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.12" "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.12" +rolldown@1.0.0-rc.13: + version "1.0.0-rc.13" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.13.tgz#9670bddcc08f10f2809f1c9b58b101e9139f155c" + integrity sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw== + dependencies: + "@oxc-project/types" "=0.123.0" + "@rolldown/pluginutils" "1.0.0-rc.13" + optionalDependencies: + "@rolldown/binding-android-arm64" "1.0.0-rc.13" + "@rolldown/binding-darwin-arm64" "1.0.0-rc.13" + "@rolldown/binding-darwin-x64" "1.0.0-rc.13" + "@rolldown/binding-freebsd-x64" "1.0.0-rc.13" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.13" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.13" + "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.13" + "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.13" + "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.13" + "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.13" + "@rolldown/binding-linux-x64-musl" "1.0.0-rc.13" + "@rolldown/binding-openharmony-arm64" "1.0.0-rc.13" + "@rolldown/binding-wasm32-wasi" "1.0.0-rc.13" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.13" + "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.13" + rope-sequence@^1.3.0: version "1.3.4" resolved "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz" @@ -7762,7 +7897,20 @@ vfile@^6.0.0: "@types/unist" "^3.0.0" vfile-message "^4.0.0" -vite@8.0.3, "vite@^6.0.0 || ^7.0.0 || ^8.0.0": +vite@8.0.6: + version "8.0.6" + resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.6.tgz#be071dcc8ff7c1c6cfc73ada928e537d68bcf253" + integrity sha512-jeOXoY6N8rOfit/mZADMd0misLqjRdWBB3/S23ZQNuPcbVsfMBJutWD8b4ftdczMOsNyMBnKro0Z1Kt0HIqq5Q== + dependencies: + lightningcss "^1.32.0" + picomatch "^4.0.4" + postcss "^8.5.8" + rolldown "1.0.0-rc.13" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +"vite@^6.0.0 || ^7.0.0 || ^8.0.0": version "8.0.3" resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.3.tgz#036d9e3b077ff57b128660b3e3a5d2d12bac9b42" integrity sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ== @@ -7775,18 +7923,18 @@ vite@8.0.3, "vite@^6.0.0 || ^7.0.0 || ^8.0.0": optionalDependencies: fsevents "~2.3.3" -vitest@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.2.tgz#3f7b36838ddf1067160489bea9a21ef465496265" - integrity sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg== +vitest@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.3.tgz#170d392242fc652a130d5bdb60957291ca4eb9df" + integrity sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw== dependencies: - "@vitest/expect" "4.1.2" - "@vitest/mocker" "4.1.2" - "@vitest/pretty-format" "4.1.2" - "@vitest/runner" "4.1.2" - "@vitest/snapshot" "4.1.2" - "@vitest/spy" "4.1.2" - "@vitest/utils" "4.1.2" + "@vitest/expect" "4.1.3" + "@vitest/mocker" "4.1.3" + "@vitest/pretty-format" "4.1.3" + "@vitest/runner" "4.1.3" + "@vitest/snapshot" "4.1.3" + "@vitest/spy" "4.1.3" + "@vitest/utils" "4.1.3" es-module-lexer "^2.0.0" expect-type "^1.3.0" magic-string "^0.30.21"