diff --git a/.env b/.env index 4bd1584..60dbdc5 100644 --- a/.env +++ b/.env @@ -1,14 +1,15 @@ MODE=dev -#Volumes Config +CONFIG_PROFILE=development + +# Volumes Config LOCAL_FILES_PATH=/mounted/local_files/ # TODO: Change value before build MINIO_PATH=/mounted/minio/ # TODO: Change value before build MONGODB_PATH=/mounted/mongodb/ # TODO: Change value before build -#Storage Service Config - -#API_KEYS -GOOGLE_API_KEY=CHANGEME +# API_KEYS +GOOGLE_API_KEY=AIzaSyAfdZFZM1mz7IYUgCpESSJX4zdJZ589eX0 +# Scrapy SCRAPY_SETTINGS_MODULE=app.crawler.settings C_FORCE_ROOT=True @@ -24,10 +25,9 @@ STORAGE_SERVICE_USERNAME=admin STORAGE_SERVICE_PASSWORD=password123 STORAGE_SERVICE_URL=minio:9000 STORAGE_SERVICE_REGION=gra +STORAGE_SERVICE_SECURE=false STORAGE_SERVICE_BUCKET_NAME=open-crawler HTML_FOLDER_NAME=html # TODO: Change value before build METADATA_FOLDER_NAME=metadata # TODO: Change value before build -LOGGER_LEVEL=INFO - DEFAULT_RECRAWL_INTERVAL=30 \ No newline at end of file diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index a585c93..65e787f 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -20,6 +20,9 @@ jobs: steps: - uses: actions/checkout@v3 + - name : Install Packages + run : pip install -r requirements.txt + - name: test run: python -m unittest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c977bc4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Testing deployment + +on: + push: + +jobs: + unit-test: + name: run unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.11 + uses: actions/setup-python@v1 + with: + python-version: 3.11 + + - name : Install Packages + run : pip install -r requirements.txt + + - name: test + run: python -m unittest \ No newline at end of file diff --git a/README.md b/README.md index 961db5e..1e80c06 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,7 @@ This endpoint allows you to create a new website configuration end execute a cra | `depth` | `integer` | Maximum depth to crawl (**Default**: 2) | | `limit` | `integer` | Maximum pages to crawl (**Default**: 400) | | `headers` | `dict[str, str]` | Headers that will be passed to all crawl requests (**Default**: {})| -| `accessibility` | `MetadataConfig` | Accessibility configuration (**Default**: {'enabled':True, 'depth' 0}) | -| `good_practices` | `MetadataConfig` | Good Practices configuration (**Default**: {'enabled': False}) | +| `lighthouse` | `MetadataConfig` | Lighthouse configuration (**Default**: {'enabled':True, 'depth' 0}) | | `technologies` | `MetadataConfig` | Technologies configuration (**Default**: {'enabled': False}) | | `responsiveness` | `MetadataConfig` | Responsiveness configuration (**Default**: {'enabled': False}) | | `carbon_footprint` | `MetadataConfig` | Carbon Footprint configuration (**Default**: {'enabled': False}) | @@ -132,7 +131,9 @@ To access the two collections, use a MongoDB console (such as MongoDB Compass fo **website_crawl_parameters** collection: ![mongodb_config](./demo/mongodb_crawl_configuration.png)## Acces simple storage service -At the end of the crawl process, all crawled html pages and metadata files are uploaded to a simple storage service (s3). +At the end of the crawl process, all crawled html pages are uploaded to a simple storage service (s3). +The metadata are directly uploaded to the storage service. + The docker-compose file deploys a MinIO service that can be accessed at http://localhost:9090. (by default) ![minio](./demo/minio.png) \ No newline at end of file diff --git a/app/api/crawls_router.py b/app/api/crawls_router.py index fb9780a..3724ebd 100644 --- a/app/api/crawls_router.py +++ b/app/api/crawls_router.py @@ -1,10 +1,5 @@ -import io -import os -from zipfile import ZipFile, ZIP_DEFLATED - from fastapi import HTTPException, APIRouter, status as statuscode from fastapi.responses import StreamingResponse -from minio import Minio import app.repositories as repositories from app.api.utils import create_crawl, start_crawl @@ -58,34 +53,23 @@ def list_crawls( status_code=statuscode.HTTP_200_OK, summary="Get a zip of all files from a crawl", ) -def get_crawl_files(website_id: str, crawl_id: str) -> StreamingResponse: +def get_crawl_files(crawl_id: str) -> StreamingResponse: """Zip the files from the storage service""" - client = Minio( - endpoint=os.environ["STORAGE_SERVICE_URL"], - access_key=os.environ["STORAGE_SERVICE_USERNAME"], - secret_key=os.environ["STORAGE_SERVICE_PASSWORD"], - secure=os.environ.get("STORAGE_SERVICE_SECURE", False), - region=os.environ.get("STORAGE_SERVICE_REGION", None), - ) - - bucket = os.environ["STORAGE_SERVICE_BUCKET_NAME"] - zip_io = io.BytesIO() - if not (crawl := repositories.crawls.get(website_id, crawl_id)): - raise HTTPException( - status_code=statuscode.HTTP_404_NOT_FOUND, - detail="Crawl not found", - ) - url = crawl.config.url.replace("https://", "").replace("http://", "") - prefix = f"{url}/{crawl_id}" - objects = client.list_objects(bucket, prefix=prefix, recursive=True) - with ZipFile(zip_io, "a", ZIP_DEFLATED, False) as zipper: - for obj in objects: - file = client.get_object(bucket, obj.object_name).read() - zipper.writestr(obj.object_name, file) + zip_io = repositories.files.zip_all_crawl_files(crawl_id) return StreamingResponse( iter([zip_io.getvalue()]), media_type="application/x-zip-compressed", headers={ - "Content-Disposition": f"attachment; filename={url}-{crawl_id}.zip" + "Content-Disposition": f"attachment; filename={crawl_id}.zip" }, ) + + +@crawls_router.delete( + "/{website_id}/crawls/{crawl_id}", + status_code=statuscode.HTTP_204_NO_CONTENT, + summary="Delete a crawl", +) +def delete_crawl(crawl_id: str) -> None: + """Zip the files from the storage service""" + return repositories.files.delete_all_crawl_files(crawl_id) diff --git a/app/api/factory.py b/app/api/factory.py index 0a33943..79e8d80 100644 --- a/app/api/factory.py +++ b/app/api/factory.py @@ -5,6 +5,7 @@ from app.api.crawls_router import crawls_router from app.api.websites_router import websites_router +from app.config import settings def create_api_app() -> FastAPI: @@ -18,7 +19,7 @@ def create_api_app() -> FastAPI: ) # Configure CORS for non-production modes - deployment_mode = os.environ.get("MODE", "production") + deployment_mode = settings.MODE if deployment_mode != "production": api_app.add_middleware( CORSMiddleware, @@ -27,6 +28,7 @@ def create_api_app() -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) + # TODO: Configure CORS for production mode api_app.include_router(websites_router) api_app.include_router(crawls_router) diff --git a/app/api/utils.py b/app/api/utils.py index b3fddbb..bf5f438 100644 --- a/app/api/utils.py +++ b/app/api/utils.py @@ -1,17 +1,27 @@ +from urllib.parse import urlparse + from celery import group, chain import app.repositories as repositories from app.celery_broker.tasks import ( METADATA_TASK_REGISTRY, start_crawl_process, - upload_html, ) from app.models.crawl import CrawlModel +from app.models.enums import ProcessStatus from app.models.website import WebsiteModel from app.services.crawler_logger import logger def create_crawl(website: WebsiteModel) -> CrawlModel: + + # Check if the path component of the URL is empty or "/" + # If the crawl target is a single page, we will ignore the depth and the limit in the request. + if not is_domain(website.url): + website.depth = 0 + website.limit = 1 + logger.warning("The url to crawl is not a domain. Only one page will be crawled") + crawl: CrawlModel = CrawlModel( website_id=website.id, config=website.to_config(), @@ -32,5 +42,10 @@ def start_crawl(crawl: CrawlModel) -> None: chain( start_crawl_process.s(crawl), metadata_tasks, - upload_html.si(crawl), ).apply_async(task_id=crawl.id) + + +def is_domain(url: str) -> bool: + parsed_url = urlparse(url) + return parsed_url.path == '' or parsed_url.path == '/' + diff --git a/app/celery_broker/crawler_utils.py b/app/celery_broker/crawler_utils.py index c9e0f8c..9762cc1 100644 --- a/app/celery_broker/crawler_utils.py +++ b/app/celery_broker/crawler_utils.py @@ -32,3 +32,14 @@ def start_crawler_process(crawl_process: CrawlProcess, results: dict): process.crawl(MenesrSpider, crawl_process=crawl_process) process.start() results["metadata"] = dict(crawl_process.metadata.items()) + + +def set_html_crawl_status(crawl: CrawlModel, request_id: str, status: ProcessStatus): + crawl.html_crawl.update( + task_id=request_id, status=status + ) + repositories.crawls.update_task( + crawl_id=crawl.id, + task_name="html_crawl", + task=crawl.html_crawl, + ) diff --git a/app/celery_broker/factory.py b/app/celery_broker/factory.py index 4c444a1..df2ccbd 100644 --- a/app/celery_broker/factory.py +++ b/app/celery_broker/factory.py @@ -2,14 +2,14 @@ from celery import Celery -from app.celery_broker.config import settings +from app.config import settings def create_celery_app() -> Celery: celery_app = Celery( "scanr", - broker=os.environ.get("CELERY_BROKER_URL"), - backend=os.environ.get("CELERY_RESULT_BACKEND"), + broker=settings.CELERY_BROKER_URL, + backend=settings.CELERY_RESULT_BACKEND, broker_connection_retry_on_startup=True, include=["app.celery_broker.tasks"], ) diff --git a/app/celery_broker/metadata_utils.py b/app/celery_broker/metadata_utils.py index fd4f699..57d8b8b 100644 --- a/app/celery_broker/metadata_utils.py +++ b/app/celery_broker/metadata_utils.py @@ -6,10 +6,7 @@ from app.models.enums import MetadataType, ProcessStatus from app.models.metadata import MetadataTask from app.models.process import CrawlProcess -from app.services.accessibility_best_practices_calculator import ( - AccessibilityError, - BestPracticesError, -) +from app.services.lighthouse_calculator import LighthouseError from app.services.carbon_calculator import CarbonCalculatorError from app.services.crawler_logger import logger from app.services.responsiveness_calculator import ResponsivenessCalculatorError @@ -46,14 +43,12 @@ def handle_metadata_result( def store_metadata_result( crawl_process: CrawlProcess, result: dict, metadata_type: MetadataType ): - base_file_path = ( - f"/{os.environ['LOCAL_FILES_PATH'].strip('/')}/{crawl_process.id}" - ) - file_path = pathlib.Path( - f"{base_file_path}/{os.environ['METADATA_FOLDER_NAME'].strip('/')}/{metadata_type}.json" + return repositories.files.store_metadata_file( + crawl_id=crawl_process.id, + object_name=f"{metadata_type}.json", + content_type='application/json', + data=json.dumps(result, indent=2, default=str) ) - file_path.parent.mkdir(exist_ok=True, parents=True) - file_path.write_text(json.dumps(result, indent=4)) def metadata_task( @@ -78,8 +73,7 @@ def metadata_task( data = calc_method(url) result[url] = data except ( - AccessibilityError, - BestPracticesError, + LighthouseError, TechnologiesError, ResponsivenessCalculatorError, CarbonCalculatorError, diff --git a/app/celery_broker/tasks.py b/app/celery_broker/tasks.py index a9ea05a..a7bc3f4 100644 --- a/app/celery_broker/tasks.py +++ b/app/celery_broker/tasks.py @@ -4,21 +4,19 @@ import shutil from multiprocessing import Process, Manager -# Third-party imports -from minio import Minio - # Local imports import app.repositories as repositories -from app.celery_broker.crawler_utils import start_crawler_process +from app.celery_broker.crawler_utils import start_crawler_process, set_html_crawl_status from app.celery_broker.main import celery_app from app.celery_broker.metadata_utils import metadata_task from app.celery_broker.utils import assume_content_type +from app.config import settings from app.models.crawl import CrawlModel from app.models.enums import MetadataType, ProcessStatus from app.models.metadata import MetadataTask from app.models.process import CrawlProcess -from app.services.accessibility_best_practices_calculator import ( - LighthouseWrapper, +from app.services.lighthouse_calculator import ( + LighthouseCalculator, ) from app.services.carbon_calculator import CarbonCalculator from app.services.crawler_logger import logger @@ -36,44 +34,49 @@ def start_crawl_process(self, crawl: CrawlModel) -> CrawlProcess: crawl_id=crawl.id, status=ProcessStatus.STARTED ) logger.debug("Html crawl started!") - crawl.html_crawl.update( - task_id=self.request.id, status=ProcessStatus.STARTED - ) - repositories.crawls.update_task( - crawl_id=crawl.id, - task_name="html_crawl", - task=crawl.html_crawl, - ) + set_html_crawl_status(crawl, self.request.id, ProcessStatus.STARTED) crawl_process = CrawlProcess.from_model(crawl) - with Manager() as manager: - shared_dict = manager.dict() - p = Process( - target=start_crawler_process, - kwargs={"crawl_process": crawl_process, "results": shared_dict}, - ) - p.start() - p.join() # TODO define and add a timeout - crawl_process.metadata.update(shared_dict["metadata"]) - - crawl.html_crawl.update(status=ProcessStatus.SUCCESS) - repositories.crawls.update_task( - crawl_id=crawl.id, - task_name="html_crawl", - task=crawl.html_crawl, - ) + + try: + with Manager() as manager: + shared_dict = manager.dict() + p = Process( + target=start_crawler_process, + kwargs={"crawl_process": crawl_process, "results": shared_dict}, + ) + p.start() + p.join() # TODO define and add a timeout + crawl_process.metadata.update(shared_dict["metadata"]) + except Exception as e: + logger.error(f"Error while crawling html files: {e}") + set_html_crawl_status(crawl, self.request.id, ProcessStatus.ERROR) + self.update_state(state='FAILURE') + return crawl_process + try: + # Attempt to upload HTML files associated with the crawl + upload_html(crawl) + except Exception as e: + logger.error(f"Error while uploading html files: {e}") + # Html crawl will be considered failed if we can't upload the html files + set_html_crawl_status(crawl, self.request.id, ProcessStatus.ERROR) + self.update_state(state='FAILURE') + return crawl_process + + set_html_crawl_status(crawl, self.request.id, ProcessStatus.SUCCESS) + logger.debug("Html crawl ended!") return crawl_process -@celery_app.task(bind=True, name="get_accessibility") -def get_accessibility(self, crawl_process: CrawlProcess): +@celery_app.task(bind=True, name="get_lighthouse") +def get_lighthouse(self, crawl_process: CrawlProcess): return metadata_task( task=MetadataTask(task_id=self.request.id), crawl_process=crawl_process, - metadata_type=MetadataType.ACCESSIBILITY, - calculator=LighthouseWrapper(), - method_name="get_accessibility", + metadata_type=MetadataType.LIGHTHOUSE, + calculator=LighthouseCalculator(), + method_name="get_lighthouse", ) @@ -88,17 +91,6 @@ def get_technologies(self, crawl_process: CrawlProcess): ) -@celery_app.task(bind=True, name="get_good_practices") -def get_good_practices(self, crawl_process: CrawlProcess): - return metadata_task( - task=MetadataTask(task_id=self.request.id), - crawl_process=crawl_process, - metadata_type=MetadataType.GOOD_PRACTICES, - calculator=LighthouseWrapper(), - method_name="get_best_practices", - ) - - @celery_app.task(bind=True, name="get_responsiveness") def get_responsiveness(self, crawl_process: CrawlProcess): return metadata_task( @@ -121,69 +113,27 @@ def get_carbon_footprint(self, crawl_process: CrawlProcess): ) -@celery_app.task(bind=True, name="upload_html") -def upload_html(self, crawl: CrawlModel): - crawl.uploads.update(task_id=self.request.id, status=ProcessStatus.STARTED) - logger.debug("Files upload started!") - repositories.crawls.update_task( - crawl_id=crawl.id, - task_name="uploads", - task=crawl.uploads, - ) - - client = Minio( - endpoint=os.environ["STORAGE_SERVICE_URL"], - access_key=os.environ["STORAGE_SERVICE_USERNAME"], - secret_key=os.environ["STORAGE_SERVICE_PASSWORD"], - secure=os.environ.get("STORAGE_SERVICE_SECURE", False), - region=os.environ.get("STORAGE_SERVICE_REGION", None), - ) +METADATA_TASK_REGISTRY = { + MetadataType.LIGHTHOUSE: get_lighthouse, + MetadataType.TECHNOLOGIES: get_technologies, + MetadataType.RESPONSIVENESS: get_responsiveness, + MetadataType.CARBON_FOOTPRINT: get_carbon_footprint, +} - bucket_name = os.environ["STORAGE_SERVICE_BUCKET_NAME"] - if not client.bucket_exists(bucket_name): - client.make_bucket(bucket_name) +def upload_html(crawl: CrawlModel): crawl_files_path = pathlib.Path( - f"/{os.environ['LOCAL_FILES_PATH'].strip('/')}/{crawl.id}" + f"/{settings.LOCAL_FILES_PATH.strip('/')}/{crawl.id}" ) - local_files_folder = f"/{os.environ['LOCAL_FILES_PATH'].strip('/')}" - - prefix = crawl.config.url.replace("https://", "").replace("http://", "") + local_files_folder = f"/{settings.LOCAL_FILES_PATH.strip('/')}" for file in crawl_files_path.rglob("*.[hj][ts][mo][ln]"): file_path = str(file) - client.fput_object( - bucket_name=bucket_name, - object_name=f"{prefix.rstrip('/')}/{file_path.removeprefix(local_files_folder).lstrip('/')}", + file_name = file_path.removeprefix(local_files_folder).lstrip('/') + repositories.files.store_html_file( + object_name=file_name, file_path=file_path, content_type=assume_content_type(file_path), ) os.remove(file) shutil.rmtree(crawl_files_path, ignore_errors=True) - crawl.uploads.update(status=ProcessStatus.SUCCESS) - repositories.crawls.update_task( - crawl_id=crawl.id, - task_name="uploads", - task=crawl.uploads, - ) - logger.debug("Files upload ended!") - repositories.crawls.update_status( - crawl_id=crawl.id, status=ProcessStatus.SUCCESS - ) - logger.info( - f"Crawl process ({crawl.id}) for website {crawl.config.url} ended" - ) - - repositories.websites.store_last_crawl( - website_id=crawl.website_id, - crawl=repositories.crawls.get(crawl_id=crawl.id).model_dump(), - ) - - -METADATA_TASK_REGISTRY = { - MetadataType.ACCESSIBILITY: get_accessibility, - MetadataType.TECHNOLOGIES: get_technologies, - MetadataType.GOOD_PRACTICES: get_good_practices, - MetadataType.RESPONSIVENESS: get_responsiveness, - MetadataType.CARBON_FOOTPRINT: get_carbon_footprint, -} diff --git a/app/celery_broker/config.py b/app/config.py similarity index 50% rename from app/celery_broker/config.py rename to app/config.py index 7c96683..f65bc56 100644 --- a/app/celery_broker/config.py +++ b/app/config.py @@ -1,19 +1,49 @@ import os -from functools import lru_cache from kombu import Queue, Exchange class BaseConfig: + """Base configuration.""" + LOGGER_LEVEL = "INFO" + LOGGER_FORMAT = "[%(asctime)s] [%(process)d] [%(levelname)s] [%(name)s] %(message)s" + + + DEFAULT_RECRAWL_INTERVAL=os.getenv("DEFAULT_RECRAWL_INTERVAL", 30) + + MODE = os.getenv("MODE", "production") + + # GOOGLE_API_KEY + GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") + + # Volume + LOCAL_FILES_PATH = os.getenv("LOCAL_FILES_PATH", "/mounted/local_files/") + + # Storage + STORAGE_SERVICE_USERNAME = os.getenv("STORAGE_SERVICE_USERNAME") + STORAGE_SERVICE_PASSWORD = os.getenv("STORAGE_SERVICE_PASSWORD") + STORAGE_SERVICE_URL = os.getenv("STORAGE_SERVICE_URL") + STORAGE_SERVICE_REGION = os.getenv("STORAGE_SERVICE_REGION", default=None) + STORAGE_SERVICE_SECURE = os.getenv("STORAGE_SERVICE_SECURE", default='False').lower() in ('true', '1', 't') + STORAGE_SERVICE_BUCKET_NAME = os.getenv("STORAGE_SERVICE_BUCKET_NAME") + HTML_FOLDER_NAME = os.getenv("HTML_FOLDER_NAME", default="html") + METADATA_FOLDER_NAME = os.getenv("METADATA_FOLDER_NAME", default="metadata") + + # Mongo + MONGO_URI = os.getenv("MONGO_URI", default="mongodb://mongodb:27017") + MONGO_DBNAME = os.getenv("MONGO_DBNAME", default="open-crawler") + MONGO_WEBSITES_COLLECTION = os.getenv("MONGO_WEBSITES_COLLECTION", default="websites") + MONGO_CRAWLS_COLLECTION = os.getenv("MONGO_CRAWLS_COLLECTION", default="crawls") + + # Celery + CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", default="redis://redis:6379") + CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", default="redis://redis:6379") + CRAWL_QUEUE_NAME = "crawl_queue" - ACCESSIBILITY_QUEUE_NAME = "accessibility_queue" + LIGHTHOUSE_QUEUE_NAME = "lighthouse_queue" TECHNOLOGIES_QUEUE_NAME = "technologies_queue" - GOOD_PRACTICES_QUEUE_NAME = "good_practices_queue" RESPONSIVENESS_QUEUE_NAME = "responsiveness_queue" CARBON_QUEUE_NAME = "carbon_footprint_queue" - UPLOAD_QUEUE_NAME = "upload_queue" - - result_backend: str = "redis://redis:6379" # The following two lines make celery execute tasks locally # task_always_eager = True @@ -29,20 +59,15 @@ class BaseConfig: routing_key=CRAWL_QUEUE_NAME, ), Queue( - ACCESSIBILITY_QUEUE_NAME, - Exchange(ACCESSIBILITY_QUEUE_NAME), - routing_key=ACCESSIBILITY_QUEUE_NAME, + LIGHTHOUSE_QUEUE_NAME, + Exchange(LIGHTHOUSE_QUEUE_NAME), + routing_key=LIGHTHOUSE_QUEUE_NAME, ), Queue( TECHNOLOGIES_QUEUE_NAME, Exchange(TECHNOLOGIES_QUEUE_NAME), routing_key=TECHNOLOGIES_QUEUE_NAME, ), - Queue( - GOOD_PRACTICES_QUEUE_NAME, - Exchange(GOOD_PRACTICES_QUEUE_NAME), - routing_key=GOOD_PRACTICES_QUEUE_NAME, - ), Queue( RESPONSIVENESS_QUEUE_NAME, Exchange(RESPONSIVENESS_QUEUE_NAME), @@ -53,27 +78,18 @@ class BaseConfig: Exchange(CARBON_QUEUE_NAME), routing_key=CARBON_QUEUE_NAME, ), - Queue( - UPLOAD_QUEUE_NAME, - Exchange(UPLOAD_QUEUE_NAME), - routing_key=UPLOAD_QUEUE_NAME, - ), ) task_routes = { "crawl": {"queue": CRAWL_QUEUE_NAME, "routing_key": CRAWL_QUEUE_NAME}, - "get_accessibility": { - "queue": ACCESSIBILITY_QUEUE_NAME, - "routing_key": ACCESSIBILITY_QUEUE_NAME, + "get_lighthouse": { + "queue": LIGHTHOUSE_QUEUE_NAME, + "routing_key": LIGHTHOUSE_QUEUE_NAME, }, "get_technologies": { "queue": TECHNOLOGIES_QUEUE_NAME, "routing_key": TECHNOLOGIES_QUEUE_NAME, }, - "get_good_practices": { - "queue": GOOD_PRACTICES_QUEUE_NAME, - "routing_key": GOOD_PRACTICES_QUEUE_NAME, - }, "get_responsiveness": { "queue": RESPONSIVENESS_QUEUE_NAME, "routing_key": RESPONSIVENESS_QUEUE_NAME, @@ -81,11 +97,7 @@ class BaseConfig: "get_carbon_footprint": { "queue": CARBON_QUEUE_NAME, "routing_key": CARBON_QUEUE_NAME, - }, - "upload_html": { - "queue": UPLOAD_QUEUE_NAME, - "routing_key": UPLOAD_QUEUE_NAME, - }, + } } def get(self, attribute_name: str): @@ -96,13 +108,12 @@ class DevelopmentConfig(BaseConfig): pass -@lru_cache() def get_settings(): config_cls_dict = { "development": DevelopmentConfig, "default": BaseConfig } - config_name = os.environ.get("CELERY_CONFIG", "default") + config_name = os.environ.get("CONFIG_PROFILE", "default") config_cls = config_cls_dict[config_name] return config_cls() diff --git a/app/crawler/middlewares.py b/app/crawler/middlewares.py index f1e6ec1..8f1020c 100644 --- a/app/crawler/middlewares.py +++ b/app/crawler/middlewares.py @@ -5,6 +5,8 @@ import os from pathlib import Path +from app.config import settings + from scrapy.downloadermiddlewares.defaultheaders import DefaultHeadersMiddleware from scrapy.exceptions import IgnoreRequest from scrapy.extensions.closespider import CloseSpider @@ -46,14 +48,14 @@ def from_crawler(cls, crawler): def _format_file_path(self, response, spider) -> Path: domain = spider.allowed_domains[0] - base_file_path = f"/{os.environ['LOCAL_FILES_PATH'].strip('/')}/{spider.crawl_process.id}" + base_file_path = f"/{settings.LOCAL_FILES_PATH.strip('/')}/{spider.crawl_process.id}" file_name = response.url.split(f"{domain}")[-1] if not file_name.endswith(".html"): file_name = f"{file_name}.html" if file_name == ".html": file_name = "index.html" return Path( - f"{base_file_path}/{os.environ['HTML_FOLDER_NAME'].strip('/')}/{file_name.lstrip('/')}" + f"{base_file_path}/{settings.HTML_FOLDER_NAME.strip('/')}/{file_name.lstrip('/')}" ) def _save_html_locally(self, response, spider): diff --git a/app/models/crawl.py b/app/models/crawl.py index 362e400..8dcac6d 100644 --- a/app/models/crawl.py +++ b/app/models/crawl.py @@ -5,7 +5,7 @@ from app.celery_broker.utils import french_datetime from app.models.enums import MetadataType, ProcessStatus -from app.models.metadata import MetadataConfig, AccessibilityModel, MetadataTask +from app.models.metadata import MetadataConfig, LighthouseModel, MetadataTask from app.models.utils import get_uuid, BaseTaskModel @@ -31,12 +31,10 @@ class CrawlModel(BaseModel): finished_at: datetime | None = None status: ProcessStatus = ProcessStatus.PENDING html_crawl: BaseTaskModel = Field(default_factory=BaseTaskModel) - accessibility: AccessibilityModel | None = None + lighthouse: LighthouseModel | None = None technologies_and_trackers: MetadataTask | None = None responsiveness: MetadataTask | None = None - good_practices: MetadataTask | None = None carbon_footprint: MetadataTask | None = None - uploads: BaseTaskModel = Field(default_factory=BaseTaskModel) @property def enabled_metadata(self) -> list[MetadataType]: @@ -47,14 +45,12 @@ def enabled_metadata(self) -> list[MetadataType]: ] def init_tasks(self) -> None: - if MetadataType.ACCESSIBILITY in self.enabled_metadata: - self.accessibility = AccessibilityModel() + if MetadataType.LIGHTHOUSE in self.enabled_metadata: + self.lighthouse = LighthouseModel() if MetadataType.TECHNOLOGIES in self.enabled_metadata: self.technologies_and_trackers = MetadataTask() if MetadataType.RESPONSIVENESS in self.enabled_metadata: self.responsiveness = MetadataTask() - if MetadataType.GOOD_PRACTICES in self.enabled_metadata: - self.good_practices = MetadataTask() if MetadataType.CARBON_FOOTPRINT in self.enabled_metadata: self.carbon_footprint = MetadataTask() diff --git a/app/models/enums.py b/app/models/enums.py index 3eb22c9..0b25960 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -2,10 +2,9 @@ class MetadataType(StrEnum): - ACCESSIBILITY = "accessibility" + LIGHTHOUSE = "lighthouse" TECHNOLOGIES = "technologies_and_trackers" RESPONSIVENESS = "responsiveness" - GOOD_PRACTICES = "good_practices" CARBON_FOOTPRINT = "carbon_footprint" diff --git a/app/models/metadata.py b/app/models/metadata.py index 3e6d3d7..89c9ff9 100644 --- a/app/models/metadata.py +++ b/app/models/metadata.py @@ -14,5 +14,5 @@ class MetadataTask(BaseTaskModel): pass -class AccessibilityModel(MetadataTask): +class LighthouseModel(MetadataTask): score: float | None = None diff --git a/app/models/request.py b/app/models/request.py index 15cd119..718ba4d 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -11,10 +11,9 @@ class UpdateWebsiteRequest(BaseModel): depth: int | None = None limit: int | None = None - accessibility: MetadataConfig | None = None + lighthouse: MetadataConfig | None = None technologies_and_trackers: MetadataConfig | None = None responsiveness: MetadataConfig | None = None - good_practices: MetadataConfig | None = None carbon_footprint: MetadataConfig | None = None headers: dict[str, Any] | None = None tags: list[str] | None = None @@ -26,16 +25,13 @@ class CreateWebsiteRequest(BaseModel): url: str depth: int = Field(ge=0, default=2) limit: int = Field(ge=0, default=400) - accessibility: MetadataConfig = Field(default=MetadataConfig()) + lighthouse: MetadataConfig = Field(default=MetadataConfig()) technologies_and_trackers: MetadataConfig = Field( default=MetadataConfig(enabled=False) ) responsiveness: MetadataConfig = Field( default=MetadataConfig(enabled=False) ) - good_practices: MetadataConfig = Field( - default=MetadataConfig(enabled=False) - ) carbon_footprint: MetadataConfig = Field( default=MetadataConfig(enabled=False) ) diff --git a/app/models/website.py b/app/models/website.py index 943d2fd..c024257 100644 --- a/app/models/website.py +++ b/app/models/website.py @@ -10,7 +10,6 @@ from app.models.metadata import MetadataConfig from app.models.utils import get_uuid -DEFAULT_RECRAWL_INTERVAL = os.environ.get("DEFAULT_RECRAWL_INTERVAL", 30) class WebsiteModel(BaseModel): @@ -18,10 +17,9 @@ class WebsiteModel(BaseModel): url: str depth: int limit: int - accessibility: MetadataConfig + lighthouse: MetadataConfig technologies_and_trackers: MetadataConfig responsiveness: MetadataConfig - good_practices: MetadataConfig carbon_footprint: MetadataConfig headers: dict[str, Any] created_at: datetime = Field(default_factory=french_datetime) @@ -36,10 +34,9 @@ def to_config(self) -> CrawlConfig: url=self.url, parameters=CrawlParameters(depth=self.depth, limit=self.limit), metadata_config={ - MetadataType.ACCESSIBILITY: self.accessibility, + MetadataType.LIGHTHOUSE: self.lighthouse, MetadataType.TECHNOLOGIES: self.technologies_and_trackers, MetadataType.RESPONSIVENESS: self.responsiveness, - MetadataType.GOOD_PRACTICES: self.good_practices, MetadataType.CARBON_FOOTPRINT: self.carbon_footprint, }, headers=self.headers, diff --git a/app/mongo.py b/app/mongo.py index 814f9f1..1a77f8f 100644 --- a/app/mongo.py +++ b/app/mongo.py @@ -1,14 +1,16 @@ import os +from app.config import settings from pymongo import MongoClient -client = MongoClient(host=os.environ["MONGO_URI"]) -db = client[os.environ["MONGO_DBNAME"]] -db[os.environ["MONGO_WEBSITES_COLLECTION"]].create_index( +client = MongoClient(host=settings.MONGO_URI) +db = client[settings.MONGO_DBNAME] + +db[settings.MONGO_WEBSITES_COLLECTION].create_index( [("id", 1)], unique=True ) -db[os.environ["MONGO_WEBSITES_COLLECTION"]].create_index( +db[settings.MONGO_WEBSITES_COLLECTION].create_index( [("url", 1)], unique=True ) -db[os.environ["MONGO_CRAWLS_COLLECTION"]].create_index([("id", 1)], unique=True) +db[settings.MONGO_CRAWLS_COLLECTION].create_index([("id", 1)], unique=True) diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py index 36733b7..5e0f6b1 100644 --- a/app/repositories/__init__.py +++ b/app/repositories/__init__.py @@ -1,4 +1,5 @@ from .crawls import crawls from .websites import websites +from .files import files -__all__ = ["crawls", "websites"] +__all__ = ["crawls", "websites", "files"] diff --git a/app/repositories/crawls.py b/app/repositories/crawls.py index d2d5bb4..905bb35 100644 --- a/app/repositories/crawls.py +++ b/app/repositories/crawls.py @@ -1,8 +1,8 @@ import os - from pymongo.results import InsertOneResult from app.celery_broker.utils import french_datetime +from app.config import settings from app.models.crawl import CrawlModel, ListCrawlResponse from app.models.enums import ProcessStatus from app.models.metadata import MetadataTask @@ -13,7 +13,7 @@ class CrawlsRepository: """Operations for crawls collection""" def __init__(self): - self.collection = db[os.environ["MONGO_CRAWLS_COLLECTION"]] + self.collection = db[settings.MONGO_CRAWLS_COLLECTION] def create(self, data: CrawlModel) -> str: result: InsertOneResult = self.collection.insert_one(data.model_dump()) diff --git a/app/repositories/files.py b/app/repositories/files.py new file mode 100644 index 0000000..23f72ce --- /dev/null +++ b/app/repositories/files.py @@ -0,0 +1,48 @@ +import io +from zipfile import ZipFile, ZIP_DEFLATED +from app.s3 import with_s3 + + +class FileRepository: + """Operations for crawls collection""" + + @staticmethod + @with_s3 + def delete_all_crawl_files(s3, bucket, crawl_id): + """Delete all crawl's files from the storage service""" + objects = s3.list_objects(bucket, prefix=crawl_id, recursive=True) + for obj in objects: + s3.remove_object(bucket, obj.object_name) + return + + @staticmethod + @with_s3 + def store_html_file(s3, bucket, object_name, file_path, content_type): + """Store a crawl file in the storage service""" + return s3.fput_object(bucket, object_name=object_name, file_path=file_path, content_type=content_type) + + @staticmethod + @with_s3 + def store_metadata_file(s3, bucket, crawl_id, object_name, content_type, data): + """Store a crawl file in the storage service""" + object_path = f"{crawl_id}/metadata/{object_name}" + # Convert the string to bytes + data_bytes = data.encode('utf-8') + # Create a BytesIO object to make the bytes readable + data_stream = io.BytesIO(data_bytes) + return s3.put_object(bucket, object_name=object_path, length=len(data_bytes), content_type=content_type, data=data_stream) + + @staticmethod + @with_s3 + def zip_all_crawl_files(s3, bucket, crawl_id) -> ZipFile: + """Zip all crawl's files from the storage service""" + zip_io = io.BytesIO() + objects = s3.list_objects(bucket, prefix=crawl_id, recursive=True) + with ZipFile(zip_io, "a", ZIP_DEFLATED, False) as zipper: + for obj in objects: + file = s3.get_object(bucket, obj.object_name).read() + zipper.writestr(obj.object_name.strip(crawl_id), file) + return zip_io + + +files = FileRepository() diff --git a/app/repositories/websites.py b/app/repositories/websites.py index f8f0565..59f91d3 100644 --- a/app/repositories/websites.py +++ b/app/repositories/websites.py @@ -4,6 +4,7 @@ from pymongo.results import InsertOneResult, UpdateResult from app.celery_broker.utils import french_datetime +from app.config import settings from app.models.enums import ProcessStatus from app.models.request import UpdateWebsiteRequest from app.models.website import WebsiteModel, ListWebsiteResponse @@ -14,7 +15,7 @@ class WebsitesRepository: """Operations for websites collection""" def __init__(self): - self.collection = db[os.environ["MONGO_WEBSITES_COLLECTION"]] + self.collection = db[settings.MONGO_WEBSITES_COLLECTION] def list( self, diff --git a/app/s3.py b/app/s3.py new file mode 100644 index 0000000..e16d51d --- /dev/null +++ b/app/s3.py @@ -0,0 +1,29 @@ +import os +from functools import wraps +from minio import Minio + +from app.config import settings + +s3 = Minio( + endpoint=settings.STORAGE_SERVICE_URL, + access_key=settings.STORAGE_SERVICE_USERNAME, + secret_key=settings.STORAGE_SERVICE_PASSWORD, + secure=settings.STORAGE_SERVICE_SECURE, + region=settings.STORAGE_SERVICE_REGION, +) + +bucket = settings.STORAGE_SERVICE_BUCKET_NAME + +if not s3.bucket_exists(bucket): + s3.make_bucket(bucket) + + +def with_s3(f): + """Decorate a function for s3 connexion.""" + @wraps(f) + def wrapper(*args, **kwargs): + print(f"Calling {f.__name__} with s3 connexion", flush=True) + print(f"args: {','.join(map(str,args))}", flush=True) + response = f(s3, bucket, *args, **kwargs) + return response + return wrapper diff --git a/app/services/accessibility_best_practices_calculator.py b/app/services/accessibility_best_practices_calculator.py deleted file mode 100644 index 6676da3..0000000 --- a/app/services/accessibility_best_practices_calculator.py +++ /dev/null @@ -1,67 +0,0 @@ -import json -import subprocess -from enum import StrEnum -from typing import Any - - -class LighthouseCategories(StrEnum): - ACCESSIBILITY = "accessibility" - BEST_PRACTICES = "best-practices" - - -class LighthouseError(Exception): - pass - - -class AccessibilityError(Exception): - pass - - -class BestPracticesError(Exception): - pass - - -class LighthouseWrapper: - def get_accessibility(self, url: str) -> dict[str, Any]: - try: - result = self.get_categories( - url=url, categories=[LighthouseCategories.ACCESSIBILITY] - ) - except LighthouseError as e: - raise AccessibilityError from e - return result["accessibility"] - - def get_best_practices(self, url: str) -> dict[str, Any]: - try: - result = self.get_categories( - url=url, categories=[LighthouseCategories.BEST_PRACTICES] - ) - except LighthouseError as e: - raise BestPracticesError from e - return result["best-practices"] - - def get_categories( - self, url: str, categories: list[LighthouseCategories] - ) -> dict[str, Any]: - try: - lighthouse_process = subprocess.run( - " ".join( - [ - "lighthouse", - url, - '--chrome-flags="--no-sandbox --headless --disable-dev-shm-usage"', - f"--only-categories={','.join(categories)}", - "--output=json", - "--disable-full-page-screenshot", - "--no-enable-error-reporting", - "--quiet", - ] - ), - stdout=subprocess.PIPE, - shell=True, - ) - lighthouse_response = json.loads(lighthouse_process.stdout) - result = lighthouse_response["categories"] - except Exception as e: - raise LighthouseError from e - return result diff --git a/app/services/lighthouse_calculator.py b/app/services/lighthouse_calculator.py new file mode 100644 index 0000000..4365a67 --- /dev/null +++ b/app/services/lighthouse_calculator.py @@ -0,0 +1,32 @@ +import json +import subprocess +from typing import Any + + +class LighthouseError(Exception): + pass + + +class LighthouseCalculator: + def get_lighthouse(self, url: str) -> dict[str, Any]: + try: + lighthouse_process = subprocess.run( + " ".join( + [ + "lighthouse", + url, + '--chrome-flags="--no-sandbox --headless --disable-dev-shm-usage"', + "--output=json", + "--disable-full-page-screenshot", + "--no-enable-error-reporting", + "--quiet", + ] + ), + stdout=subprocess.PIPE, + shell=True, + ) + lighthouse_response = json.loads(lighthouse_process.stdout) + result = lighthouse_response + except Exception as e: + raise LighthouseError from e + return result diff --git a/app/services/responsiveness_calculator.py b/app/services/responsiveness_calculator.py index a773dfe..4fcb744 100644 --- a/app/services/responsiveness_calculator.py +++ b/app/services/responsiveness_calculator.py @@ -3,6 +3,8 @@ import requests +from app.config import settings + class ResponsivenessCalculatorError(Exception): pass @@ -11,7 +13,7 @@ class ResponsivenessCalculatorError(Exception): class ResponsivenessCalculator: def __init__(self): self.base_url = "https://content-searchconsole.googleapis.com/v1/urlTestingTools/mobileFriendlyTest:run" - self._api_key = os.environ["GOOGLE_API_KEY"] + self._api_key = settings.GOOGLE_API_KEY def get_responsiveness(self, url: str) -> dict[str, Any]: response = None diff --git a/client/index.html b/client/index.html index 53b3037..5d28e73 100644 --- a/client/index.html +++ b/client/index.html @@ -3,14 +3,21 @@ - + - Vite + React + TS + Open-crawler
+ + \ No newline at end of file diff --git a/client/public/favicon.ico b/client/public/favicon.ico new file mode 100644 index 0000000..c7ff4a8 Binary files /dev/null and b/client/public/favicon.ico differ diff --git a/client/public/js/dsfr.module.min.js b/client/public/js/dsfr.module.min.js new file mode 100644 index 0000000..762a869 --- /dev/null +++ b/client/public/js/dsfr.module.min.js @@ -0,0 +1,3 @@ +/*! DSFR v1.10.0 | SPDX-License-Identifier: MIT | License-Filename: LICENSE.md | restricted use (see terms and conditions) */ +const e=new class{constructor(){this.modules={}}create(e){const t=new e;this.modules[t.type]=t}getModule(e){return this.modules[e]}add(e,t){this.modules[e].add(t)}remove(e,t){this.modules[e].remove(t)}get isActive(){return this._isActive}set isActive(e){if(e===this._isActive)return;this._isActive=e;const t=Object.keys(this.modules).map((e=>this.modules[e]));if(e)for(const e of t)e.activate();else for(const e of t)e.deactivate()}get isLegacy(){return this._isLegacy}set isLegacy(e){e!==this._isLegacy&&(this._isLegacy=e)}},t="fr",s="dsfr",i="@gouvfr",n="1.10.0";class r{constructor(e,t,s,i){switch(this.level=e,this.light=t,this.dark=s,i){case"warn":this.logger=console.warn;break;case"error":this.logger=console.error;break;default:this.logger=console.log}}log(...e){const t=new o(s);for(const s of e)t.add(s);this.print(t)}print(e){e.setColor(this.color),this.logger.apply(console,e.getMessage())}get color(){return window.matchMedia("(prefers-color-scheme: dark)").matches?this.dark:this.light}}class o{constructor(e){this.inputs=["%c"],this.styles=["font-family:Marianne","line-height: 1.5"],this.objects=[],e&&this.add(`${e} :`)}add(e){switch(typeof e){case"object":case"function":this.inputs.push("%o "),this.objects.push(e);break;default:this.inputs.push(`${e} `)}}setColor(e){this.styles.push(`color:${e}`)}getMessage(){return[this.inputs.join(""),this.styles.join(";"),...this.objects]}}const a={log:new r(0,"#616161","#989898"),debug:new r(1,"#000091","#8B8BFF"),info:new r(2,"#007c3b","#00ed70"),warn:new r(3,"#ba4500","#fa5c00","warn"),error:new r(4,"#D80600","#FF4641","error")};const h=new class{constructor(){this.level=2;for(const e in a){const t=a[e];this[e]=(...e)=>{this.level<=t.level&&t.log.apply(t,e)},this[e].print=t.print.bind(t)}}state(){const t=new o;t.add(e),this.log.print(t)}tree(){const t=e.getModule("stage");if(!t)return;const s=new o;this._branch(t.root,0,s),this.log.print(s)}_branch(e,t,s){let i="";if(t>0){let e="";for(let s=0;s{"loading"!==document.readyState?window.requestAnimationFrame(e):document.addEventListener("DOMContentLoaded",e)},l={AUTO:"auto",MANUAL:"manual",RUNTIME:"runtime",LOADED:"loaded",VUE:"vue",ANGULAR:"angular",REACT:"react"};const d=new class{constructor(){this._mode=l.AUTO,this.isStarted=!1,this.starting=this.start.bind(this),this.preventManipulation=!1}configure(e={},t,s){this.startCallback=t;const i=e.production&&(!s||"false"!==s.production);switch(!0){case s&&!isNaN(s.level):h.level=Number(s.level);break;case s&&s.verbose&&("true"===s.verbose||1===s.verbose):h.level=0;break;case i:h.level=999;break;case e.verbose:h.level=0}h.info(`version ${n}`),this.mode=e.mode||l.AUTO}set mode(e){switch(e){case l.AUTO:this.preventManipulation=!1,t=this.starting,c(t);break;case l.LOADED:this.preventManipulation=!1,c(this.starting);break;case l.RUNTIME:this.preventManipulation=!1,this.start();break;case l.MANUAL:this.preventManipulation=!1;break;case l.VUE:case l.ANGULAR:case l.REACT:this.preventManipulation=!0;break;default:return void h.error("Illegal mode")}var t;this._mode=e,h.info(`mode set to ${e}`)}get mode(){return this._mode}start(){h.info("start"),this.startCallback()}};class u{constructor(){this._collection=[]}forEach(e){this._collection.forEach(e)}map(e){return this._collection.map(e)}get length(){return this._collection.length}add(e){return!(this._collection.indexOf(e)>-1)&&(this._collection.push(e),this.onAdd&&this.onAdd(),this.onPopulate&&1===this._collection.length&&this.onPopulate(),!0)}remove(e){const t=this._collection.indexOf(e);if(-1===t)return!1;this._collection.splice(t,1),this.onRemove&&this.onRemove(),this.onEmpty&&0===this._collection.length&&this.onEmpty()}execute(...e){for(const t of this._collection)t&&t.apply(null,e)}clear(){this._collection.length=0}clone(){const e=new u;return e._collection=this._collection.slice(),e}get collection(){return this._collection}}class g extends u{constructor(e){super(),this.type=e,this.isActive=!1}activate(){}deactivate(){}}const m=e=>`${t}-${e}`;m.selector=(e,t)=>(void 0===t&&(t="."),`${t}${m(e)}`),(m.attr=e=>`data-${m(e)}`).selector=(e,t)=>{let s=m.attr(e);return void 0!==t&&(s+=`="${t}"`),`[${s}]`},m.event=e=>`${s}.${e}`,m.emission=(e,t)=>`emission:${e}.${t}`;const p=(e,t)=>Array.prototype.slice.call(e.querySelectorAll(t)),b=(e,t)=>{const s=e.parentElement;return s.matches(t)?s:s===document.documentElement?null:b(s,t)};class _{constructor(e,t,s){this.selector=e,this.InstanceClass=t,this.creator=s,this.instances=new u,this.isIntroduced=!1,this._instanceClassName=this.InstanceClass.instanceClassName,this._instanceClassNames=this.getInstanceClassNames(this.InstanceClass),this._property=this._instanceClassName.substring(0,1).toLowerCase()+this._instanceClassName.substring(1);const i=this._instanceClassName.replace(/[^a-zA-Z0-9]+/g,"-").replace(/([A-Z]+)([A-Z][a-z])/g,"$1-$2").replace(/([a-z])([A-Z])/g,"$1-$2").replace(/([0-9])([^0-9])/g,"$1-$2").replace(/([^0-9])([0-9])/g,"$1-$2").toLowerCase();this._attribute=m.attr(`js-${i}`)}getInstanceClassNames(e){const t=Object.getPrototypeOf(e);return t&&"Instance"!==t.instanceClassName?[...this.getInstanceClassNames(t),e.instanceClassName]:[e.instanceClassName]}hasInstanceClassName(e){return this._instanceClassNames.indexOf(e)>-1}introduce(){this.isIntroduced||(this.isIntroduced=!0,e.getModule("stage").parse(document.documentElement,this))}parse(e,t){const s=[];return e.matches&&e.matches(this.selector)&&s.push(e),!t&&e.querySelectorAll&&e.querySelector(this.selector)&&s.push.apply(s,p(e,this.selector)),s}create(e){if(!e.node.matches(this.selector))return;const t=new this.InstanceClass;return this.instances.add(t),t}remove(e){this.instances.remove(e)}dispose(){const e=this.instances.collection;for(let t=e.length-1;t>-1;t--)e[t]._dispose();this.creator=null}get instanceClassName(){return this._instanceClassName}get instanceClassNames(){return this._instanceClassNames}get property(){return this._property}get attribute(){return this._attribute}}class f extends g{constructor(){super("register")}register(t,s,i){const n=new _(t,s,i);return this.add(n),e.isActive&&n.introduce(),n}activate(){for(const e of this.collection)e.introduce()}remove(e){e.dispose(),super.remove(e)}}let E=0;class T{constructor(e,t){t?this.id=t:(E++,this.id=E),this.node=e,this.attributeNames=[],this.instances=[],this._children=[],this._parent=null,this._projects=[]}get proxy(){const e=this;if(!this._proxy){this._proxy={id:this.id,get parent(){return e.parent?e.parent.proxy:null},get children(){return e.children.map((e=>e.proxy))}};for(const e of this.instances)this._proxy[e.registration.property]=e.proxy}return this._proxy}get html(){if(!this.node||!this.node.outerHTML)return"";const e=this.node.outerHTML.indexOf(">");return this.node.outerHTML.substring(0,e+1)}project(e){-1===this._projects.indexOf(e)&&this._projects.push(e)}populate(){const e=this._projects.slice();this._projects.length=0;for(const t of e)this.create(t)}create(e){if(this.hasInstance(e.instanceClassName))return;h.debug(`create instance of ${e.instanceClassName} on element [${this.id}]`);const t=e.create(this);this.instances.push(t),t._config(this,e),this._proxy&&(this._proxy[e.property]=t.proxy)}remove(e){const t=this.instances.indexOf(e);t>-1&&this.instances.splice(t,1),this._proxy&&delete this._proxy[e.registration.property]}get parent(){return this._parent}get ascendants(){return[this.parent,...this.parent.ascendants]}get children(){return this._children}get descendants(){const e=[...this._children];return this._children.forEach((t=>e.push(...t.descendants))),e}addChild(e,t){return this._children.indexOf(e)>-1?null:(e._parent=this,!isNaN(t)&&t>-1&&t=0;e--){const t=this.instances[e];t&&t._dispose()}this.instances.length=0,e.remove("stage",this),this.parent.removeChild(this),this._children.length=0,h.debug(`remove element [${this.id}] ${this.html}`)}prepare(e){-1===this.attributeNames.indexOf(e)&&this.attributeNames.push(e)}examine(){const e=this.attributeNames.slice();this.attributeNames.length=0;for(let t=this.instances.length-1;t>-1;t--)this.instances[t].examine(e)}}const A={CLICK:m.emission("root","click"),KEYDOWN:m.emission("root","keydown"),KEYUP:m.emission("root","keyup")},S={TAB:{id:"tab",value:9},ESCAPE:{id:"escape",value:27},END:{id:"end",value:35},HOME:{id:"home",value:36},LEFT:{id:"left",value:37},UP:{id:"up",value:38},RIGHT:{id:"right",value:39},DOWN:{id:"down",value:40}},v=e=>Object.values(S).filter((t=>t.value===e))[0];class y extends T{constructor(){super(document.documentElement,"root"),this.node.setAttribute(m.attr("js"),!0),this.listen()}listen(){document.documentElement.addEventListener("click",this.click.bind(this),{capture:!0}),document.documentElement.addEventListener("keydown",this.keydown.bind(this),{capture:!0}),document.documentElement.addEventListener("keyup",this.keyup.bind(this),{capture:!0})}click(e){this.emit(A.CLICK,e.target)}keydown(e){this.emit(A.KEYDOWN,v(e.keyCode))}keyup(e){this.emit(A.KEYUP,v(e.keyCode))}}class C extends g{constructor(){super("stage"),this.root=new y,super.add(this.root),this.observer=new MutationObserver(this.mutate.bind(this)),this.modifications=[],this.willModify=!1,this.modifying=this.modify.bind(this)}hasElement(e){for(const t of this.collection)if(t.node===e)return!0;return!1}getElement(e){for(const t of this.collection)if(t.node===e)return t;const t=new T(e);return this.add(t),h.debug(`add element [${t.id}] ${t.html}`),t}getProxy(e){if(!this.hasElement(e))return null;return this.getElement(e).proxy}add(e){super.add(e),this.put(e,this.root)}put(e,t){let s=0;for(let i=t.children.length-1;i>-1;i--){const n=t.children[i],r=e.node.compareDocumentPosition(n.node);if(r&Node.DOCUMENT_POSITION_CONTAINS)return void this.put(e,n);if(r&Node.DOCUMENT_POSITION_CONTAINED_BY)t.removeChild(n),e.addChild(n,0);else if(r&Node.DOCUMENT_POSITION_PRECEDING){s=i+1;break}}t.addChild(e,s)}activate(){this.observer.observe(document.documentElement,{childList:!0,subtree:!0,attributes:!0})}deactivate(){this.observer.disconnect()}mutate(e){const t=[];e.forEach((e=>{switch(e.type){case"childList":e.removedNodes.forEach((e=>this.dispose(e))),e.addedNodes.forEach((e=>this.parse(e)));break;case"attributes":if(this.hasElement(e.target)){const s=this.getElement(e.target);s.prepare(e.attributeName),-1===t.indexOf(s)&&t.push(s);for(const e of s.descendants)-1===t.indexOf(e)&&t.push(e)}-1===this.modifications.indexOf(e.target)&&this.modifications.push(e.target)}})),t.forEach((e=>e.examine())),this.modifications.length&&!this.willModify&&(this.willModify=!0,window.requestAnimationFrame(this.modifying))}modify(){this.willModify=!1;const e=this.modifications.slice();this.modifications.length=0;for(const t of e)document.documentElement.contains(t)&&this.parse(t)}dispose(e){const t=[];this.forEach((s=>{e.contains(s.node)&&t.push(s)}));for(const e of t)e.dispose(),this.remove(e)}parse(t,s,i){const n=s?[s]:e.getModule("register").collection,r=[];for(const e of n){const s=e.parse(t,i);for(const t of s){const s=this.getElement(t);s.project(e),-1===r.indexOf(s)&&r.push(s)}}for(const e of r)e.populate()}}class D extends g{constructor(){super("render"),this.rendering=this.render.bind(this),this.nexts=new u}activate(){window.requestAnimationFrame(this.rendering)}request(e){this.nexts.add(e)}render(){if(!e.isActive)return;if(window.requestAnimationFrame(this.rendering),this.forEach((e=>e.render())),!this.nexts.length)return;const t=this.nexts.clone();this.nexts.clear(),t.forEach((e=>e.next()))}}class w extends g{constructor(){super("resize"),this.requireResize=!1,this.resizing=this.resize.bind(this);const e=this.request.bind(this);document.fonts&&document.fonts.ready.then(e),window.addEventListener("resize",e),window.addEventListener("orientationchange",e)}activate(){this.request()}request(){this.requireResize||(this.requireResize=!0,window.requestAnimationFrame(this.resizing))}resize(){this.requireResize&&(this.forEach((e=>e.resize())),this.requireResize=!1)}}class L extends g{constructor(){super("lock"),this._isLocked=!1,this._scrollY=0,this.onPopulate=this.lock.bind(this),this.onEmpty=this.unlock.bind(this)}get isLocked(){return this._isLocked}lock(){if(!this._isLocked){this._isLocked=!0,this._scrollY=window.scrollY;const e=window.innerWidth-document.documentElement.clientWidth;document.documentElement.setAttribute(m.attr("scrolling"),"false"),document.body.style.top=-this._scrollY+"px",this.behavior=getComputedStyle(document.documentElement).getPropertyValue("scroll-behavior"),"smooth"===this.behavior&&(document.documentElement.style.scrollBehavior="auto"),e>0&&document.documentElement.style.setProperty("--scrollbar-width",`${e}px`)}}unlock(){this._isLocked&&(this._isLocked=!1,document.documentElement.removeAttribute(m.attr("scrolling")),document.body.style.top="",window.scrollTo(0,this._scrollY),"smooth"===this.behavior&&document.documentElement.style.removeProperty("scroll-behavior"),document.documentElement.style.removeProperty("--scrollbar-width"))}move(e){this._isLocked?(this._scrollY+=e,document.body.style.top=-this._scrollY+"px"):window.scrollTo(0,window.scrollY+e)}}class N extends g{constructor(){super("load"),this.loading=this.load.bind(this)}activate(){window.addEventListener("load",this.loading)}load(){this.forEach((e=>e.load()))}}const O=["Marianne","Spectral"];class I extends g{constructor(){super("font-swap"),this.swapping=this.swap.bind(this)}activate(){document.fonts&&document.fonts.addEventListener("loadingdone",this.swapping)}swap(){const e=O.filter((e=>document.fonts.check(`16px ${e}`)));this.forEach((t=>t.swapFont(e)))}}class R extends g{constructor(){super("mouse-move"),this.requireMove=!1,this._isMoving=!1,this.moving=this.move.bind(this),this.requesting=this.request.bind(this),this.onPopulate=this.listen.bind(this),this.onEmpty=this.unlisten.bind(this)}listen(){this._isMoving||(this._isMoving=!0,this.requireMove=!1,document.documentElement.addEventListener("mousemove",this.requesting))}unlisten(){this._isMoving&&(this._isMoving=!1,this.requireMove=!1,document.documentElement.removeEventListener("mousemove",this.requesting))}request(e){this._isMoving&&(this.point={x:e.clientX,y:e.clientY},this.requireMove||(this.requireMove=!0,window.requestAnimationFrame(this.moving)))}move(){this.requireMove&&(this.forEach((e=>e.mouseMove(this.point))),this.requireMove=!1)}}class x extends g{constructor(){super("hash"),this.handling=this.handle.bind(this),this.getLocationHash()}activate(){window.addEventListener("hashchange",this.handling)}deactivate(){window.removeEventListener("hashchange",this.handling)}_sanitize(e){return"#"===e.charAt(0)?e.substring(1):e}set hash(e){const t=this._sanitize(e);this._hash!==t&&(window.location.hash=t)}get hash(){return this._hash}getLocationHash(){const e=window.location.hash;this._hash=this._sanitize(e)}handle(e){this.getLocationHash(),this.forEach((t=>t.handleHash(this._hash,e)))}}const k=new class{constructor(){e.create(f),e.create(C),e.create(D),e.create(w),e.create(L),e.create(N),e.create(I),e.create(R),e.create(x);const t=e.getModule("register");this.register=t.register.bind(t)}get isActive(){return e.isActive}start(){h.debug("START"),e.isActive=!0}stop(){h.debug("STOP"),e.isActive=!1}};const P=e=>{switch(!0){case e.hover:return"-hover";case e.active:return"-active";default:return""}},M=new class{getColor(e,t,s,i={}){const n=`--${e}-${t}-${s}${P(i)}`;return getComputedStyle(document.documentElement).getPropertyValue(n).trim()||null}},H=e=>"."===e.charAt(0)?e.substr(1):e,$=e=>e.className?e.className.split(" "):[],G=(e,t,s)=>{t=H(t);const i=$(e),n=i.indexOf(t);!0===s?n>-1&&i.splice(n,1):-1===n&&i.push(t),e.className=i.join(" ")},B=(e,t)=>G(e,t),U=(e,t)=>G(e,t,!0),q=(e,t)=>$(e).indexOf(H(t))>-1,F=['[tabindex]:not([tabindex="-1"])',"a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])","audio[controls]","video[controls]",'[contenteditable]:not([contenteditable="false"])',"details>summary:first-of-type","details","iframe"].join(),z=e=>e.querySelectorAll(F);let j=0;const W=e=>{if(!document.getElementById(e))return e;let t=!0;const s=e;for(;t;)j++,e=`${s}-${j}`,t=document.getElementById(e);return e},K={};K.addClass=B,K.hasClass=q,K.removeClass=U,K.queryParentSelector=b,K.querySelectorAllArray=p,K.queryActions=z,K.uniqueId=W;const Y={supportLocalStorage:()=>{try{return"localStorage"in window&&null!==window.localStorage}catch(e){return!1}},supportAspectRatio:()=>!!window.CSS&&CSS.supports("aspect-ratio: 16 / 9")},V={NONE:m.selector("transition-none")},Q={};Q.TransitionSelector=V;const J=(e,...t)=>(t.forEach((t=>{const s=Object.keys(t).reduce(((e,s)=>(e[s]=Object.getOwnPropertyDescriptor(t,s),e)),{});Object.getOwnPropertySymbols(t).forEach((e=>{const i=Object.getOwnPropertyDescriptor(t,e);i.enumerable&&(s[e]=i)})),Object.defineProperties(e,s)})),e),X={};X.completeAssign=J;const Z={},ee={};Object.defineProperty(ee,"isLegacy",{get:()=>e.isLegacy}),ee.setLegacy=()=>{e.isLegacy=!0},Z.legacy=ee,Z.dom=K,Z.support=Y,Z.motion=Q,Z.property=X,Z.ns=m,Z.register=k.register,Z.state=e,Z.query=(e=>{if(e&&e.search){const e=new URLSearchParams(window.location.search).entries();return Object.fromEntries(e)}return null})(window.location),Object.defineProperty(Z,"preventManipulation",{get:()=>d.preventManipulation}),Object.defineProperty(Z,"stage",{get:()=>e.getModule("stage")});const te=t=>e.getModule("stage").getProxy(t);te.version=n,te.prefix=t,te.organisation=i,te.Modes=l,Object.defineProperty(te,"mode",{set:e=>{d.mode=e},get:()=>d.mode}),te.internals=Z,te.version=n,te.start=k.start,te.stop=k.stop,te.inspector=h,te.colors=M;const se=window[s];te.internals.configuration=se,d.configure(se,te.start,te.internals.query),window[s]=te;class ie{constructor(){this.emissions={}}add(e,t){if("function"!=typeof t)throw new Error("closure must be a function");this.emissions[e]||(this.emissions[e]=[]),this.emissions[e].push(t)}remove(e,t){if(this.emissions[e])if(t){const s=this.emissions[e].indexOf(t);s>-1&&this.emissions[e].splice(s)}else delete this.emissions[e]}emit(e,t){if(!this.emissions[e])return[];const s=[];for(const i of this.emissions[e])i&&s.push(i(t));return s}dispose(){this.emissions=null}}class ne{constructor(e,t){this.id=e,this.minWidth=t}test(){return window.matchMedia(`(min-width: ${this.minWidth}em)`).matches}}const re={XS:new ne("xs",0),SM:new ne("sm",36),MD:new ne("md",48),LG:new ne("lg",62),XL:new ne("xl",78)};class oe{constructor(e=!0){this.jsAttribute=e,this._isRendering=!1,this._isResizing=!1,this._isScrollLocked=!1,this._isLoading=!1,this._isSwappingFont=!1,this._isEnabled=!0,this._isDisposed=!1,this._listeners={},this.handlingClick=this.handleClick.bind(this),this._hashes=[],this._hash="",this._keyListenerTypes=[],this._keys=[],this.handlingKey=this.handleKey.bind(this),this._emitter=new ie,this._ascent=new ie,this._descent=new ie,this._registrations=[],this._nexts=[]}static get instanceClassName(){return"Instance"}_config(e,t){this.element=e,this.registration=t,this.node=e.node,this.id=e.node.id,this.jsAttribute&&this.setAttribute(t.attribute,!0),this.init()}init(){}get proxy(){const e=this;return J({render:()=>e.render(),resize:()=>e.resize()},{get node(){return this.node},get isEnabled(){return e.isEnabled},set isEnabled(t){e.isEnabled=t}})}log(...e){e.unshift(`${this.registration.instanceClassName} #${this.id} - `),h.log.apply(h,e)}debug(...e){e.unshift(`${this.registration.instanceClassName} #${this.id} - `),h.debug.apply(h,e)}info(...e){e.unshift(`${this.registration.instanceClassName} #${this.id} - `),h.info.apply(h,e)}warn(...e){e.unshift(`${this.registration.instanceClassName} #${this.id} - `),h.warn.apply(h,e)}error(...e){e.unshift(`${this.registration.instanceClassName} #${this.id} - `),h.error.apply(h,e)}register(t,s){const i=e.getModule("register").register(t,s,this);this._registrations.push(i)}getRegisteredInstances(e){for(const t of this._registrations)if(t.hasInstanceClassName(e))return t.instances.collection;return[]}dispatch(e,t,s,i){const n=new CustomEvent(e,{detail:t,bubble:!0===s,cancelable:!0===i});this.node.dispatchEvent(n)}listen(e,t,s){this._listeners[e]||(this._listeners[e]=[]);const i=this._listeners[e],n=new he(this.node,e,t,s);i.push(n),n.listen()}unlisten(e,t,s){if(!e){for(const e in this._listeners)this.unlisten(e);return}const i=this._listeners[e];if(!i)return;if(!t)return void i.forEach((t=>this.unlisten(e,t.closure)));const n=i.filter((e=>e.closure===t&&e.matchOptions(s)));n.forEach((e=>e.unlisten())),this._listeners[e]=i.filter((e=>-1===n.indexOf(e)))}listenClick(e){this.listen("click",this.handlingClick,e)}unlistenClick(e){this.unlisten("click",this.handlingClick,e)}handleClick(e){}set hash(t){e.getModule("hash").hash=t}get hash(){return e.getModule("hash").hash}listenHash(t,s){0===this._hashes.length&&e.add("hash",this);const i=new ce(t,s);this._hashes=this._hashes.filter((e=>e.hash!==t)),this._hashes.push(i)}unlistenHash(t){this._hashes=this._hashes.filter((e=>e.hash!==t)),0===this._hashes.length&&e.remove("hash",this)}handleHash(e,t){for(const s of this._hashes)s.handle(e,t)}listenKey(e,t,s=!1,i=!1,n="down"){-1===this._keyListenerTypes.indexOf(n)&&(this.listen(`key${n}`,this.handlingKey),this._keyListenerTypes.push(n)),this._keys.push(new ae(n,e,t,s,i))}unlistenKey(e,t){this._keys=this._keys.filter((s=>s.code!==e||s.closure!==t)),this._keyListenerTypes.forEach((e=>{this._keys.some((t=>t.type===e))||this.unlisten(`key${e}`,this.handlingKey)}))}handleKey(e){for(const t of this._keys)t.handle(e)}get isEnabled(){return this._isEnabled}set isEnabled(e){this._isEnabled=e}get isRendering(){return this._isRendering}set isRendering(t){this._isRendering!==t&&(t?e.add("render",this):e.remove("render",this),this._isRendering=t)}render(){}request(t){this._nexts.push(t),e.getModule("render").request(this)}next(){const e=this._nexts.slice();this._nexts.length=0;for(const t of e)t&&t()}get isResizing(){return this._isResizing}set isResizing(t){this._isResizing!==t&&(t?(e.add("resize",this),this.resize()):e.remove("resize",this),this._isResizing=t)}resize(){}isBreakpoint(e){return!0==("string"==typeof e)?re[e.toUpperCase()].test():e.test()}get isScrollLocked(){return this._isScrollLocked}set isScrollLocked(t){this._isScrollLocked!==t&&(t?e.add("lock",this):e.remove("lock",this),this._isScrollLocked=t)}get isLoading(){return this._isLoading}set isLoading(t){this._isLoading!==t&&(t?e.add("load",this):e.remove("load",this),this._isLoading=t)}load(){}get isSwappingFont(){return this._isSwappingFont}set isSwappingFont(t){this._isSwappingFont!==t&&(t?e.add("font-swap",this):e.remove("font-swap",this),this._isSwappingFont=t)}swapFont(){}get isMouseMoving(){return this._isMouseMoving}set isMouseMoving(t){this._isMouseMoving!==t&&(t?e.add("mouse-move",this):e.remove("mouse-move",this),this._isMouseMoving=t)}mouseMove(e){}examine(e){this.node.matches(this.registration.selector)?this.mutate(e):this._dispose()}mutate(e){}retrieveNodeId(e,t){if(e.id)return e.id;const s=W(`${this.id}-${t}`);return this.warn(`add id '${s}' to ${t}`),e.setAttribute("id",s),s}get isDisposed(){return this._isDisposed}_dispose(){this.debug(`dispose instance of ${this.registration.instanceClassName} on element [${this.element.id}]`),this.removeAttribute(this.registration.attribute),this.unlisten(),this._hashes=null,this._keys=null,this.isRendering=!1,this.isResizing=!1,this._nexts=null,e.getModule("render").nexts.remove(this),this.isScrollLocked=!1,this.isLoading=!1,this.isSwappingFont=!1,this._emitter.dispose(),this._emitter=null,this._ascent.dispose(),this._ascent=null,this._descent.dispose(),this._descent=null,this.element.remove(this);for(const t of this._registrations)e.remove("register",t);this._registrations=null,this.registration.remove(this),this._isDisposed=!0,this.dispose()}dispose(){}emit(e,t){return this.element.emit(e,t)}addEmission(e,t){this._emitter.add(e,t)}removeEmission(e,t){this._emitter.remove(e,t)}ascend(e,t){return this.element.ascend(e,t)}addAscent(e,t){this._ascent.add(e,t)}removeAscent(e,t){this._ascent.remove(e,t)}descend(e,t){return this.element.descend(e,t)}addDescent(e,t){this._descent.add(e,t)}removeDescent(e,t){this._descent.remove(e,t)}get style(){return this.node.style}addClass(e){B(this.node,e)}removeClass(e){U(this.node,e)}hasClass(e){return q(this.node,e)}get classNames(){return $(this.node)}remove(){this.node.parentNode.removeChild(this.node)}setAttribute(e,t){this.node.setAttribute(e,t)}getAttribute(e){return this.node.getAttribute(e)}hasAttribute(e){return this.node.hasAttribute(e)}removeAttribute(e){this.node.removeAttribute(e)}setProperty(e,t){this.node.style.setProperty(e,t)}removeProperty(e){this.node.style.removeProperty(e)}focus(){this.node.focus()}blur(){this.node.blur()}focusClosest(){const e=this._focusClosest(this.node.parentNode);e&&e.focus()}_focusClosest(e){if(!e)return null;const t=[...z(e)];if(t.length<=1)return this._focusClosest(e.parentNode);{const e=t.indexOf(this.node);return t[e+(ewindow.innerHeight&&s.move(t.bottom-window.innerHeight+50)}matches(e){return this.node.matches(e)}querySelector(e){return this.node.querySelector(e)}querySelectorAll(e){return p(this.node,e)}queryParentSelector(e){return b(this.node,e)}getRect(){const e=this.node.getBoundingClientRect();return e.center=e.left+.5*e.width,e.middle=e.top+.5*e.height,e}get isLegacy(){return e.isLegacy}}class ae{constructor(e,t,s,i,n){this.type=e,this.eventType=`key${e}`,this.keyCode=t,this.closure=s,this.preventDefault=!0===i,this.stopPropagation=!0===n}handle(e){e.type===this.eventType&&e.keyCode===this.keyCode.value&&(this.closure(e),this.preventDefault&&e.preventDefault(),this.stopPropagation&&e.stopPropagation())}}class he{constructor(e,t,s,i){this._node=e,this._type=t,this._closure=s,this._options=i}get closure(){return this._closure}listen(){this._node.addEventListener(this._type,this._closure,this._options)}matchOptions(e=null){switch(!0){case null===e:case"boolean"==typeof this._options&&"boolean"==typeof e&&this._options===e:return!0;case Object.keys(this._options).length!==Object.keys(e).length:return!1;case Object.keys(e).every((t=>this._options[t]===e[t])):return!0}return!1}unlisten(){this._node.removeEventListener(this._type,this._closure,this._options)}}class ce{constructor(e,t){this.hash=e,this.add=t}handle(e,t){this.hash===e&&this.add(t)}}const le={DISCLOSE:m.event("disclose"),CONCEAL:m.event("conceal")},de={RESET:m.emission("disclosure","reset"),ADDED:m.emission("disclosure","added"),RETRIEVE:m.emission("disclosure","retrieve"),REMOVED:m.emission("disclosure","removed"),GROUP:m.emission("disclosure","group"),UNGROUP:m.emission("disclosure","ungroup"),SPOTLIGHT:m.emission("disclosure","spotlight")};class ue extends oe{constructor(e,t,s,i){super(),this.type=e,this._selector=t,this.DisclosureButtonInstanceClass=s,this.disclosuresGroupInstanceClassName=i,this.modifier=this._selector+"--"+this.type.id,this._isPristine=!0,this._isRetrievingPrimaries=!1,this._hasRetrieved=!1,this._primaryButtons=[]}static get instanceClassName(){return"Disclosure"}init(){this.addDescent(de.RESET,this.reset.bind(this)),this.addDescent(de.GROUP,this.update.bind(this)),this.addDescent(de.UNGROUP,this.update.bind(this)),this.addAscent(de.SPOTLIGHT,this.disclose.bind(this)),this.register(`[aria-controls="${this.id}"]`,this.DisclosureButtonInstanceClass),this.ascend(de.ADDED),this.listenHash(this.id,this._spotlight.bind(this)),this.update()}get isEnabled(){return super.isEnabled}set isEnabled(e){this.isEnabled!==e&&(super.isEnabled=e,e?this.ascend(de.ADDED):this.ascend(de.REMOVED))}get isPristine(){return this._isPristine}get proxy(){const e=this,t=Object.assign(super.proxy,{disclose:e.disclose.bind(e),focus:e.focus.bind(e)});this.type.canConceal&&(t.conceal=e.conceal.bind(e));return J(t,{get buttons(){return e.buttons.map((e=>e.proxy))},get group(){const t=e.group;return t?t.proxy:null},get isDisclosed(){return e.isDisclosed}})}get buttons(){return this.getRegisteredInstances(this.DisclosureButtonInstanceClass.instanceClassName)}update(){this.getGroup(),this.retrievePrimaries()}getGroup(){if(!this.disclosuresGroupInstanceClassName)return void(this._group=null);const e=this.element.getAscendantInstance(this.disclosuresGroupInstanceClassName,this.constructor.instanceClassName);e&&e.validate(this)?this._group=e:this._group=null}get group(){return this._group}disclose(e){return!(!0===this.isDisclosed||!this.isEnabled)&&(this._isPristine=!1,this.isDisclosed=!0,!e&&this.group&&(this.group.current=this),!0)}conceal(e,t=!0){return!1!==this.isDisclosed&&(!(!this.type.canConceal&&this.group&&this.group.current===this)&&(this.isDisclosed=!1,!e&&this.group&&this.group.current===this&&(this.group.current=null),t||this.focus(),this._isPristine||this.descend(de.RESET),!0))}get isDisclosed(){return this._isDisclosed}set isDisclosed(e){if(this._isDisclosed!==e&&(this.isEnabled||!0!==e)){this.dispatch(e?le.DISCLOSE:le.CONCEAL,this.type),this._isDisclosed=e,e?this.addClass(this.modifier):this.removeClass(this.modifier);for(let t=0;te.isInitiallyDisclosed))}hasRetrieved(){return this._hasRetrieved}reset(){}toggle(e){if(this.type.canConceal)switch(!0){case!e:case this.isDisclosed:this.conceal(!1,!1);break;default:this.disclose()}else this.disclose()}get buttonHasFocus(){return this.buttons.some((e=>e.hasFocus))}get hasFocus(){return!!super.hasFocus||(!!this.buttonHasFocus||this.querySelectorAll(":focus").length>0)}focus(){this._primaryButtons.length>0&&this._primaryButtons[0].focus()}get primaryButtons(){return this._primaryButtons}retrievePrimaries(){this._isRetrievingPrimaries||(this._isRetrievingPrimaries=!0,this.request(this._retrievePrimaries.bind(this)))}_retrievePrimaries(){if(this._isRetrievingPrimaries=!1,this._primaryButtons=this._electPrimaries(this.buttons),!this._hasRetrieved&&0!==this._primaryButtons.length)if(this.retrieved(),this._hasRetrieved=!0,this.applyAbility(!0),this.group)this.group.retrieve();else if(this._isPristine&&this.isEnabled&&!this.group)switch(!0){case this.hash===this.id:this._spotlight();break;case this.isInitiallyDisclosed:this.disclose()}}retrieved(){}_spotlight(){this.disclose(),this.request((()=>{this.ascend(de.SPOTLIGHT)}))}_electPrimaries(e){return e.filter((e=>e.canDisclose&&!this.node.contains(e.node)))}applyAbility(e=!1){const t=!this._primaryButtons.every((e=>e.isDisabled));this.isEnabled!==t&&(this.isEnabled=t,e||(!this.isEnabled&&this.isDisclosed&&(this.group?this.ascend(de.REMOVED):this.type.canConceal&&this.conceal()),this.isEnabled&&(this.group&&this.ascend(de.ADDED),this.hash===this.id&&this._spotlight())))}dispose(){this._group=null,this._primaryButtons=null,super.dispose(),this.ascend(de.REMOVED)}}class ge extends oe{constructor(e){super(),this.type=e,this.attributeName=e.ariaState?"aria-"+e.id:m.attr(e.id),this._canDisclose=!1}static get instanceClassName(){return"DisclosureButton"}get isPrimary(){return this.registration.creator.primaryButtons.includes(this)}get canDisclose(){return this._canDisclose}get isDisabled(){return this.type.canDisable&&this.hasAttribute("disabled")}init(){this._canDisclose=this.hasAttribute(this.attributeName),this._isInitiallyDisclosed=this.isDisclosed,this._isContained=this.registration.creator.node.contains(this.node),this.controlsId=this.getAttribute("aria-controls"),this.registration.creator.retrievePrimaries(),this.listenClick()}get proxy(){return Object.assign(super.proxy,{focus:this.focus.bind(this)})}handleClick(e){this.registration.creator&&this.registration.creator.toggle(this.canDisclose)}mutate(e){this._canDisclose=this.hasAttribute(this.attributeName),this.registration.creator.applyAbility(),!this._isApplying&&this.isPrimary&&e.indexOf(this.attributeName)>-1&&this.registration.creator&&(this.isDisclosed?this.registration.creator.disclose():this.type.canConceal&&this.registration.creator.conceal())}apply(e){this.canDisclose&&(this._isApplying=!0,this.setAttribute(this.attributeName,e),this.request((()=>{this._isApplying=!1})))}get isDisclosed(){return"true"===this.getAttribute(this.attributeName)}get isInitiallyDisclosed(){return this._isInitiallyDisclosed}focus(){super.focus(),this.scrollIntoView()}measure(e){const t=this.rect;this._dx=e.x-t.x,this._dy=e.y-t.y}get dx(){return this._dx}get dy(){return this._dy}}class me extends oe{constructor(e,t){super(t),this.disclosureInstanceClassName=e,this._members=[],this._index=-1,this._isRetrieving=!1,this._hasRetrieved=!1}static get instanceClassName(){return"DisclosuresGroup"}init(){this.addAscent(de.ADDED,this.update.bind(this)),this.addAscent(de.RETRIEVE,this.retrieve.bind(this)),this.addAscent(de.REMOVED,this.update.bind(this)),this.descend(de.GROUP),this.update()}get proxy(){const e=this,t={set index(t){e.index=t},get index(){return e.index},get length(){return e.length},get current(){const t=e.current;return t?t.proxy:null},get members(){return e.members.map((e=>e.proxy))},get hasFocus(){return e.hasFocus}};return J(super.proxy,t)}validate(e){return!0}getMembers(){const e=this.element.getDescendantInstances(this.disclosureInstanceClassName,this.constructor.instanceClassName,!0);this._members=e.filter(this.validate.bind(this)).filter((e=>e.isEnabled));e.filter((e=>!this._members.includes(e))).forEach((e=>e.conceal()))}retrieve(e=!1){this._isRetrieving||this._hasRetrieved&&!e||(this._isRetrieving=!0,this.request(this._retrieve.bind(this)))}_retrieve(){if(this.getMembers(),this._isRetrieving=!1,this._hasRetrieved=!0,this.hash)for(let e=0;e{this.ascend(de.SPOTLIGHT)})),e}for(let e=0;e=this.length||e===this._index)){this._index=e;for(let t=0;tt.map((t=>m.selector(`${e}--${t}`))).join(","),we=`${m.selector("responsive-img")}, ${De("responsive-img",Ce)}, ${m.selector("responsive-vid")}, ${De("responsive-vid",["16x9","4x3","1x1"])}`,Le={RATIO:`${m.selector("ratio")}, ${De("ratio",Ce)}, ${we}`},Ne=window[s];const Oe={TOP:m.selector("placement--top"),RIGHT:m.selector("placement--right"),BOTTOM:m.selector("placement--bottom"),LEFT:m.selector("placement--left")},Ie={START:m.selector("placement--start"),CENTER:m.selector("placement--center"),END:m.selector("placement--end")},Re={TOP:"place_top",RIGHT:"place_right",BOTTOM:"place_bottom",LEFT:"place_left"},xe={START:"align_start",CENTER:"align_center",END:"align_end"},ke={AUTO:"placement_auto",MANUAL:"placement_manual"};te.core={Instance:oe,Breakpoints:re,KeyCodes:S,Disclosure:ue,DisclosureButton:ge,DisclosuresGroup:me,DisclosureType:pe,DisclosureEvent:le,DisclosureSelector:be,DisclosureEmission:de,Collapse:class extends ue{constructor(){super(pe.EXPAND,fe.COLLAPSE,_e,"CollapsesGroup")}static get instanceClassName(){return"Collapse"}init(){super.init(),this.listen("transitionend",this.transitionend.bind(this))}transitionend(e){this.removeClass(fe.COLLAPSING),this.isDisclosed||(this.isLegacy?this.style.maxHeight="":this.style.removeProperty("--collapse-max-height"))}unbound(){this.isLegacy?this.style.maxHeight="none":this.style.setProperty("--collapse-max-height","none")}disclose(e){if(!0===this.isDisclosed||!this.isEnabled)return!1;this.unbound(),this.request((()=>{this.addClass(fe.COLLAPSING),this.adjust(),this.request((()=>{super.disclose(e)}))}))}conceal(e,t){if(!1===this.isDisclosed)return!1;this.request((()=>{this.addClass(fe.COLLAPSING),this.adjust(),this.request((()=>{super.conceal(e,t)}))}))}adjust(){this.setProperty("--collapser","none");const e=this.node.offsetHeight;this.setProperty("--collapse",-e+"px"),this.setProperty("--collapser","")}reset(){this.isPristine||(this.isDisclosed=!1)}_electPrimaries(e){const t=this.element.parent.instances.map((e=>e.collapsePrimary)).filter((t=>void 0!==t&&e.indexOf(t)>-1));if(1===t.length)return t;if(1===(e=super._electPrimaries(e)).length)return e;const s=e.filter((e=>e.dy>=0));if(s.length>0&&(e=s),1===e.length)return e;const i=Math.min(...e.map((e=>e.dy))),n=e.filter((e=>e.dy===i));return n.length>0&&(e=n),1===e.length||e.sort(((e,t)=>Math.abs(t.dx)-Math.abs(e.dx))),e}},CollapseButton:_e,CollapsesGroup:class extends me{constructor(){super("Collapse")}static get instanceClassName(){return"CollapsesGroup"}},CollapseSelector:fe,RootSelector:{ROOT:":root"},RootEmission:A,Equisized:class extends oe{static get instanceClassName(){return"Equisized"}init(){this.ascend(Ee.CHANGE)}measure(){return this.isLegacy&&(this.style.width="auto"),this.getRect().width}adjust(e){this.isLegacy&&(this.style.width=`${e}px`)}dispose(){this.ascend(Ee.CHANGE)}},EquisizedEmission:Ee,Toggle:class extends oe{static get instanceClassName(){return"Toggle"}init(){this.pressed="true"===this.pressed,this.listenClick()}handleClick(){this.toggle()}toggle(){this.pressed="true"!==this.pressed}get pressed(){return this.getAttribute("aria-pressed")}set pressed(e){this.setAttribute("aria-pressed",e?"true":"false"),this.dispatch(Te.TOGGLE,e)}get proxy(){const e=this,t=Object.assign(super.proxy,{toggle:e.toggle.bind(e)});return J(t,{get pressed(){return e.pressed},set pressed(t){e.pressed=t}})}},EquisizedsGroup:class extends oe{static get instanceClassName(){return"EquisizedsGroup"}init(){this.isResizing=!0,this.isLoading=!0,this.addAscent(Ee.CHANGE,this.resize.bind(this))}load(){this.resize()}resize(){const e=this.element.getDescendantInstances("Equisized");this.isLegacy||this.style.setProperty("--equisized-width","auto");const t=Math.max(...e.map((e=>e.measure())));this.isLegacy?e.forEach((e=>e.adjust(t))):this.style.setProperty("--equisized-width",`${t}px`)}},InjectSvg:class extends oe{static get instanceClassName(){return"InjectSvg"}init(){this.node&&(this.img=this.node.querySelector("img")),this.isLegacy||this.replace()}get proxy(){const e=this;return Object.assign(super.proxy,{replace:e.replace.bind(e),restore:e.restore.bind(e)})}fetch(){this.img&&(this.imgID=this.img.getAttribute("id"),this.imgClass=this.img.getAttribute("class"),this.imgURL=this.img.getAttribute("src"),fetch(this.imgURL).then((e=>e.text())).then((e=>{const t=(new DOMParser).parseFromString(e,"text/html");this.svg=t.querySelector("svg"),this.svg&&this.replace()})))}replace(){if(!this.svg)return void this.fetch();this.imgID&&void 0!==this.imgID&&this.svg.setAttribute("id",this.imgID);let e=this.imgURL.match(/[ \w-]+\./)[0];e&&(e=e.slice(0,-1),["dark","light","system"].includes(e)&&(this.svg.innerHTML=this.svg.innerHTML.replaceAll('id="artwork-',`id="${e}-artwork-`),this.svg.innerHTML=this.svg.innerHTML.replaceAll('"#artwork-',`"#${e}-artwork-`))),this.imgClass&&void 0!==this.imgClass&&this.svg.setAttribute("class",this.imgClass),this.svg.hasAttribute("xmlns:a")&&this.svg.removeAttribute("xmlns:a"),this.node.setAttribute("data-fr-inject-svg",!0);var t,s;t=this.svg,s={"aria-hidden":!0,focusable:!1},Object.keys(s).forEach((e=>t.setAttribute(e,s[e]))),this.node.replaceChild(this.svg,this.img)}restore(){this.img&&this.svg&&(this.node.setAttribute("data-fr-inject-svg",!1),this.node.replaceChild(this.img,this.svg))}},InjectSvgSelector:Ae,Artwork:class extends oe{static get instanceClassName(){return"Artwork"}init(){this.isLegacy&&this.replace()}get proxy(){return Object.assign(super.proxy,{replace:this.replace.bind(this)})}fetch(){this.xlink=this.node.getAttribute("href");const e=this.xlink.split("#");this.svgUrl=e[0],this.svgName=e[1];const t=new XMLHttpRequest;t.onload=()=>{const e=(new DOMParser).parseFromString(t.responseText,"text/html");this.realSvgContent=e.getElementById(this.svgName),this.realSvgContent&&(this.realSvgContent.classList.add(this.node.classList),this.replace())},t.open("GET",this.svgUrl),t.send()}replace(){this.realSvgContent?this.node.parentNode.replaceChild(this.realSvgContent,this.node):this.fetch()}},ArtworkSelector:Se,AssessFile:class extends oe{static get instanceClassName(){return"AssessFile"}init(){this.lang=this.getLang(this.node),this.href=this.getAttribute("href"),this.hreflang=this.getAttribute("hreflang"),this.file={},this.gather(),this.addAscent(ye.ADDED,this.update.bind(this)),this.addDescent(ye.ADDED,this.update.bind(this))}getFileLength(){void 0!==this.href?fetch(this.href,{method:"HEAD",mode:"cors"}).then((e=>{this.length=e.headers.get("content-length")||-1,-1===this.length&&h.warn("File size unknown: "+this.href+'\nUnable to get HTTP header: "content-length"'),this.gather()})):this.length=-1}mutate(e){-1!==e.indexOf("href")&&(this.href=this.getAttribute("href"),this.getFileLength()),-1!==e.indexOf("hreflang")&&(this.hreflang=this.getAttribute("hreflang"),this.gather())}gather(){if(this.isLegacy&&(this.length=-1),this.length){if(this.details=[],this.href){const e=this.parseExtension(this.href);e&&this.details.push(e.toUpperCase())}-1!==this.length&&this.details.push(this.bytesToSize(this.length)),this.hreflang&&this.details.push(this.getLangDisplayName(this.hreflang)),this.update()}else this.getFileLength()}update(){this.details&&(this.descend(ye.UPDATE,this.details),this.ascend(ye.UPDATE,this.details))}getLang(e){return e.lang?e.lang:document.documentElement===e?window.navigator.language:this.getLang(e.parentElement)}parseExtension(e){return e.match(/\.(\w{1,9})(?:$|[?#])/)[0].replace(".","")}getLangDisplayName(e){if(this.isLegacy)return e;const t=new Intl.DisplayNames([this.lang],{type:"language"}).of(e);return t.charAt(0).toUpperCase()+t.slice(1)}bytesToSize(e){if(-1===e)return null;let t=["octets","ko","Mo","Go","To"];"bytes"===this.getAttribute(m.attr("assess-file"))&&(t=["bytes","KB","MB","GB","TB"]);const s=parseInt(Math.floor(Math.log(e)/Math.log(1e3)),10);if(0===s)return`${e} ${t[s]}`;const i=e/1e3**s,n=Math.round(100*(i+Number.EPSILON))/100;return`${String(n).replace(".",",")} ${t[s]}`}},AssessDetail:class extends oe{static get instanceClassName(){return"AssessDetail"}init(){this.addDescent(ye.UPDATE,this.update.bind(this)),this.ascend(ye.ADDED)}update(e){this.node.innerHTML=e.join(" - ")}},AssessEmission:ye,AssessSelector:ve,Ratio:class extends oe{static get instanceClassName(){return"Ratio"}init(){if(!Ne.internals.support.supportAspectRatio()){this.ratio=16/9;for(const e in this.classNames)if(this.registration.selector.indexOf(this.classNames[e])>0){const t=this.classNames[e].split("ratio-");t[1]&&(this.ratio=t[1].split("x")[0]/t[1].split("x")[1])}this.isRendering=!0,this.update()}}render(){this.getRect().width!==this.currentWidth&&this.update()}update(){this.currentWidth=this.getRect().width,this.style.height=this.currentWidth/this.ratio+"px"}},RatioSelector:Le,Placement:class extends oe{constructor(e=ke.AUTO,t=[Re.BOTTOM,Re.TOP,Re.LEFT,Re.RIGHT],s=[xe.CENTER,xe.START,xe.END],i=16){super(),this._mode=e,this._places=t,this._aligns=s,this._safeAreaMargin=i,this._isShown=!1}static get instanceClassName(){return"Placement"}init(){this.isResizing=!0}get proxy(){const e=this,t=Object.assign(super.proxy,{show:e.show.bind(e),hide:e.hide.bind(e)});return J(t,{get mode(){return e.mode},set mode(t){e.mode=t},get place(){return e.place},set place(t){e.place=t},get align(){return e.align},set align(t){e.align=t},get isShown(){return e.isShown},set isShown(t){e.isShown=t}})}get mode(){return this._mode}set mode(e){this._mode=e}get place(){return this._place}set place(e){if(this._place!==e){switch(this._place){case Re.TOP:this.removeClass(Oe.TOP);break;case Re.RIGHT:this.removeClass(Oe.RIGHT);break;case Re.BOTTOM:this.removeClass(Oe.BOTTOM);break;case Re.LEFT:this.removeClass(Oe.LEFT)}switch(this._place=e,this._place){case Re.TOP:this.addClass(Oe.TOP);break;case Re.RIGHT:this.addClass(Oe.RIGHT);break;case Re.BOTTOM:this.addClass(Oe.BOTTOM);break;case Re.LEFT:this.addClass(Oe.LEFT)}}}get align(){return this._align}set align(e){if(this._align!==e){switch(this._align){case xe.START:this.removeClass(Ie.START);break;case xe.CENTER:this.removeClass(Ie.CENTER);break;case xe.END:this.removeClass(Ie.END)}switch(this._align=e,this._align){case xe.START:this.addClass(Ie.START);break;case xe.CENTER:this.addClass(Ie.CENTER);break;case xe.END:this.addClass(Ie.END)}}}show(){this.isShown=!0}hide(){this.isShown=!1}get isShown(){return this._isShown}set isShown(e){this._isShown!==e&&this.isEnabled&&(this.isRendering=e,this._isShown=e)}setReferent(e){this._referent=e}resize(){this.safeArea={top:this._safeAreaMargin,right:window.innerWidth-this._safeAreaMargin,bottom:window.innerHeight-this._safeAreaMargin,left:this._safeAreaMargin,center:.5*window.innerWidth,middle:.5*window.innerHeight}}render(){if(!this._referent)return;if(this.rect=this.getRect(),this.referentRect=this._referent.getRect(),this.mode===ke.AUTO)switch(this.place=this.getPlace(),this.place){case Re.TOP:case Re.BOTTOM:this.align=this.getHorizontalAlign();break;case Re.LEFT:case Re.RIGHT:this.align=this.getVerticalAlign()}let e,t;switch(this.place){case Re.TOP:t=this.referentRect.top-this.rect.height;break;case Re.RIGHT:e=this.referentRect.right;break;case Re.BOTTOM:t=this.referentRect.bottom;break;case Re.LEFT:e=this.referentRect.left-this.rect.width}switch(this.place){case Re.TOP:case Re.BOTTOM:switch(this.align){case xe.CENTER:e=this.referentRect.center-.5*this.rect.width;break;case xe.START:e=this.referentRect.left;break;case xe.END:e=this.referentRect.right-this.rect.width}break;case Re.RIGHT:case Re.LEFT:switch(this.align){case xe.CENTER:t=this.referentRect.middle-.5*this.rect.height;break;case xe.START:t=this.referentRect.top;break;case xe.END:t=this.referentRect.bottom-this.rect.height}}this._x===e&&this._y===t||(this._x=e+.5|0,this._y=t+.5|0,this.node.style.transform=`translate(${this._x}px,${this._y}px)`)}getPlace(){for(const e of this._places)switch(e){case Re.TOP:if(this.referentRect.top-this.rect.height>this.safeArea.top)return Re.TOP;break;case Re.RIGHT:if(this.referentRect.right+this.rect.widththis.safeArea.left)return Re.LEFT}return this._places[0]}getHorizontalAlign(){for(const e of this._aligns)switch(e){case xe.CENTER:if(this.referentRect.center-.5*this.rect.width>this.safeArea.left&&this.referentRect.center+.5*this.rect.widththis.safeArea.left)return xe.END}return this._aligns[0]}getVerticalAlign(){for(const e of this._aligns)switch(e){case xe.CENTER:if(this.referentRect.middle-.5*this.rect.height>this.safeArea.top&&this.referentRect.middle+.5*this.rect.heightthis.safeArea.top)return xe.END}return this._aligns[0]}dispose(){this._referent=null,super.dispose()}},PlacementReferent:class extends oe{constructor(){super(),this._isShown=!1}static get instanceClassName(){return"PlacementReferent"}init(){this.registration.creator.setReferent(this),this._placement=this.registration.creator}get placement(){return this._placement}get isShown(){return this._isShown}set isShown(e){this._isShown!==e&&this.isEnabled&&(this._isShown=e,e?this.registration.creator.show():this.registration.creator.hide())}show(){this.isShown=!0}hide(){this.isShown=!1}},PlacementAlign:xe,PlacementPosition:Re,PlacementMode:ke},te.internals.register(te.core.CollapseSelector.COLLAPSE,te.core.Collapse),te.internals.register(te.core.InjectSvgSelector.INJECT_SVG,te.core.InjectSvg),te.internals.register(te.core.RatioSelector.RATIO,te.core.Ratio),te.internals.register(te.core.AssessSelector.ASSESS_FILE,te.core.AssessFile),te.internals.register(te.core.AssessSelector.DETAIL,te.core.AssessDetail);const Pe={SYSTEM:"system",LIGHT:"light",DARK:"dark"},Me={THEME:Ne.internals.ns.attr("theme"),SCHEME:Ne.internals.ns.attr("scheme"),TRANSITION:Ne.internals.ns.attr("transition")},He={LIGHT:"light",DARK:"dark"},$e={SCHEME:Ne.internals.ns.emission("scheme","scheme"),THEME:Ne.internals.ns.emission("scheme","theme"),ASK:Ne.internals.ns.emission("scheme","ask")},Ge={SCHEME:Ne.internals.ns.event("scheme"),THEME:Ne.internals.ns.event("theme")};class Be extends Ne.core.Instance{constructor(){super(!1)}static get instanceClassName(){return"Scheme"}init(){this.changing=this.change.bind(this),this.hasAttribute(Me.TRANSITION)&&(this.removeAttribute(Me.TRANSITION),this.request(this.restoreTransition.bind(this)));const e=Ne.internals.support.supportLocalStorage()?localStorage.getItem("scheme"):"",t=this.getAttribute(Me.SCHEME);switch(e){case Pe.DARK:case Pe.LIGHT:case Pe.SYSTEM:this.scheme=e;break;default:switch(t){case Pe.DARK:this.scheme=Pe.DARK;break;case Pe.LIGHT:this.scheme=Pe.LIGHT;break;default:this.scheme=Pe.SYSTEM}}this.addAscent($e.ASK,this.ask.bind(this)),this.addAscent($e.SCHEME,this.apply.bind(this))}get proxy(){const e=this,t={get scheme(){return e.scheme},set scheme(t){e.scheme=t}};return Ne.internals.property.completeAssign(super.proxy,t)}restoreTransition(){this.setAttribute(Me.TRANSITION,"")}ask(){this.descend($e.SCHEME,this.scheme)}apply(e){this.scheme=e}get scheme(){return this._scheme}set scheme(e){if(this._scheme!==e){switch(this._scheme=e,e){case Pe.SYSTEM:this.listenPreferences();break;case Pe.DARK:this.unlistenPreferences(),this.theme=He.DARK;break;case Pe.LIGHT:this.unlistenPreferences(),this.theme=He.LIGHT;break;default:return void(this.scheme=Pe.SYSTEM)}this.descend($e.SCHEME,e),Ne.internals.support.supportLocalStorage()&&localStorage.setItem("scheme",e),this.setAttribute(Me.SCHEME,e),this.dispatch(Ge.SCHEME,{scheme:this._scheme})}}get theme(){return this._theme}set theme(e){if(this._theme!==e)switch(e){case He.LIGHT:case He.DARK:this._theme=e,this.setAttribute(Me.THEME,e),this.descend($e.THEME,e),this.dispatch(Ge.THEME,{theme:this._theme}),document.documentElement.style.colorScheme=e===He.DARK?"dark":""}}listenPreferences(){this.isListening||(this.isListening=!0,this.mediaQuery=window.matchMedia("(prefers-color-scheme: dark)"),this.mediaQuery.addEventListener("change",this.changing),this.change())}unlistenPreferences(){this.isListening&&(this.isListening=!1,this.mediaQuery.removeEventListener("change",this.changing),this.mediaQuery=null)}change(){this.isListening&&(this.theme=this.mediaQuery.matches?He.DARK:He.LIGHT)}mutate(e){e.indexOf(Me.SCHEME)>-1&&(this.scheme=this.getAttribute(Me.SCHEME)),e.indexOf(Me.THEME)>-1&&(this.theme=this.getAttribute(Me.THEME))}dispose(){this.unlistenPreferences()}}const Ue={SCHEME:`:root${Ne.internals.ns.attr.selector("theme")}, :root${Ne.internals.ns.attr.selector("scheme")}`,SWITCH_THEME:Ne.internals.ns.selector("switch-theme"),RADIO_BUTTONS:`input[name="${Ne.internals.ns("radios-theme")}"]`};Ne.scheme={Scheme:Be,SchemeValue:Pe,SchemeSelector:Ue,SchemeEmission:$e,SchemeTheme:He,SchemeEvent:Ge},Ne.internals.register(Ne.scheme.SchemeSelector.SCHEME,Ne.scheme.Scheme);const qe=Ne.internals.ns.selector("accordion"),Fe=Ne.internals.ns.selector("collapse"),ze={GROUP:Ne.internals.ns.selector("accordions-group"),ACCORDION:qe,COLLAPSE:`${qe} > ${Fe}, ${qe} > *:not(${qe}, ${Fe}) > ${Fe}, ${qe} > *:not(${qe}, ${Fe}) > *:not(${qe}, ${Fe}) > ${Fe}`,COLLAPSE_LEGACY:`${qe} ${Fe}`,BUTTON:`${qe}__btn`};class je extends Ne.core.Instance{static get instanceClassName(){return"Accordion"}get collapsePrimary(){return this.element.children.map((e=>e.getInstance("CollapseButton"))).filter((e=>null!==e&&e.hasClass(ze.BUTTON)))[0]}}class We extends Ne.core.CollapsesGroup{static get instanceClassName(){return"AccordionsGroup"}validate(e){const t=e.node.matches(Ne.internals.legacy.isLegacy?ze.COLLAPSE_LEGACY:ze.COLLAPSE);return super.validate(e)&&t}}Ne.accordion={Accordion:je,AccordionSelector:ze,AccordionsGroup:We},Ne.internals.register(Ne.accordion.AccordionSelector.GROUP,Ne.accordion.AccordionsGroup),Ne.internals.register(Ne.accordion.AccordionSelector.ACCORDION,Ne.accordion.Accordion);const Ke={EQUISIZED_BUTTON:`${Ne.internals.ns.selector("btns-group--equisized")} ${Ne.internals.ns.selector("btn")}`,EQUISIZED_GROUP:Ne.internals.ns.selector("btns-group--equisized")};Ne.button={ButtonSelector:Ke},Ne.internals.register(Ne.button.ButtonSelector.EQUISIZED_BUTTON,Ne.core.Equisized),Ne.internals.register(Ne.button.ButtonSelector.EQUISIZED_GROUP,Ne.core.EquisizedsGroup);class Ye extends Ne.core.Instance{static get instanceClassName(){return"CardDownload"}init(){this.addAscent(Ne.core.AssessEmission.UPDATE,(e=>{this.descend(Ne.core.AssessEmission.UPDATE,e)})),this.addAscent(Ne.core.AssessEmission.ADDED,(()=>{this.descend(Ne.core.AssessEmission.ADDED)}))}}const Ve={DOWNLOAD:Ne.internals.ns.selector("card--download"),DOWNLOAD_DETAIL:`${Ne.internals.ns.selector("card--download")} ${Ne.internals.ns.selector("card__end")} ${Ne.internals.ns.selector("card__detail")}`};Ne.card={CardSelector:Ve,CardDownload:Ye},Ne.internals.register(Ne.card.CardSelector.DOWNLOAD,Ne.card.CardDownload),Ne.internals.register(Ne.card.CardSelector.DOWNLOAD_DETAIL,Ne.core.AssessDetail);const Qe={BREADCRUMB:Ne.internals.ns.selector("breadcrumb"),BUTTON:Ne.internals.ns.selector("breadcrumb__button")};class Je extends Ne.core.Instance{constructor(){super(),this.count=0,this.focusing=this.focus.bind(this)}static get instanceClassName(){return"Breadcrumb"}init(){this.getCollapse(),this.isResizing=!0}get proxy(){const e=this;return Object.assign(super.proxy,{focus:e.focus.bind(e),disclose:e.collapse.disclose.bind(e.collapse)})}getCollapse(){const e=this.collapse;e?e.listen(Ne.core.DisclosureEvent.DISCLOSE,this.focusing):this.addAscent(Ne.core.DisclosureEmission.ADDED,this.getCollapse.bind(this))}resize(){const e=this.collapse,t=this.links;e&&t.length&&(this.isBreakpoint(Ne.core.Breakpoints.MD)?e.buttonHasFocus&&t[0].focus():t.indexOf(document.activeElement)>-1&&e.focus())}get links(){return[...this.querySelectorAll("a[href]")]}get collapse(){return this.element.getDescendantInstances(Ne.core.Collapse.instanceClassName,null,!0)[0]}focus(){this.count=0,this._focus()}_focus(){const e=this.links[0];e&&(e.focus(),this.request(this.verify.bind(this)))}verify(){if(this.count++,this.count>100)return;const e=this.links[0];e&&document.activeElement!==e&&this._focus()}get collapsePrimary(){return this.element.children.map((e=>e.getInstance("CollapseButton"))).filter((e=>null!==e&&e.hasClass(Qe.BUTTON)))[0]}}Ne.breadcrumb={BreadcrumbSelector:Qe,Breadcrumb:Je},Ne.internals.register(Ne.breadcrumb.BreadcrumbSelector.BREADCRUMB,Ne.breadcrumb.Breadcrumb);const Xe={TOOLTIP:Ne.internals.ns.selector("tooltip"),SHOWN:Ne.internals.ns.selector("tooltip--shown"),BUTTON:Ne.internals.ns.selector("btn--tooltip")},Ze=1,et=2;class tt extends Ne.core.PlacementReferent{constructor(){super(),this._state=0}static get instanceClassName(){return"TooltipReferent"}init(){if(super.init(),this.listen("focusin",this.focusIn.bind(this)),this.listen("focusout",this.focusOut.bind(this)),!this.matches(Xe.BUTTON)){const e=this.mouseover.bind(this);this.listen("mouseover",e),this.placement.listen("mouseover",e);const t=this.mouseout.bind(this);this.listen("mouseout",t),this.placement.listen("mouseout",t)}this.addEmission(Ne.core.RootEmission.KEYDOWN,this._keydown.bind(this)),this.listen("click",this._click.bind(this)),this.addEmission(Ne.core.RootEmission.CLICK,this._clickOut.bind(this))}_click(){this.focus()}_clickOut(e){this.node.contains(e)||this.blur()}_keydown(e){if(e===Ne.core.KeyCodes.ESCAPE)this.blur(),this.close()}close(){this.state=0}get state(){return this._state}set state(e){this._state!==e&&(this.isShown=e>0,this._state=e)}focusIn(){this.state|=Ze}focusOut(){this.state&=~Ze}mouseover(){this.state|=et}mouseout(){this.state&=~et}}const st={SHOW:m.event("show"),HIDE:m.event("hide")},it="hidden",nt="shown",rt="hiding";class ot extends Ne.core.Placement{constructor(){super(Ne.core.PlacementMode.AUTO,[Ne.core.PlacementPosition.TOP,Ne.core.PlacementPosition.BOTTOM],[Ne.core.PlacementAlign.CENTER,Ne.core.PlacementAlign.START,Ne.core.PlacementAlign.END]),this.modifier="",this._state=it}static get instanceClassName(){return"Tooltip"}init(){super.init(),this.register(`[aria-describedby="${this.id}"]`,tt),this.listen("transitionend",this.transitionEnd.bind(this))}transitionEnd(){this._state===rt&&(this._state=it,this.isShown=!1)}get isShown(){return super.isShown}set isShown(e){if(this.isEnabled)switch(!0){case e:this._state=nt,this.addClass(Xe.SHOWN),this.dispatch(st.SHOW),super.isShown=!0;break;case this.isShown&&!e&&this._state===nt:this._state=rt,this.removeClass(Xe.SHOWN);break;case this.isShown&&!e&&this._state===it:this.dispatch(st.HIDE),super.isShown=!1}}render(){super.render();let e=this.referentRect.center-this.rect.center;const t=.5*this.rect.width-8;e<-t&&(e=-t),e>t&&(e=t),this.setProperty("--arrow-x",`${e.toFixed(2)}px`)}}Ne.tooltip={Tooltip:ot,TooltipSelector:Xe,TooltipEvent:st},Ne.internals.register(Ne.tooltip.TooltipSelector.TOOLTIP,Ne.tooltip.Tooltip);class at extends Ne.core.Instance{static get instanceClassName(){return"ToggleInput"}get isChecked(){return this.node.checked}}class ht extends Ne.core.Instance{static get instanceClassName(){return"ToggleStatusLabel"}init(){this.register(`input[id="${this.getAttribute("for")}"]`,at),this.update(),this.isSwappingFont=!0}get proxy(){return Object.assign(super.proxy,{update:this.update.bind(this)})}get input(){return this.getRegisteredInstances("ToggleInput")[0]}update(){this.node.style.removeProperty("--toggle-status-width");const e=this.input.isChecked,t=getComputedStyle(this.node,":before");let s=parseFloat(t.width);this.input.node.checked=!e;const i=getComputedStyle(this.node,":before"),n=parseFloat(i.width);n>s&&(s=n),this.input.node.checked=e,this.node.style.setProperty("--toggle-status-width",s/16+"rem")}swapFont(e){this.update()}}const ct={STATUS_LABEL:`${Ne.internals.ns.selector("toggle__label")}${Ne.internals.ns.attr.selector("checked-label")}${Ne.internals.ns.attr.selector("unchecked-label")}`};Ne.toggle={ToggleStatusLabel:ht,ToggleSelector:ct},Ne.internals.register(Ne.toggle.ToggleSelector.STATUS_LABEL,Ne.toggle.ToggleStatusLabel);const lt=Ne.internals.ns.selector("sidemenu__item"),dt=Ne.internals.ns.selector("collapse"),ut={LIST:Ne.internals.ns.selector("sidemenu__list"),COLLAPSE:`${lt} > ${dt}, ${lt} > *:not(${lt}, ${dt}) > ${dt}, ${lt} > *:not(${lt}, ${dt}) > *:not(${lt}, ${dt}) > ${dt}`,COLLAPSE_LEGACY:`${lt} ${dt}`,ITEM:Ne.internals.ns.selector("sidemenu__item"),BUTTON:Ne.internals.ns.selector("sidemenu__btn")};class gt extends Ne.core.CollapsesGroup{static get instanceClassName(){return"SidemenuList"}validate(e){return super.validate(e)&&e.node.matches(Ne.internals.legacy.isLegacy?ut.COLLAPSE_LEGACY:ut.COLLAPSE)}}class mt extends Ne.core.Instance{static get instanceClassName(){return"SidemenuItem"}get collapsePrimary(){return this.element.children.map((e=>e.getInstance("CollapseButton"))).filter((e=>null!==e&&e.hasClass(ut.BUTTON)))[0]}}Ne.sidemenu={SidemenuList:gt,SidemenuItem:mt,SidemenuSelector:ut},Ne.internals.register(Ne.sidemenu.SidemenuSelector.LIST,Ne.sidemenu.SidemenuList),Ne.internals.register(Ne.sidemenu.SidemenuSelector.ITEM,Ne.sidemenu.SidemenuItem);const pt={MODAL:Ne.internals.ns.selector("modal"),SCROLL_DIVIDER:Ne.internals.ns.selector("scroll-divider"),BODY:Ne.internals.ns.selector("modal__body"),TITLE:Ne.internals.ns.selector("modal__title")};class bt extends Ne.core.DisclosureButton{constructor(){super(Ne.core.DisclosureType.OPENED)}static get instanceClassName(){return"ModalButton"}}const _t={CONCEALING_BACKDROP:Ne.internals.ns.attr("concealing-backdrop")};class ft extends Ne.core.Disclosure{constructor(){super(Ne.core.DisclosureType.OPENED,pt.MODAL,bt,"ModalsGroup"),this._isActive=!1,this.scrolling=this.resize.bind(this,!1),this.resizing=this.resize.bind(this,!0)}static get instanceClassName(){return"Modal"}init(){super.init(),this._isDialog="DIALOG"===this.node.tagName,this.isScrolling=!1,this.listenClick(),this.addEmission(Ne.core.RootEmission.KEYDOWN,this._keydown.bind(this))}_keydown(e){if(e===Ne.core.KeyCodes.ESCAPE)this._escape()}_escape(){switch(document.activeElement?document.activeElement.tagName:void 0){case"INPUT":case"LABEL":case"TEXTAREA":case"SELECT":case"AUDIO":case"VIDEO":break;default:this.isDisclosed&&(this.conceal(),this.focus())}}retrieved(){this._ensureAccessibleName()}get body(){return this.element.getDescendantInstances("ModalBody","Modal")[0]}handleClick(e){e.target===this.node&&"false"!==this.getAttribute(_t.CONCEALING_BACKDROP)&&this.conceal()}disclose(e){return!!super.disclose(e)&&(this.body&&this.body.activate(),this.isScrollLocked=!0,this.setAttribute("aria-modal","true"),this.setAttribute("open","true"),this._isDialog||this.activateModal(),!0)}conceal(e,t){return!!super.conceal(e,t)&&(this.isScrollLocked=!1,this.removeAttribute("aria-modal"),this.removeAttribute("open"),this.body&&this.body.deactivate(),this._isDialog||this.deactivateModal(),!0)}get isDialog(){return this._isDialog}set isDialog(e){this._isDialog=e}activateModal(){this._isActive||(this._isActive=!0,this._hasDialogRole="dialog"===this.getAttribute("role"),this._hasDialogRole||this.setAttribute("role","dialog"))}deactivateModal(){this._isActive&&(this._isActive=!1,this._hasDialogRole||this.removeAttribute("role"))}_setAccessibleName(e,t){const s=this.retrieveNodeId(e,t);this.warn(`add reference to ${t} for accessible name (aria-labelledby)`),this.setAttribute("aria-labelledby",s)}_ensureAccessibleName(){if(this.hasAttribute("aria-labelledby")||this.hasAttribute("aria-label"))return;this.warn("missing accessible name");const e=this.node.querySelector(pt.TITLE),t=this.primaryButtons[0];switch(!0){case null!==e:this._setAccessibleName(e,"title");break;case void 0!==t:this.warn("missing required title, fallback to primary button"),this._setAccessibleName(t,"primary")}}}const Et=['[tabindex="0"]',"a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])","audio[controls]","video[controls]",'[contenteditable]:not([contenteditable="false"])',"details>summary:first-of-type","details","iframe"].join(),Tt=['[tabindex]:not([tabindex="-1"]):not([tabindex="0"])'].join(),At=(e,t)=>{if(!(e instanceof Element))return!1;const s=window.getComputedStyle(e);if(!s)return!1;if("hidden"===s.visibility)return!1;for(void 0===t&&(t=e);t.contains(e);){if("none"===s.display)return!1;e=e.parentElement}return!0};class St{constructor(e,t){this.element=null,this.activeElement=null,this.onTrap=e,this.onUntrap=t,this.waiting=this.wait.bind(this),this.handling=this.handle.bind(this),this.focusing=this.maintainFocus.bind(this),this.current=null}get trapped(){return null!==this.element}trap(e){this.trapped&&this.untrap(),this.element=e,this.isTrapping=!0,this.wait(),this.onTrap&&this.onTrap()}wait(){At(this.element)?this.trapping():window.requestAnimationFrame(this.waiting)}trapping(){if(!this.isTrapping)return;this.isTrapping=!1;const e=this.focusables;e.length&&-1===e.indexOf(document.activeElement)&&e[0].focus(),this.element.setAttribute("aria-modal",!0),window.addEventListener("keydown",this.handling),document.body.addEventListener("focus",this.focusing,!0)}stun(e){for(const t of e.children)t!==this.element&&(t.contains(this.element)?this.stun(t):this.stunneds.push(new vt(t)))}maintainFocus(e){if(!this.element.contains(e.target)){const t=this.focusables;if(0===t.length)return;const s=t[0];e.preventDefault(),s.focus()}}handle(e){if(9!==e.keyCode)return;const t=this.focusables;if(0===t.length)return;const s=t[0],i=t[t.length-1],n=t.indexOf(document.activeElement);e.shiftKey?!this.element.contains(document.activeElement)||n<1?(e.preventDefault(),i.focus()):(document.activeElement.tabIndex>0||t[n-1].tabIndex>0)&&(e.preventDefault(),t[n-1].focus()):this.element.contains(document.activeElement)&&n!==t.length-1&&-1!==n?document.activeElement.tabIndex>0&&(e.preventDefault(),t[n+1].focus()):(e.preventDefault(),s.focus())}get focusables(){let e=Ne.internals.dom.querySelectorAllArray(this.element,Et);const t=Ne.internals.dom.querySelectorAllArray(document.documentElement,'input[type="radio"]');if(t.length){const s={};for(const e of t){const t=e.getAttribute("name");void 0===s[t]&&(s[t]=new yt(t)),s[t].push(e)}e=e.filter((e=>{if("input"!==e.tagName.toLowerCase()||"radio"!==e.getAttribute("type").toLowerCase())return!0;const t=e.getAttribute("name");return s[t].keep(e)}))}const s=Ne.internals.dom.querySelectorAllArray(this.element,Tt);s.sort(((e,t)=>e.tabIndex-t.tabIndex));const i=e.filter((e=>-1===s.indexOf(e)));return s.concat(i).filter((e=>"-1"!==e.tabIndex&&At(e,this.element)))}untrap(){this.trapped&&(this.isTrapping=!1,this.element.removeAttribute("aria-modal"),window.removeEventListener("keydown",this.handling),document.body.removeEventListener("focus",this.focusing,!0),this.element=null,this.onUntrap&&this.onUntrap())}dispose(){this.untrap()}}class vt{constructor(e){this.element=e,this.inert=e.getAttribute("inert"),this.element.setAttribute("inert","")}unstun(){null===this.inert?this.element.removeAttribute("inert"):this.element.setAttribute("inert",this.inert)}}class yt{constructor(e){this.name=e,this.buttons=[]}push(e){this.buttons.push(e),(e===document.activeElement||e.checked||void 0===this.selected)&&(this.selected=e)}keep(e){return this.selected===e}}class Ct extends Ne.core.DisclosuresGroup{constructor(){super("Modal",!1),this.focusTrap=new St}static get instanceClassName(){return"ModalsGroup"}apply(e,t){super.apply(e,t),null===this.current?this.focusTrap.untrap():this.focusTrap.trap(this.current.node)}}class Dt extends Ne.core.Instance{static get instanceClassName(){return"ModalBody"}init(){this.listen("scroll",this.divide.bind(this))}activate(){this.isResizing=!0,this.resize()}deactivate(){this.isResizing=!1}divide(){this.node.scrollHeight>this.node.clientHeight?this.node.offsetHeight+this.node.scrollTop>=this.node.scrollHeight?this.removeClass(pt.SCROLL_DIVIDER):this.addClass(pt.SCROLL_DIVIDER):this.removeClass(pt.SCROLL_DIVIDER)}resize(){this.adjust(),this.request(this.adjust.bind(this))}adjust(){const e=32*(this.isBreakpoint(Ne.core.Breakpoints.MD)?2:1);this.isLegacy?this.style.maxHeight=window.innerHeight-e+"px":this.style.setProperty("--modal-max-height",window.innerHeight-e+"px"),this.divide()}}Ne.modal={Modal:ft,ModalButton:bt,ModalBody:Dt,ModalsGroup:Ct,ModalSelector:pt},Ne.internals.register(Ne.modal.ModalSelector.MODAL,Ne.modal.Modal),Ne.internals.register(Ne.modal.ModalSelector.BODY,Ne.modal.ModalBody),Ne.internals.register(Ne.core.RootSelector.ROOT,Ne.modal.ModalsGroup);const wt={TOGGLE:Ne.internals.ns.emission("password","toggle"),ADJUST:Ne.internals.ns.emission("password","adjust")};class Lt extends Ne.core.Instance{static get instanceClassName(){return"PasswordToggle"}init(){this.listenClick(),this.ascend(wt.ADJUST,this.width),this.isSwappingFont=!0,this._isChecked=this.isChecked}get width(){const e=getComputedStyle(this.node.parentNode);return parseInt(e.width)}get isChecked(){return this.node.checked}set isChecked(e){this._isChecked=e,this.ascend(wt.TOGGLE,e)}handleClick(){this.isChecked=!this._isChecked}swapFont(e){this.ascend(wt.ADJUST,this.width)}}class Nt extends Ne.core.Instance{static get instanceClassName(){return"Password"}init(){this.addAscent(wt.TOGGLE,this.toggle.bind(this)),this.addAscent(wt.ADJUST,this.adjust.bind(this))}toggle(e){this.descend(wt.TOGGLE,e)}adjust(e){this.descend(wt.ADJUST,e)}}const Ot={PASSWORD:Ne.internals.ns.selector("password"),INPUT:Ne.internals.ns.selector("password__input"),LABEL:Ne.internals.ns.selector("password__label"),TOOGLE:`${Ne.internals.ns.selector("password__checkbox")} input[type="checkbox"]`};class It extends Ne.core.Instance{static get instanceClassName(){return"PasswordInput"}init(){this.addDescent(wt.TOGGLE,this.toggle.bind(this)),this._isRevealed="password"===this.hasAttribute("type"),this.listen("keydown",this.capslock.bind(this)),this.listen("keyup",this.capslock.bind(this))}toggle(e){this.isRevealed=e,this.setAttribute("type",e?"text":"password")}get isRevealed(){return this._isRevealed}capslock(e){e&&"function"!=typeof e.getModifierState||(e.getModifierState("CapsLock")?this.node.parentNode.setAttribute(Ne.internals.ns.attr("capslock"),""):this.node.parentNode.removeAttribute(Ne.internals.ns.attr("capslock")))}set isRevealed(e){this._isRevealed=e,this.setAttribute("type",e?"text":"password")}}class Rt extends Ne.core.Instance{static get instanceClassName(){return"PasswordLabel"}init(){this.addDescent(wt.ADJUST,this.adjust.bind(this))}adjust(e){const t=Math.ceil(e/16);this.node.style.paddingRight=t+"rem"}}Ne.password={Password:Nt,PasswordToggle:Lt,PasswordSelector:Ot,PasswordInput:It,PasswordLabel:Rt},Ne.internals.register(Ne.password.PasswordSelector.INPUT,Ne.password.PasswordInput),Ne.internals.register(Ne.password.PasswordSelector.PASSWORD,Ne.password.Password),Ne.internals.register(Ne.password.PasswordSelector.TOOGLE,Ne.password.PasswordToggle),Ne.internals.register(Ne.password.PasswordSelector.LABEL,Ne.password.PasswordLabel);const xt=Ne.internals.ns.selector("nav__item"),kt=Ne.internals.ns.selector("collapse"),Pt={NAVIGATION:Ne.internals.ns.selector("nav"),COLLAPSE:`${xt} > ${kt}, ${xt} > *:not(${xt}, ${kt}) > ${kt}, ${xt} > *:not(${xt}, ${kt}) > *:not(${xt}, ${kt}) > ${kt}`,COLLAPSE_LEGACY:`${xt} ${kt}`,ITEM:xt,ITEM_RIGHT:`${xt}--align-right`,MENU:Ne.internals.ns.selector("menu"),BUTTON:Ne.internals.ns.selector("nav__btn"),TRANSLATE_BUTTON:Ne.internals.ns.selector("translate__btn")};class Mt extends Ne.core.Instance{constructor(){super(),this._isRightAligned=!1}static get instanceClassName(){return"NavigationItem"}init(){this.addAscent(Ne.core.DisclosureEmission.ADDED,this.calculate.bind(this)),this.addAscent(Ne.core.DisclosureEmission.REMOVED,this.calculate.bind(this)),this.isResizing=!0,this.calculate()}resize(){this.calculate()}calculate(){const e=this.element.getDescendantInstances(Ne.core.Collapse.instanceClassName,null,!0)[0];if(e&&this.isBreakpoint(Ne.core.Breakpoints.LG)&&e.element.node.matches(Pt.MENU)){const t=this.element.node.parentElement.getBoundingClientRect().right,s=e.element.node.getBoundingClientRect().width,i=this.element.node.getBoundingClientRect().left;this.isRightAligned=i+s>t}else this.isRightAligned=!1}get isRightAligned(){return this._isRightAligned}set isRightAligned(e){this._isRightAligned!==e&&(this._isRightAligned=e,e?Ne.internals.dom.addClass(this.element.node,Pt.ITEM_RIGHT):Ne.internals.dom.removeClass(this.element.node,Pt.ITEM_RIGHT))}get collapsePrimary(){return this.element.children.map((e=>e.getInstance("CollapseButton"))).filter((e=>null!==e&&(e.hasClass(Pt.BUTTON)||e.hasClass(Pt.TRANSLATE_BUTTON))))[0]}}const Ht={NONE:-1,INSIDE:0,OUTSIDE:1};class $t extends Ne.core.CollapsesGroup{static get instanceClassName(){return"Navigation"}init(){super.init(),this.clicked=!1,this.out=!1,this.listen("focusout",this.focusOutHandler.bind(this)),this.listen("mousedown",this.mouseDownHandler.bind(this)),this.listenClick({capture:!0})}validate(e){return super.validate(e)&&e.element.node.matches(Ne.internals.legacy.isLegacy?Pt.COLLAPSE_LEGACY:Pt.COLLAPSE)}mouseDownHandler(e){this.isBreakpoint(Ne.core.Breakpoints.LG)&&-1!==this.index&&this.current&&(this.position=this.current.node.contains(e.target)?Ht.INSIDE:Ht.OUTSIDE,this.requestPosition())}clickHandler(e){!e.target.matches("a, button")||e.target.matches("[aria-controls]")||e.target.matches(Ne.core.DisclosureSelector.PREVENT_CONCEAL)||(this.index=-1)}focusOutHandler(e){this.isBreakpoint(Ne.core.Breakpoints.LG)&&(this.out=!0,this.requestPosition())}requestPosition(){this.isRequesting||(this.isRequesting=!0,this.request(this.getPosition.bind(this)))}getPosition(){if(this.out)switch(this.position){case Ht.OUTSIDE:this.index=-1;break;case Ht.INSIDE:this.current&&!this.current.node.contains(document.activeElement)&&this.current.focus();break;default:this.index>-1&&!this.current.hasFocus&&(this.index=-1)}this.request(this.requested.bind(this))}requested(){this.position=Ht.NONE,this.out=!1,this.isRequesting=!1}get index(){return super.index}set index(e){-1===e&&this.current&&this.current.hasFocus&&this.current.focus(),super.index=e}}Ne.navigation={Navigation:$t,NavigationItem:Mt,NavigationMousePosition:Ht,NavigationSelector:Pt},Ne.internals.register(Ne.navigation.NavigationSelector.NAVIGATION,Ne.navigation.Navigation),Ne.internals.register(Ne.navigation.NavigationSelector.ITEM,Ne.navigation.NavigationItem);class Gt extends Ne.core.DisclosureButton{constructor(){super(Ne.core.DisclosureType.SELECT)}static get instanceClassName(){return"TabButton"}handleClick(e){super.handleClick(e),this.focus()}apply(e){super.apply(e),this.isPrimary&&(this.setAttribute("tabindex",e?"0":"-1"),e&&this.list&&this.list.focalize(this))}get list(){return this.element.getAscendantInstance("TabsList","TabsGroup")}}const Bt={TAB:Ne.internals.ns.selector("tabs__tab"),GROUP:Ne.internals.ns.selector("tabs"),PANEL:Ne.internals.ns.selector("tabs__panel"),LIST:Ne.internals.ns.selector("tabs__list"),SHADOW:Ne.internals.ns.selector("tabs__shadow"),SHADOW_LEFT:Ne.internals.ns.selector("tabs__shadow--left"),SHADOW_RIGHT:Ne.internals.ns.selector("tabs__shadow--right"),PANEL_START:Ne.internals.ns.selector("tabs__panel--direction-start"),PANEL_END:Ne.internals.ns.selector("tabs__panel--direction-end")},Ut="direction-start",qt="direction-end",Ft="none";class zt extends Ne.core.Disclosure{constructor(){super(Ne.core.DisclosureType.SELECT,Bt.PANEL,Gt,"TabsGroup"),this._direction=Ft,this._isPreventingTransition=!1}static get instanceClassName(){return"TabPanel"}get direction(){return this._direction}set direction(e){if(e!==this._direction){switch(this._direction){case Ut:this.removeClass(Bt.PANEL_START);break;case qt:this.removeClass(Bt.PANEL_END);break;case Ft:break;default:return}switch(this._direction=e,this._direction){case Ut:this.addClass(Bt.PANEL_START);break;case qt:this.addClass(Bt.PANEL_END)}}}get isPreventingTransition(){return this._isPreventingTransition}set isPreventingTransition(e){this._isPreventingTransition!==e&&(e?this.addClass(Ne.internals.motion.TransitionSelector.NONE):this.removeClass(Ne.internals.motion.TransitionSelector.NONE),this._isPreventingTransition=!0===e)}translate(e,t){this.isPreventingTransition=t,this.direction=e}reset(){this.group&&this.group.retrieve(!0)}_electPrimaries(e){return this.group&&this.group.list?super._electPrimaries(e).filter((e=>this.group.list.node.contains(e.node))):[]}}const jt="tab_keys_left",Wt="tab_keys_right",Kt="tab_keys_home",Yt="tab_keys_end",Vt={PRESS_KEY:Ne.internals.ns.emission("tab","press_key"),LIST_HEIGHT:Ne.internals.ns.emission("tab","list_height")};class Qt extends Ne.core.DisclosuresGroup{constructor(){super("TabPanel")}static get instanceClassName(){return"TabsGroup"}init(){super.init(),this.listen("transitionend",this.transitionend.bind(this)),this.addAscent(Vt.PRESS_KEY,this.pressKey.bind(this)),this.addAscent(Vt.LIST_HEIGHT,this.setListHeight.bind(this)),this.isRendering=!0}getIndex(e=0){super.getIndex(e)}get list(){return this.element.getDescendantInstances("TabsList","TabsGroup",!0)[0]}setListHeight(e){this.listHeight=e}transitionend(e){this.isPreventingTransition=!0}get buttonHasFocus(){return this.members.some((e=>e.buttonHasFocus))}pressKey(e){switch(e){case jt:this.pressLeft();break;case Wt:this.pressRight();break;case Kt:this.pressHome();break;case Yt:this.pressEnd()}}pressRight(){this.buttonHasFocus&&(this.index0?this.index--:this.index=this.length-1,this.focus())}pressHome(){this.buttonHasFocus&&(this.index=0,this.focus())}pressEnd(){this.buttonHasFocus&&(this.index=this.length-1,this.focus())}focus(){this.current&&this.current.focus()}apply(){for(let e=0;es.right&&this.node.scrollTo(i-s.right+t.right+16,0)}get isScrolling(){return this._isScrolling}set isScrolling(e){this._isScrolling!==e&&(this._isScrolling=e,this.apply())}apply(){this._isScrolling?(this.addClass(Bt.SHADOW),this.scroll()):(this.removeClass(Bt.SHADOW_RIGHT),this.removeClass(Bt.SHADOW_LEFT),this.removeClass(Bt.SHADOW))}scroll(){const e=this.node.scrollLeft,t=e<=16,s=this.node.scrollWidth-this.node.clientWidth-16,i=Math.abs(e)>=s,n="rtl"===document.documentElement.getAttribute("dir"),r=n?Bt.SHADOW_RIGHT:Bt.SHADOW_LEFT,o=n?Bt.SHADOW_LEFT:Bt.SHADOW_RIGHT;t?this.removeClass(r):this.addClass(r),i?this.removeClass(o):this.addClass(o)}resize(){this.isScrolling=this.node.scrollWidth>this.node.clientWidth+16;const e=this.getRect().height;this.setProperty("--tabs-list-height",`${e}px`),this.ascend(Vt.LIST_HEIGHT,e)}dispose(){this.isScrolling=!1}}Ne.tab={TabPanel:zt,TabButton:Gt,TabsGroup:Qt,TabsList:Jt,TabSelector:Bt,TabEmission:Vt},Ne.internals.register(Ne.tab.TabSelector.PANEL,Ne.tab.TabPanel),Ne.internals.register(Ne.tab.TabSelector.GROUP,Ne.tab.TabsGroup),Ne.internals.register(Ne.tab.TabSelector.LIST,Ne.tab.TabsList);const Xt={SCROLLABLE:Ne.internals.ns.emission("table","scrollable"),CHANGE:Ne.internals.ns.emission("table","change"),CAPTION_HEIGHT:Ne.internals.ns.emission("table","captionheight")};class Zt extends Ne.core.Instance{static get instanceClassName(){return"Table"}init(){this.addAscent(Xt.CAPTION_HEIGHT,this.setCaptionHeight.bind(this))}setCaptionHeight(e){this.setProperty("--table-offset",`calc(${e}px + 1rem)`)}}const es={TABLE:Ne.internals.ns.selector("table"),SHADOW:Ne.internals.ns.selector("table__shadow"),SHADOW_LEFT:Ne.internals.ns.selector("table__shadow--left"),SHADOW_RIGHT:Ne.internals.ns.selector("table__shadow--right"),ELEMENT:`${Ne.internals.ns.selector("table")}:not(${Ne.internals.ns.selector("table--no-scroll")}) table`,CAPTION:`${Ne.internals.ns.selector("table")} table caption`};class ts extends Ne.core.Instance{static get instanceClassName(){return"TableElement"}init(){this.listen("scroll",this.scroll.bind(this)),this.content=this.querySelector("tbody"),this.isResizing=!0}get isScrolling(){return this._isScrolling}set isScrolling(e){this._isScrolling!==e&&(this._isScrolling=e,e?(this.addClass(es.SHADOW),this.scroll()):(this.removeClass(es.SHADOW),this.removeClass(es.SHADOW_LEFT),this.removeClass(es.SHADOW_RIGHT)))}scroll(){const e=this.node.scrollLeft<=8,t=this.content.offsetWidth-this.node.offsetWidth-8,s=Math.abs(this.node.scrollLeft)>=t,i="rtl"===document.documentElement.getAttribute("dir"),n=i?es.SHADOW_RIGHT:es.SHADOW_LEFT,r=i?es.SHADOW_LEFT:es.SHADOW_RIGHT;e?this.removeClass(n):this.addClass(n),s?this.removeClass(r):this.addClass(r)}resize(){this.isScrolling=this.content.offsetWidth>this.node.offsetWidth}dispose(){this.isScrolling=!1}}class ss extends Ne.core.Instance{static get instanceClassName(){return"TableCaption"}init(){this.height=0,this.isResizing=!0}resize(){const e=this.getRect().height;this.height!==e&&(this.height=e,this.ascend(Xt.CAPTION_HEIGHT,e))}}Ne.table={Table:Zt,TableElement:ts,TableCaption:ss,TableSelector:es},Ne.internals.register(Ne.table.TableSelector.TABLE,Ne.table.Table),Ne.internals.register(Ne.table.TableSelector.ELEMENT,Ne.table.TableElement),Ne.internals.register(Ne.table.TableSelector.CAPTION,Ne.table.TableCaption);const is={DISMISS:Ne.internals.ns.event("dismiss")};class ns extends Ne.core.Instance{static get instanceClassName(){return"TagDismissible"}init(){this.listenClick()}handleClick(){switch(this.focusClosest(),Ne.mode){case Ne.Modes.ANGULAR:case Ne.Modes.REACT:case Ne.Modes.VUE:this.request(this.verify.bind(this));break;default:this.remove()}this.dispatch(is.DISMISS)}verify(){document.body.contains(this.node)&&this.warn(`a TagDismissible has just been dismissed and should be removed from the dom. In ${Ne.mode} mode, the api doesn't handle dom modification. An event ${is.DISMISS} is dispatched by the element to trigger the removal`)}}const rs={PRESSABLE:`${Ne.internals.ns.selector("tag")}[aria-pressed]`,DISMISSIBLE:`${Ne.internals.ns.selector("tag--dismiss")}`};Ne.tag={TagDismissible:ns,TagSelector:rs,TagEvent:is},Ne.internals.register(Ne.tag.TagSelector.PRESSABLE,Ne.core.Toggle),Ne.internals.register(Ne.tag.TagSelector.DISMISSIBLE,Ne.tag.TagDismissible);const os=Ne.internals.ns.selector("transcription"),as={TRANSCRIPTION:os,BUTTON:`${os}__btn`};class hs extends Ne.core.Instance{static get instanceClassName(){return"Transcription"}get collapsePrimary(){return this.element.children.map((e=>e.getInstance("CollapseButton"))).filter((e=>null!==e&&e.hasClass(as.BUTTON)))[0]}}Ne.transcription={Transcription:hs,TranscriptionSelector:as},Ne.internals.register(Ne.transcription.TranscriptionSelector.TRANSCRIPTION,Ne.transcription.Transcription);class cs extends Ne.core.Instance{static get instanceClassName(){return"TileDownload"}init(){this.addAscent(Ne.core.AssessEmission.UPDATE,(e=>{this.descend(Ne.core.AssessEmission.UPDATE,e)})),this.addAscent(Ne.core.AssessEmission.ADDED,(()=>{this.descend(Ne.core.AssessEmission.ADDED)}))}}const ls={DOWNLOAD:Ne.internals.ns.selector("tile--download"),DOWNLOAD_DETAIL:`${Ne.internals.ns.selector("tile--download")} ${Ne.internals.ns.selector("tile__detail")}`};Ne.tile={TileSelector:ls,TileDownload:cs},Ne.internals.register(Ne.tile.TileSelector.DOWNLOAD,Ne.tile.TileDownload),Ne.internals.register(Ne.tile.TileSelector.DOWNLOAD_DETAIL,Ne.core.AssessDetail);const ds={HEADER:Ne.internals.ns.selector("header"),TOOLS_LINKS:Ne.internals.ns.selector("header__tools-links"),MENU_LINKS:Ne.internals.ns.selector("header__menu-links"),BUTTONS:`${Ne.internals.ns.selector("header__tools-links")} ${Ne.internals.ns.selector("btns-group")}, ${Ne.internals.ns.selector("header__tools-links")} ${Ne.internals.ns.selector("links-group")}`,MODALS:`${Ne.internals.ns.selector("header__search")}${Ne.internals.ns.selector("modal")}, ${Ne.internals.ns.selector("header__menu")}${Ne.internals.ns.selector("modal")}`};class us extends Ne.core.Instance{static get instanceClassName(){return"HeaderLinks"}init(){const e=this.queryParentSelector(ds.HEADER);this.toolsLinks=e.querySelector(ds.TOOLS_LINKS),this.menuLinks=e.querySelector(ds.MENU_LINKS);const t="-mobile",s=this.toolsLinks.innerHTML.replace(/ +/g," "),i=this.menuLinks.innerHTML.replace(/ +/g," ");let n=s.replace(/id="(.*?)"/gm,'id="$1'+t+'"');if(n=n.replace(/()/gm,'$1 aria-controls="$2'+t+'"$3'),n!==i)switch(Ne.mode){case Ne.Modes.ANGULAR:case Ne.Modes.REACT:case Ne.Modes.VUE:this.warn(`header__tools-links content is different from header__menu-links content.\nAs you're using a dynamic framework, you should handle duplication of this content yourself, please refer to documentation:\n${Ne.header.doc}`);break;default:this.menuLinks.innerHTML=n}}}class gs extends Ne.core.Instance{constructor(){super(),this._clickHandling=this.clickHandler.bind(this)}static get instanceClassName(){return"HeaderModal"}init(){this.isResizing=!0}resize(){this.isBreakpoint(Ne.core.Breakpoints.LG)?this.deactivateModal():this.activateModal()}activateModal(){const e=this.element.getInstance("Modal");e&&(e.isEnabled=!0,this.listen("click",this._clickHandling,{capture:!0}))}deactivateModal(){const e=this.element.getInstance("Modal");e&&(e.conceal(),e.isEnabled=!1,this.unlisten("click",this._clickHandling,{capture:!0}))}clickHandler(e){if(e.target.matches("a, button")&&!e.target.matches("[aria-controls]")&&!e.target.matches(Ne.core.DisclosureSelector.PREVENT_CONCEAL)){this.element.getInstance("Modal").conceal()}}}Ne.header={HeaderLinks:us,HeaderModal:gs,HeaderSelector:ds,doc:"https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/en-tete"},Ne.internals.register(Ne.header.HeaderSelector.TOOLS_LINKS,Ne.header.HeaderLinks),Ne.internals.register(Ne.header.HeaderSelector.MODALS,Ne.header.HeaderModal);const ms={DISPLAY:Ne.internals.ns.selector("display"),RADIO_BUTTONS:`input[name="${Ne.internals.ns("radios-theme")}"]`,FIELDSET:Ne.internals.ns.selector("fieldset")};class ps extends Ne.core.Instance{static get instanceClassName(){return"Display"}init(){if(this.radios=this.querySelectorAll(ms.RADIO_BUTTONS),Ne.scheme){this.changing=this.change.bind(this);for(const e of this.radios)e.addEventListener("change",this.changing);this.addDescent(Ne.scheme.SchemeEmission.SCHEME,this.apply.bind(this)),this.ascend(Ne.scheme.SchemeEmission.ASK)}else this.querySelector(ms.FIELDSET).setAttribute("disabled","")}get scheme(){return this._scheme}set scheme(e){if(this._scheme!==e&&Ne.scheme)switch(e){case Ne.scheme.SchemeValue.SYSTEM:case Ne.scheme.SchemeValue.LIGHT:case Ne.scheme.SchemeValue.DARK:this._scheme=e;for(const t of this.radios)t.checked=t.value===e;this.ascend(Ne.scheme.SchemeEmission.SCHEME,e)}}change(){for(const e of this.radios)if(e.checked)return void(this.scheme=e.value)}apply(e){this.scheme=e}dispose(){for(const e of this.radios)e.removeEventListener("change",this.changing)}}Ne.display={Display:ps,DisplaySelector:ms},Ne.internals.register(Ne.display.DisplaySelector.DISPLAY,Ne.display.Display); +//# sourceMappingURL=dsfr.module.min.js.map diff --git a/client/public/vite.svg b/client/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/client/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/src/_api/websites.ts b/client/src/_api/websites.ts index 76617f8..a1d76ba 100644 --- a/client/src/_api/websites.ts +++ b/client/src/_api/websites.ts @@ -18,7 +18,7 @@ export async function getWebsiteInfo(id: string): Promise { } export async function getCrawls(id: string): Promise { - const crawls = await fetch(`${API_URL}/${id}/crawls`) + const crawls = await fetch(`${API_URL}/${id}/crawls?limit=50`) .then(res => res.json()) .catch(() => ({ data: [], count: 0 })); return crawls; diff --git a/client/src/_dsfr/hooks/useDSFRConfig.tsx b/client/src/_dsfr/hooks/useDSFRConfig.tsx index 11e212e..e9d4797 100644 --- a/client/src/_dsfr/hooks/useDSFRConfig.tsx +++ b/client/src/_dsfr/hooks/useDSFRConfig.tsx @@ -1,5 +1,4 @@ "use client"; - import { createContext, useContext, diff --git a/client/src/_types/crawls.ts b/client/src/_types/crawls.ts index abad05e..72bf537 100644 --- a/client/src/_types/crawls.ts +++ b/client/src/_types/crawls.ts @@ -18,13 +18,11 @@ export type Crawl = { started_at: string; finished_at: string; next_run_at: string; - accessibility: MetadataResult; + lighthouse: MetadataResult; technologies_and_trackers: MetadataResult; - good_practices: MetadataResult; responsiveness: MetadataResult; carbon_footprint: MetadataResult; html_crawl: MetadataResult; - uploads: MetadataResult; } export type CrawlCount = { diff --git a/client/src/_types/websites.ts b/client/src/_types/websites.ts index db055b6..696a2ac 100644 --- a/client/src/_types/websites.ts +++ b/client/src/_types/websites.ts @@ -10,10 +10,9 @@ export type WebsiteFormBody = { depth: number; limit: number; tags: string[]; - accessibility: Metadata; + lighthouse: Metadata; technologies_and_trackers: Metadata; responsiveness: Metadata; - good_practices: Metadata; carbon_footprint: Metadata; headers: Record; } diff --git a/client/src/components/WebsiteForm.tsx b/client/src/components/WebsiteForm.tsx index 8d7c05a..8f62172 100644 --- a/client/src/components/WebsiteForm.tsx +++ b/client/src/components/WebsiteForm.tsx @@ -8,11 +8,10 @@ const DEFAULT_INITIAL_FORM = { url: '', crawl_every: 30, depth: 2, - limit: 400, + limit: 5, tags: [], headers: {}, - accessibility: { enabled: true, depth: 0 }, - good_practices: { enabled: true, depth: 0 }, + lighthouse: { enabled: true, depth: 0 }, carbon_footprint: { enabled: true, depth: 0 }, responsiveness: { enabled: true, depth: 0 }, technologies_and_trackers: { enabled: true, depth: 0 }, @@ -29,7 +28,7 @@ type WebsiteFormProps = { function sanitize(form: Record): WebsiteFormBody { const fields = [ 'url', 'crawl_every', 'depth', 'limit', 'tags', 'headers', - 'accessibility', 'good_practices', 'carbon_footprint', 'responsiveness', + 'lighthouse', 'carbon_footprint', 'responsiveness', 'technologies_and_trackers' ]; const body: Record = {}; @@ -74,7 +73,7 @@ export default function WebsiteForm({ updateForm({ tags })} + onTagsChange={(tags) => updateForm({ tags: tags.map((tag) => tag.toUpperCase()) })} /> @@ -140,11 +139,11 @@ export default function WebsiteForm({ ) => updateForm({ accessibility: { enabled: e.target.checked, depth: 0 } })} + label="Lighthouse" + hint="Désactivez cette option si vous ne souhaitez pas crawler les informations de lighthouse." + onChange={(e: ChangeEvent) => updateForm({ lighthouse: { enabled: e.target.checked, depth: 0 } })} /> ) => updateForm({ responsiveness: { enabled: e.target.checked, depth: 0 } })} /> - ) => updateForm({ good_practices: { enabled: e.target.checked, depth: 0 } })} - /> -
+
setOpen((prev) => !prev)} />
- {(['success', 'error'].includes(job.status)) && ( + {(['success', 'error', 'partial_error'].includes(job.status)) && ( <> il y a @@ -39,7 +39,7 @@ export default function Job({ job }: { job: Crawl }) { en attente depuis {' '} - {timeSince(new Date(job.started_at))} + {timeSince(new Date(job.created_at))} )} diff --git a/client/src/pages/websites/[id]/crawls/components/selected-job.tsx b/client/src/pages/websites/[id]/crawls/components/selected-job.tsx index ffcebb8..224168f 100644 --- a/client/src/pages/websites/[id]/crawls/components/selected-job.tsx +++ b/client/src/pages/websites/[id]/crawls/components/selected-job.tsx @@ -4,17 +4,15 @@ import { Crawl, MetadataResult } from '../../../../../_types/crawls' import { timeBetween } from '../utils/dates'; import { getJobStatus } from '../utils/status'; -type Metadata = 'html_crawl' | 'accessibility' | 'responsiveness' | 'technologies_and_trackers' | 'good_practices' | 'carbon_footprint' | 'uploads'; -const metadatas: Metadata[] = ['html_crawl', 'accessibility', 'responsiveness', 'technologies_and_trackers', 'good_practices', 'carbon_footprint', 'uploads'] +type Metadata = 'html_crawl' | 'lighthouse' | 'responsiveness' | 'technologies_and_trackers' | 'carbon_footprint'; +const metadatas: Metadata[] = ['html_crawl', 'lighthouse', 'responsiveness', 'technologies_and_trackers', 'carbon_footprint'] const nameMap: { [key in Metadata]: string } = { html_crawl: 'Crawl', - accessibility: 'Accessibilité', + lighthouse: 'LightHouse', responsiveness: 'Responsive', technologies_and_trackers: 'Technologies', - good_practices: 'Bonnes pratiques', carbon_footprint: 'Empreinte carbone', - uploads: 'Upload' } function downloadFiles(url: string) { @@ -46,27 +44,24 @@ function MetadataReport({ name, data }: { name: Metadata, data: MetadataResult } export default function SelectedJob({ job }: { job: Crawl | null }) { if (!job) return null; return ( - -
-
-
- - {metadatas.map((metadata: Metadata) => ( - - - - ))} +
+
+ + {metadatas.map((metadata: Metadata) => ( + + + + ))} + +
+ {['success', 'error', 'partial_error'].includes(job.status) && ( + + + + + -
- {['success', 'error', 'partial_error'].includes(job.status) && ( - - - - - - - )} -
+ )}
); diff --git a/client/src/pages/websites/[id]/crawls/index.tsx b/client/src/pages/websites/[id]/crawls/index.tsx index 628b384..747d21f 100644 --- a/client/src/pages/websites/[id]/crawls/index.tsx +++ b/client/src/pages/websites/[id]/crawls/index.tsx @@ -1,13 +1,22 @@ -import { Col, Container, Row, Text, Title } from '../../../../_dsfr'; +import { Button, Col, Container, Row, Text, Title } from '../../../../_dsfr'; import { useParams } from 'react-router-dom'; import JobList from './components/job-list'; import './styles/jobs.scss'; -import { getCrawls } from '../../../../_api/websites'; -import { useQuery } from '@tanstack/react-query'; +import { API_URL, getCrawls } from '../../../../_api/websites'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; export default function Crawls() { const { id = "" } = useParams(); - const { data, isLoading, error } = useQuery({ queryKey: ['websites', id, 'crawls'], queryFn: () => getCrawls(id), refetchOnWindowFocus: false, staleTime: 1000 * 60 }); + const queryClient = useQueryClient(); + const { data, isLoading, error } = useQuery({ queryKey: ['websites', id, 'crawls'], queryFn: () => getCrawls(id), refetchOnWindowFocus: true, refetchInterval: 1000 * 15 }); + const { isLoading: isMutating, mutate: createNewCrawl } = useMutation({ + mutationFn: () => + fetch(`${API_URL}/${id}/crawls`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['websites', id, 'crawls'] }) + }, + onError: () => { }, + }); if (isLoading || !data) return

Loading...

; if (error) return

error

; const { data: crawls = [], count } = data; @@ -20,6 +29,11 @@ export default function Crawls() { {count} crawl{count > 1 ? 's effectués' : ' effectué'}
+
+ +
diff --git a/client/src/pages/websites/[id]/crawls/styles/jobs.scss b/client/src/pages/websites/[id]/crawls/styles/jobs.scss index 38384fd..9d9bd35 100644 --- a/client/src/pages/websites/[id]/crawls/styles/jobs.scss +++ b/client/src/pages/websites/[id]/crawls/styles/jobs.scss @@ -1,74 +1,18 @@ -.error-border { - border-left: .25rem solid var(--jobs-error-color) !important; - border-radius: .25rem; -} - -.success-border { - border-left: .25rem solid var(--jobs-success-color) !important; - border-radius: .25rem; -} - -.started-border { - border-left: .25rem solid var(--jobs-started-color) !important; - border-radius: .25rem; -} - -.pending-border { - border-left: .25rem solid var(--jobs-pending-color) !important; - border-radius: .25rem; -} - -.error-border-right { - border-right: .25rem solid var(--jobs-error-color) !important; - border-radius: .25rem; -} +$statuses: error, success, started, pending; -.success-border-right { - border-right: .25rem solid var(--jobs-success-color) !important; - border-radius: .25rem; -} - -.started-border-right { - border-right: .25rem solid var(--jobs-started-color) !important; - border-radius: .25rem; -} - -.pending-border-right { - border-right: .25rem solid var(--jobs-pending-color) !important; - border-radius: .25rem; -} - -.job-list { - margin: -.5rem; -} - -.job { - padding: 0.5rem; - - // :first-child { - // background-color: var(--hover); - // } -} - -.selected-job { - padding: 0.5rem; +@each $status in $statuses { + .#{$status}-border { + --primaryColor: var(--jobs-#{$status}-color); + border-left: .25rem solid var(--primaryColor) !important; + } - :first-child { - background-color: inherit; + .#{$status}-border-right { + --primaryColor: var(--jobs-#{$status}-color); + border-right: .25rem solid var(--primaryColor) !important; } } -.job-code { - white-space: pre-wrap; - word-break: break-word; - // max-height: 200px; - // overflow-y: scroll; -} -.card-button { - cursor: pointer !important; - color: inherit !important; -} .metadata { display: flex; diff --git a/docker-compose.yml b/docker-compose.yml index 88df871..010c518 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,6 @@ services: condition: on-failure crawl_worker: &worker -# container_name: crawl_worker build: . env_file: .env command: watchfiles --filter python 'celery -A celery_broker.main.celery_app worker -l info -P solo -n crawl_worker -Q crawl_queue' @@ -43,19 +42,19 @@ services: deploy: restart_policy: condition: on-failure -# replicas: 2 + replicas: 1 - accessibility_worker: + lighthouse_worker: <<: *worker -# container_name: accessibility_worker build: context: . dockerfile: Lighthouse.Dockerfile - command: watchfiles --filter python '/bin/bash -c "source /opt/venv/bin/activate && celery -A celery_broker.main.celery_app worker -l info -P solo -n accessibility_worker -Q accessibility_queue"' + command: watchfiles --filter python '/bin/bash -c "source /opt/venv/bin/activate && celery -A celery_broker.main.celery_app worker -l info -P solo -n lighthouse_worker -Q lighthouse_queue"' + deploy: + replicas: 1 technologies_worker: <<: *worker -# container_name: technologies_worker build: context: . dockerfile: Wappalyzer.Dockerfile @@ -63,29 +62,18 @@ services: deploy: replicas: 1 - good_practices_worker: - <<: *worker -# container_name: good_practices_worker - build: - context: . - dockerfile: Lighthouse.Dockerfile - command: watchfiles --filter python '/bin/bash -c "source /opt/venv/bin/activate && celery -A celery_broker.main.celery_app worker -l info -P solo -n good_practices_worker -Q good_practices_queue"' - responsiveness_worker: <<: *worker -# container_name: responsiveness_worker command: watchfiles --filter python 'celery -A celery_broker.main.celery_app worker -l info -P solo -n responsiveness_worker -Q responsiveness_queue' + deploy: + replicas: 1 carbon_footprint_worker: <<: *worker -# container_name: carbon_footprint_worker command: watchfiles --filter python 'celery -A celery_broker.main.celery_app worker -l info -P solo -n carbon_footprint_worker -Q carbon_footprint_queue' + deploy: + replicas: 1 - upload_worker: - <<: *worker -# container_name: upload_worker - command: watchfiles --filter python 'celery -A celery_broker.main.celery_app worker -l info -P solo -n upload_worker -Q upload_queue' - env_file: .env diff --git a/local/s3.py b/local/s3.py new file mode 100644 index 0000000..078e640 --- /dev/null +++ b/local/s3.py @@ -0,0 +1,46 @@ +import os +from functools import wraps +from minio import Minio + +from app.config import settings + +s3 = Minio( + endpoint=settings.STORAGE_SERVICE_URL, + access_key=settings.STORAGE_SERVICE_USERNAME, + secret_key=settings.STORAGE_SERVICE_PASSWORD, + secure=settings.STORAGE_SERVICE_SECURE, + region=settings.STORAGE_SERVICE_REGION, +) + +bucket = settings.STORAGE_SERVICE_BUCKET_NAME + +if not s3.bucket_exists(bucket): + s3.make_bucket(bucket) + + +def with_s3(f): + """Decorate a function for mongo connexion.""" + @wraps(f) + def wrapper(*args, **kwargs): + response = f(s3, bucket *args, **kwargs) + return response + return wrapper + + +class S3: + """S3 service.""" + @with_s3 + def get_object(s3, bucket, *args, **kwargs): + """Get an object from S3.""" + return s3.get_object(bucket, *args, **kwargs) + + @with_s3 + def list_objects(s3, bucket, *args, **kwargs): + """List objects from S3.""" + return s3.list_objects(bucket, *args, **kwargs) + + @with_s3 + def put_object(s3, bucket, *args, **kwargs): + """Put an object to S3.""" + return s3.put_object(bucket, *args, **kwargs) + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8c0a6e2..9fb7dbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,67 @@ +amqp==5.1.1 +annotated-types==0.5.0 +anyio==3.7.1 +attrs==23.1.0 +Automat==22.10.0 +billiard==4.1.0 celery==5.3.1 +certifi==2023.7.22 +cffi==1.15.1 +charset-normalizer==3.2.0 +click==8.1.7 +click-didyoumean==0.3.0 +click-plugins==1.1.1 +click-repl==0.3.0 +colorama==0.4.6 +constantly==15.1.0 +cryptography==41.0.4 +cssselect==1.2.0 +dnspython==2.4.2 fastapi==0.103.1 -# https://github.com/scrapy/scrapy/issues/6024 -Twisted==22.10.0 -Scrapy==2.9.0 -uvicorn==0.23.1 +filelock==3.12.4 gevent==23.7.0 -watchfiles==0.19.0 +greenlet==2.0.2 +h11==0.14.0 +hyperlink==21.0.0 +idna==3.4 +incremental==22.10.0 +itemadapter==0.8.0 +itemloaders==1.1.0 +jmespath==1.0.1 +kombu==5.3.2 +lxml==4.9.3 minio==7.1.15 +packaging==23.1 +parsel==1.8.1 +prompt-toolkit==3.0.39 +Protego==0.3.0 +pyasn1==0.5.0 +pyasn1-modules==0.3.0 +pycparser==2.21 +pydantic==2.4.1 +pydantic_core==2.10.1 +PyDispatcher==2.0.7 pymongo==4.4.1 -redis==4.6.0 \ No newline at end of file +pyOpenSSL==23.2.0 +python-dateutil==2.8.2 +queuelib==1.6.2 +redis==4.6.0 +requests==2.31.0 +requests-file==1.5.1 +Scrapy==2.9.0 +service-identity==23.1.0 +six==1.16.0 +sniffio==1.3.0 +starlette==0.27.0 +tldextract==3.6.0 +Twisted==22.10.0 +typing_extensions==4.8.0 +tzdata==2023.3 +urllib3==2.0.5 +uvicorn==0.23.1 +vine==5.0.0 +w3lib==2.1.2 +watchfiles==0.19.0 +wcwidth==0.2.6 +zope.event==5.0 +zope.interface==6.0 diff --git a/tests/test_config.py b/tests/test_config.py index 2957a94..29a33d4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,13 +1,10 @@ import unittest from unittest.mock import patch -from app.celery_broker.config import get_settings, BaseConfig +from app.config import get_settings, BaseConfig class TestSettings(unittest.TestCase): - def setUp(self): - # Clear the lru_cache before each test - get_settings.cache_clear() def test_default_setting_is_development(self): with patch.dict( diff --git a/tests/test_utils.py b/tests/test_utils.py index 599dd8f..a02ec41 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ import unittest -from celery_broker.utils import assume_content_type +from app.celery_broker.utils import assume_content_type class TestAssumeContentType(unittest.TestCase): diff --git a/tests/tests_models/test_crawl.py b/tests/tests_models/test_crawl.py index a3236f0..2f87f00 100644 --- a/tests/tests_models/test_crawl.py +++ b/tests/tests_models/test_crawl.py @@ -7,7 +7,7 @@ ListCrawlResponse, ) from app.models.enums import MetadataType -from app.models.metadata import MetadataConfig, AccessibilityModel +from app.models.metadata import MetadataConfig, LighthouseModel class TestCrawlParametersConfig(unittest.TestCase): @@ -16,7 +16,7 @@ def test_instantiation(self): config = CrawlConfig( url="http://example.com", parameters=params, - metadata_config={MetadataType.ACCESSIBILITY: MetadataConfig()}, + metadata_config={MetadataType.LIGHTHOUSE: MetadataConfig()}, headers={}, tags=[], ) @@ -30,7 +30,7 @@ def test_default_values(self): config = CrawlConfig( url="http://example.com", parameters=CrawlParameters(depth=2, limit=400), - metadata_config={MetadataType.ACCESSIBILITY: MetadataConfig()}, + metadata_config={MetadataType.LIGHTHOUSE: MetadataConfig()}, headers={}, tags=[], ) @@ -38,33 +38,33 @@ def test_default_values(self): self.assertIsNotNone(crawl.id) self.assertIsNotNone(crawl.created_at) - self.assertIsNone(crawl.accessibility) + self.assertIsNone(crawl.lighthouse) def test_enabled_metadata_property(self): config = CrawlConfig( url="http://example.com", parameters=CrawlParameters(depth=2, limit=400), metadata_config={ - MetadataType.ACCESSIBILITY: MetadataConfig(), + MetadataType.LIGHTHOUSE: MetadataConfig(), MetadataType.TECHNOLOGIES: MetadataConfig(enabled=False), }, headers={}, tags=[], ) crawl = CrawlModel(website_id="website_123", config=config) - self.assertEqual(crawl.enabled_metadata, [MetadataType.ACCESSIBILITY]) + self.assertEqual(crawl.enabled_metadata, [MetadataType.LIGHTHOUSE]) def test_init_tasks_method(self): config = CrawlConfig( url="http://example.com", parameters=CrawlParameters(depth=2, limit=400), - metadata_config={MetadataType.ACCESSIBILITY: MetadataConfig()}, + metadata_config={MetadataType.LIGHTHOUSE: MetadataConfig()}, headers={}, tags=[], ) crawl = CrawlModel(website_id="website_123", config=config) crawl.init_tasks() - self.assertIsInstance(crawl.accessibility, AccessibilityModel) + self.assertIsInstance(crawl.lighthouse, LighthouseModel) # Add more methods to test `update_task` and `update_status` @@ -74,7 +74,7 @@ def test_instantiation(self): config = CrawlConfig( url="http://example.com", parameters=CrawlParameters(depth=2, limit=400), - metadata_config={MetadataType.ACCESSIBILITY: MetadataConfig()}, + metadata_config={MetadataType.LIGHTHOUSE: MetadataConfig()}, headers={}, tags=[], ) diff --git a/tests/tests_models/test_enums.py b/tests/tests_models/test_enums.py index 76b6dae..8ac9144 100644 --- a/tests/tests_models/test_enums.py +++ b/tests/tests_models/test_enums.py @@ -6,15 +6,15 @@ class TestMetadataType(unittest.TestCase): def test_enum_members(self): # Check if the enum has the expected members - self.assertEqual(MetadataType.ACCESSIBILITY, "accessibility") - self.assertEqual(MetadataType.TECHNOLOGIES, "technologies_and_trackers") + self.assertEqual(MetadataType.LIGHTHOUSE, "lighthouse") + self.assertEqual(MetadataType.TECHNOLOGIES, + "technologies_and_trackers") self.assertEqual(MetadataType.RESPONSIVENESS, "responsiveness") - self.assertEqual(MetadataType.GOOD_PRACTICES, "good_practices") self.assertEqual(MetadataType.CARBON_FOOTPRINT, "carbon_footprint") def test_enum_member_count(self): # Check if the enum has only the expected members - self.assertEqual(len(MetadataType), 5) + self.assertEqual(len(MetadataType), 4) class TestProcessStatus(unittest.TestCase): diff --git a/tests/tests_models/test_metadata.py b/tests/tests_models/test_metadata.py index 1c52ce5..59bca6b 100644 --- a/tests/tests_models/test_metadata.py +++ b/tests/tests_models/test_metadata.py @@ -3,7 +3,7 @@ from pydantic import ValidationError from app.models.enums import ProcessStatus -from app.models.metadata import MetadataConfig, MetadataTask, AccessibilityModel +from app.models.metadata import MetadataConfig, MetadataTask, LighthouseModel class TestMetadataConfig(unittest.TestCase): @@ -28,9 +28,9 @@ def test_instantiation(self): self.assertEqual(task.status, ProcessStatus.PENDING) -class TestAccessibilityModel(unittest.TestCase): +class TestLighthouseModel(unittest.TestCase): def test_default_values(self): - model = AccessibilityModel() + model = LighthouseModel() self.assertIsNone(model.score) self.assertIsNone(model.task_id) diff --git a/tests/tests_models/test_process.py b/tests/tests_models/test_process.py index 564f436..5e5323f 100644 --- a/tests/tests_models/test_process.py +++ b/tests/tests_models/test_process.py @@ -28,7 +28,7 @@ def test_from_model_classmethod(self): config=CrawlConfig( url="http://example.com", parameters=CrawlParameters(depth=2, limit=400), - metadata_config={MetadataType.ACCESSIBILITY: MetadataConfig()}, + metadata_config={MetadataType.LIGHTHOUSE: MetadataConfig()}, headers={}, tags=[], ), @@ -42,7 +42,7 @@ def test_enabled_metadata_property(self): url="http://example.com", parameters=CrawlParameters(depth=2, limit=400), metadata_config={ - MetadataType.ACCESSIBILITY: MetadataConfig(), + MetadataType.LIGHTHOUSE: MetadataConfig(), MetadataType.TECHNOLOGIES: MetadataConfig(enabled=False), }, headers={}, @@ -51,7 +51,7 @@ def test_enabled_metadata_property(self): process = CrawlProcess( id="crawl_123", website_id="website_123", config=config ) - self.assertEqual(process.enabled_metadata, [MetadataType.ACCESSIBILITY]) + self.assertEqual(process.enabled_metadata, [MetadataType.LIGHTHOUSE]) # Write more test methods for `save_url_for_metadata`, `set_from`, `set_metadata_status`, and `metadata_needs_save`. diff --git a/tests/tests_models/test_request.py b/tests/tests_models/test_request.py index 7550a40..e3a53f1 100644 --- a/tests/tests_models/test_request.py +++ b/tests/tests_models/test_request.py @@ -1,5 +1,5 @@ import unittest -from datetime import datetime, timedelta, timezone +from datetime import timedelta from pydantic import ValidationError @@ -21,10 +21,9 @@ def test_default_values(self): self.assertEqual(request.crawl_every, 30) # Assuming MetadataConfig has a property called "enabled" - self.assertTrue(request.accessibility.enabled) + self.assertTrue(request.lighthouse.enabled) self.assertFalse(request.technologies_and_trackers.enabled) self.assertFalse(request.responsiveness.enabled) - self.assertFalse(request.good_practices.enabled) self.assertFalse(request.carbon_footprint.enabled) def test_depth_field_constraints(self): @@ -69,10 +68,9 @@ def test_default_values(self): self.assertIsNone(request.next_crawl_at) # For MetadataConfig properties - self.assertIsNone(request.accessibility) + self.assertIsNone(request.lighthouse) self.assertIsNone(request.technologies_and_trackers) self.assertIsNone(request.responsiveness) - self.assertIsNone(request.good_practices) self.assertIsNone(request.carbon_footprint) def test_crawl_every_field_constraints(self): @@ -88,7 +86,7 @@ def test_assigning_values(self): tags=["example", "test"], crawl_every=20, next_crawl_at=now, - accessibility=MetadataConfig(enabled=True), + lighthouse=MetadataConfig(enabled=True), ) self.assertEqual(request.depth, 3) @@ -97,7 +95,7 @@ def test_assigning_values(self): self.assertEqual(request.tags, ["example", "test"]) self.assertEqual(request.crawl_every, 20) self.assertEqual(request.next_crawl_at, now) - self.assertTrue(request.accessibility.enabled) + self.assertTrue(request.lighthouse.enabled) if __name__ == "__main__": diff --git a/tests/tests_models/test_website.py b/tests/tests_models/test_website.py index cce9b35..628a93e 100644 --- a/tests/tests_models/test_website.py +++ b/tests/tests_models/test_website.py @@ -12,10 +12,9 @@ def test_default_values(self): url="http://example.com", depth=2, limit=400, - accessibility=MetadataConfig(), + lighthouse=MetadataConfig(), technologies_and_trackers=MetadataConfig(), responsiveness=MetadataConfig(), - good_practices=MetadataConfig(), carbon_footprint=MetadataConfig(), headers={}, tags=[], @@ -33,10 +32,9 @@ def test_to_config_method(self): url="http://example.com", depth=2, limit=400, - accessibility=MetadataConfig(), + lighthouse=MetadataConfig(), technologies_and_trackers=MetadataConfig(), responsiveness=MetadataConfig(), - good_practices=MetadataConfig(), carbon_footprint=MetadataConfig(), headers={}, tags=["test"], @@ -54,10 +52,9 @@ def test_refresh_next_crawl_date(self): url="http://example.com", depth=2, limit=400, - accessibility=MetadataConfig(), + lighthouse=MetadataConfig(), technologies_and_trackers=MetadataConfig(), responsiveness=MetadataConfig(), - good_practices=MetadataConfig(), carbon_footprint=MetadataConfig(), headers={}, tags=[], @@ -74,10 +71,9 @@ def test_instantiation(self): url="http://example1.com", depth=2, limit=400, - accessibility=MetadataConfig(), + lighthouse=MetadataConfig(), technologies_and_trackers=MetadataConfig(), responsiveness=MetadataConfig(), - good_practices=MetadataConfig(), carbon_footprint=MetadataConfig(), headers={}, tags=[], @@ -87,10 +83,9 @@ def test_instantiation(self): url="http://example2.com", depth=3, limit=500, - accessibility=MetadataConfig(enabled=False), + lighthouse=MetadataConfig(enabled=False), technologies_and_trackers=MetadataConfig(), responsiveness=MetadataConfig(), - good_practices=MetadataConfig(), carbon_footprint=MetadataConfig(), headers={}, tags=["sample"], diff --git a/tests/tests_services/test_accessibility_best_practices_calculator.py b/tests/tests_services/test_accessibility_best_practices_calculator.py deleted file mode 100644 index 447551b..0000000 --- a/tests/tests_services/test_accessibility_best_practices_calculator.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -import unittest -from unittest.mock import patch, Mock - -from app.services.accessibility_best_practices_calculator import ( - LighthouseWrapper, - LighthouseError, - AccessibilityError, - BestPracticesError, -) - - -class TestLighthouseWrapper(unittest.TestCase): - @patch("subprocess.run") - def test_get_accessibility(self, mock_run): - # Mock a lighthouse response - mock_response = {"categories": {"accessibility": {"score": 100}}} - mock_run.return_value = Mock( - stdout=json.dumps(mock_response).encode("utf-8") - ) - wrapper = LighthouseWrapper() - result = wrapper.get_accessibility(url="http://example.com") - self.assertEqual(result, {"score": 100}) - - @patch("subprocess.run") - def test_get_best_practices(self, mock_run): - # Mock a lighthouse response - mock_response = {"categories": {"best-practices": {"score": 90}}} - mock_run.return_value = Mock( - stdout=json.dumps(mock_response).encode("utf-8") - ) - wrapper = LighthouseWrapper() - result = wrapper.get_best_practices(url="http://example.com") - self.assertEqual(result, {"score": 90}) - - @patch("subprocess.run") - def test_get_accessibility_error(self, mock_run): - mock_run.side_effect = LighthouseError - wrapper = LighthouseWrapper() - with self.assertRaises(AccessibilityError): - wrapper.get_accessibility(url="http://example.com") - - @patch("subprocess.run") - def test_get_best_practices_error(self, mock_run): - mock_run.side_effect = LighthouseError - wrapper = LighthouseWrapper() - with self.assertRaises(BestPracticesError): - wrapper.get_best_practices(url="http://example.com") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/tests_services/test_lighthouse_calculator.py b/tests/tests_services/test_lighthouse_calculator.py new file mode 100644 index 0000000..f63c317 --- /dev/null +++ b/tests/tests_services/test_lighthouse_calculator.py @@ -0,0 +1,33 @@ +import json +import unittest +from unittest.mock import patch, Mock + +from app.services.lighthouse_calculator import ( + LighthouseCalculator, + LighthouseError, +) + + +class TestLighthouseCalculator(unittest.TestCase): + @patch("subprocess.run") + def test_get_lighthouse(self, mock_run): + # Mock a lighthouse response + mock_response = {"categories": {"accessibility": {"score": 100}}} + mock_run.return_value = Mock( + stdout=json.dumps(mock_response).encode("utf-8") + ) + wrapper = LighthouseCalculator() + result = wrapper.get_lighthouse(url="http://example.com") + self.assertEqual( + result, {"categories": {"accessibility": {"score": 100}}}) + + @patch("subprocess.run") + def test_get_lighthouse_error(self, mock_run): + mock_run.side_effect = LighthouseError + wrapper = LighthouseCalculator() + with self.assertRaises(LighthouseError): + wrapper.get_lighthouse(url="http://example.com") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tests_services/test_responsiveness_calculator.py b/tests/tests_services/test_responsiveness_calculator.py index e381b55..24e75ce 100644 --- a/tests/tests_services/test_responsiveness_calculator.py +++ b/tests/tests_services/test_responsiveness_calculator.py @@ -4,6 +4,7 @@ import requests.models +from app.config import settings from app.services.responsiveness_calculator import ( ResponsivenessCalculator, ResponsivenessCalculatorError, @@ -13,7 +14,7 @@ class TestResponsivenessCalculator(unittest.TestCase): def setUp(self): # Set a fake API key for testing - os.environ["GOOGLE_API_KEY"] = "FAKE_API_KEY" + settings.GOOGLE_API_KEY = "FAKE_API_KEY" @patch("requests.post") def test_get_responsiveness(self, mock_post):