diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 24467c732..896463f3d 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -259,7 +259,12 @@ export class AdminController { } catch (error) { Logger.error(error, 'AdminController'); - throw new HttpException(error.message, StatusCodes.BAD_REQUEST); + const message = + error instanceof Error + ? error.message + : 'Scraper test failed. Check the URL, selector, and that the site returns the expected content.'; + + throw new HttpException(message, StatusCodes.BAD_REQUEST); } } diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index 7392f0914..449d30fea 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -276,24 +276,52 @@ export class ManualService implements DataProviderInterface { ): Promise { let locale = scraperConfiguration.locale; - const response = await fetch(scraperConfiguration.url, { - headers: scraperConfiguration.headers as HeadersInit, - signal: AbortSignal.timeout( - this.configurationService.get('REQUEST_TIMEOUT') - ) - }); + let response: Response; + + try { + response = await fetch(scraperConfiguration.url, { + headers: scraperConfiguration.headers as HeadersInit, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'Unknown network error'; + throw new Error( + `Request failed: ${message}. Check the URL and that the site is reachable.` + ); + } + + if (!response.ok) { + throw new Error( + `HTTP ${response.status} (${response.statusText}). The site may be blocking requests, rate-limiting, or returning an error page.` + ); + } let value: string; if (response.headers.get('content-type')?.includes('application/json')) { - const object = await response.json(); + const object: unknown = await response.json(); - value = String( - query({ - object, - pathExpression: scraperConfiguration.selector - })[0] - ); + const matched = query({ + object: object as object, + pathExpression: scraperConfiguration.selector + })[0] as unknown; + + if (matched === undefined || matched === null) { + throw new Error( + 'Selector did not match any value in the response. Check the path expression.' + ); + } + + if (typeof matched === 'object') { + throw new Error( + 'Selector matched an object or array. Expected a string or number value.' + ); + } + + value = typeof matched === 'string' ? matched : String(matched as number); } else { const $ = cheerio.load(await response.text()); @@ -305,6 +333,12 @@ export class ManualService implements DataProviderInterface { value = $(scraperConfiguration.selector).first().text(); + if (!value?.trim()) { + throw new Error( + 'Selector matched no element on the page. Check the selector and that the page structure matches.' + ); + } + const lines = value?.split('\n') ?? []; const lineWithDigits = lines.find((line) => { @@ -314,13 +348,26 @@ export class ManualService implements DataProviderInterface { if (lineWithDigits) { value = lineWithDigits; } + } - return extractNumberFromString({ - locale, - value - }); + const parsed = extractNumberFromString({ + locale, + value: value ?? '' + }); + + if ( + parsed === undefined || + (typeof parsed === 'number' && Number.isNaN(parsed)) + ) { + const preview = + (value ?? '').trim().length > 50 + ? `${(value ?? '').trim().slice(0, 50)}…` + : (value ?? '').trim(); + throw new Error( + `Could not extract a number from the selected text. Got: "${preview}"` + ); } - return extractNumberFromString({ locale, value }); + return parsed; } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index cbd8deba3..0c173c590 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -691,9 +691,20 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { symbol: this.data.symbol }) .pipe( - catchError(({ error }) => { + catchError((err: HttpErrorResponse) => { + const body: unknown = err.error; + const message: string = + typeof body === 'object' && + body !== null && + 'message' in body && + typeof (body as { message: unknown }).message === 'string' + ? (body as { message: string }).message + : typeof err.message === 'string' + ? err.message + : $localize`Scraper test failed`; + this.notificationService.alert({ - message: error?.message, + message, title: $localize`Error` }); return EMPTY;