├── .github ├── ISSUE_TEMPLATE │ └── missing_center.md ├── assets │ └── logo.png ├── codecov.yml ├── disabled │ ├── cron_doctolib.yml │ ├── cron_keldoc.yml │ ├── cron_maiia.yml │ ├── cron_mapharma.yml │ ├── cron_ordoclic.yml │ ├── scrape_doctolib.yml │ ├── scrape_keldoc.yml │ ├── scrape_maiia.yml │ ├── scrape_mapharma.yml │ ├── scrape_merge.yml │ └── scrape_ordoclic.yml ├── pull_request_template.md └── workflows │ ├── bimedoc_center_scrap.yml │ ├── cancel.yml │ ├── doctolib_center_scrap.yml │ ├── gitlab_trigger_scrape.yml │ ├── keldoc_center_scrap.yml │ ├── maiia_center_scrap.yml │ ├── mesoigner_center_scrap.yml │ ├── mirror.yml │ ├── scrape.yml │ ├── scrape_and_publish.yml │ ├── scrape_and_publish_nosleep.yml │ ├── test.yml │ └── valwin_center_scrap.yml ├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── __init__.py ├── config.json ├── contributors.py ├── data ├── input │ ├── benevoles.csv │ ├── benevoles.csv.howto.md │ ├── cedex_to_insee.json │ ├── centers_blocklist.json │ ├── codepostal_to_insee.json │ ├── dep-pop.csv │ ├── departements-france.csv │ ├── insee_to_codepostal_and_code_departement.json │ ├── map.svg │ ├── mapharma_campagnes.json │ ├── mapharma_campagnes_inconnues.json │ └── mapharma_campagnes_valides.json └── output │ ├── centres_open_data.json │ ├── departements.json │ ├── doctolib-centers.json │ ├── maiia_centers.json │ ├── mapharma_open_data.json │ └── valwin_centers.json ├── dev ├── __init__.py ├── model │ ├── __init__.py │ ├── department.py │ └── schedule.py └── quality_checks.py ├── gitlab-runner ├── .gitignore ├── Vagrantfile ├── envs │ ├── GRA11 │ │ └── terragrunt.hcl │ ├── GRA5 │ │ └── terragrunt.hcl │ ├── SBG5 │ │ └── terragrunt.hcl │ └── terragrunt.hcl └── modules │ └── runner │ ├── backend.tf │ ├── main.tf │ ├── outputs.tf │ ├── providers.tf │ ├── provision.sh │ ├── variables.tf │ └── versions.tf ├── pyproject.toml ├── scrape.py ├── scraper ├── __init__.py ├── avecmondoc │ ├── __init__.py │ └── avecmondoc.py ├── bimedoc │ ├── bimedoc.py │ └── bimedoc_center_scrap.py ├── circuit_breaker.py ├── creneaux │ ├── __init__.py │ └── creneau.py ├── doctolib │ ├── __init__.py │ ├── doctolib.py │ ├── doctolib_center_scrap.py │ ├── doctolib_filters.py │ └── doctolib_parsers.py ├── error.py ├── export │ ├── __init__.py │ ├── export_v2.py │ ├── resource.py │ ├── resource_centres.py │ └── resource_creneaux_quotidiens.py ├── keldoc │ ├── __init__.py │ ├── keldoc.py │ ├── keldoc_center.py │ ├── keldoc_center_scrap.py │ ├── keldoc_filters.py │ └── keldoc_routes.py ├── maiia │ ├── __init__.py │ ├── maiia.py │ ├── maiia_center_scrap.py │ └── maiia_utils.py ├── main.py ├── mapharma │ ├── __init__.py │ └── mapharma.py ├── mesoigner │ ├── __init__.py │ ├── mesoigner.py │ └── mesoigner_center_scrap.py ├── ordoclic │ └── ordoclic.py ├── pattern │ ├── __init__.py │ ├── center_info.py │ ├── center_location.py │ ├── scraper_request.py │ ├── scraper_result.py │ ├── tags.py │ └── vaccine.py ├── profiler.py ├── scraper.py └── valwin │ ├── __init__.py │ ├── valwin.py │ └── valwin_center_scrap.py ├── scripts ├── contributors ├── coverage ├── create-index.sh ├── install ├── scrape └── test ├── setup.cfg ├── setup.py ├── stats_generation ├── by_vaccine.py ├── stats_available_centers.py ├── stats_center_types.py └── stats_map.py ├── tests ├── __init__.py ├── dev │ ├── __init__.py │ └── model │ │ ├── __init__.py │ │ └── test_center.py ├── fixtures │ ├── avecmondoc │ │ ├── center.json │ │ ├── centerdict.json │ │ ├── get_availabilities.json │ │ ├── get_availabilities_week1.json │ │ ├── get_availabilities_week2.json │ │ ├── get_by_doctor.json │ │ ├── get_by_organization.json │ │ ├── get_doctor_slug.json │ │ ├── get_organization_slug.json │ │ ├── get_reasons.json │ │ ├── iterator_search_result.json │ │ ├── search-result.json │ │ └── search-result.schema │ ├── bimedoc │ │ ├── bimedoc_center_info.json │ │ ├── bimedoc_centers.json │ │ ├── slots_available.json │ │ └── slots_unavailable.json │ ├── doctolib │ │ ├── basic-availabilities.json │ │ ├── basic-booking.json │ │ ├── booking-with-doctors.json │ │ ├── category-availabilities.json │ │ ├── category-booking.json │ │ ├── next-slot-availabilities.json │ │ ├── next-slot-booking.json │ │ └── scrap-center-result.json │ ├── keldoc │ │ ├── cabinet-16913-centerinfo.json │ │ ├── center1-cabinet-16910.json │ │ ├── center1-cabinet-16913.json │ │ ├── center1-cabinet-18780.json │ │ ├── center1-cabinet.json │ │ ├── center1-info.json │ │ ├── center1-motives.json │ │ ├── center1-timetable-81484.json │ │ ├── center1-timetable-81486.json │ │ ├── center1-timetable-81488.json │ │ ├── center1-timetable-82874.json │ │ ├── department-ain.json │ │ ├── motives-ain.json │ │ ├── resource-ain.json │ │ └── result-ain.json │ ├── maiia │ │ ├── availabilities.json │ │ ├── availability-closests.json │ │ ├── consultation-reason-hcd.json │ │ ├── scrap-center-result.json │ │ └── scrap-center.json │ ├── mapharma │ │ ├── mapharma_open_data.json │ │ └── slots.json │ ├── mesoigner │ │ ├── mesoigner_center_info.json │ │ ├── mesoigner_centers.json │ │ ├── slots_available.json │ │ └── slots_unavailable.json │ ├── ordoclic │ │ ├── empty_slots.json │ │ ├── fetchslot-profile.json │ │ ├── fetchslot-profile2.json │ │ ├── fetchslot-reasons.json │ │ ├── fetchslot-slots.json │ │ ├── full_slots.json │ │ ├── lancelot_profile.json │ │ ├── lancelot_reasons.json │ │ ├── nextavailable_slots.json │ │ ├── reasons.json │ │ ├── reasons.schema │ │ ├── search-result.json │ │ ├── search.json │ │ └── search.schema │ ├── stats │ │ ├── info-centres.json │ │ └── stat-output.json │ ├── utils │ │ └── info_centres.json │ └── valwin │ │ ├── slots_available.json │ │ ├── slots_unavailable.json │ │ ├── valwin_center_info.json │ │ └── valwin_centers.json ├── stats_generation │ ├── __init__.py │ └── test_by_vaccine.py ├── test_avecmondoc.py ├── test_bimedoc.py ├── test_center_info.py ├── test_center_location.py ├── test_circuit_breaker.py ├── test_departement.py ├── test_doctolib.py ├── test_doctolib_scraper.py ├── test_geo_api.py ├── test_keldoc.py ├── test_keldoc_center_scrap.py ├── test_maiia.py ├── test_mapharma.py ├── test_mesoigner.py ├── test_ordoclic.py ├── test_resource_creneaux_quotidiens.py ├── test_resource_par_departement.py ├── test_scraper.py ├── test_stats.py ├── test_utils.py ├── test_valwin.py └── utils.py └── utils ├── vmd_blocklist.py ├── vmd_center_sort.py ├── vmd_config.py ├── vmd_duplicated.py ├── vmd_geo_api.py ├── vmd_logger.py ├── vmd_opendata.py └── vmd_utils.py /.github/ISSUE_TEMPLATE/missing_center.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Centre Manquant 3 | about: Mon centre n'apparait pas sur Vite Ma Dose 4 | 5 | --- 6 | 7 | **Checklist** 8 | 9 | * [ ] J'ai réessayé a 15min d'intervalle 10 | * [ ] Mon issue contient l'URL du centre manquant 11 | * [ ] Mon issue contient l'URL vers le département / code postal Vite Ma Dose 12 | 13 | URL complète à insérer ici 14 | 15 | **Notes** 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/.github/assets/logo.png -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | 9 | parsers: 10 | gcov: 11 | branch_detection: 12 | conditional: yes 13 | loop: yes 14 | method: no 15 | macro: no 16 | 17 | comment: 18 | layout: "reach,diff,flags,files,footer" 19 | behavior: default 20 | require_changes: no 21 | -------------------------------------------------------------------------------- /.github/disabled/cron_doctolib.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Cron Doctolib 4 | 5 | on: 6 | workflow_run: 7 | workflows: ["Scrap Doctolib"] 8 | types: 9 | - completed 10 | 11 | jobs: 12 | sleep: 13 | name: "Sleep" 14 | runs-on: "ubuntu-20.04" 15 | 16 | steps: 17 | - name: "Sleep 120s" 18 | run: sleep 120 -------------------------------------------------------------------------------- /.github/disabled/cron_keldoc.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Cron Keldoc 4 | 5 | on: 6 | workflow_run: 7 | workflows: ["Scrap Keldoc"] 8 | types: 9 | - completed 10 | 11 | jobs: 12 | sleep: 13 | name: "Sleep" 14 | runs-on: "ubuntu-20.04" 15 | 16 | steps: 17 | - name: "Sleep 120s" 18 | run: sleep 120 -------------------------------------------------------------------------------- /.github/disabled/cron_maiia.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Cron Maiia 4 | 5 | on: 6 | workflow_run: 7 | workflows: ["Scrap Maiia"] 8 | types: 9 | - completed 10 | 11 | jobs: 12 | sleep: 13 | name: "Sleep" 14 | runs-on: "ubuntu-20.04" 15 | 16 | steps: 17 | - name: "Sleep 120s" 18 | run: sleep 120 -------------------------------------------------------------------------------- /.github/disabled/cron_mapharma.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Cron Mapharma 4 | 5 | on: 6 | workflow_run: 7 | workflows: ["Scrap Mapharma"] 8 | types: 9 | - completed 10 | 11 | jobs: 12 | sleep: 13 | name: "Sleep" 14 | runs-on: "ubuntu-20.04" 15 | 16 | steps: 17 | - name: "Sleep 120s" 18 | run: sleep 120 -------------------------------------------------------------------------------- /.github/disabled/cron_ordoclic.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Cron Ordoclic 4 | 5 | on: 6 | workflow_run: 7 | workflows: ["Scrap Ordoclic"] 8 | types: 9 | - completed 10 | 11 | jobs: 12 | sleep: 13 | name: "Sleep" 14 | runs-on: "ubuntu-20.04" 15 | 16 | steps: 17 | - name: "Sleep 120s" 18 | run: sleep 120 -------------------------------------------------------------------------------- /.github/disabled/scrape_doctolib.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Scrap Doctolib 4 | 5 | on: 6 | schedule: 7 | - cron: "*/5 * * * *" 8 | push: 9 | branches: ["main"] 10 | workflow_run: 11 | workflows: ["Cron Doctolib"] 12 | types: 13 | - completed 14 | 15 | jobs: 16 | 17 | scrape_doctolib: 18 | 19 | name: "Scrape Doctolib" 20 | runs-on: "ubuntu-20.04" 21 | 22 | steps: 23 | - uses: "actions/checkout@v2" 24 | with: 25 | token: ${{ secrets.PAT_GRZ }} 26 | - uses: "actions/setup-python@v2" 27 | with: 28 | python-version: "3.8" 29 | - name: "Install" 30 | run: scripts/install 31 | - name: "Scraping Doctolib..." 32 | run: | 33 | scripts/scrape -p doctolib 34 | cp data/output/pool/doctolib.json /tmp/doctolib.json 35 | - name: "Switch to data-auto" 36 | run: | 37 | git fetch --all --prune --force 38 | git reset --hard origin/data-auto 39 | git switch data-auto 40 | cp /tmp/doctolib.json data/output/pool/doctolib.json 41 | - uses: stefanzweifel/git-auto-commit-action@v4 42 | continue-on-error: true 43 | with: 44 | commit_message: "Updated scraper data: doctolib (pool/doctolib.json)" 45 | file_pattern: data/output/pool/doctolib.json -------------------------------------------------------------------------------- /.github/disabled/scrape_keldoc.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Scrap Keldoc 4 | 5 | on: 6 | schedule: 7 | - cron: "*/5 * * * *" 8 | push: 9 | branches: ["main"] 10 | workflow_dispatch: 11 | workflow_run: 12 | workflows: ["Cron Keldoc"] 13 | types: 14 | - completed 15 | 16 | jobs: 17 | 18 | scrape_keldoc: 19 | 20 | name: "Scrape Keldoc" 21 | runs-on: "ubuntu-20.04" 22 | 23 | steps: 24 | - uses: "actions/checkout@v2" 25 | with: 26 | token: ${{ secrets.PAT_GRZ }} 27 | - uses: "actions/setup-python@v2" 28 | with: 29 | python-version: "3.8" 30 | - name: "Install" 31 | run: scripts/install 32 | - name: "Scraping Keldoc..." 33 | run: | 34 | scripts/scrape -p keldoc 35 | cp data/output/pool/keldoc.json /tmp/keldoc.json 36 | - name: "Switch to data-auto" 37 | run: | 38 | git fetch --all --prune --force 39 | git reset --hard origin/data-auto 40 | git switch data-auto 41 | cp /tmp/keldoc.json data/output/pool/keldoc.json 42 | - uses: stefanzweifel/git-auto-commit-action@v4 43 | continue-on-error: true 44 | with: 45 | commit_message: "Updated scraper data: keldoc (pool/keldoc.json)" 46 | file_pattern: data/output/pool/keldoc.json 47 | -------------------------------------------------------------------------------- /.github/disabled/scrape_maiia.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Scrap Maiia 4 | 5 | on: 6 | schedule: 7 | - cron: "*/5 * * * *" 8 | push: 9 | branches: ["main"] 10 | workflow_dispatch: 11 | workflow_run: 12 | workflows: ["Cron Maiia"] 13 | types: 14 | - completed 15 | 16 | jobs: 17 | 18 | scrape_maiia: 19 | 20 | name: "Scrape Maiia" 21 | runs-on: "ubuntu-20.04" 22 | 23 | steps: 24 | - uses: "actions/checkout@v2" 25 | with: 26 | token: ${{ secrets.PAT_GRZ }} 27 | - uses: "actions/setup-python@v2" 28 | with: 29 | python-version: "3.8" 30 | - name: "Install" 31 | run: scripts/install 32 | - name: "Scraping Maiia..." 33 | run: | 34 | scripts/scrape -p maiia 35 | cp data/output/pool/maiia.json /tmp/maiia.json 36 | - name: "Switch to data-auto" 37 | run: | 38 | git fetch --all --prune --force 39 | git reset --hard origin/data-auto 40 | git switch data-auto 41 | cp /tmp/maiia.json data/output/pool/maiia.json 42 | - uses: stefanzweifel/git-auto-commit-action@v4 43 | continue-on-error: true 44 | with: 45 | commit_message: "Updated scraper data: maiia (pool/maiia.json)" 46 | file_pattern: data/output/pool/maiia.json 47 | -------------------------------------------------------------------------------- /.github/disabled/scrape_mapharma.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Scrap Mapharma 3 | 4 | on: 5 | schedule: 6 | - cron: "*/5 * * * *" 7 | push: 8 | branches: ["main"] 9 | workflow_dispatch: 10 | workflow_run: 11 | workflows: ["Cron Mapharma"] 12 | types: 13 | - completed 14 | 15 | jobs: 16 | 17 | scrape_mapharma: 18 | 19 | name: "Scrape Mapharma" 20 | runs-on: "ubuntu-20.04" 21 | 22 | steps: 23 | - uses: "actions/checkout@v2" 24 | with: 25 | token: ${{ secrets.PAT_GRZ }} 26 | - uses: "actions/setup-python@v2" 27 | with: 28 | python-version: "3.8" 29 | - name: "Install" 30 | run: scripts/install 31 | - name: "Scraping Mapharma..." 32 | run: | 33 | scripts/scrape -p mapharma 34 | cp data/output/pool/mapharma.json /tmp/mapharma.json 35 | - name: "Switch to data-auto" 36 | run: | 37 | git fetch --all --prune --force 38 | git reset --hard origin/data-auto 39 | git switch data-auto 40 | cp /tmp/mapharma.json data/output/pool/mapharma.json 41 | - uses: stefanzweifel/git-auto-commit-action@v4 42 | continue-on-error: true 43 | with: 44 | commit_message: "Updated scraper data: mapharma (pool/mapharma.json)" 45 | file_pattern: data/output/pool/mapharma.json 46 | -------------------------------------------------------------------------------- /.github/disabled/scrape_merge.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Scrap merge 4 | 5 | on: 6 | schedule: 7 | - cron: "*/5 * * * *" 8 | push: 9 | branches: ["main", "data-auto"] 10 | workflow_dispatch: 11 | workflow_run: 12 | workflows: ["CI build"] 13 | types: 14 | - completed 15 | 16 | jobs: 17 | 18 | scrape_merge: 19 | 20 | name: "Scrape merge" 21 | runs-on: "ubuntu-20.04" 22 | 23 | steps: 24 | - uses: "actions/checkout@v2" 25 | with: 26 | token: ${{ secrets.PAT_GRZ }} 27 | - uses: "actions/setup-python@v2" 28 | with: 29 | python-version: "3.8" 30 | - name: "Install" 31 | run: scripts/install 32 | - name: "Download platform files..." 33 | run: | 34 | git clone --branch data-auto https://github.com/CovidTrackerFr/vitemadose /tmp/repo/ 35 | cp /tmp/repo/data/output/pool/* data/output/pool 36 | - name: "Merge scrapers..." 37 | run: | 38 | scripts/scrape -m 39 | - name: "Copy output files..." 40 | run: | 41 | mkdir /tmp/output 42 | cp `find data/output -type f -exec basename '{}' ';' | egrep '^.{1,3}.json$' | sed 's/.*/data\/output\/&/'` /tmp/output 43 | cp data/output/centres_open_data.json /tmp/output 44 | cp data/output/info_centres.json /tmp/output 45 | - name: "Switch to data-auto" 46 | run: | 47 | git fetch --all --prune --force 48 | git reset --hard origin/data-auto 49 | git switch data-auto 50 | cp /tmp/output/* data/output/ 51 | - uses: stefanzweifel/git-auto-commit-action@v4 52 | with: 53 | commit_message: Automatic Update 54 | file_pattern: data/output/*.json -------------------------------------------------------------------------------- /.github/disabled/scrape_ordoclic.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Scrap Ordoclic 4 | 5 | on: 6 | schedule: 7 | - cron: "*/5 * * * *" 8 | push: 9 | branches: ["main"] 10 | workflow_dispatch: 11 | workflow_run: 12 | workflows: ["Cron Ordoclic"] 13 | types: 14 | - completed 15 | 16 | jobs: 17 | 18 | scrape_ordoclic: 19 | 20 | name: "Scrape Ordoclic" 21 | runs-on: "ubuntu-20.04" 22 | 23 | steps: 24 | - uses: "actions/checkout@v2" 25 | with: 26 | token: ${{ secrets.PAT_GRZ }} 27 | - uses: "actions/setup-python@v2" 28 | with: 29 | python-version: "3.8" 30 | - name: "Install" 31 | run: scripts/install 32 | - name: "Scraping Ordoclic..." 33 | run: | 34 | scripts/scrape -p ordoclic 35 | cp data/output/pool/ordoclic.json /tmp/ordoclic.json 36 | - name: "Switch to data-auto" 37 | run: | 38 | git fetch --all --prune --force 39 | git reset --hard origin/data-auto 40 | git switch data-auto 41 | cp /tmp/ordoclic.json data/output/pool/ordoclic.json 42 | - uses: stefanzweifel/git-auto-commit-action@v4 43 | continue-on-error: true 44 | with: 45 | commit_message: "Updated scraper data: ordoclic (pool/ordoclic.json)" 46 | file_pattern: data/output/pool/ordoclic.json -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | **Checklist** 8 | 9 | - [ ] Fix #issue 10 | - [ ] J'ai ajouté des tests (si nécessaire) 11 | - [ ] J'ai formatté/identé mon code en utilisant [black](https://github.com/psf/black) - `black -l 120 fileXX fileYY` 12 | 13 | **Description** 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/bimedoc_center_scrap.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bimedoc center scrap 3 | 4 | env: 5 | BIMEDOC_API_KEY: ${{ secrets.BIMEDOC_API_KEY }} 6 | on: 7 | schedule: 8 | - cron: "0 * * * *" 9 | # Allow running manually 10 | workflow_dispatch: 11 | 12 | jobs: 13 | scrape: 14 | name: "Bimedoc center scrap" 15 | runs-on: "ubuntu-20.04" 16 | steps: 17 | - uses: "actions/checkout@v2" 18 | with: 19 | token: ${{ secrets.PAT_GRZ }} 20 | - uses: "actions/setup-python@v2" 21 | with: 22 | python-version: "3.8" 23 | - name: "Install" 24 | run: make install 25 | - name: "Bimedoc scrap..." 26 | run: make bimedocscrap 27 | - name: "Copy output file" 28 | run: cp data/output/bimedoc_centers.json . 29 | - name: "Get folder from data-auto" 30 | run: | 31 | git clone --branch data-auto https://github.com/CovidTrackerFr/vitemadose.git tmp/ 32 | cp -R tmp/data/output/* data/output 33 | rm -rf tmp/ 34 | cp bimedoc_centers.json bimedoc_center_list.json 35 | cp bimedoc_centers.json bimedoc_center_list.json data/output 36 | - uses: stefanzweifel/git-auto-commit-action@v4 37 | with: 38 | commit_message: Updated Bimedoc centers 39 | push_options: "--force HEAD:data-auto" 40 | -------------------------------------------------------------------------------- /.github/workflows/cancel.yml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | name: Cancel 4 | 5 | on: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | sleep: 10 | name: "cancel" 11 | runs-on: "ubuntu-20.04" 12 | 13 | steps: 14 | - name: Cancel Workflow Runs 15 | uses: potiuk/cancel-workflow-runs@v4_7 16 | with: 17 | cancelMode: allDuplicates 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | selfPreservation: true 20 | -------------------------------------------------------------------------------- /.github/workflows/doctolib_center_scrap.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Doctolib center scrap 3 | env: 4 | DOCTOLIB_API_KEY: ${{ secrets.DOCTOLIB_API_KEY }} 5 | VPN_CONFIG: ${{ secrets.VPN_CONFIG }} 6 | VPN_CONNECT: ${{ secrets.VPN_CONNECT }} 7 | 8 | on: 9 | schedule: 10 | - cron: "0 * * * *" 11 | # Allow running manually 12 | workflow_dispatch: 13 | 14 | jobs: 15 | 16 | scrape: 17 | name: "Doctolib center scrap" 18 | runs-on: "ubuntu-20.04" 19 | steps: 20 | - uses: "actions/checkout@v2" 21 | with: 22 | token: ${{ secrets.PAT_GRZ }} 23 | - uses: "actions/setup-python@v2" 24 | with: 25 | python-version: "3.8" 26 | - name: "Install" 27 | run: make install 28 | 29 | - name: Install Open VPN and config files 30 | run: | 31 | sudo apt-get install openvpn 32 | mkdir ~/.ssh 33 | pwd 34 | ls 35 | sudo echo "$VPN_CONFIG" >> config.ovpn 36 | sudo echo "$VPN_CONNECT" >> auth.txt 37 | 38 | - name: Connect VPN 39 | run: sudo openvpn --config config.ovpn --auth-user-pass auth.txt --daemon 40 | 41 | - name: Check VPN 42 | run: | 43 | sleep 30 44 | echo ${{ steps.connect_vpn.outputs.STATUS }} 45 | curl https://ipecho.net/plain 46 | 47 | - name: "Doctolib scrap..." 48 | run: make doctoscrap 49 | - name: "Copy output file" 50 | run: cp data/output/doctolib-centers.json . 51 | - name: "Get folder from data-auto" 52 | run: | 53 | git clone --branch data-auto https://github.com/CovidTrackerFr/vitemadose.git tmp/ 54 | cp -R tmp/data/output/* data/output 55 | rm -rf tmp/ 56 | cp doctolib-centers.json doctolib_center_list.json 57 | cp doctolib-centers.json doctolib_center_list.json data/output 58 | - uses: stefanzweifel/git-auto-commit-action@v4 59 | with: 60 | commit_message: Updated Doctolib centers 61 | push_options: '--force HEAD:data-auto' 62 | -------------------------------------------------------------------------------- /.github/workflows/gitlab_trigger_scrape.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Gitlab trigger scrape 3 | 4 | on: 5 | schedule: 6 | - cron: "*/3 * * * *" # Every 20 mins on fridays 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | 12 | jobs: 13 | 14 | trigger_scrape: 15 | name: "Gitlab scrape trigger" 16 | runs-on: "ubuntu-20.04" 17 | steps: 18 | - name: "Call gitlab api" 19 | run: "curl -X POST -F token=${{secrets.GITLAB_TRIGGER_KEY}} -F ref=main https://gitlab.com/api/v4/projects/27510169/trigger/pipeline" 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/keldoc_center_scrap.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Keldoc center scrap 3 | 4 | on: 5 | schedule: 6 | - cron: "0 * * * *" 7 | # Allow running manually 8 | workflow_dispatch: 9 | 10 | jobs: 11 | 12 | scrape: 13 | name: "Keldoc center scrap" 14 | runs-on: "ubuntu-20.04" 15 | steps: 16 | - uses: "actions/checkout@v2" 17 | with: 18 | token: ${{ secrets.PAT_GRZ }} 19 | - uses: "actions/setup-python@v2" 20 | with: 21 | python-version: "3.8" 22 | - name: "Install" 23 | run: make install 24 | - name: "Keldoc scrap..." 25 | run: make keldocscrap 26 | - name: "Copy output file" 27 | run: cp data/output/keldoc_centers.json . 28 | - name: "Get folder from data-auto" 29 | run: | 30 | git clone --branch data-auto https://github.com/CovidTrackerFr/vitemadose.git tmp/ 31 | cp -R tmp/data/output/* data/output 32 | rm -rf tmp/ 33 | cp keldoc_centers.json keldoc_center_list.json 34 | cp keldoc_centers.json keldoc_center_list.json data/output 35 | - uses: stefanzweifel/git-auto-commit-action@v4 36 | with: 37 | commit_message: Updated Keldoc centers 38 | push_options: '--force HEAD:data-auto' 39 | -------------------------------------------------------------------------------- /.github/workflows/maiia_center_scrap.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Maiia center scrap 3 | 4 | on: 5 | schedule: 6 | - cron: "0 * * * *" 7 | # Allow running manually 8 | workflow_dispatch: 9 | 10 | jobs: 11 | 12 | scrape: 13 | name: "Maiia center scrap" 14 | runs-on: "ubuntu-20.04" 15 | steps: 16 | - uses: "actions/checkout@v2" 17 | with: 18 | token: ${{ secrets.PAT_GRZ }} 19 | - uses: "actions/setup-python@v2" 20 | with: 21 | python-version: "3.8" 22 | - name: "Install" 23 | run: make install 24 | - name: "Maiia scrap..." 25 | run: make maiiascrap 26 | - name: "Copy output file" 27 | run: cp data/output/maiia_centers.json . 28 | - name: "Get folder from data-auto" 29 | run: | 30 | git clone --branch data-auto https://github.com/CovidTrackerFr/vitemadose.git tmp/ 31 | cp -R tmp/data/output/* data/output 32 | rm -rf tmp/ 33 | cp maiia_centers.json maiia_center_list.json 34 | cp maiia_centers.json maiia_center_list.json data/output 35 | - uses: stefanzweifel/git-auto-commit-action@v4 36 | with: 37 | commit_message: Updated Maiia centers 38 | push_options: '--force HEAD:data-auto' 39 | -------------------------------------------------------------------------------- /.github/workflows/mesoigner_center_scrap.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Mesoigner center scrap 3 | 4 | env: 5 | MESOIGNER_API_KEY: ${{ secrets.MESOIGNER_API_KEY }} 6 | on: 7 | schedule: 8 | - cron: "0 * * * *" 9 | # Allow running manually 10 | workflow_dispatch: 11 | 12 | jobs: 13 | scrape: 14 | name: "Mesoigner center scrap" 15 | runs-on: "ubuntu-20.04" 16 | steps: 17 | - uses: "actions/checkout@v2" 18 | with: 19 | token: ${{ secrets.PAT_GRZ }} 20 | - uses: "actions/setup-python@v2" 21 | with: 22 | python-version: "3.8" 23 | - name: "Install" 24 | run: make install 25 | - name: "Mesoigner scrap..." 26 | run: make mesoignerscrap 27 | - name: "Copy output file" 28 | run: cp data/output/mesoigner_centers.json . 29 | - name: "Get folder from data-auto" 30 | run: | 31 | git clone --branch data-auto https://github.com/CovidTrackerFr/vitemadose.git tmp/ 32 | cp -R tmp/data/output/* data/output 33 | rm -rf tmp/ 34 | cp mesoigner_centers.json mesoigner_center_list.json 35 | cp mesoigner_centers.json mesoigner_center_list.json data/output 36 | - uses: stefanzweifel/git-auto-commit-action@v4 37 | with: 38 | commit_message: Updated Mesoigner centers 39 | push_options: "--force HEAD:data-auto" 40 | -------------------------------------------------------------------------------- /.github/workflows/mirror.yml: -------------------------------------------------------------------------------- 1 | name: Test then Push Gitlab 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | jobs: 8 | test: 9 | name: "Test" 10 | runs-on: "ubuntu-20.04" 11 | steps: 12 | - uses: "actions/checkout@v2" 13 | - uses: "actions/setup-python@v2" 14 | with: 15 | python-version: "3.8" 16 | - name: "Install dependencies" 17 | run: scripts/install 18 | - name: "Run tests" 19 | run: scripts/test 20 | - name: "List all contributors" 21 | run: scripts/contributors 22 | continue-on-error: true 23 | - name: "Install coverage badge" 24 | run: pip install pytest coverage coverage-badge 25 | continue-on-error: true 26 | - name: "Generate coverage badge" 27 | run: coverage-badge 28 | continue-on-error: true 29 | - name: "Upload coverage to Codecov" 30 | uses: codecov/codecov-action@v1 31 | continue-on-error: true 32 | with: 33 | fail_ci_if_error: true 34 | 35 | push_gitlab: 36 | 37 | name: "Push On Gitlab" 38 | runs-on: "ubuntu-20.04" 39 | needs: test 40 | steps: 41 | - uses: "actions/checkout@v2" 42 | with: 43 | ref: main 44 | fetch-depth: 0 45 | - name: "Mirror to Gitlab.com" 46 | shell: bash 47 | env: 48 | GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} 49 | run: | 50 | git push "https://vitemadose-github:${GITLAB_TOKEN}@gitlab.com/ViteMaDose/vitemadose.git" main --force 51 | 52 | 53 | -------------------------------------------------------------------------------- /.github/workflows/scrape.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Scrape 3 | 4 | on: 5 | # push: 6 | # branches: ["main"] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | 11 | scrape: 12 | 13 | name: "Scrape" 14 | 15 | runs-on: "ubuntu-20.04" 16 | 17 | steps: 18 | - uses: "actions/checkout@v2" 19 | with: 20 | token: ${{ secrets.PAT_GRZ }} 21 | - uses: "actions/setup-python@v2" 22 | with: 23 | python-version: "3.8" 24 | - name: "Install" 25 | run: scripts/install 26 | - name: "Scraping..." 27 | run: scripts/scrape 28 | - name: "Stats" 29 | run: make stats 30 | -------------------------------------------------------------------------------- /.github/workflows/scrape_and_publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Scrape And Publish 3 | 4 | on: 5 | ## Disabled. Enable if emergency 6 | # push: 7 | # branches: 8 | # - data-auto 9 | # schedule: 10 | # - cron: "*/5 * * * *" 11 | workflow_dispatch: 12 | 13 | jobs: 14 | 15 | scrape_and_publish: 16 | 17 | name: "Scrape And Publish" 18 | 19 | runs-on: "ubuntu-20.04" 20 | 21 | steps: 22 | - uses: "actions/checkout@v2" 23 | with: 24 | token: ${{ secrets.PAT_GRZ }} 25 | - uses: "actions/setup-python@v2" 26 | with: 27 | python-version: "3.8" 28 | - name: "Install" 29 | run: scripts/install 30 | - name: "Scraping..." 31 | run: scripts/scrape 32 | - name: "Stats" 33 | run: make stats 34 | - name: "Run tests" 35 | run: scripts/test 36 | - name: "Install coverage badge" 37 | run: pip install pytest coverage coverage-badge 38 | - name: "Generate coverage badge" 39 | run: rm -rf .github/coverage.svg; coverage-badge -o .github/coverage.svg 40 | - uses: stefanzweifel/git-auto-commit-action@v4 41 | with: 42 | commit_message: Automatic Update 43 | push_options: '--force HEAD:data-auto' 44 | -------------------------------------------------------------------------------- /.github/workflows/scrape_and_publish_nosleep.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Scrape And Publish (nosleep) 3 | 4 | on: 5 | # Allow running manually 6 | workflow_dispatch: 7 | 8 | jobs: 9 | 10 | scrape_and_publish_nosleep: 11 | 12 | name: "Scrape And Publish No Sleep" 13 | 14 | runs-on: "ubuntu-20.04" 15 | 16 | steps: 17 | - uses: "actions/checkout@v2" 18 | with: 19 | token: ${{ secrets.PAT_GRZ }} 20 | - uses: "actions/setup-python@v2" 21 | with: 22 | python-version: "3.8" 23 | - name: "Install" 24 | run: scripts/install 25 | - name: "Scraping..." 26 | run: scripts/scrape 27 | - name: "Stats" 28 | run: make stats 29 | - name: "Run tests" 30 | run: scripts/test 31 | - name: "Install coverage badge" 32 | run: pip install pytest coverage coverage-badge 33 | - name: "Generate coverage badge" 34 | run: rm -rf .github/coverage.svg; coverage-badge -o .github/coverage.svg 35 | - uses: stefanzweifel/git-auto-commit-action@v4 36 | with: 37 | commit_message: Automatic Update 38 | push_options: '--force HEAD:data-auto' 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | 7 | jobs: 8 | test: 9 | name: "Test" 10 | runs-on: "ubuntu-20.04" 11 | steps: 12 | - uses: "actions/checkout@v2" 13 | - uses: "actions/setup-python@v2" 14 | with: 15 | python-version: "3.8" 16 | - name: "Install dependencies" 17 | run: scripts/install 18 | - name: "Run tests" 19 | run: scripts/test 20 | - name: "List all contributors" 21 | run: scripts/contributors 22 | continue-on-error: true 23 | - name: "Install coverage badge" 24 | run: pip install pytest coverage coverage-badge 25 | continue-on-error: true 26 | - name: "Generate coverage badge" 27 | run: coverage-badge 28 | continue-on-error: true 29 | - name: "Upload coverage to Codecov" 30 | continue-on-error: true 31 | uses: codecov/codecov-action@v1 32 | with: 33 | fail_ci_if_error: true 34 | -------------------------------------------------------------------------------- /.github/workflows/valwin_center_scrap.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Valwin center scrap 3 | 4 | on: 5 | schedule: 6 | - cron: "0 * * * *" 7 | # Allow running manually 8 | workflow_dispatch: 9 | 10 | jobs: 11 | scrape: 12 | name: "Valwin center scrap" 13 | runs-on: "ubuntu-20.04" 14 | steps: 15 | - uses: "actions/checkout@v2" 16 | with: 17 | token: ${{ secrets.PAT_GRZ }} 18 | - uses: "actions/setup-python@v2" 19 | with: 20 | python-version: "3.8" 21 | - name: "Install" 22 | run: make install 23 | - name: "Valwin scrap..." 24 | run: make valwinscrap 25 | - name: "Copy output file" 26 | run: cp data/output/valwin_centers.json . 27 | - name: "Get folder from data-auto" 28 | run: | 29 | git clone --branch data-auto https://github.com/CovidTrackerFr/vitemadose.git tmp/ 30 | cp -R tmp/data/output/* data/output 31 | rm -rf tmp/ 32 | cp valwin_centers.json valwin_center_list.json 33 | cp valwin_centers.json valwin_center_list.json data/output 34 | - uses: stefanzweifel/git-auto-commit-action@v4 35 | with: 36 | commit_message: Updated Valwin centers 37 | push_options: "--force HEAD:data-auto" 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # IDEA settings 124 | .idea 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | 138 | # GENERATED DATA 139 | data/output/contributors* 140 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | - scrape 4 | - deploy 5 | cache: 6 | paths: 7 | - venv 8 | - cache 9 | test: 10 | stage: test 11 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:3.8-alpine 12 | timeout: "2 minutes" 13 | before_script: 14 | - ./scripts/install 15 | script: 16 | - ./scripts/test 17 | except: 18 | - schedules 19 | - triggers 20 | - branches 21 | 22 | lister_contributeurs: 23 | stage: scrape 24 | allow_failure: true 25 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:3.8-alpine 26 | timeout: "3 minutes" 27 | before_script: 28 | - ./scripts/install 29 | script: 30 | - ./scripts/contributors 31 | artifacts: 32 | name: "contributors" 33 | expire_in: "1 week" 34 | paths: 35 | - data/output 36 | only: 37 | - main 38 | - gitlab-publish 39 | - schedules 40 | trouver_les_rdv: 41 | stage: scrape 42 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:3.8-alpine 43 | timeout: "2 hours" 44 | tags: 45 | - clevercloud 46 | before_script: 47 | - apk add --no-cache make curl 48 | - ./scripts/install 49 | - echo IP publique de sortie du gitlab runner $(curl -s https://ifconfig.me/ip ) 50 | - traceroute -4 -l -I partners.doctolib.fr 51 | - curl --silent --fail --head https://partners.doctolib.fr/ 52 | script: 53 | - ./scripts/scrape 54 | - make stats 55 | artifacts: 56 | name: "rdv" 57 | expire_in: "2 days" 58 | paths: 59 | - data/output 60 | only: 61 | - main 62 | - gitlab-publish 63 | - schedules 64 | pages: 65 | stage: deploy 66 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/python:3.8-alpine 67 | tags: 68 | - clevercloud 69 | script: 70 | - mkdir -p public 71 | - cp -r data/output/* public/ 72 | - scripts/create-index.sh public 73 | - gzip -k -9 $(find public -type f) 74 | only: 75 | - main 76 | - gitlab-publish 77 | - schedules 78 | artifacts: 79 | expire_in: "1 week" 80 | paths: 81 | - public 82 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 20.8b1 4 | hooks: 5 | - id: black 6 | - repo: git@github.com:humitos/mirrors-autoflake.git 7 | rev: v1.3 8 | hooks: 9 | - id: autoflake 10 | args: ['--in-place', '--remove-all-unused-imports', '--remove-unused-variable', '--ignore-init-module-imports'] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .DEFAULT_GOAL := help 3 | 4 | URL = 5 | 6 | .PHONY: help test install 7 | help: ## provides cli help for this makefile (default) 📖 8 | @grep -E '^[a-zA-Z_0-9-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 9 | 10 | install: ## sets up package and its dependencies 11 | scripts/install 12 | 13 | test: ## runs tests 14 | scripts/test 15 | 16 | coverage: ## reports test coverage (automatically run by `test`) 17 | scripts/coverage 18 | 19 | scrape: ## runs the full scraping experience 20 | scripts/scrape $(URL) 21 | 22 | stats: ## Run the statistic scripts 23 | venv/bin/python -m stats_generation.stats_available_centers 24 | venv/bin/python -m stats_generation.by_vaccine 25 | 26 | doctoscrap: ## Scrap all doctolib centers, output : data/output/doctolib-centers.json 27 | venv/bin/python -m scraper.doctolib.doctolib_center_scrap 28 | 29 | keldocscrap: ## Scrap all doctolib centers, output : data/output/keldoc-centers.json 30 | venv/bin/python -m scraper.keldoc.keldoc_center_scrap 31 | 32 | maiiascrap: ## Retrieve maiia centers from API 33 | venv/bin/python -m scraper.maiia.maiia_center_scrap 34 | 35 | mesoignerscrap: ## Scrap all mesoigner centers, output : data/output/mesoigner_centers.json 36 | venv/bin/python -m scraper.mesoigner.mesoigner_center_scrap 37 | 38 | bimedocscrap: ## Scrap all bimedoc centers, output : data/output/bimedoc_centers.json 39 | venv/bin/python -m scraper.bimedoc.bimedoc_center_scrap 40 | 41 | valwinscrap: ## Scrap all valwin centers, output : data/output/valwin_centers.json 42 | venv/bin/python -m scraper.valwin.valwin_center_scrap 43 | 44 | blocklistmanager: ## Blocklist command line manager 45 | venv/bin/python -m management_scripts.manage_blocklist 46 | 47 | lint: install 48 | venv/bin/pip install black 49 | venv/bin/black $$(git ls-files | grep .py$$) 50 | 51 | lint-check: install 52 | venv/bin/pip install black 53 | venv/bin/black --check $$(git ls-files | grep .py$$) 54 | 55 | contributors: install 56 | scripts/contributors 57 | 58 | clean: 59 | rm -rf data/output 60 | git checkout data/output 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Vite Ma Dose ! 2 | 3 | [Vite Ma Dose !](https://covidtracker.fr/vitemadose) est un outil open source de [CovidTracker](https://covidtracker.fr) permettant de détecter les rendez-vous disponibles dans votre département afin de vous faire vacciner (sous réserve d'éligibilité). 4 | 5 | [![Contributeurs][contributors-shield]][contributors-url] 6 | [![Issues][issues-shield]][issues-url] 7 | [![Licence][license-shield]][license-url] 8 | [![codecov](https://codecov.io/gh/CovidTrackerFr/vitemadose/branch/main/graph/badge.svg?token=UQEI3UXY67)](https://codecov.io/gh/CovidTrackerFr/vitemadose) 9 | 10 | ## Signaler un Problème, une idée de modification 11 | 12 | 13 | Ouvrez une [issue Github](https://github.com/CovidTrackerFr/vitemadose/issues/new) si vous souhaitez signaler un problème. 14 | 15 | ## Comment Contribuer 16 | 17 | Le développement de l'application étant tres actif nous recommendons de joindre [le Mattermost Général de Vite Ma Dose](https://mattermost.covidtracker.fr/covidtracker/channels/town-square) pour être sûr que personne ne travaille déjà sur ce que vous comptez faire. Si ce n'est pas le cas, quelqu'un vous aiguillera si vous avez besoin d'aide. 18 | Pour proposer une modification, un ajout, ou decrire un bug sur l'outil de détection, vous pouvez ouvrir une [issue](https://github.com/CovidTrackerFr/vitemadose/issues/new) ou une [Pull Request](https://github.com/CovidTrackerFr/vitemadose/pulls) avec vos modifications. 19 | 20 | La [documentation](https://hackmd.io/YHcjKsUzQ1-cMomOUuTpXw) permet de centraliser les informations importantes relatives au développement de l'outil : comment ça marche, quelles sont les grosses tâches du moment, comment on communique ... 21 | 22 | Pour le code en Python, veillez à respecter le standard PEP8 avant de soumettre une Pull-Request. 23 | La plupart des IDEs et éditeurs de code moderne proposent des outils permettant de mettre en page votre code en suivant ce standard automatiquement. 24 | 25 | ## Plateformes supportées 26 | 27 | | Plateforme | Lien | Supporté | 28 | | ------------- |:-------------:| :-----:| 29 | | | https://doctolib.fr/ | | 30 | | | https://keldoc.com | | 31 | | | https://maiia.com | | 32 | | | https://ordoclic.fr | | 33 | | | https://www.mapharma.net/ | | 34 | | | https://www.avecmondoc.com/ | | 35 | | | https://www.mesoigner.fr/ | | 36 | | | https://www.bimedoc.com/ | | 37 | 38 | ## Utilisation 39 | 40 | Installer les dépendances (À la racine de `vitemadose`) : 41 | 42 | ```bash 43 | make install 44 | ``` 45 | 46 | Lancer le scraper : 47 | 48 | ```bash 49 | make scrape 50 | ``` 51 | 52 | Générer des statistiques : 53 | 54 | ```bash 55 | make stats 56 | ``` 57 | 58 | Lancer des tests unitaires : 59 | 60 | ```bash 61 | make test 62 | ``` 63 | 64 | 65 | [contributors-shield]: https://img.shields.io/github/contributors/CovidTrackerFr/vitemadose.svg?style=for-the-badge 66 | [contributors-url]: https://github.com/CovidTrackerFr/vitemadose/graphs/contributors 67 | [forks-shield]: https://img.shields.io/github/forks/CovidTrackerFr/vitemadose.svg?style=for-the-badge 68 | [forks-url]: https://github.com/CovidTrackerFr/vitemadose/network/members 69 | [issues-shield]: https://img.shields.io/github/issues/CovidTrackerFr/vitemadose.svg?style=for-the-badge 70 | [issues-url]: https://github.com/CovidTrackerFr/vitemadose/issues 71 | [license-shield]: https://img.shields.io/github/license/CovidTrackerFr/vitemadose.svg?style=for-the-badge 72 | [license-url]: https://github.com/CovidTrackerFr/vitemadose/blob/master/LICENSE 73 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/__init__.py -------------------------------------------------------------------------------- /data/input/benevoles.csv.howto.md: -------------------------------------------------------------------------------- 1 | Comment mettre à jour la liste des contributeurs ? 2 | ================================================== 3 | 4 | 1. Aller chercher le document [ici](https://docs.google.com/spreadsheets/d/16hy7k6RywMNaw29GM-_sW75EtOWN_GWmsG-kCqnzh-Q/edit#gid=0) 5 | 2. Télécharger en tant que CSV avec `File > Download > Comma-separated values (csv)` 6 | 3. Enregistrer à la place du fichier `data/input/benevoles.csv` 7 | 4. ⚠️ enlever la première ligne ! la première ligne doit comporter seulement les nom des colonnes (pas la petite annonce) 8 | 5. Lancer `make contributors` (ou `scripts/contributors`) pour vérifier que la génération se passe sans problèmes 9 | 10 | -------------------------------------------------------------------------------- /data/input/centers_blocklist.json: -------------------------------------------------------------------------------- 1 | { 2 | "centers_not_displayed": [ 3 | { 4 | "name": "Pharmacie du March\u00e9", 5 | "url": "https://doctolib.fr/pharmacie/les-clayes-sous-bois/pharmacie-du-marche-les-clayes-sous-bois", 6 | "issue": null, 7 | "details": null 8 | }, 9 | { 10 | "name": "Captain Pharma", 11 | "url": "https://app.ordoclic.fr/app/pharmacie/captain-pharma-nanterre", 12 | "issue": null, 13 | "details": "La pharmacie est sur Doctolib et Ordoclic et ne veut pas apparaitre via Ordoclic (rdv tel uniquement) sur VMD" 14 | }, 15 | { 16 | "name": " Pharmacies de Combs", 17 | "url": "https://www.doctolib.fr/pharmacie/combs-la-ville/pharmacies-de-combs?pid=practice-175094", 18 | "issue": "https://github.com/CovidTrackerFr/vitemadose/issues/559", 19 | "details": null 20 | }, 21 | { 22 | "name": "Pharmacie des Corsaires Saint-Malo", 23 | "url": "https://www.doctolib.fr/pharmacie/saint-malo/pharmacie-des-corsaires-saint-malo?pid=practice-175541", 24 | "issue": "", 25 | "details": "" 26 | }, 27 | { 28 | "name": "Centre de vaccination Covid-19 de Limeil", 29 | "url": "https://www.doctolib.fr/vaccination-covid-19/limeil-brevannes/centre-de-vaccination-covid-19-de-limeil?pid=practice-186067", 30 | "issue": "", 31 | "details": "Demande du centre par e-mail" 32 | }, 33 | { 34 | "name": "Selarl Pharmacie Rondelet", 35 | "url": "https://app.ordoclic.fr/app/pharmacie/selarl-pharmacie-rondelet-montpellier", 36 | "issue": "", 37 | "details": "Demande par e-mail" 38 | } 39 | ] 40 | } -------------------------------------------------------------------------------- /data/input/dep-pop.csv: -------------------------------------------------------------------------------- 1 | dep;departmentPopulation 2 | 01;649012 3 | 02;552529 4 | 03;351626 5 | 04;166635 6 | 05;146060 7 | 06;1097556 8 | 07;333781 9 | 08;285612 10 | 09;157904 11 | 10;316888 12 | 11;376667 13 | 12;290199 14 | 13;2045149 15 | 14;709986 16 | 15;151920 17 | 16;366289 18 | 17;658529 19 | 18;317101 20 | 19;250077 21 | 2A;155285 22 | 2B;177438 23 | 21;546601 24 | 22;618156 25 | 23;124569 26 | 24;427579 27 | 25;551143 28 | 26;519264 29 | 27;619392 30 | 28;445361 31 | 29;936478 32 | 30;754170 33 | 31;1361286 34 | 32;198213 35 | 33;1578386 36 | 34;1140030 37 | 35;1070462 38 | 36;230546 39 | 37;619651 40 | 38;1278347 41 | 39;270474 42 | 40;416642 43 | 41;343392 44 | 42;775977 45 | 43;234555 46 | 44;1400585 47 | 45;691291 48 | 46;179573 49 | 47;343059 50 | 48;80176 51 | 49;833080 52 | 50;517500 53 | 51;585622 54 | 52;184987 55 | 53;318079 56 | 54;748528 57 | 55;196681 58 | 56;767610 59 | 57;1064593 60 | 58;219019 61 | 59;2641081 62 | 60;841252 63 | 61;295936 64 | 62;1496824 65 | 63;664386 66 | 64;690788 67 | 65;236017 68 | 66;479421 69 | 67;1134800 70 | 68;777878 71 | 69;1852002 72 | 70;245130 73 | 71;573281 74 | 72;583151 75 | 73;441669 76 | 74;816748 77 | 75;2228409 78 | 76;1283249 79 | 77;1412250 80 | 78;1454532 81 | 79;385395 82 | 80;584143 83 | 81;398190 84 | 82;261452 85 | 83;1065985 86 | 84;569618 87 | 85;685673 88 | 86;445927 89 | 87;384226 90 | 88;385043 91 | 89;351302 92 | 90;147799 93 | 91;1294240 94 | 92;1620776 95 | 93;1603095 96 | 94;1384068 97 | 95;1231356 98 | 971;404542 99 | 972;386875 100 | 973;262381 101 | 974;860896 102 | 976;270372 -------------------------------------------------------------------------------- /data/input/departements-france.csv: -------------------------------------------------------------------------------- 1 | code_departement,nom_departement,code_region,nom_region 2 | 01,Ain,84,Auvergne-Rhône-Alpes 3 | 02,Aisne,32,Hauts-de-France 4 | 03,Allier,84,Auvergne-Rhône-Alpes 5 | 04,Alpes-de-Haute-Provence,93,Provence-Alpes-Côte d'Azur 6 | 05,Hautes-Alpes,93,Provence-Alpes-Côte d'Azur 7 | 06,Alpes-Maritimes,93,Provence-Alpes-Côte d'Azur 8 | 07,Ardèche,84,Auvergne-Rhône-Alpes 9 | 08,Ardennes,44,Grand Est 10 | 09,Ariège,76,Occitanie 11 | 10,Aube,44,Grand Est 12 | 11,Aude,76,Occitanie 13 | 12,Aveyron,76,Occitanie 14 | 13,Bouches-du-Rhône,93,Provence-Alpes-Côte d'Azur 15 | 14,Calvados,28,Normandie 16 | 15,Cantal,84,Auvergne-Rhône-Alpes 17 | 16,Charente,75,Nouvelle-Aquitaine 18 | 17,Charente-Maritime,75,Nouvelle-Aquitaine 19 | 18,Cher,24,Centre-Val de Loire 20 | 19,Corrèze,75,Nouvelle-Aquitaine 21 | 21,Côte-d'Or,27,Bourgogne-Franche-Comté 22 | 22,Côtes-d'Armor,53,Bretagne 23 | 23,Creuse,75,Nouvelle-Aquitaine 24 | 24,Dordogne,75,Nouvelle-Aquitaine 25 | 25,Doubs,27,Bourgogne-Franche-Comté 26 | 26,Drôme,84,Auvergne-Rhône-Alpes 27 | 27,Eure,28,Normandie 28 | 28,Eure-et-Loir,24,Centre-Val de Loire 29 | 29,Finistère,53,Bretagne 30 | 2A,Corse-du-Sud,94,Corse 31 | 2B,Haute-Corse,94,Corse 32 | 30,Gard,76,Occitanie 33 | 31,Haute-Garonne,76,Occitanie 34 | 32,Gers,76,Occitanie 35 | 33,Gironde,75,Nouvelle-Aquitaine 36 | 34,Hérault,76,Occitanie 37 | 35,Ille-et-Vilaine,53,Bretagne 38 | 36,Indre,24,Centre-Val de Loire 39 | 37,Indre-et-Loire,24,Centre-Val de Loire 40 | 38,Isère,84,Auvergne-Rhône-Alpes 41 | 39,Jura,27,Bourgogne-Franche-Comté 42 | 40,Landes,75,Nouvelle-Aquitaine 43 | 41,Loir-et-Cher,24,Centre-Val de Loire 44 | 42,Loire,84,Auvergne-Rhône-Alpes 45 | 43,Haute-Loire,84,Auvergne-Rhône-Alpes 46 | 44,Loire-Atlantique,52,Pays de la Loire 47 | 45,Loiret,24,Centre-Val de Loire 48 | 46,Lot,76,Occitanie 49 | 47,Lot-et-Garonne,75,Nouvelle-Aquitaine 50 | 48,Lozère,76,Occitanie 51 | 49,Maine-et-Loire,52,Pays de la Loire 52 | 50,Manche,28,Normandie 53 | 51,Marne,44,Grand Est 54 | 52,Haute-Marne,44,Grand Est 55 | 53,Mayenne,52,Pays de la Loire 56 | 54,Meurthe-et-Moselle,44,Grand Est 57 | 55,Meuse,44,Grand Est 58 | 56,Morbihan,53,Bretagne 59 | 57,Moselle,44,Grand Est 60 | 58,Nièvre,27,Bourgogne-Franche-Comté 61 | 59,Nord,32,Hauts-de-France 62 | 60,Oise,32,Hauts-de-France 63 | 61,Orne,28,Normandie 64 | 62,Pas-de-Calais,32,Hauts-de-France 65 | 63,Puy-de-Dôme,84,Auvergne-Rhône-Alpes 66 | 64,Pyrénées-Atlantiques,75,Nouvelle-Aquitaine 67 | 65,Hautes-Pyrénées,76,Occitanie 68 | 66,Pyrénées-Orientales,76,Occitanie 69 | 67,Bas-Rhin,44,Grand Est 70 | 68,Haut-Rhin,44,Grand Est 71 | 69,Rhône,84,Auvergne-Rhône-Alpes 72 | 70,Haute-Saône,27,Bourgogne-Franche-Comté 73 | 71,Saône-et-Loire,27,Bourgogne-Franche-Comté 74 | 72,Sarthe,52,Pays de la Loire 75 | 73,Savoie,84,Auvergne-Rhône-Alpes 76 | 74,Haute-Savoie,84,Auvergne-Rhône-Alpes 77 | 75,Paris,11,Île-de-France 78 | 76,Seine-Maritime,28,Normandie 79 | 77,Seine-et-Marne,11,Île-de-France 80 | 78,Yvelines,11,Île-de-France 81 | 79,Deux-Sèvres,75,Nouvelle-Aquitaine 82 | 80,Somme,32,Hauts-de-France 83 | 81,Tarn,76,Occitanie 84 | 82,Tarn-et-Garonne,76,Occitanie 85 | 83,Var,93,Provence-Alpes-Côte d'Azur 86 | 84,Vaucluse,93,Provence-Alpes-Côte d'Azur 87 | 85,Vendée,52,Pays de la Loire 88 | 86,Vienne,75,Nouvelle-Aquitaine 89 | 87,Haute-Vienne,75,Nouvelle-Aquitaine 90 | 88,Vosges,44,Grand Est 91 | 89,Yonne,27,Bourgogne-Franche-Comté 92 | 90,Territoire de Belfort,27,Bourgogne-Franche-Comté 93 | 91,Essonne,11,Île-de-France 94 | 92,Hauts-de-Seine,11,Île-de-France 95 | 93,Seine-Saint-Denis,11,Île-de-France 96 | 94,Val-de-Marne,11,Île-de-France 97 | 95,Val-d'Oise,11,Île-de-France 98 | 971,Guadeloupe,01,Guadeloupe 99 | 972,Martinique,02,Martinique 100 | 973,Guyane,03,Guyane 101 | 974,La Réunion,04,La Réunion 102 | 976,Mayotte,06,Mayotte 103 | om,Collectivités d'Outremer,-1,Outremer 104 | -------------------------------------------------------------------------------- /data/input/mapharma_campagnes.json: -------------------------------------------------------------------------------- 1 | { 2 | "vaccin": [ 3 | { 4 | "campagneId": "47", 5 | "optionId": "1", 6 | "optionName": "Vaccination covid" 7 | }, 8 | { 9 | "campagneId": "48", 10 | "optionId": "1", 11 | "optionName": "Vaccination Covid19" 12 | }, 13 | { 14 | "campagneId": "60", 15 | "optionId": "1", 16 | "optionName": "Vaccination covid 1 injection (30 min.)" 17 | }, 18 | { 19 | "campagneId": "70", 20 | "optionId": "1", 21 | "optionName": "Primo vaccination (15 min.)" 22 | }, 23 | { 24 | "campagneId": "92", 25 | "optionId": "1", 26 | "optionName": "Vaccination Covid-19" 27 | }, 28 | { 29 | "campagneId": "96", 30 | "optionId": "1", 31 | "optionName": "COVID 1ERE INJ ASTRA ZENECA (30 min.)" 32 | }, 33 | { 34 | "campagneId": "102", 35 | "optionId": "1", 36 | "optionName": "1ère injection (10 min.)" 37 | }, 38 | { 39 | "campagneId": "113", 40 | "optionId": "1", 41 | "optionName": "vaccination covid vendredi (10 min.)" 42 | }, 43 | { 44 | "campagneId": "113", 45 | "optionId": "2", 46 | "optionName": "vaccination covid samedi (10 min.)" 47 | }, 48 | { 49 | "campagneId": "128", 50 | "optionId": "1", 51 | "optionName": "Vaccination COVID 1 injection" 52 | }, 53 | { 54 | "campagneId": "131", 55 | "optionId": "1", 56 | "optionName": "Vaccination Covid 1 injection" 57 | }, 58 | { 59 | "campagneId": "155", 60 | "optionId": "1", 61 | "optionName": "Vaccination AZ 2(25 et 26/03)" 62 | }, 63 | { 64 | "campagneId": "168", 65 | "optionId": "1", 66 | "optionName": "VACCINATION COVID" 67 | }, 68 | { 69 | "campagneId": "177", 70 | "optionId": "1", 71 | "optionName": "Vaccination covid" 72 | }, 73 | { 74 | "campagneId": "186", 75 | "optionId": "1", 76 | "optionName": "1ère injection vaccin COVID-19 (AstraZeneca)" 77 | }, 78 | { 79 | "campagneId": "189", 80 | "optionId": "1", 81 | "optionName": "Astra 1" 82 | }, 83 | { 84 | "campagneId": "191", 85 | "optionId": "1", 86 | "optionName": "Vaccination anti COVID ASTRAZENECA (15 min.)" 87 | }, 88 | { 89 | "campagneId": "201", 90 | "optionId": "1", 91 | "optionName": "Vaccination COVID - 1ère injection (15 min.)" 92 | } 93 | ], 94 | "autre": 95 | [ 96 | { 97 | "campagneId": "70", 98 | "optionId": "2", 99 | "optionName": "Rappel (15 min.)" 100 | }, 101 | { 102 | "campagneId": "120", 103 | "optionId": "1", 104 | "optionName": "Test Antigénique (15 min.)" 105 | }, 106 | { 107 | "campagneId": "120", 108 | "optionId": "2", 109 | "optionName": "Test Sérologique (15 min.)" 110 | }, 111 | { 112 | "campagneId": "81", 113 | "optionId": "1", 114 | "optionName": "Entretien diététique" 115 | }, 116 | { 117 | "campagneId": "82", 118 | "optionId": "1", 119 | "optionName": "Entretien micro nutrition aroma" 120 | }, 121 | { 122 | "campagneId": "193", 123 | "optionId": "1", 124 | "optionName": "Test Antigeniques COVID Naso-Pharynges" 125 | }, 126 | { 127 | "campagneId": "196", 128 | "optionId": "1", 129 | "optionName": "Test Covid TROC Sanguins" 130 | } 131 | ] 132 | } -------------------------------------------------------------------------------- /data/input/mapharma_campagnes_inconnues.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /data/input/mapharma_campagnes_valides.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id_campagne": 59, 4 | "id_type": 1, 5 | "nom": "COVID 1\u00e8re injection", 6 | "debut": "2021-03-19", 7 | "fin": "2021-07-31", 8 | "url": "https://mapharma.net/45430?c=59&l=1" 9 | }, 10 | { 11 | "id_campagne": 201, 12 | "id_type": 1, 13 | "nom": "Vaccination COVID (Vaccination COVID - 1\u00e8re injection)", 14 | "debut": "2021-04-13", 15 | "fin": "2021-04-14", 16 | "vaccine_type": "astrazeneca", 17 | "url": "https://mapharma.net/49100-5?c=201&l=1" 18 | }, 19 | { 20 | "id_campagne": 93, 21 | "id_type": 1, 22 | "nom": "VACCINATION COVID", 23 | "debut": "2021-03-23", 24 | "fin": "2021-08-11", 25 | "url": "https://mapharma.net/02100-2?c=93&l=1" 26 | }, 27 | { 28 | "id_campagne": 48, 29 | "id_type": 1, 30 | "nom": "Vaccination Covid19", 31 | "debut": "2021-03-22", 32 | "fin": "2021-06-10", 33 | "vaccine_type": "astrazeneca", 34 | "url": "https://mapharma.net/57730?c=48&l=1" 35 | }, 36 | { 37 | "id_campagne": 92, 38 | "id_type": 1, 39 | "nom": "Vaccination Covid-19", 40 | "debut": "2021-03-22", 41 | "fin": "2021-04-30", 42 | "vaccine_type": "astrazeneca", 43 | "url": "https://mapharma.net/88400?c=92&l=1" 44 | }, 45 | { 46 | "id_campagne": 60, 47 | "id_type": 1, 48 | "nom": "Vaccination covid 1 injection", 49 | "debut": "2021-03-29", 50 | "fin": "2021-05-15", 51 | "url": "https://mapharma.net/97200?c=60&l=1" 52 | } 53 | ] 54 | -------------------------------------------------------------------------------- /dev/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/dev/__init__.py -------------------------------------------------------------------------------- /dev/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/dev/model/__init__.py -------------------------------------------------------------------------------- /dev/model/department.py: -------------------------------------------------------------------------------- 1 | """Dev helper file to explore `data/output/info_centres.json` and similar files. 2 | 3 | Typically, you’d evaluate this file in a REPL, then: `data = load()` and start exploring. 4 | 5 | Example: Figuring out if `vaccine_type` is always a list of one vaccine type: 6 | 7 | (Start a REPL and evaluate this file) 8 | >>> from dev.model import department 9 | >>> data = department.load_all() 10 | >>> assert { 11 | ... centre 12 | ... for department_data in data.values() 13 | ... for centre in department_data 14 | ... if len(centre.vaccine_type) > 1 15 | ... } 16 | True 17 | 18 | """ 19 | from __future__ import annotations 20 | 21 | import json 22 | from datetime import datetime 23 | from itertools import chain 24 | from pathlib import Path 25 | from scraper.pattern.vaccine import Vaccine 26 | from typing import Dict, Iterator, List, Optional 27 | 28 | from pydantic import BaseModel, Field 29 | 30 | from dev.model.schedule import Schedule 31 | 32 | 33 | class Location(BaseModel): 34 | longitude: float 35 | latitude: float 36 | city: Optional[str] 37 | 38 | 39 | class Center(BaseModel): 40 | department: str = Field(alias="departement") 41 | name: str = Field(alias="nom") 42 | url: str 43 | location: Optional[Location] 44 | metadata: dict 45 | next_appointment: Optional[datetime] = Field(alias="prochain_rdv") 46 | platform: str = Field(alias="plateforme") 47 | type: str 48 | appointment_count: int 49 | internal_id: Optional[str] 50 | vaccine_type: Optional[List[Vaccine]] 51 | appointment_by_phone_only: Optional[bool] 52 | error: Optional[str] = Field(alias="error") 53 | last_scan_with_availabilities: Optional[datetime] 54 | gid: str 55 | 56 | 57 | class Department(BaseModel): 58 | version: str 59 | last_updated: datetime 60 | available_centers: List[Center] = Field(alias="centres_disponibles") 61 | unavailable_centers: List[Center] = Field(alias="centres_indisponibles") 62 | 63 | def __iter__(self) -> Iterator[Center]: 64 | return chain(self.available_centers, self.unavailable_centers) 65 | 66 | @classmethod 67 | def load(cls, path: Path = Path("data", "output", "01.json")) -> Department: 68 | with open(path) as json_file: 69 | return cls(**json.load(json_file)) 70 | 71 | 72 | def load_all(path: Path = Path("data", "output", "info_centres.json")) -> Dict[str, Department]: 73 | with open(path) as json_file: 74 | return {department: Department(**data) for department, data in json.load(json_file).items()} 75 | -------------------------------------------------------------------------------- /dev/model/schedule.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class Schedule(BaseModel): 7 | name: str 8 | from_: datetime # "2021-05-10T00:00:00+02:00" 9 | to: datetime # "2021-05-11T23:59:59+02:00" 10 | total: int 11 | 12 | def __init__(self, **kwargs): 13 | args = kwargs 14 | if "from" in args: 15 | args["from_"] = args.pop("from") 16 | super().__init__(**args) 17 | -------------------------------------------------------------------------------- /dev/quality_checks.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from dev.model import department 3 | 4 | from dev.model.department import Center, Department 5 | 6 | Departments = List[Department] 7 | 8 | 9 | def _empty_or_null(string: Optional[str]) -> bool: 10 | return string is None or string == "" 11 | 12 | 13 | def check_department_available_centers(department: Department) -> bool: 14 | return len(department.available_centers) > 0 15 | 16 | 17 | department_checks = ((check_department_available_centers, "no available centers"),) 18 | 19 | 20 | def check_center_no_empty_name(center: Center) -> bool: 21 | return not _empty_or_null(center.name) 22 | 23 | 24 | def check_center_no_empty_url(center: Center): 25 | return not _empty_or_null(center.url) 26 | 27 | 28 | def check_center_no_empty_location(center: Center): 29 | return (location := center.location) and location.latitude and location.longitude 30 | 31 | 32 | def check_only_one_vaccine_type(center: Center): 33 | return center.vaccine_type and len(center.vaccine_type) == 1 34 | 35 | 36 | center_checks = ( 37 | (check_center_no_empty_name, "empty name"), 38 | (check_center_no_empty_url, "empty URL"), 39 | (check_center_no_empty_location, "empty or incomplete location"), 40 | (check_only_one_vaccine_type, "0 or more than one vaccine type"), 41 | ) 42 | 43 | 44 | data = department.load_all() 45 | 46 | for check, message in department_checks: 47 | failed = [department_id for department_id, department in data.items() if not check(department)] 48 | if failed: 49 | print(f"Departments with {message}") 50 | for department_id in failed: 51 | print(f" - {department_id}") 52 | 53 | for check, message in center_checks: 54 | failed = [center for department in data.values() for center in department.available_centers if not check(center)] 55 | if failed: 56 | print(f"Centers with {message}") 57 | for center in failed: 58 | print(f" - [{center.department}] {center.name}") 59 | -------------------------------------------------------------------------------- /gitlab-runner/.gitignore: -------------------------------------------------------------------------------- 1 | .terragrunt-cache/ 2 | .terraform/ 3 | *.tfstate.backup 4 | *.tfstate 5 | .vagrant 6 | -------------------------------------------------------------------------------- /gitlab-runner/Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | # All Vagrant configuration is done below. The "2" in Vagrant.configure 5 | # configures the configuration version (we support older styles for 6 | # backwards compatibility). Please don't change it unless you know what 7 | # you're doing. 8 | local_user_name = ENV['USERNAME'] || ENV['USER'] || "inconnu" 9 | gitlab_runner_token = ENV['GITLAB_RUNNER_TOKEN'] 10 | 11 | Vagrant.configure("2") do |config| 12 | # The most common configuration options are documented and commented below. 13 | # For a complete reference, please see the online documentation at 14 | # https://docs.vagrantup.com. 15 | 16 | # Every Vagrant development environment requires a box. You can search for 17 | # boxes at https://vagrantcloud.com/search. 18 | 19 | config.vm.box = "debian/stretch64" 20 | config.vm.define "local-runner" do |runner| 21 | runner.vm.provider "virtualbox" do |vm| 22 | vm.cpus = 4 23 | vm.memory = 1024 * 2 24 | end 25 | runner.vm.network "private_network", ip: "192.168.50.5" 26 | runner.vm.provision "shell", path: "./modules/runner/provision.sh", env: { 27 | "GITLAB_RUNNER_TOKEN" => gitlab_runner_token, 28 | "RUNNER_LOCATION" => local_user_name, 29 | "TAG_LIST" => "ovh" 30 | } 31 | runner.trigger.before :destroy do |trigger| 32 | trigger.run_remote = { 'inline' => "gitlab-runner unregister --all-runners || true" } 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /gitlab-runner/envs/GRA11/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | terraform { 2 | source = "../../modules/runner" 3 | } 4 | 5 | include { 6 | path = find_in_parent_folders() 7 | } 8 | 9 | inputs = { 10 | nb_instances = 1 11 | } 12 | -------------------------------------------------------------------------------- /gitlab-runner/envs/GRA5/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | terraform { 2 | source = "../../modules/runner" 3 | } 4 | 5 | include { 6 | path = find_in_parent_folders() 7 | } 8 | 9 | inputs = { 10 | nb_instances = 1 11 | } 12 | -------------------------------------------------------------------------------- /gitlab-runner/envs/SBG5/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | terraform { 2 | source = "../../modules/runner" 3 | } 4 | 5 | include { 6 | path = find_in_parent_folders() 7 | } 8 | 9 | -------------------------------------------------------------------------------- /gitlab-runner/envs/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | remote_state { 2 | backend = "swift" 3 | config = { 4 | container = "vmd-tfstate" 5 | state_name = "${path_relative_to_include()}.tfstate" 6 | 7 | auth_url = get_env("OS_AUTH_URL") 8 | tenant_id = get_env("OS_TENANT_ID") 9 | tenant_name = get_env("OS_TENANT_NAME") 10 | user_name = get_env("OS_USERNAME") 11 | password = get_env("OS_PASSWORD") 12 | region_name = "GRA" 13 | } 14 | } 15 | 16 | inputs = { 17 | nb_instances = 0 18 | ovh_region = path_relative_to_include() 19 | gitlab_runner_token = get_env("GITLAB_RUNNER_TOKEN") 20 | flavor = "c2-7" 21 | datadog_api_key = get_env("DD_API_KEY") 22 | } 23 | -------------------------------------------------------------------------------- /gitlab-runner/modules/runner/backend.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "swift" {} 3 | } 4 | 5 | -------------------------------------------------------------------------------- /gitlab-runner/modules/runner/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | provision_script_url = "https://gitlab.com/ViteMaDose/vitemadose/-/raw/main/gitlab-runner/deploy-dev-runner.sh" 3 | } 4 | 5 | 6 | data "openstack_images_image_v2" "debian_9" { 7 | provider = openstack.ovh 8 | name = "Debian 10" 9 | most_recent = true 10 | } 11 | 12 | resource "openstack_compute_instance_v2" "runner" { 13 | provider = openstack.ovh 14 | count = var.nb_instances 15 | flavor_name = var.flavor 16 | image_id = data.openstack_images_image_v2.debian_9.id 17 | name = "${var.name}-${count.index}-${var.ovh_region}" 18 | security_groups = ["default"] 19 | key_pair = openstack_compute_keypair_v2.runner_keypair.name 20 | 21 | lifecycle { 22 | create_before_destroy = false 23 | } 24 | } 25 | 26 | resource null_resource "register_runner" { 27 | count = var.nb_instances 28 | triggers = { 29 | private_key = openstack_compute_keypair_v2.runner_keypair.private_key 30 | host = openstack_compute_instance_v2.runner[count.index].access_ip_v4 31 | user = "debian" 32 | gitlab_runner_token = var.gitlab_runner_token 33 | ovh_region = var.ovh_region 34 | datadog_api_key = var.datadog_api_key 35 | } 36 | connection { 37 | user = self.triggers.user 38 | private_key = self.triggers.private_key 39 | host = self.triggers.host 40 | timeout = "45s" 41 | } 42 | 43 | provisioner "file" { 44 | source = "./provision.sh" 45 | destination = "/tmp/provision-${random_string.provision_name.id}.sh" 46 | } 47 | 48 | provisioner "remote-exec" { 49 | inline = [ 50 | "export GITLAB_RUNNER_TOKEN=${self.triggers.gitlab_runner_token}", 51 | "export RUNNER_LOCATION='OVH ${self.triggers.ovh_region} (num ${count.index})'", 52 | "export TAG_LIST='ovh,ovh-${self.triggers.ovh_region},${formatdate("DD MMM YYYY hh:mm ZZZ", timestamp())}'", 53 | "export DD_API_KEY=${self.triggers.datadog_api_key}", 54 | "export GITLAB_RUN_UNTAGGED=yes", 55 | "sudo -E bash /tmp/provision-${random_string.provision_name.id}.sh" 56 | ] 57 | } 58 | 59 | provisioner "remote-exec" { 60 | when = destroy 61 | on_failure = continue 62 | inline = [ 63 | "sudo gitlab-runner unregister --all-runners" 64 | ] 65 | } 66 | } 67 | 68 | resource random_string provision_name { 69 | length = 6 70 | special = false 71 | } 72 | 73 | resource "openstack_compute_keypair_v2" "runner_keypair" { 74 | provider = openstack.ovh 75 | name = "runners-${random_string.key_pair_name.result}" 76 | } 77 | 78 | resource "random_string" "key_pair_name" { 79 | length = 20 80 | special = false 81 | } 82 | -------------------------------------------------------------------------------- /gitlab-runner/modules/runner/outputs.tf: -------------------------------------------------------------------------------- 1 | output "public_ips" { 2 | value = openstack_compute_instance_v2.runner[*].access_ip_v4 3 | } 4 | 5 | output "instance_ids" { 6 | value = openstack_compute_instance_v2.runner[*].id 7 | } 8 | -------------------------------------------------------------------------------- /gitlab-runner/modules/runner/providers.tf: -------------------------------------------------------------------------------- 1 | provider "openstack" { 2 | alias = "ovh" 3 | region = var.ovh_region 4 | } 5 | -------------------------------------------------------------------------------- /gitlab-runner/modules/runner/provision.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | set -o errtrace 7 | 8 | GITLAB_HOST="gitlab.com" 9 | RUNNER_CONCURRENCY=${RUNNER_CONCURRENCY:-1} 10 | RUNNER_NAME="Chez ${RUNNER_LOCATION:-inconnu}" 11 | GITLAB_RUNNER_TOKEN=${GITLAB_RUNNER_TOKEN} 12 | TAG_LIST="${TAG_LIST:-no_tag},privileged" 13 | RUN_UNTAGGED=true 14 | GITLAB_RUN_UNTAGGED=${GITLAB_RUN_UNTAGGED:-true} 15 | if [[ "${GITLAB_RUN_UNTAGGED}" = "false" ]]; then 16 | RUN_UNTAGGED=false 17 | fi 18 | if test -z "${GITLAB_RUNNER_TOKEN}"; then 19 | echo vous devez définir GITLAB_RUNNER_TOKEN en variable d\'environnement >&2 20 | exit 1 21 | fi 22 | 23 | function main () { 24 | cartouche "Installing Docker" 25 | install_docker 26 | 27 | cartouche "Installing Gitlab Runner" 28 | install_gitlab_runner 29 | 30 | cartouche "Installing Datadog Agent" 31 | install_dd_agent 32 | 33 | cartouche "Register Gitlab Runner" 34 | register_gitlab_runner 35 | 36 | cartouche "Programmer le nettoyage" 37 | schedule_cleanup 38 | } 39 | 40 | 41 | function install_gitlab_runner () { 42 | wget -O- --quiet https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | bash 43 | apt-get install -y gitlab-runner 44 | gitlab-runner start 45 | adduser gitlab-runner docker 46 | } 47 | 48 | function install_dd_agent () { 49 | export DD_AGENT_MAJOR_VERSION=7 50 | export DD_API_KEY=${DD_API_KEY:-empty} 51 | export DD_SITE="datadoghq.com" 52 | if [[ "${DD_API_KEY}" = "empty" ]]; then 53 | echo "Skipping Datadog setup because no DD_API_KEY was specified" 54 | else 55 | bash -c "$(curl -L https://s3.amazonaws.com/dd-agent/scripts/install_script.sh)" 56 | fi 57 | } 58 | 59 | function register_gitlab_runner () { 60 | LOCATION_TAG=$(slugify ${RUNNER_LOCATION}) 61 | timedatectl set-timezone Europe/Paris 62 | gitlab-runner unregister --all-runners 63 | gitlab-runner register -n \ 64 | --url https://${GITLAB_HOST}/ \ 65 | --run-untagged=${RUN_UNTAGGED} \ 66 | --tag-list "${TAG_LIST},${LOCATION_TAG}" \ 67 | --registration-token ${GITLAB_RUNNER_TOKEN} \ 68 | --docker-image docker:19.03.12 \ 69 | --executor docker \ 70 | --description "${RUNNER_NAME}" \ 71 | --docker-privileged \ 72 | --docker-volumes "/certs/client" \ 73 | --env "DOCKER_DRIVER=overlay2" 74 | 75 | sed -ri "s/concurrent = .+/concurrent = ${RUNNER_CONCURRENCY}/" /etc/gitlab-runner/config.toml 76 | service gitlab-runner restart 77 | } 78 | 79 | function schedule_cleanup () { 80 | set +o pipefail 81 | crontab -l \ 82 | | grep -v '@gitlab-runner-prune' \ 83 | | { cat; echo "0 13,20 * * * docker system prune -f # @gitlab-runner-prune"; } \ 84 | | crontab - 85 | set -o pipefail 86 | } 87 | 88 | function install_docker () { 89 | apt-get -y install \ 90 | apt-transport-https \ 91 | ca-certificates \ 92 | software-properties-common \ 93 | htop 94 | 95 | wget --quiet -O- 'https://download.docker.com/linux/debian/gpg' | apt-key add - 96 | 97 | add-apt-repository \ 98 | "deb [arch=amd64] https://download.docker.com/linux/debian \ 99 | $(lsb_release -cs) \ 100 | stable" 101 | 102 | apt-get update 103 | apt-get install docker-ce -y 104 | 105 | cat > /etc/docker/daemon.json < self.time_limit: 63 | raise CircuitBreakerTooLongException(self.name) 64 | 65 | with self.policies.transact(): 66 | self.policies.append("ON") 67 | if len(self.policies) < self.trigger: 68 | self.policies.append("ON") 69 | return value 70 | 71 | except CircuitBreakerTooLongException: 72 | self.count_error() 73 | return value 74 | except Exception as e: 75 | self.count_error() 76 | raise e 77 | 78 | def breaker_enabled(self, enabled: bool): 79 | self.enabled = enabled 80 | 81 | def get_policy(self): 82 | start_time = time.time() 83 | while (time.time() - start_time) < self.time_limit: 84 | with self.policies.transact(): 85 | try: 86 | return self.policies.popleft() 87 | except IndexError: 88 | time.sleep(0.200) 89 | return "OFF" 90 | 91 | def count_error(self): 92 | with self.policies.transact(): 93 | if len(self.policies) == 0: 94 | self.policies += ["OFF"] * self.release 95 | self.policies += ["ON"] * self.trigger 96 | 97 | def call_off(self, *args, **kwargs): 98 | if self.off_func is not None: 99 | return self.off_func(*args, **kwargs) 100 | else: 101 | raise CircuitBreakerOffException(self.name) 102 | 103 | 104 | class CircuitBreakerOffException(RuntimeError): 105 | def __init__(self, name): 106 | msg = f"CircuitBreaker '{name}' is currently off" 107 | super().__init__(self, msg) 108 | self.message = msg 109 | self.name = name 110 | 111 | 112 | class CircuitBreakerTooLongException(RuntimeError): 113 | def __init__(self, name): 114 | msg = f"CircuitBreaker '{name}' execution took too long" 115 | super().__init__(self, msg) 116 | self.message = msg 117 | self.name = name 118 | -------------------------------------------------------------------------------- /scraper/creneaux/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/creneaux/__init__.py -------------------------------------------------------------------------------- /scraper/creneaux/creneau.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from pytz import timezone as Timezone 4 | from datetime import datetime 5 | from typing import Optional, List 6 | from scraper.pattern.center_location import CenterLocation 7 | from scraper.pattern.scraper_request import ScraperRequest 8 | from scraper.pattern.vaccine import Vaccine 9 | 10 | 11 | class Plateforme(str, Enum): 12 | DOCTOLIB = "Doctolib" 13 | MAIIA = "Maiia" 14 | ORDOCLIC = "Ordoclic" 15 | KELDOC = "Keldoc" 16 | MAPHARMA = "Mapharma" 17 | AVECMONDOC = "AvecMonDoc" 18 | MESOIGNER = "mesoigner" 19 | BIMEDOC = "Bimedoc" 20 | VALWIN = "Valwin" 21 | 22 | 23 | @dataclass 24 | class Lieu: 25 | departement: str 26 | nom: str 27 | url: str 28 | lieu_type: str 29 | internal_id: str 30 | location: Optional[CenterLocation] = None 31 | metadata: Optional[dict] = None 32 | plateforme: Optional[Plateforme] = None 33 | atlas_gid: Optional[int] = None 34 | 35 | 36 | @dataclass 37 | class Creneau: 38 | horaire: datetime 39 | lieu: Lieu 40 | reservation_url: str 41 | dose: list = None 42 | timezone: Timezone = Timezone("Europe/Paris") 43 | type_vaccin: Optional[List[Vaccine]] = None 44 | 45 | disponible: bool = True 46 | 47 | 48 | @dataclass 49 | class PasDeCreneau: 50 | lieu: Lieu 51 | phone_only: bool = False 52 | disponible: bool = False 53 | dose: int = None 54 | -------------------------------------------------------------------------------- /scraper/doctolib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/doctolib/__init__.py -------------------------------------------------------------------------------- /scraper/doctolib/doctolib_filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from scraper.pattern.scraper_result import DRUG_STORE, GENERAL_PRACTITIONER, VACCINATION_CENTER 4 | from utils.vmd_config import get_conf_platform 5 | 6 | DOCTOLIB_CONF = get_conf_platform("doctolib") 7 | DOCTOLIB_FILTERS = DOCTOLIB_CONF.get("filters", {}) 8 | 9 | DOCTOLIB_CATEGORY = DOCTOLIB_FILTERS.get("appointment_category", []) 10 | DOCTOLIB_CATEGORY = [c.lower().strip() for c in DOCTOLIB_CATEGORY] 11 | 12 | 13 | def is_category_relevant(category): 14 | if not category: 15 | return False 16 | 17 | category = category.lower().strip() 18 | category = re.sub(" +", " ", category) 19 | for allowed_categories in DOCTOLIB_CATEGORY: 20 | if allowed_categories in category: 21 | return True 22 | # Weird centers. But it's vaccination related COVID-19. 23 | if category == "vaccination": 24 | return True 25 | return False 26 | 27 | 28 | # Filter by relevant appointments 29 | def is_appointment_relevant(motive_id): 30 | 31 | vaccination_motives = [int(item) for item in DOCTOLIB_FILTERS["motives"].keys()] 32 | """Tell if an appointment name is related to COVID-19 vaccination 33 | 34 | Example 35 | ---------- 36 | >>> is_appointment_relevant(6970) 37 | True 38 | >>> is_appointment_relevant(245617) 39 | False 40 | """ 41 | if not motive_id: 42 | return False 43 | 44 | if motive_id in vaccination_motives: 45 | return True 46 | 47 | return False 48 | 49 | 50 | def dose_number(motive_id: int): 51 | if not motive_id: 52 | return None 53 | dose_number = DOCTOLIB_FILTERS["motives"][str(motive_id)]["dose"] 54 | if dose_number: 55 | return dose_number 56 | return None 57 | 58 | 59 | # Parse practitioner type from Doctolib booking data. 60 | def parse_practitioner_type(name, data): 61 | if "pharmacie" in name.lower(): 62 | return DRUG_STORE 63 | profile = data.get("profile", {}) 64 | specialty = profile.get("speciality", {}) 65 | if specialty: 66 | slug = specialty.get("slug", None) 67 | if slug and slug == "medecin-generaliste": 68 | return GENERAL_PRACTITIONER 69 | return VACCINATION_CENTER 70 | 71 | 72 | def is_vaccination_center(center_dict): 73 | """Determine if a center provide COVID19 vaccinations. 74 | See: https://github.com/CovidTrackerFr/vitemadose/issues/271 75 | 76 | Parameters 77 | ---------- 78 | center_dict : "Center" dict 79 | Center dict, output by the doctolib_center_scrap.center_from_doctor_dict 80 | 81 | Returns 82 | ---------- 83 | bool 84 | True if if we think the center provide COVID19 vaccination 85 | 86 | Example 87 | ---------- 88 | >>> center_without_vaccination = {'gid': 'd258630', 'visit_motives_ids': [224512]} 89 | >>> is_vaccination_center(center_without_vaccination) 90 | False 91 | >>> center_with_vaccination = {'gid': 'd257554', 'visit_motives_ids': [6970]} 92 | >>> is_vaccination_center(center_with_vaccination) 93 | True 94 | """ 95 | motives = center_dict.get("visit_motives_ids", []) 96 | 97 | # We don't have any motiv 98 | # so this criteria isn't relevant to determine if a center is a vaccination center 99 | # considering it as a vaccination one to prevent mass filtering 100 | # see https://github.com/CovidTrackerFr/vitemadose/issues/271 101 | if len(motives) == 0: 102 | return True 103 | 104 | for motive in motives: 105 | if is_appointment_relevant(motive): # first vaccine motive, it's a vaccination center 106 | return True 107 | 108 | return False # No vaccination motives found 109 | -------------------------------------------------------------------------------- /scraper/error.py: -------------------------------------------------------------------------------- 1 | class ScrapeError(Exception): 2 | def __init__(self, plateforme="Autre", raison="Erreur de scrapping"): 3 | super().__init__(f"ERREUR DE SCRAPPING ({plateforme}): {raison}") 4 | self.plateforme = plateforme 5 | self.raison = raison 6 | 7 | 8 | class Blocked403(ScrapeError): 9 | def __init__(self, platform, url): 10 | super().__init__(platform, f"Doctolib bloque nos appels: 403 {url}") 11 | 12 | 13 | class RequestError(ScrapeError): 14 | def __init__(self, url, response_code="wrong-url"): 15 | super().__init__("Doctolib", f"Erreur {response_code} lors de l'accès à {url}") 16 | self.blocked = True 17 | 18 | 19 | class DoublonDoctolib(ScrapeError): 20 | def __init__(self, url): 21 | super().__init__( 22 | "Doctolib", f"Le centre est un doublon ou ne propose pas de motif de vaccination sur ce lieu {url}" 23 | ) 24 | -------------------------------------------------------------------------------- /scraper/export/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/export/__init__.py -------------------------------------------------------------------------------- /scraper/export/export_v2.py: -------------------------------------------------------------------------------- 1 | from utils.vmd_utils import q_iter 2 | from scraper.creneaux.creneau import Creneau 3 | from scraper.export.resource_centres import ResourceParDepartement, ResourceTousDepartements 4 | from scraper.export.resource_creneaux_quotidiens import ResourceCreneauxQuotidiens 5 | from scraper.pattern.tags import CURRENT_TAGS 6 | import os 7 | import json 8 | import logging 9 | from typing import Iterator 10 | from dataclasses import dataclass 11 | import sys 12 | from utils.vmd_config import get_conf_outputs, get_config 13 | 14 | logger = logging.getLogger("scraper") 15 | 16 | 17 | class JSONExporter: 18 | def __init__(self, departements=None, outpath_format="data/output/{}.json"): 19 | self.outpath_format = outpath_format 20 | departements = departements if departements else Departement.all() 21 | resources_departements = { 22 | departement.code: ResourceParDepartement(departement.code) for departement in departements 23 | } 24 | resources_creneaux_quotidiens = { 25 | f"{departement.code}/creneaux-quotidiens": ResourceCreneauxQuotidiens(departement.code, tags=CURRENT_TAGS) 26 | for departement in departements 27 | } 28 | self.resources = { 29 | "info_centres": ResourceTousDepartements(), 30 | **resources_departements, 31 | **resources_creneaux_quotidiens, 32 | } 33 | 34 | def export(self, creneaux: Iterator[Creneau]): 35 | count = 0 36 | for creneau in creneaux: 37 | count += 1 38 | 39 | for resource in self.resources.values(): 40 | resource.on_creneau(creneau) 41 | 42 | lieux_avec_dispo = len(self.resources["info_centres"].centres_disponibles) 43 | lieux_sans_dispo = len(self.resources["info_centres"].centres_indisponibles) 44 | lieux_bloques_mais_dispo = len(self.resources["info_centres"].centres_bloques_mais_disponibles) 45 | 46 | if lieux_avec_dispo == 0: 47 | logger.error( 48 | "Aucune disponibilité n'a été trouvée sur aucun centre, c'est bizarre, alors c'est probablement une erreur" 49 | ) 50 | exit(code=1) 51 | 52 | logger.info( 53 | f"{lieux_avec_dispo} centres ont des disponibilités sur {lieux_avec_dispo+lieux_sans_dispo} centre scannés (+{lieux_bloques_mais_dispo} bloqués)" 54 | ) 55 | logger.info(f"{count} créneaux dans {lieux_avec_dispo} centres") 56 | print("\n") 57 | if lieux_bloques_mais_dispo > 0: 58 | logger.info(f"{lieux_bloques_mais_dispo} centres sont bloqués mais ont des disponibilités : ") 59 | for centre_bloque in self.resources["info_centres"].centres_bloques_mais_disponibles: 60 | logger.info(f"Le centre {centre_bloque} est bloqué mais a des disponibilités.") 61 | 62 | for key, resource in self.resources.items(): 63 | outfile_path = self.outpath_format.format(key) 64 | os.makedirs(os.path.dirname(outfile_path), exist_ok=True) 65 | with open(outfile_path, "w") as outfile: 66 | logger.debug(f"Writing file {outfile_path}") 67 | json.dump(resource.asdict(), outfile, indent=2) 68 | 69 | with open(get_conf_outputs().get("data_gouv"), "w") as outfile: 70 | logger.debug(f'Writing file {get_conf_outputs().get("data_gouv")}') 71 | json.dump(self.resources["info_centres"].opendata, outfile, indent=2) 72 | 73 | 74 | @dataclass 75 | class Departement: 76 | code_departement: str 77 | nom_departement: str 78 | code_region: int 79 | nom_region: str 80 | 81 | @property 82 | def code(self) -> str: 83 | return self.code_departement 84 | 85 | @property 86 | def nom(self) -> str: 87 | return self.nom_departement 88 | 89 | @classmethod 90 | def all(cls): 91 | dir_path = os.path.dirname(os.path.realpath(__file__)) 92 | json_source_path = os.path.join(dir_path, "../../data/output/departements.json") 93 | with open(json_source_path, "r") as source: 94 | departements = json.load(source) 95 | return [Departement(**dep) for dep in departements] 96 | -------------------------------------------------------------------------------- /scraper/export/resource.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from datetime import datetime 3 | from typing import Iterator, Union 4 | from scraper.creneaux.creneau import Creneau, Lieu, Plateforme, PasDeCreneau 5 | 6 | 7 | class Resource(ABC): 8 | @abstractmethod 9 | def on_creneau(self, creneau: Union[Creneau, PasDeCreneau]): 10 | return None 11 | 12 | @abstractmethod 13 | def asdict(self): 14 | return {} 15 | 16 | @classmethod 17 | def from_creneaux(cls, creneaux: Iterator[Union[Creneau, PasDeCreneau]], *args, **kwargs): 18 | """ 19 | On retourne un iterateur qui contient un seul et unique ResourceParDepartement pour pouvoir découpler 20 | l'invocation de l'execution car l'execution ne se lance alors qu'à l'appel 21 | de `next(ResourceParDepartement.from_creneaux())` 22 | """ 23 | resource = cls(*args, **kwargs) 24 | for creneau in creneaux: 25 | resource.on_creneau(creneau) 26 | yield resource 27 | -------------------------------------------------------------------------------- /scraper/export/resource_creneaux_quotidiens.py: -------------------------------------------------------------------------------- 1 | import dateutil 2 | from dateutil.tz import gettz 3 | from datetime import datetime, timedelta 4 | from typing import Iterator, Union 5 | from .resource import Resource 6 | from scraper.creneaux.creneau import Creneau, Lieu, Plateforme, PasDeCreneau 7 | from utils.vmd_config import get_config 8 | 9 | DEFAULT_NEXT_DAYS = get_config().get("scrape_on_n_days", 7) 10 | 11 | DEFAULT_TAGS = {"all": [lambda creneau: True]} 12 | 13 | 14 | class ResourceCreneauxQuotidiens(Resource): 15 | def __init__(self, departement, next_days=DEFAULT_NEXT_DAYS, now=datetime.now, tags=DEFAULT_TAGS): 16 | super().__init__() 17 | self.departement = departement 18 | self.now = now 19 | self.next_days = next_days 20 | today = now(tz=gettz("Europe/Paris")) 21 | self.dates = {} 22 | for days_from_now in range(0, next_days + 1): 23 | day = today + timedelta(days=days_from_now) 24 | date = as_date(day) 25 | self.dates[date] = ResourceCreneauxParDate(date=date, tags=tags) 26 | 27 | def on_creneau(self, creneau: Union[Creneau, PasDeCreneau]): 28 | if creneau.disponible and creneau.lieu.departement == self.departement: 29 | date = as_date(creneau.horaire) 30 | if date in self.dates: 31 | self.dates[date].on_creneau(creneau) 32 | 33 | def asdict(self): 34 | return { 35 | "departement": self.departement, 36 | "creneaux_quotidiens": [ 37 | date.asdict() for date in self.dates.values() if isinstance(date, ResourceCreneauxParDate) 38 | ], 39 | } 40 | 41 | 42 | class ResourceCreneauxParDate(Resource): 43 | def __init__(self, date: str, tags=DEFAULT_TAGS): 44 | super().__init__() 45 | self.date = date 46 | self.total = 0 47 | self.tags = tags 48 | self.lieux = {} 49 | 50 | def on_creneau(self, creneau: Union[Creneau, PasDeCreneau]): 51 | if creneau.disponible and as_date(creneau.horaire) == self.date: 52 | self.total += 1 53 | if not creneau.lieu.internal_id in self.lieux: 54 | self.lieux[creneau.lieu.internal_id] = ResourceCreneauxParLieu( 55 | internal_id=creneau.lieu.internal_id, tags=self.tags 56 | ) 57 | 58 | self.lieux[creneau.lieu.internal_id].on_creneau(creneau) 59 | 60 | def asdict(self): 61 | return { 62 | "date": self.date, 63 | "total": self.total, 64 | "creneaux_par_lieu": [lieu.asdict() for lieu in self.lieux.values()], 65 | } 66 | 67 | 68 | class ResourceCreneauxParLieu(Resource): 69 | def __init__(self, internal_id: str, tags=DEFAULT_TAGS): 70 | super().__init__() 71 | self.internal_id = internal_id 72 | self.total = 0 73 | self.tags = tags 74 | self.par_tag = {tag: {"tag": tag, "creneaux": 0} for tag in tags.keys()} 75 | 76 | def on_creneau(self, creneau: Union[Creneau, PasDeCreneau]): 77 | if creneau.disponible and creneau.lieu.internal_id == self.internal_id: 78 | self.total += 1 79 | for tag, qualifies_list in self.tags.items(): 80 | for qualifies in qualifies_list: 81 | if qualifies(creneau): 82 | self.par_tag[tag]["creneaux"] += 1 83 | 84 | def asdict(self): 85 | return {"lieu": self.internal_id, "creneaux_par_tag": list(self.par_tag.values())} 86 | 87 | 88 | def as_date(datetime): 89 | return datetime.isoformat()[:10] 90 | -------------------------------------------------------------------------------- /scraper/keldoc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/keldoc/__init__.py -------------------------------------------------------------------------------- /scraper/keldoc/keldoc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | import httpx 5 | from typing import Dict, Iterator, List, Optional, Tuple, Set 6 | from scraper.keldoc.keldoc_center import KeldocCenter 7 | from scraper.keldoc.keldoc_filters import filter_vaccine_motives 8 | from scraper.pattern.scraper_request import ScraperRequest 9 | from scraper.profiler import Profiling 10 | from utils.vmd_config import get_conf_platform, get_config, get_conf_outputs 11 | from utils.vmd_utils import DummyQueue 12 | from scraper.circuit_breaker import ShortCircuit 13 | from scraper.creneaux.creneau import Creneau, Lieu, Plateforme, PasDeCreneau 14 | import json 15 | import requests 16 | from cachecontrol import CacheControl 17 | from cachecontrol.caches.file_cache import FileCache 18 | 19 | 20 | # PLATFORM MUST BE LOW, PLEASE LET THE "lower()" IN CASE OF BAD INPUT FORMAT. 21 | PLATFORM = "keldoc".lower() 22 | 23 | PLATFORM_CONF = get_conf_platform("keldoc") 24 | PLATFORM_ENABLED = PLATFORM_CONF.get("enabled", False) 25 | 26 | PLATFORM_TIMEOUT = PLATFORM_CONF.get("timeout", 25) 27 | 28 | SCRAPE_ONLY_ATLAS = get_config().get("scrape_only_atlas_centers", False) 29 | 30 | timeout = httpx.Timeout(PLATFORM_TIMEOUT, connect=PLATFORM_TIMEOUT) 31 | # change KELDOC_KILL_SWITCH to True to bypass Keldoc scraping 32 | 33 | KELDOC_HEADERS = { 34 | "User-Agent": os.environ.get("KELDOC_API_KEY", ""), 35 | } 36 | session = httpx.Client(timeout=timeout, headers=KELDOC_HEADERS) 37 | logger = logging.getLogger("scraper") 38 | 39 | # Allow 10 bad runs of keldoc_slot before giving up for the 200 next tries 40 | #@ShortCircuit("keldoc_slot", trigger=10, release=200, time_limit=40.0) 41 | #@Profiling.measure("keldoc_slot") 42 | def fetch_slots(request: ScraperRequest, creneau_q=DummyQueue()): 43 | if "keldoc.com" in request.url: 44 | logger.debug(f"Fixing wrong hostname in request: {request.url}") 45 | request.url = request.url.replace("keldoc.com", "vaccination-covid.keldoc.com") 46 | if not PLATFORM_ENABLED: 47 | return None 48 | center = KeldocCenter(request, client=session, creneau_q=creneau_q) 49 | center.vaccine_motives = filter_vaccine_motives(center.appointment_motives) 50 | 51 | center.lieu = Lieu( 52 | plateforme=Plateforme[PLATFORM.upper()], 53 | url=request.url, 54 | location=request.center_info.location, 55 | nom=request.center_info.nom, 56 | internal_id=f"keldoc{request.internal_id}", 57 | departement=request.center_info.departement, 58 | lieu_type=request.practitioner_type, 59 | metadata=request.center_info.metadata, 60 | atlas_gid=request.atlas_gid, 61 | ) 62 | 63 | # Find the first availability 64 | date, count = center.find_first_availability(request.get_start_date()) 65 | if not date and center.lieu: 66 | if center.lieu: 67 | center.found_creneau(PasDeCreneau(lieu=center.lieu, phone_only=request.appointment_by_phone_only)) 68 | request.update_appointment_count(0) 69 | return None 70 | 71 | request.update_appointment_count(count) 72 | return date.strftime("%Y-%m-%dT%H:%M:%S.%f%z") 73 | 74 | 75 | def center_iterator(client=None) -> Iterator[Dict]: 76 | if not PLATFORM_ENABLED: 77 | logger.warning(f"{PLATFORM.capitalize()} scrap is disabled in configuration file.") 78 | return [] 79 | 80 | if SCRAPE_ONLY_ATLAS: 81 | logger.warning(f"{PLATFORM.capitalize()} will only scrape ATLASSANTE centers.") 82 | 83 | session = CacheControl(requests.Session(), cache=FileCache("./cache")) 84 | 85 | if client: 86 | session = client 87 | try: 88 | url = f'{get_config().get("base_urls").get("github_public_path")}{get_conf_outputs().get("centers_json_path").format(PLATFORM)}' 89 | response = session.get(url) 90 | # Si on ne vient pas des tests unitaires 91 | if not client: 92 | if response.from_cache: 93 | logger.info(f"Liste des centres pour {PLATFORM} vient du cache") 94 | else: 95 | logger.info(f"Liste des centres pour {PLATFORM} est une vraie requête") 96 | 97 | data = response.json() 98 | 99 | if SCRAPE_ONLY_ATLAS: 100 | data = [center for center in data if center["atlas_gid"]] 101 | 102 | logger.info(f"Found {len(data)} {PLATFORM.capitalize()} centers (external scraper).") 103 | 104 | for center in data: 105 | yield center 106 | 107 | except Exception as e: 108 | logger.warning(f"Unable to scrape {PLATFORM} centers: {e}") 109 | -------------------------------------------------------------------------------- /scraper/keldoc/keldoc_routes.py: -------------------------------------------------------------------------------- 1 | from utils.vmd_config import get_conf_platform 2 | 3 | KELDOC_CONF = get_conf_platform("keldoc") 4 | KELDOC_API = KELDOC_CONF.get("api") 5 | 6 | # Center info route 7 | API_KELDOC_CENTER = KELDOC_API.get("booking") 8 | 9 | # Motive list route 10 | API_KELDOC_MOTIVES = KELDOC_API.get("motives") 11 | 12 | # Cabinet list route 13 | API_KELDOC_CABINETS = KELDOC_API.get("cabinets") 14 | 15 | # Calendar details route 16 | API_KELDOC_CALENDAR = KELDOC_API.get("slots") 17 | 18 | API_SPECIALITY_IDS = KELDOC_CONF.get("filters").get("vaccination_speciality_ids") 19 | -------------------------------------------------------------------------------- /scraper/maiia/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/maiia/__init__.py -------------------------------------------------------------------------------- /scraper/maiia/maiia_utils.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import json 3 | import logging 4 | import os 5 | 6 | from scraper.pattern.scraper_request import ScraperRequest 7 | from utils.vmd_config import get_conf_platform 8 | 9 | MAIIA_CONF = get_conf_platform("maiia") 10 | MAIIA_SCRAPER = MAIIA_CONF.get("center_scraper", {}) 11 | MAIIA_HEADERS = { 12 | "User-Agent": os.environ.get("MAIIA_API_KEY", ""), 13 | } 14 | 15 | timeout = httpx.Timeout(MAIIA_CONF.get("timeout", 25), connect=MAIIA_CONF.get("timeout", 25)) 16 | DEFAULT_CLIENT = httpx.Client(timeout=timeout, headers=MAIIA_HEADERS) 17 | logger = logging.getLogger("scraper") 18 | 19 | MAIIA_LIMIT = MAIIA_SCRAPER.get("centers_per_page") 20 | 21 | 22 | def get_paged( 23 | url: str, 24 | limit: MAIIA_LIMIT, 25 | client: httpx.Client = DEFAULT_CLIENT, 26 | request: ScraperRequest = None, 27 | request_type: str = None, 28 | ) -> dict: 29 | result = dict() 30 | result["items"] = [] 31 | page = 0 32 | while True: 33 | base_url = f"{url}&limit={limit}&page={page}&size={limit}" 34 | if request: 35 | request.increase_request_count(request_type) 36 | try: 37 | r = client.get(base_url, headers=MAIIA_HEADERS) 38 | r.raise_for_status() 39 | except httpx.HTTPStatusError as hex: 40 | logger.warning(f"{base_url} returned error {hex.response.status_code}") 41 | break 42 | try: 43 | payload = r.json() 44 | except json.decoder.JSONDecodeError as jde: 45 | logger.warning(f"{base_url} raised {jde}") 46 | break 47 | result["total"] = payload["total"] 48 | if not payload["items"]: 49 | break 50 | result["items"].extend(payload["items"]) 51 | if len(payload["items"]) < limit: 52 | break 53 | page += 1 54 | return result 55 | -------------------------------------------------------------------------------- /scraper/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from scraper.scraper import scrape, scrape_debug 4 | 5 | 6 | def main(): # pragma: no cover 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument("--platform", "-p", help="scrape platform. (eg: doctolib,keldoc or all)") 9 | parser.add_argument("--url", "-u", action="append", help="scrape one url, can be repeated") 10 | parser.add_argument("--url-file", type=argparse.FileType("r"), help="scrape urls listed in file (one per line)") 11 | args = parser.parse_args() 12 | 13 | if args.url_file: 14 | args.url = [line.rstrip() for line in args.url_file] 15 | if args.url: 16 | scrape_debug(args.url) 17 | return 18 | platforms = [] 19 | if args.platform and args.platform != "all": 20 | platforms = args.platform.split(",") 21 | scrape(platforms=platforms) 22 | 23 | 24 | if __name__ == "__main__": # pragma: no cover 25 | main() 26 | -------------------------------------------------------------------------------- /scraper/mapharma/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/mapharma/__init__.py -------------------------------------------------------------------------------- /scraper/mesoigner/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/mesoigner/__init__.py -------------------------------------------------------------------------------- /scraper/pattern/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/pattern/__init__.py -------------------------------------------------------------------------------- /scraper/pattern/center_location.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import asdict 4 | from typing import Optional 5 | 6 | from pydantic.dataclasses import dataclass 7 | 8 | from utils.vmd_utils import departementUtils 9 | from utils.vmd_logger import get_logger 10 | 11 | logger = get_logger() 12 | 13 | 14 | @dataclass 15 | class CenterLocation: 16 | longitude: float 17 | latitude: float 18 | city: Optional[str] = None 19 | cp: Optional[str] = None 20 | 21 | # TODO: Use `asdict()` directly, default is not clear. 22 | def default(self): 23 | return asdict(self) 24 | 25 | @classmethod 26 | def from_csv_data(cls, data: dict) -> Optional[CenterLocation]: 27 | long = data.get("long_coor1") 28 | lat = data.get("lat_coor1") 29 | city = data.get("com_nom") 30 | cp = data.get("com_cp") 31 | 32 | if long and lat: 33 | if address := data.get("address"): 34 | if not city: 35 | city = departementUtils.get_city(address) 36 | if not cp: 37 | cp = departementUtils.get_cp(address) 38 | try: 39 | return CenterLocation(long, lat, city, cp) 40 | except Exception as e: 41 | logger.warning("Failed to parse CenterLocation from {}".format(data)) 42 | logger.warning(e) 43 | return 44 | 45 | 46 | convert_csv_data_to_location = CenterLocation.from_csv_data 47 | -------------------------------------------------------------------------------- /scraper/pattern/scraper_request.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | 4 | class ScraperRequest: 5 | def __init__( 6 | self, 7 | url: str, 8 | start_date: str, 9 | center_info=None, 10 | practitioner_type=None, 11 | internal_id=None, 12 | input_data=None, 13 | atlas_gid=None, 14 | ): 15 | self.url = url 16 | self.start_date = start_date 17 | self.center_info = center_info 18 | self.internal_id = internal_id 19 | self.practitioner_type = practitioner_type 20 | self.appointment_count = 0 21 | self.vaccine_type = None 22 | self.appointment_by_phone_only = False 23 | self.requests = None 24 | self.input_data = input_data 25 | self.atlas_gid = atlas_gid 26 | 27 | def update_internal_id(self, internal_id: str) -> str: 28 | self.internal_id = internal_id 29 | return self.internal_id 30 | 31 | def update_practitioner_type(self, practitioner_type: str) -> str: 32 | self.practitioner_type = practitioner_type 33 | return self.practitioner_type 34 | 35 | def update_appointment_count(self, appointment_count: int) -> int: 36 | self.appointment_count = appointment_count 37 | return self.appointment_count 38 | 39 | def increase_request_count(self, request_type: str) -> int: 40 | if self.requests is None: 41 | self.requests = {} 42 | request_type = request_type or "unknown" 43 | if request_type not in self.requests: 44 | self.requests[request_type] = 1 45 | else: 46 | self.requests[request_type] += 1 47 | return self.requests[request_type] 48 | 49 | def add_vaccine_type(self, vaccine_name: Optional[str]): 50 | # Temp fix due to iOS app issues with empty list 51 | if self.vaccine_type is None: 52 | self.vaccine_type = [] 53 | if vaccine_name and vaccine_name not in self.vaccine_type: 54 | self.vaccine_type.append(vaccine_name) 55 | 56 | def get_url(self) -> str: 57 | return self.url 58 | 59 | def get_start_date(self) -> str: 60 | return self.start_date 61 | 62 | def set_appointments_only_by_phone(self, only_by_phone: bool): 63 | self.appointment_by_phone_only = only_by_phone 64 | -------------------------------------------------------------------------------- /scraper/pattern/scraper_result.py: -------------------------------------------------------------------------------- 1 | from scraper.pattern.scraper_request import ScraperRequest 2 | 3 | 4 | # Practitioner type enum 5 | GENERAL_PRACTITIONER = "general-practitioner" 6 | VACCINATION_CENTER = "vaccination-center" 7 | DRUG_STORE = "drugstore" 8 | 9 | 10 | class ScraperResult: 11 | def __init__(self, request: ScraperRequest, platform, next_availability): 12 | self.request = request 13 | self.platform = platform 14 | self.next_availability = next_availability 15 | 16 | def default(self): 17 | return self.__dict__ 18 | -------------------------------------------------------------------------------- /scraper/pattern/tags.py: -------------------------------------------------------------------------------- 1 | from scraper.creneaux.creneau import Creneau 2 | from scraper.pattern.vaccine import Vaccine 3 | 4 | 5 | def tag_all(creneau: Creneau): 6 | return True 7 | 8 | 9 | def first_dose(creneau: Creneau): 10 | if creneau.dose: 11 | if "1" in creneau.dose or 1 in creneau.dose: 12 | return True 13 | 14 | 15 | def second_dose(creneau: Creneau): 16 | if creneau.dose: 17 | if "2" in creneau.dose or 2 in creneau.dose: 18 | return True 19 | 20 | 21 | def third_dose(creneau: Creneau): 22 | if creneau.dose: 23 | if "3" in creneau.dose or 3 in creneau.dose: 24 | return True 25 | 26 | 27 | 28 | def kid_first_dose(creneau: Creneau): 29 | if creneau.dose: 30 | if "1_kid" in creneau.dose: 31 | return True 32 | 33 | 34 | def unknown_dose(creneau: Creneau): 35 | if not creneau.dose: 36 | return True 37 | if len(creneau.dose) == 0: 38 | return True 39 | 40 | 41 | CURRENT_TAGS = { 42 | "all": [tag_all], 43 | "first_or_second_dose": [first_dose, second_dose], 44 | "kids_first_dose": [kid_first_dose], 45 | "third_dose": [third_dose], 46 | "unknown_dose": [unknown_dose], 47 | } 48 | -------------------------------------------------------------------------------- /scraper/pattern/vaccine.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | from utils.vmd_config import get_config 5 | 6 | from scraper.doctolib.doctolib_filters import DOCTOLIB_FILTERS 7 | 8 | VACCINE_CONF = get_config().get("vaccines", {}) 9 | 10 | DOCTOLIB_APPOINTMENT_MOTIVES = DOCTOLIB_FILTERS["motives"] 11 | 12 | 13 | class Vaccine(str, Enum): 14 | PFIZER = "Pfizer-BioNTech" 15 | MODERNA = "Moderna" 16 | ASTRAZENECA = "AstraZeneca" 17 | JANSSEN = "Janssen" 18 | ARNM = "ARNm" 19 | 20 | 21 | VACCINES_NAMES = { 22 | Vaccine.PFIZER: VACCINE_CONF.get(Vaccine.PFIZER, []), 23 | Vaccine.MODERNA: VACCINE_CONF.get(Vaccine.MODERNA, []), 24 | Vaccine.ARNM: VACCINE_CONF.get(Vaccine.ARNM, []), 25 | Vaccine.ASTRAZENECA: VACCINE_CONF.get(Vaccine.ASTRAZENECA, []), 26 | Vaccine.JANSSEN: VACCINE_CONF.get(Vaccine.JANSSEN, []), 27 | } 28 | 29 | 30 | def get_doctolib_vaccine_name(visit_motive_id: int, fallback: Optional[Vaccine] = None) -> Optional[Vaccine]: 31 | if not visit_motive_id: 32 | return fallback 33 | name = DOCTOLIB_APPOINTMENT_MOTIVES[str(visit_motive_id)]["vaccine"] 34 | return name 35 | 36 | 37 | def get_vaccine_name(name: Optional[str], fallback: Optional[Vaccine] = None) -> Optional[Vaccine]: 38 | if not name: 39 | return fallback 40 | name = name.lower().strip() 41 | if "contre indications" in name: 42 | return fallback 43 | for vaccine, vaccine_names in VACCINES_NAMES.items(): 44 | for vaccine_name in vaccine_names: 45 | if vaccine_name in name: 46 | if vaccine == Vaccine.ASTRAZENECA: 47 | return get_vaccine_astrazeneca_minus_55_edgecase(name) 48 | return vaccine 49 | return fallback 50 | 51 | 52 | def get_vaccine_astrazeneca_minus_55_edgecase(name: str) -> Vaccine: 53 | has_minus = "-" in name or "–" in name or "–" in name or "moins" in name 54 | if has_minus and "55" in name and "suite" in name: 55 | return Vaccine.ARNM 56 | return Vaccine.ASTRAZENECA 57 | -------------------------------------------------------------------------------- /scraper/valwin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/scraper/valwin/__init__.py -------------------------------------------------------------------------------- /scraper/valwin/valwin_center_scrap.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from utils.vmd_logger import get_logger 3 | from utils.vmd_config import get_conf_platform 4 | from utils.vmd_utils import departementUtils, format_phone_number 5 | import json 6 | import os 7 | 8 | PLATFORM = "Valwin" 9 | 10 | PLATFORM_HEADERS = {} 11 | 12 | PLATFORM_CONF = get_conf_platform(PLATFORM) 13 | PLATFORM_ENABLED = PLATFORM_CONF.get("enabled", False) 14 | 15 | SCRAPER_CONF = PLATFORM_CONF.get("center_scraper", {}) 16 | CENTER_LIST_URL = PLATFORM_CONF.get("api", {}).get("center_list", {}) 17 | 18 | DEFAULT_CLIENT = httpx.Client() 19 | 20 | logger = get_logger() 21 | 22 | 23 | def scrap_centers(): 24 | if not PLATFORM_ENABLED: 25 | return None 26 | 27 | logger.info(f"[{PLATFORM.lower().capitalize()} centers] Parsing centers from API") 28 | try: 29 | r = DEFAULT_CLIENT.get( 30 | CENTER_LIST_URL, 31 | headers=PLATFORM_HEADERS, 32 | ) 33 | api_centers = r.json() 34 | 35 | if r.status_code != 200: 36 | logger.error(f"Can't access API - {r.status_code} => {json.loads(r.text)['message']}") 37 | return None 38 | 39 | except: 40 | logger.error(f"Can't access API") 41 | return None 42 | 43 | return api_centers 44 | 45 | 46 | def get_coordinates(center): 47 | longitude = center["geoTag"]["longitude"] 48 | latitude = center["geoTag"]["latitude"] 49 | if longitude: 50 | longitude = float(longitude) 51 | if latitude: 52 | latitude = float(latitude) 53 | return longitude, latitude 54 | 55 | 56 | def set_center_type(center_type: str): 57 | center_types = PLATFORM_CONF.get("center_types", {}) 58 | center_type_format = [center_types[i] for i in center_types if i in center_type] 59 | return center_type_format[0] 60 | 61 | 62 | def parse_platform_business_hours(place: dict): 63 | # Opening hours 64 | business_hours = dict() 65 | if not place["opening_hours"]: 66 | return None 67 | 68 | for opening_hour in place["opening_hours"]: 69 | format_hours = "" 70 | key_name = SCRAPER_CONF["business_days"][opening_hour["day"] - 1] 71 | if not opening_hour["ranges"] or len(opening_hour["ranges"]) == 0: 72 | business_hours[key_name] = None 73 | continue 74 | for range in opening_hour["ranges"]: 75 | if len(format_hours) > 0: 76 | format_hours += ", " 77 | format_hours += f"{range[0]}-{range[1]}" 78 | business_hours[key_name] = format_hours 79 | return business_hours 80 | 81 | 82 | def parse_platform_centers(): 83 | 84 | unique_centers = [] 85 | centers_list = scrap_centers() 86 | useless_keys = ["id", "hasAvailableSlot", "geoTag", "linkToAllSlots", "geoTag", "name", "websiteUrl"] 87 | 88 | if centers_list is None: 89 | return None 90 | 91 | for centre_name, centre in centers_list.items(): 92 | logger.info(f'[Valwin] Found Center {centre["name"]} - {centre["address"]["zipCode"]}') 93 | 94 | if centre["websiteUrl"] not in [unique_center["rdv_site_web"] for unique_center in unique_centers]: 95 | centre["gid"] = centre["id"] 96 | centre["rdv_site_web"] = centre["websiteUrl"] 97 | centre["nom"] = centre["name"] 98 | centre["com_insee"] = departementUtils.cp_to_insee(centre["address"]["zipCode"]) 99 | long_coor1, lat_coor1 = get_coordinates(centre) 100 | address = f'{centre["address"]["street"]}, {centre["address"]["zipCode"]} {centre["address"]["city"]}' 101 | centre["address"] = address 102 | centre["long_coor1"] = long_coor1 103 | centre["lat_coor1"] = lat_coor1 104 | centre["type"] = set_center_type("pharmacie") 105 | centre["platform_is"] = PLATFORM 106 | 107 | [centre.pop(key) for key in list(centre.keys()) if key in useless_keys] 108 | unique_centers.append(centre) 109 | 110 | return unique_centers 111 | 112 | 113 | if __name__ == "__main__": # pragma: no cover 114 | if PLATFORM_ENABLED: 115 | centers = parse_platform_centers() 116 | path_out = SCRAPER_CONF.get("result_path", False) 117 | if not path_out: 118 | logger.error(f"Valwin - No result_path in config file.") 119 | exit(1) 120 | 121 | if not centers: 122 | exit(1) 123 | 124 | logger.info(f"Found {len(centers)} centers on Valwin") 125 | if len(centers) < SCRAPER_CONF.get("minimum_results", 0): 126 | logger.error(f"[NOT SAVING RESULTS]{len(centers)} does not seem like enough Valwin centers") 127 | else: 128 | logger.info(f"> Writing them on {path_out}") 129 | with open(path_out, "w") as f: 130 | f.write(json.dumps(centers, indent=2)) 131 | else: 132 | logger.error(f"Valwin scraper is disabled in configuration file.") 133 | exit(1) 134 | -------------------------------------------------------------------------------- /scripts/contributors: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | BIN="venv/bin/" 4 | 5 | ${BIN}python contributors.py $@ 6 | 7 | -------------------------------------------------------------------------------- /scripts/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | BIN="venv/bin/" 4 | 5 | set -x 6 | 7 | ${BIN}coverage report --show-missing --skip-covered --fail-under=80 8 | -------------------------------------------------------------------------------- /scripts/create-index.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | cd $1 6 | 7 | find -type f -name '*.json' \ 8 | | cut -c3- \ 9 | | sort \ 10 | | xargs du -h \ 11 | | awk 'BEGIN { print "

Ressources disponibles

\n" }' \ 14 | > index.html 15 | -------------------------------------------------------------------------------- /scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | PYTHON="python3.8" 4 | VENV="venv" 5 | BIN="$VENV/bin/" 6 | 7 | set -x 8 | 9 | if [ ! -d $VENV ]; then 10 | $PYTHON -m venv $VENV 11 | fi 12 | 13 | ${BIN}pip install -U pip wheel 14 | ${BIN}pip install -e . 15 | -------------------------------------------------------------------------------- /scripts/scrape: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | BIN="venv/bin/" 4 | 5 | ${BIN}python scrape.py $@ 6 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | BIN="venv/bin/" 4 | 5 | set -x 6 | 7 | ${BIN}coverage run -m pytest --ignore tests/ 8 | 9 | scripts/coverage 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = scraper/ tests/ --doctest-modules 3 | 4 | [coverage:run] 5 | omit = venv/* 6 | scraper/profiler.py 7 | # not used for now 8 | include = scraper/*, tests/* 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="vitemadose", 5 | version="0.0.1", 6 | packages=["scraper"], 7 | install_requires=[ 8 | "pytz==2021.1", 9 | "httpx==0.17.1", 10 | "requests[socks]==2.25.1", 11 | "pytest==6.2.2", 12 | "beautifulsoup4==4.9.3", 13 | "coverage==5.5", 14 | "terminaltables==3.1.0", 15 | "python-dateutil==2.8.1", 16 | "coverage-badge==1.0.1", 17 | "unidecode==1.2.0", 18 | "jsonschema==3.2.0", 19 | "pydantic==1.8.2", 20 | "diskcache==5.2.1", 21 | "dotmap==1.3.23", 22 | "cachecontrol==0.12.6", 23 | "lockfile==0.12.2", 24 | "colorclass==2.2.0", 25 | ], 26 | ) 27 | -------------------------------------------------------------------------------- /stats_generation/by_vaccine.py: -------------------------------------------------------------------------------- 1 | """Sums available appointments per vaccine type. 2 | 3 | Note: There are ~10% of centers that report multiple vaccine types. 4 | For those, the individual breakdown is not clear, so they are left out at the moment. 5 | 6 | Issue: https://github.com/CovidTrackerFr/vitemadose/issues/266 7 | 8 | Usage: 9 | 10 | ```shell 11 | python3 -m stats_generation.chronodoses_by_vaccine \ 12 | --input="some/file.json" # Optional (should follow the same structure as data/output/info_centres.json.)\ 13 | --output="put/it/here.json" # Optional 14 | ``` 15 | 16 | """ 17 | import argparse 18 | from collections import defaultdict 19 | import json 20 | import sys 21 | from functools import reduce 22 | from pathlib import Path 23 | from typing import DefaultDict, Iterator, List, Tuple 24 | 25 | from utils.vmd_config import get_conf_outputs, get_conf_outstats 26 | 27 | _default_input = Path(get_conf_outputs().get("last_scans")) 28 | _default_output = Path(get_conf_outstats().get("by_vaccine_type")) 29 | 30 | 31 | def parse_args(args: List[str]) -> argparse.Namespace: 32 | parser = argparse.ArgumentParser() 33 | parser.add_argument( 34 | "--input", 35 | default=_default_input, 36 | type=Path, 37 | help="File with the statistics per department. Should follow the same structure as info_centres.json.", 38 | ) 39 | parser.add_argument( 40 | "--output", 41 | default=_default_output, 42 | type=Path, 43 | help="Where to put the resulting statistics.", 44 | ) 45 | return parser.parse_args(args) 46 | 47 | 48 | def merge(data: dict, new: tuple) -> dict: 49 | vaccine_type, appointments = new 50 | if vaccine_type in data: 51 | data[vaccine_type] += appointments 52 | else: 53 | data[vaccine_type] = appointments 54 | return data 55 | 56 | 57 | def flatten_vaccine_types_schedules(data: dict) -> Iterator[Tuple[str, int]]: 58 | count = defaultdict(int) 59 | for center in data["centres_disponibles"]: 60 | for vaccine_name in center["vaccine_type"]: 61 | count[vaccine_name] += 1 62 | return ( 63 | (vaccine_name, count[vaccine_name]) 64 | for center in data["centres_disponibles"] 65 | for vaccine_name in center["vaccine_type"] 66 | ) 67 | 68 | 69 | def main(argv): 70 | args = parse_args(argv[1:]) 71 | 72 | with open(args.input) as f: 73 | data = json.load(f) 74 | 75 | available_center_schedules = flatten_vaccine_types_schedules(data) 76 | by_vaccine_type = reduce(merge, available_center_schedules, {}) 77 | 78 | with open(args.output, "w") as f: 79 | json.dump(by_vaccine_type, f) 80 | 81 | 82 | if __name__ == "__main__": 83 | main(sys.argv) 84 | -------------------------------------------------------------------------------- /stats_generation/stats_center_types.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from datetime import datetime 4 | 5 | import pytz 6 | import requests 7 | 8 | from utils.vmd_config import get_conf_outstats, get_config, get_conf_inputs 9 | 10 | logger = logging.getLogger("scraper") 11 | 12 | DATA_AUTO = get_config().get("base_urls").get("gitlab_public_path") 13 | 14 | 15 | def compute_plateforme_data(centres_info): 16 | plateformes = {} 17 | center_types = {} 18 | for centre_dispo in centres_info["centres_disponibles"] + centres_info["centres_indisponibles"]: 19 | 20 | plateforme = centre_dispo["plateforme"] 21 | if not plateforme: 22 | plateforme = "Autre" 23 | 24 | center_type = centre_dispo["type"] 25 | if not center_type: 26 | center_type = "Autre" 27 | 28 | next_app = centre_dispo.get("prochain_rdv", None) 29 | if plateforme not in plateformes: 30 | plateforme_data = {"disponible": 0, "total": 0, "creneaux": 0} 31 | else: 32 | plateforme_data = plateformes[plateforme] 33 | 34 | if center_type not in center_types: 35 | center_type_data = {"disponible": 0, "total": 0, "creneaux": 0} 36 | else: 37 | center_type_data = center_types[center_type] 38 | 39 | plateforme_data["disponible"] += 1 if next_app else 0 40 | plateforme_data["total"] += 1 41 | plateforme_data["creneaux"] += centre_dispo.get("appointment_count", 0) 42 | plateformes[plateforme] = plateforme_data 43 | 44 | center_type_data["disponible"] += 1 if next_app else 0 45 | center_type_data["total"] += 1 46 | center_type_data["creneaux"] += centre_dispo.get("appointment_count", 0) 47 | center_types[center_type] = center_type_data 48 | 49 | return plateformes, center_types 50 | 51 | 52 | def generate_stats_center_types(centres_info): 53 | stats_path = get_conf_inputs().get("from_gitlab_public").get("center_types") 54 | stats_data = {"dates": [], "plateformes": {}, "center_types": {}} 55 | 56 | try: 57 | history_rq = requests.get(f"{DATA_AUTO}{stats_path}") 58 | data = history_rq.json() 59 | if data: 60 | stats_data = data 61 | except Exception: 62 | logger.warning(f"Unable to fetch {DATA_AUTO}{stats_path}: generating a template file.") 63 | ctz = pytz.timezone("Europe/Paris") 64 | current_time = datetime.now(tz=ctz).strftime("%Y-%m-%d %H:00:00") 65 | if current_time in stats_data["dates"]: 66 | with open(f"data/output/{stats_path}", "w") as stat_graph_file: 67 | json.dump(stats_data, stat_graph_file) 68 | logger.info(f"Stats file already updated: {stats_path}") 69 | return 70 | 71 | if "center_types" not in stats_data: 72 | stats_data["center_types"] = {} 73 | 74 | stats_data["dates"].append(current_time) 75 | current_calc = compute_plateforme_data(centres_info) 76 | for plateforme in current_calc[0]: 77 | plateform_data = current_calc[0][plateforme] 78 | if plateforme not in stats_data["plateformes"]: 79 | stats_data["plateformes"][plateforme] = { 80 | "disponible": [plateform_data["disponible"]], 81 | "total": [plateform_data["total"]], 82 | "creneaux": [plateform_data["creneaux"]], 83 | } 84 | continue 85 | current_data = stats_data["plateformes"][plateforme] 86 | current_data["disponible"].append(plateform_data["disponible"]) 87 | current_data["total"].append(plateform_data["total"]) 88 | current_data["creneaux"].append(plateform_data["creneaux"]) 89 | 90 | for center_type in current_calc[1]: 91 | center_type_data = current_calc[1][center_type] 92 | if center_type not in stats_data["center_types"]: 93 | stats_data["center_types"][center_type] = { 94 | "disponible": [center_type_data["disponible"]], 95 | "total": [center_type_data["total"]], 96 | "creneaux": [center_type_data["creneaux"]], 97 | } 98 | continue 99 | current_data = stats_data["center_types"][center_type] 100 | current_data["disponible"].append(center_type_data["disponible"]) 101 | current_data["total"].append(center_type_data["total"]) 102 | current_data["creneaux"].append(center_type_data["creneaux"]) 103 | 104 | with open(f"data/output/{stats_path}", "w") as stat_graph_file: 105 | json.dump(stats_data, stat_graph_file) 106 | logger.info(f"Updated stats file: {stats_path}") 107 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/tests/__init__.py -------------------------------------------------------------------------------- /tests/dev/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/tests/dev/__init__.py -------------------------------------------------------------------------------- /tests/dev/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/tests/dev/model/__init__.py -------------------------------------------------------------------------------- /tests/dev/model/test_center.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | from dev.model.department import Center, Schedule 5 | 6 | 7 | path = Path("tests", "fixtures", "utils", "info_centres.json") 8 | 9 | with open(path) as fixture: 10 | data = json.load(fixture) 11 | 12 | 13 | def test_unavailable_center(): 14 | center = Center(**data["01"]["centres_indisponibles"][0]) 15 | assert center.department == "01" 16 | assert center.appointment_count == 0 17 | 18 | 19 | def test_available_center(): 20 | center = Center(**data["01"]["centres_disponibles"][0]) 21 | assert center.department == "01" 22 | assert center.appointment_count == 35 23 | 24 | 25 | def test_center_iteration(): 26 | center = Center(**data["01"]["centres_disponibles"][0]) 27 | i = 0 28 | for _ in center: 29 | i += 1 30 | assert i > 0 31 | -------------------------------------------------------------------------------- /tests/fixtures/avecmondoc/center.json: -------------------------------------------------------------------------------- 1 | { 2 | "appointment_by_phone_only": false, 3 | "appointment_count": 0, 4 | "departement": "28", 5 | "erreur": null, 6 | "internal_id": "amd159", 7 | "last_scan_with_availabilities": null, 8 | "location": { 9 | "city": "Chartres", 10 | "cp": "28000", 11 | "latitude": 48.447586, 12 | "longitude": 1.481373 13 | }, 14 | "metadata": { 15 | "address": "21 Rue Nicole 28000 Chartres", 16 | "business_hours": { 17 | "Dimanche": "", 18 | "Jeudi": "08:30-12:30 13:30-17:00", 19 | "Lundi": "08:30-12:30 13:30-17:00", 20 | "Mardi": "08:30-12:30 13:30-17:00", 21 | "Mercredi": "08:30-12:30 13:30-17:00", 22 | "Samedi": "", 23 | "Vendredi": "08:30-12:30 13:30-17:00" 24 | }, 25 | "phone_number": "0033143987678" 26 | }, 27 | "nom": "Delphine ROUSSEAU", 28 | "plateforme": null, 29 | "prochain_rdv": null, 30 | "request_counts": null, 31 | "type": null, 32 | "url": "https://patient.avecmondoc.com/fiche/structure/delphine-rousseau-159", 33 | "vaccine_type": null 34 | } -------------------------------------------------------------------------------- /tests/fixtures/avecmondoc/centerdict.json: -------------------------------------------------------------------------------- 1 | { 2 | "rdv_site_web": "https://patient.avecmondoc.com/fiche/structure/delphine-rousseau-159", 3 | "nom": "Delphine ROUSSEAU", 4 | "type": "drugstore", 5 | "business_hours": { 6 | "Lundi": "08:30-12:30 13:30-17:00", 7 | "Mardi": "08:30-12:30 13:30-17:00", 8 | "Mercredi": "08:30-12:30 13:30-17:00", 9 | "Jeudi": "08:30-12:30 13:30-17:00", 10 | "Vendredi": "08:30-12:30 13:30-17:00", 11 | "Samedi": "", 12 | "Dimanche": "" 13 | }, 14 | "phone_number": "0033143987678", 15 | "address": "21 Rue Nicole 28000 Chartres, 28000 Chartres", 16 | "long_coor1": 1.481373, 17 | "lat_coor1": 48.447586, 18 | "com_nom": "Chartres", 19 | "com_cp": "28000", 20 | "com_insee": "28085", 21 | "gid": "amd159" 22 | } -------------------------------------------------------------------------------- /tests/fixtures/avecmondoc/get_by_doctor.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 159, 4 | "status": "disabled", 5 | "needSubscription": 1, 6 | "name": "Delphine ROUSSEAU", 7 | "subtitle": "Pharmacie", 8 | "finessNumber": null, 9 | "paymentMethods": "[]", 10 | "address": "21 Rue Nicole 28000 Chartres", 11 | "zipCode": "28000", 12 | "city": "Chartres", 13 | "country": "France", 14 | "phone": "0033143987678", 15 | "informations": "TEST PRESENTATION\nuygsuygsuysuusygsyuvgsygvs\nskuysvgusyvsuygsogosygs\nskuusvusgyusyogsuiygsuiygss", 16 | "accessInformations": null, 17 | "timezone": "Europe/Paris", 18 | "slug": "delphine-rousseau-159", 19 | "createdAt": "2020-11-20T01:49:56.000Z", 20 | "updatedAt": "2021-03-11T18:06:52.000Z", 21 | "coordinates": { 22 | "x": 1.481373, 23 | "y": 48.447586 24 | }, 25 | "coordinatesFetchingStatus": "success", 26 | "prescriptionService": 0, 27 | "teletransmissionService": 0, 28 | "medicalTransportService": 1, 29 | "origin": "migration", 30 | "isOptic2000": 0 31 | } 32 | ] -------------------------------------------------------------------------------- /tests/fixtures/avecmondoc/get_by_organization.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "organization": "Ordre des médecins", 4 | "phone": null, 5 | "stripeAccountStatus": "unverified", 6 | "photoPath": null, 7 | "sector": 0, 8 | "companyName": null, 9 | "companyAccessDetails": null, 10 | "companyAddress": "47 Avenue Wilson", 11 | "companyZipCode": "94300", 12 | "companyCity": "Vincennes", 13 | "companyCountry": "France", 14 | "companyCoordinates": null, 15 | "companyCoordinatesFetchingStatus": "not_found", 16 | "isPrivateCalendar": false, 17 | "optam": null, 18 | "holidayDaysAccepted": false, 19 | "timezone": "Europe/Paris", 20 | "appointmentDuration": 10, 21 | "workdayStart": "09:00:00", 22 | "workdayEnd": "18:00:00", 23 | "slug": "delphine-rousseau-216", 24 | "informations": null, 25 | "paymentMethods": [ 26 | "Bank Check", 27 | "Card", 28 | "Cash", 29 | "Third-party Payment", 30 | "Vitale Card" 31 | ], 32 | "daysWeekToHide": [], 33 | "patientRestriction": "off", 34 | "selectedFormulaCode": null, 35 | "organizationInvitationId": null, 36 | "isProfileComplete": true, 37 | "showInPublicSearch": true, 38 | "delayBwtNowAndTimeAppointment": "0", 39 | "id": 216, 40 | "appUserId": 1066, 41 | "professionId": 14, 42 | "appUser": { 43 | "firstname": "Delphine", 44 | "lastname": "ROUSSEAU", 45 | "id": 1066 46 | }, 47 | "specialitiesToDoctors": [ 48 | { 49 | "isDefault": 1, 50 | "id": 85, 51 | "doctorId": 216, 52 | "specialityId": 190, 53 | "speciality": { 54 | "label": "Pharmacie", 55 | "code": "50", 56 | "nomenclature": "Pharmacie", 57 | "status": "enabled", 58 | "isMain": null, 59 | "color": null, 60 | "icon": null, 61 | "id": 190, 62 | "professionId": 24 63 | } 64 | } 65 | ] 66 | } 67 | ] -------------------------------------------------------------------------------- /tests/fixtures/avecmondoc/get_doctor_slug.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 216, 3 | "firstname": "Delphine", 4 | "lastname": "ROUSSEAU", 5 | "informations": null, 6 | "paymentMethods": [ 7 | "Bank Check", 8 | "Card", 9 | "Cash", 10 | "Third-party Payment", 11 | "Vitale Card" 12 | ], 13 | "address": { 14 | "name": null, 15 | "address": "47 Avenue Wilson", 16 | "city": "Vincennes", 17 | "zipCode": "94300", 18 | "gps": null 19 | }, 20 | "phone": null, 21 | "specialities": [ 22 | { 23 | "label": "Pharmacie", 24 | "isDefault": 1, 25 | "id": 190 26 | } 27 | ], 28 | "sector": 0, 29 | "patientRestriction": "off" 30 | } -------------------------------------------------------------------------------- /tests/fixtures/avecmondoc/get_reasons.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "reason": "Première injection vaccinale COVID-19 Pfizer", 4 | "code": "VGP", 5 | "type": "inOffice", 6 | "price": 0, 7 | "active": true, 8 | "deleted": false, 9 | "isALD": false, 10 | "isCMU": false, 11 | "showOnlyDoctor": false, 12 | "instructionText": "Après avoir confirmé ce rendez-vous, nous vous inviterons à prendre rendez-vous pour votre seconde injection. Lors du rendez-vous pensez à vous munir de votre ordonnance.", 13 | "modalTitle": "Validation du RDV", 14 | "modalContent": "Vous allez être redirigé vers la prise de rendez-vous pour votre seconde injection à réaliser dans les 9 à 12 semaines suivant la précédente.", 15 | "checkboxContent": "Je déclare sur l'honneur être éligible à la vaccination ou prendre rendez-vous pour un proche éligible.", 16 | "isCheckboxRequired": true, 17 | "httpReturnLink": "https://vaccination-info-service.fr/Les-maladies-et-leurs-vaccins/COVID-19", 18 | "nextAction": "appointment", 19 | "id": 604, 20 | "specialityId": 190, 21 | "organizationId": 159 22 | }, 23 | { 24 | "reason": "Seconde injection vaccinale COVID-19 Janssen", 25 | "code": "VGP", 26 | "type": "inOffice", 27 | "price": 0, 28 | "active": true, 29 | "deleted": false, 30 | "isALD": false, 31 | "isCMU": false, 32 | "showOnlyDoctor": false, 33 | "instructionText": null, 34 | "modalTitle": "Validation du RDV", 35 | "modalContent": "La seconde injection est à réaliser dans les 9 à 12 semaines suivant la précédente.", 36 | "checkboxContent": "Je déclare sur l'honneur être éligible à la vaccination ou prendre rendez-vous pour un proche éligible.", 37 | "isCheckboxRequired": true, 38 | "httpReturnLink": "https://vaccination-info-service.fr/Les-maladies-et-leurs-vaccins/COVID-19", 39 | "nextAction": "confirmation", 40 | "id": 605, 41 | "specialityId": 190, 42 | "organizationId": 159 43 | }, 44 | { 45 | "reason": "Vaccination grippe", 46 | "code": "VGP", 47 | "type": "inOffice", 48 | "price": 0, 49 | "active": true, 50 | "deleted": false, 51 | "isALD": false, 52 | "isCMU": false, 53 | "showOnlyDoctor": false, 54 | "instructionText": null, 55 | "modalTitle": null, 56 | "modalContent": null, 57 | "checkboxContent": null, 58 | "isCheckboxRequired": true, 59 | "httpReturnLink": null, 60 | "nextAction": null, 61 | "id": 606, 62 | "specialityId": 190, 63 | "organizationId": 159 64 | } 65 | ] -------------------------------------------------------------------------------- /tests/fixtures/avecmondoc/iterator_search_result.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "name": "Delphine ROUSSEAU", 5 | "url": "https://patient.avecmondoc.com/fiche/structure/delphine-rousseau-159", 6 | "address": "21 Rue Nicole 28000 Chartres", 7 | "zipCode": "28000", 8 | "city": "Chartres", 9 | "country": "France", 10 | "businessHoursCovidCount": 10 11 | } 12 | ], 13 | "page": 1, 14 | "pages": 1, 15 | "limit": 1, 16 | "hasPreviousPage": false, 17 | "hasNextPage": false, 18 | "count": 1 19 | } -------------------------------------------------------------------------------- /tests/fixtures/avecmondoc/search-result.schema: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "object", 4 | "properties": { 5 | "data": { 6 | "type": "array", 7 | "items": { 8 | "type": "object", 9 | "properties": { 10 | "name": { 11 | "type": "string" 12 | }, 13 | "url": { 14 | "type": "string" 15 | }, 16 | "address": { 17 | "type": [ 18 | "string", 19 | "null" 20 | ] 21 | }, 22 | "zipCode": { 23 | "type": [ 24 | "string", 25 | "null" 26 | ] 27 | }, 28 | "city": { 29 | "type": [ 30 | "string", 31 | "null" 32 | ] 33 | }, 34 | "country": { 35 | "type": [ 36 | "string", 37 | "null" 38 | ] 39 | }, 40 | "businessHoursCovidCount": { 41 | "type": "integer" 42 | } 43 | }, 44 | "required": [ 45 | "name", 46 | "url", 47 | "address", 48 | "zipCode", 49 | "city", 50 | "country", 51 | "businessHoursCovidCount" 52 | ] 53 | } 54 | }, 55 | "page": { 56 | "type": "integer" 57 | }, 58 | "pages": { 59 | "type": "integer" 60 | }, 61 | "limit": { 62 | "type": "integer" 63 | }, 64 | "hasPreviousPage": { 65 | "type": "boolean" 66 | }, 67 | "hasNextPage": { 68 | "type": "boolean" 69 | }, 70 | "count": { 71 | "type": "integer" 72 | } 73 | }, 74 | "required": [ 75 | "data", 76 | "page", 77 | "pages", 78 | "limit", 79 | "hasPreviousPage", 80 | "hasNextPage", 81 | "count" 82 | ] 83 | } -------------------------------------------------------------------------------- /tests/fixtures/bimedoc/bimedoc_center_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "phone_number": "+33321382253", 3 | "vaccine_names": [ 4 | "Pfizer-BioNTech" 5 | ], 6 | "rdv_site_web": "https://app.bimedoc.com/application/scheduler/9cf46288-0080-4a8d-8856-8e9998ced9f7?vmd=true", 7 | "platform_is": "bimedoc", 8 | "gid": "bimedoc9cf46288-0080-4a8d-8856-8e9998ced9f7", 9 | "nom": "Pharmacie de Thêatre | Silvie", 10 | "com_insee": "62905", 11 | "address": "51 Place du Marechal Foch, 62500 Saint-Omer", 12 | "long_coor1": 2.252316, 13 | "lat_coor1": 50.750673, 14 | "type": "drugstore" 15 | } -------------------------------------------------------------------------------- /tests/fixtures/bimedoc/bimedoc_centers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "phone_number": "+33478542582", 4 | "vaccine_names": [ 5 | "Moderna" 6 | ], 7 | "rdv_site_web": "https://app.bimedoc.com/application/scheduler/37df4bc4-8afb-46e5-964a-3e91b72e44b3?vmd=true", 8 | "platform_is": "bimedoc", 9 | "gid": "bimedoc37df4bc4-8afb-46e5-964a-3e91b72e44b3", 10 | "nom": "PHARMACIE GRANDCLEMENT", 11 | "com_insee": "69266", 12 | "address": "2 Rue LEON BLUM, 69100 Villeurbanne", 13 | "long_coor1": 4.89097, 14 | "lat_coor1": 45.759376, 15 | "type": "drugstore" 16 | }, 17 | { 18 | "phone_number": "+33321381376", 19 | "vaccine_names": [ 20 | "Pfizer-BioNTech", 21 | "Moderna", 22 | "Janssen" 23 | ], 24 | "rdv_site_web": "https://app.bimedoc.com/application/scheduler/a8c890ec-f95c-40c9-bb6b-db8d4a0877cc?vmd=true", 25 | "platform_is": "bimedoc", 26 | "gid": "bimedoca8c890ec-f95c-40c9-bb6b-db8d4a0877cc", 27 | "nom": "SELARL DE PHARMACIENS D'OFFICINE", 28 | "com_insee": "62905", 29 | "address": "158 Rue DE DUNKERQUE, 62500 Saint-Omer", 30 | "long_coor1": 2.258574, 31 | "lat_coor1": 50.753255, 32 | "type": "drugstore" 33 | }, 34 | { 35 | "phone_number": "+33321382253", 36 | "vaccine_names": [ 37 | "Pfizer-BioNTech" 38 | ], 39 | "rdv_site_web": "https://app.bimedoc.com/application/scheduler/9cf46288-0080-4a8d-8856-8e9998ced9f7?vmd=true", 40 | "platform_is": "bimedoc", 41 | "gid": "bimedoc9cf46288-0080-4a8d-8856-8e9998ced9f7", 42 | "nom": "Pharmacie de Thêatre | Silvie", 43 | "com_insee": "62905", 44 | "address": "51 Place du Marechal Foch, 62500 Saint-Omer", 45 | "long_coor1": 2.252316, 46 | "lat_coor1": 50.750673, 47 | "type": "drugstore" 48 | } 49 | ] -------------------------------------------------------------------------------- /tests/fixtures/bimedoc/slots_unavailable.json: -------------------------------------------------------------------------------- 1 | { 2 | "slots": [ 3 | ] 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/doctolib/basic-availabilities.json: -------------------------------------------------------------------------------- 1 | { 2 | "availabilities": [ 3 | { 4 | "slots": [ 5 | { 6 | "start_date": "2021-04-10T21:45:00.000+02:00" 7 | }, 8 | { 9 | "start_date": "2021-03-25T21:45:00.000+02:00" 10 | } 11 | ] 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /tests/fixtures/doctolib/basic-booking.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "profile": { 4 | "id": "123456789" 5 | }, 6 | "visit_motive_categories": [], 7 | "visit_motives": [ 8 | { 9 | "id": 2, 10 | "name": "1ère injection vaccin COVID-19 (Moderna)", 11 | "vaccination_motive": true, 12 | "first_shot_motive": true, 13 | "ref_visit_motive_id": 6970 14 | } 15 | ], 16 | "agendas": [ 17 | { 18 | "id": 3, 19 | "visit_motive_ids_by_practice_id": { 20 | "165752": [ 21 | 2 22 | ] 23 | }, 24 | "booking_disabled": false 25 | } 26 | ], 27 | "places": [ 28 | { 29 | "id": "practice-165752", 30 | "practice_ids": [ 31 | 165752 32 | ] 33 | } 34 | ] 35 | } 36 | } -------------------------------------------------------------------------------- /tests/fixtures/doctolib/category-availabilities.json: -------------------------------------------------------------------------------- 1 | { 2 | "availabilities": [ 3 | { 4 | "slots": [ 5 | { 6 | "start_date": "2021-04-10" 7 | } 8 | ] 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tests/fixtures/doctolib/category-booking.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "visit_motive_categories": [ 4 | { 5 | "id": 1, 6 | "name": "Non professionnels de santé" 7 | } 8 | ], 9 | "visit_motives": [ 10 | { 11 | "id": 2, 12 | "visit_motive_category_id": 1, 13 | "ref_visit_motive_id": 8740, 14 | "name": "1ère injection vaccin COVID-19 (Moderna)", 15 | "vaccination_motive": true, 16 | "first_shot_motive": true 17 | } 18 | ], 19 | "agendas": [ 20 | { 21 | "id": 3, 22 | "visit_motive_ids_by_practice_id": { 23 | "165752": [ 24 | 2 25 | ] 26 | }, 27 | "booking_disabled": false 28 | } 29 | ], 30 | "places": [ 31 | { 32 | "id": "practice-165752", 33 | "practice_ids": [ 34 | 165752 35 | ] 36 | } 37 | ] 38 | } 39 | } -------------------------------------------------------------------------------- /tests/fixtures/doctolib/next-slot-availabilities.json: -------------------------------------------------------------------------------- 1 | { 2 | "availabilities": [ 3 | { 4 | "slots": [] 5 | } 6 | ], 7 | "next_slot": "2021-04-10T12:00:00.000+02:00" 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/doctolib/next-slot-booking.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "visit_motive_categories": [ 4 | { 5 | "id": 1, 6 | "name": "Non professionnels de santé" 7 | } 8 | ], 9 | "visit_motives": [ 10 | { 11 | "id": 2, 12 | "visit_motive_category_id": 1, 13 | "name": "1ère injection vaccin COVID-19 (Moderna)", 14 | "ref_visit_motive_id": 8740, 15 | "vaccination_motive": true, 16 | "first_shot_motive": true 17 | } 18 | ], 19 | "agendas": [ 20 | { 21 | "id": 3, 22 | "visit_motive_ids_by_practice_id": { 23 | "165752": [ 24 | 2 25 | ] 26 | }, 27 | "booking_disabled": false 28 | } 29 | ], 30 | "places": [ 31 | { 32 | "id": "practice-165752", 33 | "practice_ids": [ 34 | 165752 35 | ] 36 | } 37 | ] 38 | } 39 | } -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-cabinet-16913.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "motive_category_id": 2526, 3 | "name": "Je suis une personne de + 70 ANS (PF)", 4 | "position": 0, 5 | "resource_type": "Clinic", 6 | "motives": [{ 7 | "id": 81484, 8 | "name": "1ère injection Vaccin COVID +70 ANS (PF)", 9 | "position": 0, 10 | "teleconsultation": false, 11 | "booking_delay_until": 1440, 12 | "agendas": [{ 13 | "id": 49335, 14 | "name": "Equipe de vaccination LE FAOUET - Maison de santé", 15 | "hidden": true 16 | }, { 17 | "id": 51414, 18 | "name": "Equipe de vaccination 2 LE FAOUET - Maison de santé", 19 | "hidden": true 20 | }] 21 | }] 22 | }, { 23 | "motive_category_id": 2527, 24 | "name": "Je suis professionnel.le de santé liberal.e (PF)", 25 | "position": 1, 26 | "resource_type": "Clinic", 27 | "motives": [{ 28 | "id": 81486, 29 | "name": "1ère injection Vaccin COVID LIBERAUX (PF)", 30 | "position": 0, 31 | "teleconsultation": false, 32 | "booking_delay_until": 1440, 33 | "agendas": [{ 34 | "id": 49335, 35 | "name": "Equipe de vaccination LE FAOUET - Maison de santé", 36 | "hidden": true 37 | }] 38 | }] 39 | }, { 40 | "motive_category_id": 2528, 41 | "name": "Je suis professionnel.le du GHBS (PF)", 42 | "position": 2, 43 | "resource_type": "Clinic", 44 | "motives": [{ 45 | "id": 81488, 46 | "name": "1ère injection Vaccin COVID Professionnel.le.s GHBS (PF)", 47 | "position": 0, 48 | "teleconsultation": false, 49 | "booking_delay_until": 1440, 50 | "agendas": [{ 51 | "id": 49335, 52 | "name": "Equipe de vaccination LE FAOUET - Maison de santé", 53 | "hidden": true 54 | }] 55 | }] 56 | }, { 57 | "motive_category_id": 2763, 58 | "name": "Je suis une personne vulnérable à très haut risque (PF)", 59 | "position": 999, 60 | "resource_type": "Clinic", 61 | "motives": [{ 62 | "id": 82874, 63 | "name": "1ère injection Vaccin COVID personne vulnérable à très haut risque (PF)", 64 | "position": 999, 65 | "teleconsultation": false, 66 | "booking_delay_until": 720, 67 | "agendas": [{ 68 | "id": 49335, 69 | "name": "Equipe de vaccination LE FAOUET - Maison de santé", 70 | "hidden": true 71 | }, { 72 | "id": 51414, 73 | "name": "Equipe de vaccination 2 LE FAOUET - Maison de santé", 74 | "hidden": true 75 | }] 76 | }] 77 | }] -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-cabinet-18780.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "motive_category_id": 2526, 3 | "name": "Je suis une personne de + 70 ANS (PF)", 4 | "position": 0, 5 | "resource_type": "Clinic", 6 | "motives": [{ 7 | "id": 81484, 8 | "name": "1ère injection Vaccin COVID +70 ANS (PF)", 9 | "position": 0, 10 | "teleconsultation": false, 11 | "booking_delay_until": 1440, 12 | "agendas": [{ 13 | "id": 49335, 14 | "name": "Equipe de vaccination LE FAOUET - Maison de santé", 15 | "hidden": true 16 | }, { 17 | "id": 51414, 18 | "name": "Equipe de vaccination 2 LE FAOUET - Maison de santé", 19 | "hidden": true 20 | }] 21 | }] 22 | }, { 23 | "motive_category_id": 2527, 24 | "name": "Je suis professionnel.le de santé liberal.e (PF)", 25 | "position": 1, 26 | "resource_type": "Clinic", 27 | "motives": [{ 28 | "id": 81486, 29 | "name": "1ère injection Vaccin COVID LIBERAUX (PF)", 30 | "position": 0, 31 | "teleconsultation": false, 32 | "booking_delay_until": 1440, 33 | "agendas": [{ 34 | "id": 49335, 35 | "name": "Equipe de vaccination LE FAOUET - Maison de santé", 36 | "hidden": true 37 | }] 38 | }] 39 | }, { 40 | "motive_category_id": 2528, 41 | "name": "Je suis professionnel.le du GHBS (PF)", 42 | "position": 2, 43 | "resource_type": "Clinic", 44 | "motives": [{ 45 | "id": 81488, 46 | "name": "1ère injection Vaccin COVID Professionnel.le.s GHBS (PF)", 47 | "position": 0, 48 | "teleconsultation": false, 49 | "booking_delay_until": 1440, 50 | "agendas": [{ 51 | "id": 49335, 52 | "name": "Equipe de vaccination LE FAOUET - Maison de santé", 53 | "hidden": true 54 | }] 55 | }] 56 | }, { 57 | "motive_category_id": 2763, 58 | "name": "Je suis une personne vulnérable à très haut risque (PF)", 59 | "position": 999, 60 | "resource_type": "Clinic", 61 | "motives": [{ 62 | "id": 82874, 63 | "name": "1ère injection Vaccin COVID personne vulnérable à très haut risque (PF)", 64 | "position": 999, 65 | "teleconsultation": false, 66 | "booking_delay_until": 720, 67 | "agendas": [{ 68 | "id": 49335, 69 | "name": "Equipe de vaccination LE FAOUET - Maison de santé", 70 | "hidden": true 71 | }, { 72 | "id": 51414, 73 | "name": "Equipe de vaccination 2 LE FAOUET - Maison de santé", 74 | "hidden": true 75 | }] 76 | }] 77 | }] -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-cabinet.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": 18780, 3 | "name": "Centre de Vaccination Caudan Ville", 4 | "location": "Salle Des Fêtes Joseph Le Ravallec 10 Rue du 19eme Dragon, 56850, Caudan,France", 5 | "img": "https://www.keldoc.com/assets/uploads/clinic/photos/2563/square_medium_groupe-hospitalier-bretagne-sud-lorient-hopital-du-scorff_f7dec759-df77-4cea-b975-547afb50bb7b.jpg", 6 | "latitude": 47.811366, 7 | "longitude": -3.353233 8 | }, { 9 | "id": 16913, 10 | "name": "Centre de vaccination - LE FAOUET - Maison de santé", 11 | "location": "104 Rue de Saint Fiacre, 56320 Le Faouët", 12 | "img": "https://www.keldoc.com/assets/uploads/clinic/photos/2563/square_medium_groupe-hospitalier-bretagne-sud-lorient-hopital-du-scorff_f7dec759-df77-4cea-b975-547afb50bb7b.jpg", 13 | "latitude": 48.031233, 14 | "longitude": -3.490112 15 | }, { 16 | "id": 16910, 17 | "name": "Centre de vaccination K2 Lorient La Base", 18 | "location": "Rue Etienne d’Orves, 56100 Lorient", 19 | "img": "https://www.keldoc.com/assets/uploads/clinic/photos/2563/square_medium_groupe-hospitalier-bretagne-sud-lorient-hopital-du-scorff_f7dec759-df77-4cea-b975-547afb50bb7b.jpg", 20 | "latitude": 47.732493, 21 | "longitude": -3.37471 22 | }, { 23 | "id": 16571, 24 | "name": "Centre de vaccination pour les Professionnels - GHBS Lorient - Bâtiment Onc'Oriant", 25 | "location": "1 Rampe de l'Hôpital des Armées, 56100 Lorient ", 26 | "img": "https://www.keldoc.com/assets/uploads/cabinet/photos/16571/square_medium_centre-de-vaccination-du-ghbs-lorient-hopital-du-scorff_c00e58f5-4606-43c8-9ebe-fc477e91e5bc.jpg", 27 | "latitude": 47.753227, 28 | "longitude": -3.359273 29 | }, { 30 | "id": 16579, 31 | "name": "GHBS Professionnels Quimperlé Hôpital La Villeneuve et Hôpital Le Faouët ", 32 | "location": "20 bis avenue Général Leclerc, 29300 Quimperlé", 33 | "img": "https://www.keldoc.com/assets/uploads/cabinet/photos/16579/square_medium_centre-de-vaccination-du-ghbs-quimperle-hopital-la-villeneuve_2fd68618-2dd7-4690-af7c-53129f23db66.jpg", 34 | "latitude": 47.868873, 35 | "longitude": -3.557478 36 | }] -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2563, 3 | "specialties": [{ 4 | "id": 144, 5 | "name": "Maladies infectieuses", 6 | "skills": [{ 7 | "name": "Centre de vaccination COVID-19" 8 | }] 9 | }, 10 | { 11 | "id": 9302, 12 | "name": "foobar", 13 | "skills": [{ 14 | "name": "put a bar in foo?" 15 | }] 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-motives.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 81484, 4 | "vaccine_type": null, 5 | "agendas": [ 6 | 49335, 7 | 51414 8 | ], 9 | "dose": "1" 10 | }, 11 | { 12 | "id": 81486, 13 | "vaccine_type": null, 14 | "agendas": [ 15 | 49335 16 | ], 17 | "dose": "1" 18 | }, 19 | { 20 | "id": 81488, 21 | "vaccine_type": null, 22 | "agendas": [ 23 | 49335 24 | ], 25 | "dose": "1" 26 | }, 27 | { 28 | "id": 82874, 29 | "vaccine_type": null, 30 | "agendas": [ 31 | 49335, 32 | 51414 33 | ], 34 | "dose": "1" 35 | } 36 | ] -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-timetable-81484.json: -------------------------------------------------------------------------------- 1 | { 2 | "text": "Prochain RDV disponible le 20 Avril 2021 \u00e0 16h55", 3 | "date": "2021-04-20T16:55:00.000+02:00" 4 | } -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-timetable-81486.json: -------------------------------------------------------------------------------- 1 | { 2 | "text": "La prise de RDV pour cette consultation est uniquement disponible au :", 3 | "num": "02 97 06 97 94", 4 | "num_text": "02 97 06... Afficher le num\u00e9ro", 5 | "phone_number": "+33297069794" 6 | } -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-timetable-81488.json: -------------------------------------------------------------------------------- 1 | { 2 | "text": "La prise de RDV pour cette consultation est uniquement disponible au :", 3 | "num": "02 97 06 97 94", 4 | "num_text": "02 97 06... Afficher le num\u00e9ro", 5 | "phone_number": "+33297069794" 6 | } -------------------------------------------------------------------------------- /tests/fixtures/keldoc/center1-timetable-82874.json: -------------------------------------------------------------------------------- 1 | { 2 | "text": "Prochain RDV disponible le 20 Avril 2021 \u00e0 16h55", 3 | "date": "2021-04-20T16:55:00.000+02:00" 4 | } -------------------------------------------------------------------------------- /tests/fixtures/keldoc/department-ain.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "location_input": "Ain", 4 | "location_text": "Ain (01)", 5 | "next_page": false, 6 | "previous_page": false, 7 | "empty": false, 8 | "search_type": "departement", 9 | "specialty_id": "maladies-infectieuses" 10 | }, 11 | "results": { 12 | "section_1": { 13 | "data": [ 14 | { 15 | "id": 17136, 16 | "type": "Cabinet", 17 | "img": "https://www.keldoc.com/assets/be/front/clinics/clinic-missing-square_medium-1f94da4f68250e10b53f55d503bc6884.png", 18 | "alt": "Centre de vaccination de Jean Marinet à Valserhône 01200", 19 | "title": "Centre de vaccination de Jean Marinet", 20 | "sub_title": "Jean Marinet - Centre de vaccination COVID", 21 | "convention_type_name": null, 22 | "cabinet": { 23 | "street": "Place Jeanne d'arc", 24 | "zipcode": "01200", 25 | "city": "Valserhône", 26 | "location": "Place Jeanne d'arc, 01200, Valserhône" 27 | }, 28 | "agenda": { 29 | "ids": [ 30 | 51314, 31 | 50247, 32 | 51313, 33 | 49968, 34 | 49969, 35 | 56107 36 | ], 37 | "specialty_id": 144, 38 | "next_availability": "2021-05-20T09:30:00.000+02:00", 39 | "default_online_motive_id": 84927 40 | }, 41 | "specialty_ids": [ 42 | 144 43 | ], 44 | "coordinates": "46.108467,5.82781", 45 | "url": "/cabinet-medical/valserhone-01200/jean-marinet/centre-de-vaccination-de-jean-marinet" 46 | } 47 | ], 48 | "empty_description": null, 49 | "section_title": "Prendre un RDV avec un maladies infectieuses dans l’Ain (01)" 50 | }, 51 | "section_2": { 52 | "data": [], 53 | "section_title": "Contacter un maladies infectieuses dans l’Ain (01)" 54 | }, 55 | "section_3": { 56 | "data": [] 57 | }, 58 | "section_4": { 59 | "data": [] 60 | } 61 | }, 62 | "seo": { 63 | "title": "Maladies infectieuses à Ain (01)", 64 | "keywords": "Maladies infectieuses, Maladies infectieuses Ain 01, 01", 65 | "description": "Trouvez votre Maladies infectieuses à Ain 01 et prenez rendez-vous directement en ligne en quelques clics seulement ! Rapide, Gratuit et Sécurisé." 66 | } 67 | } -------------------------------------------------------------------------------- /tests/fixtures/keldoc/resource-ain.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2737, 3 | "title": "Jean Marinet - Centre de vaccination COVID", 4 | "url": "/cabinet-medical/valserhone-01200/jean-marinet", 5 | "description": "
est un centre de vaccination COVID
", 6 | "main_description": "
est un centre de vaccination COVID
", 7 | "cabinets": [ 8 | { 9 | "id": 17136, 10 | "name": "Centre de vaccination de Jean Marinet", 11 | "location": "Place Jeanne d'arc, 01200, Valserhône", 12 | "slug": "centre-de-vaccination-de-jean-marinet", 13 | "latitude": 46.108467, 14 | "longitude": 5.82781, 15 | "transportations": [] 16 | } 17 | ], 18 | "type": "Clinic", 19 | "img": "https://www.keldoc.com/assets/be/front/clinics/clinic-missing-square_medium-1f94da4f68250e10b53f55d503bc6884.png", 20 | "specialties": [ 21 | { 22 | "id": 144, 23 | "name": "Maladies infectieuses", 24 | "skills": [ 25 | { 26 | "name": "Centre de vaccination COVID-19" 27 | } 28 | ] 29 | } 30 | ], 31 | "seo": { 32 | "noindex": false, 33 | "title": "Jean Marinet - Centre de vaccination COVID (01200) : Prendre RDV en ligne", 34 | "description": "Jean Marinet - Centre de vaccination COVID. Prenez rendez-vous en ligne chez votre maladies infectieuses grâce à KelDoc." 35 | }, 36 | "breadcrumbs": [ 37 | { 38 | "title": "Accueil", 39 | "path": "/" 40 | }, 41 | { 42 | "title": "Ain (01)" 43 | }, 44 | { 45 | "title": "Valserhône 01200" 46 | }, 47 | { 48 | "title": "Jean Marinet - Centre de vaccination COVID", 49 | "path": "/cabinet-medical/valserhone-01200/jean-marinet" 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /tests/fixtures/maiia/availability-closests.json: -------------------------------------------------------------------------------- 1 | { 2 | "availabilityCount": 1, 3 | "closestPhysicalAvailability": { 4 | "id": "607869c8d6b1747a79b0eace", 5 | "practitionerId": "6007098df398765a70e9f560", 6 | "centerId": "6005bad42475225a68a3f19f", 7 | "timeSlotId": "6065b6fbfce3b9120436a859", 8 | "weekId": "6065ab31e02c8d118b4e4387", 9 | "weekTemplateCycleId": "6065ab31e02c8d118b4e4383", 10 | "consultationReasonId": "6007098df398765a70e9f564", 11 | "creationDate": "2021-04-15T16:28:56.249Z", 12 | "updateDate": "2021-04-15T16:28:56.249Z", 13 | "startDateTime": "2021-05-26T12:55:00.000Z", 14 | "endDateTime": "2021-05-26T13:00:00.000Z", 15 | "percentageNewPatient": 85.41666666666666, 16 | "usedResource": [], 17 | "substitute": {} 18 | }, 19 | "firstPhysicalStartDateTime": "2021-05-26T12:55:00.000Z" 20 | } -------------------------------------------------------------------------------- /tests/fixtures/maiia/consultation-reason-hcd.json: -------------------------------------------------------------------------------- 1 | {"items":[{"id":"6065da8801a987762e623dc9","name":"Dispositif CNAM - \"Allez-vers\" +de 75 ans","position":5,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"6054856eabadcc540688049e","name":"Première injection - etudiant en santé - avec attestation de formation","position":3,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605b33a7664f4e3807876189","name":"Première injection professionnel de santé hors hôpital du gier (moins de 55ans)","position":4,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605b337c3420b25a1c4e2f90","name":"Première injection professionnel de santé hôpital du gier","position":3,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605dc40ddcb2f83f2c77fc2f","name":"Première injection vaccin anti covid-19 ( +50 ans avec comorbidité)","position":2,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605dc2e8014785294b527b0e","name":"Première injection vaccin anti covid-19 (+50 ans avec comorbidité)","position":2,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"605dc53c40f8fe05bb02f945","name":"Première injection vaccin anti covid-19 (personnes +18 ans à très haut-risque avec ordonnance médicale)","position":1,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"603686d04428f40a0ef44d23","name":"Première injection vaccin anti covid-19 (personnes +18ans à très haut risque avec ordonnance médicale) - rive de gier","position":1,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"602a56db18afb4512ac77c19","name":"Première injection vaccin anti covid-19 (personnes +60 ans)","position":0,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42},{"id":"603686d04428f40a0ef44d27","name":"Première injection vaccin anti covid-19 (personnes +60 ans) - rive de gier","position":0,"consultationType":"PHYSICAL","patientLimitation":"PUBLIC","allowAvailabilitiesXHoursBefore":1008,"injectionType":"FIRST","nextAppointmentDelayInDays":42}],"total":10} -------------------------------------------------------------------------------- /tests/fixtures/mesoigner/mesoigner_center_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "rdv_site_web": "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription", 3 | "platform_is": "mesoigner", 4 | "name": "Pharmacie des Pyrénées", 5 | "center_type": "pharmacie", 6 | "booking_disabled": false, 7 | "phone_number": "+33562450461", 8 | "gid": "1722", 9 | "nom": "Pharmacie Des Ailes", 10 | "com_insee": "03310", 11 | "address": "1 Impasse du Stade, 65310 ODOS", 12 | "long_coor1": 3.41534, 13 | "lat_coor1": 46.14076, 14 | "business_hours": { 15 | "lundi": "08:00-20:00", 16 | "mardi": "08:00-20:00", 17 | "mercredi": "08:00-20:00", 18 | "jeudi": "08:00-20:00", 19 | "vendredi": "08:00-20:00", 20 | "samedi": "08:00-20:00", 21 | "dimanche": null 22 | }, 23 | "type": "drugstore" 24 | } -------------------------------------------------------------------------------- /tests/fixtures/mesoigner/mesoigner_centers.json: -------------------------------------------------------------------------------- 1 | [{"adress_city": "CHAUSSIN", 2 | "adress_street": "6 Rue de l'Hotel de Ville", 3 | "booking_disabled": false, 4 | "center_type": "pharmacie", 5 | "id": "1707", 6 | "name": "Pharmacie Grizard", 7 | "opening_hours": [{"day": 1, 8 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]}, 9 | {"day": 2, 10 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]}, 11 | {"day": 3, 12 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]}, 13 | {"day": 4, 14 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]}, 15 | {"day": 5, 16 | "ranges": [["09:00", "12:00"], ["14:30", "19:00"]]}, 17 | {"day": 6, 18 | "ranges": [["09:00", "12:00"], ["14:30", "17:00"]]}, 19 | {"day": 7, "ranges": null}], 20 | "phone_number": "+33384818141", 21 | "platform_is": "mesoigner", 22 | "position": {"latitude": "46.96735700", "longitude": "5.40696680"}, 23 | "rdv_site_web": "https://pharmacie-chaussin.pharm-upp.fr/rendez-vous/vaccination/502-vaccination-covid-19/pre-inscription?origin=vmd", 24 | "zipcode": "39120"}, 25 | 26 | {"adress_city": "ÉTUEFFONT", 27 | "adress_street": "6 Rue de Giromagny", 28 | "booking_disabled": false, 29 | "center_type": "pharmacie", 30 | "id": "1709", 31 | "name": "Pharmacie du Fayé", 32 | "opening_hours": [{"day": 1, 33 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]}, 34 | {"day": 2, 35 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]}, 36 | {"day": 3, 37 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]}, 38 | {"day": 4, 39 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]}, 40 | {"day": 5, 41 | "ranges": [["09:00", "12:00"], ["14:00", "19:00"]]}, 42 | {"day": 6, "ranges": [["09:00", "12:00"]]}, 43 | {"day": 7, "ranges": null}], 44 | "phone_number": "+33384546226", 45 | "platform_is": "mesoigner", 46 | "position": {"latitude": "47.72310260", "longitude": "6.91901160"}, 47 | "rdv_site_web": "https://pharmacie-etueffont.pharm-upp.fr/rendez-vous/vaccination/670-vaccination-covid-19/pre-inscription?origin=vmd", 48 | "zipcode": "90170"}, 49 | 50 | {"adress_city": "Les Sables d'Olonne", 51 | "adress_street": "87 Avenue François Mitterrand", 52 | "booking_disabled": false, 53 | "center_type": "pharmacie", 54 | "id": "1712", 55 | "name": "Pharmacie Ylium", 56 | "opening_hours": [{"day": 1, "ranges": [["08:30", "20:00"]]}, 57 | {"day": 2, "ranges": [["08:30", "20:00"]]}, 58 | {"day": 3, "ranges": [["08:30", "20:00"]]}, 59 | {"day": 4, "ranges": [["08:30", "20:00"]]}, 60 | {"day": 5, "ranges": [["08:30", "20:00"]]}, 61 | {"day": 6, "ranges": [["08:30", "20:00"]]}, 62 | {"day": 7, "ranges": null}], 63 | "phone_number": "+33251328736", 64 | "platform_is": "mesoigner", 65 | "position": {"latitude": "46.51561320", "longitude": "-1.77794900"}, 66 | "rdv_site_web": "https://pharmacie-ylium.apothical.fr/rendez-vous/vaccination/528-vaccination-covid-19/pre-inscription?origin=vmd", 67 | "zipcode": "85340"}, 68 | 69 | {"adress_city": "SAINT-MEDARD-EN-JALLES", 70 | "adress_street": "7 Place de la Liberté", 71 | "booking_disabled": false, 72 | "center_type": "pharmacie", 73 | "id": "1713", 74 | "name": "Pharmacie de la Liberté", 75 | "opening_hours": [{"day": 1, 76 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]}, 77 | {"day": 2, 78 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]}, 79 | {"day": 3, 80 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]}, 81 | {"day": 4, 82 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]}, 83 | {"day": 5, 84 | "ranges": [["08:30", "12:30"], ["14:00", "20:00"]]}, 85 | {"day": 6, 86 | "ranges": [["09:00", "12:30"], ["14:00", "19:30"]]}, 87 | {"day": 7, "ranges": null}], 88 | "phone_number": "+33556050421", 89 | "platform_is": "mesoigner", 90 | "position": {"latitude": "44.88846600", "longitude": "-0.70182240"}, 91 | "rdv_site_web": "https://pharmaciedelaliberte.pharmacorp.fr/rendez-vous/vaccination/18-vaccination-covid-19/pre-inscription?origin=vmd", 92 | "zipcode": "33160"}] -------------------------------------------------------------------------------- /tests/fixtures/mesoigner/slots_available.json: -------------------------------------------------------------------------------- 1 | {"total": 4, 2 | "slots": [ 3 | { 4 | "2021-06-16": [ 5 | { 6 | "slot_beginning": "2021-06-16T14:50:00+02:00", 7 | "available_vaccines": ["Moderna"], 8 | "number_of_slots": 1 9 | } 10 | ] 11 | }, 12 | {"2021-06-17": []}, 13 | {"2021-06-185": []}, 14 | { 15 | "2021-07-26": [ 16 | { 17 | "slot_beginning": "2021-07-26T14:30:00+02:00", 18 | "available_vaccines": ["AstraZeneca"], 19 | "number_of_slots": 1 20 | }, 21 | { 22 | "slot_beginning": "2021-07-26T14:55:00+02:00", 23 | "available_vaccines": ["AstraZeneca"], 24 | "number_of_slots": 1 25 | }, 26 | { 27 | "slot_beginning": "2021-07-26T16:05:00+02:00", 28 | "available_vaccines": ["Moderna"], 29 | "number_of_slots": 1 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /tests/fixtures/mesoigner/slots_unavailable.json: -------------------------------------------------------------------------------- 1 | { 2 | "total": 0, 3 | "slots": [ 4 | {"2021-07-16": []}, 5 | {"2021-07-17": []}, 6 | {"2021-07-18": []} 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/ordoclic/empty_slots.json: -------------------------------------------------------------------------------- 1 | { 2 | "slots": [], 3 | "nextAvailableSlotDate": null 4 | } -------------------------------------------------------------------------------- /tests/fixtures/ordoclic/fetchslot-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "profileSlug": "pharmacie-oceane-paris", 3 | "entityId": "03674d71-b200-4682-8e0a-3ab9687b2b59", 4 | "name": "Pharmacie Oceane", 5 | "type": "Pharmacie", 6 | "typeId": 1, 7 | "phone": "+33145788618", 8 | "address": "19 rue Lourmel ", 9 | "city": "Paris", 10 | "zip": "75015", 11 | "listAddress": [{ 12 | "id": "b7fd84f8-86d9-4bbe-b8f0-93b2191e7a72", 13 | "lat": 48.84922659999999, 14 | "lng": 2.2914519, 15 | "address": "19 rue Lourmel ", 16 | "city": "Paris", 17 | "zip": "75015", 18 | "createdAt": "2021-03-17T14:00:01.406087Z", 19 | "lastUpdate": "2021-04-14T08:29:23.138829Z", 20 | "activated": true, 21 | "isMainAddress": true 22 | }], 23 | "seoPublishedAt": "2021-03-17T14:00:19.998659Z", 24 | "imagesIds": [], 25 | "timeBlocks": [{ 26 | "id": 6422, 27 | "day": "1", 28 | "opening": "08:00:00", 29 | "closing": "20:00:00", 30 | "isFullDay": false 31 | }, { 32 | "id": 6423, 33 | "day": "2", 34 | "opening": "08:00:00", 35 | "closing": "20:00:00", 36 | "isFullDay": false 37 | }, { 38 | "id": 6424, 39 | "day": "3", 40 | "opening": "08:00:00", 41 | "closing": "20:00:00", 42 | "isFullDay": false 43 | }, { 44 | "id": 6425, 45 | "day": "4", 46 | "opening": "08:00:00", 47 | "closing": "20:00:00", 48 | "isFullDay": false 49 | }, { 50 | "id": 6426, 51 | "day": "5", 52 | "opening": "08:00:00", 53 | "closing": "20:00:00", 54 | "isFullDay": false 55 | }, { 56 | "id": 6427, 57 | "day": "6", 58 | "opening": "08:00:00", 59 | "closing": "20:00:00", 60 | "isFullDay": false 61 | }, { 62 | "id": 6428, 63 | "day": "7", 64 | "opening": "09:00:00", 65 | "closing": "19:00:00", 66 | "isFullDay": false 67 | }], 68 | "publicProfessionals": [{ 69 | "id": "5c8fc562-d836-4aed-84d6-225e5af8075e", 70 | "fullName": "Laurent HALWANI", 71 | "firstName": "Laurent", 72 | "lastName": "HALWANI", 73 | "title": "Monsieur", 74 | "jobId": 1, 75 | "job": "Pharmacien", 76 | "specialty": "", 77 | "address": "", 78 | "city": "", 79 | "zip": "", 80 | "activatedAt": "2021-03-11T10:11:48.656562Z", 81 | "entities": "Pharmacie Oceane", 82 | "publicProfileSlug": "laurent-halwani" 83 | }], 84 | "attributeValues": [{ 85 | "label": "introduction", 86 | "value": "/!\\ Vaccin actuellement disponible : ASTRAZENECA /!\\ \nNOTE IMPORTANTE : La Pharmacie se réserve le droit de modifier ou d'annuler les rendez-vous selon les disponibilités d'approvisionnement\n \n" 87 | }, { 88 | "label": "service_price", 89 | "value": [] 90 | }, { 91 | "label": "keywords", 92 | "value": ["Rendez-Vous", "Covid-19"] 93 | }, { 94 | "label": "public_pro", 95 | "value": ["5c8fc562-d836-4aed-84d6-225e5af8075e"] 96 | }, { 97 | "label": "waiting_room_link", 98 | "value": { 99 | "url": "", 100 | "enabled": true 101 | } 102 | }, { 103 | "label": "booking_settings", 104 | "value": { 105 | "option": "internal" 106 | } 107 | }], 108 | "showPublicProfileDocument": true, 109 | "partnerId": "b0d54363-04ce-4c43-88d4-7f61889f6c02", 110 | "lobbyRoom": { 111 | "id": "615d47f8-108b-41bf-82e4-0dfd10c6a511", 112 | "entityId": "03674d71-b200-4682-8e0a-3ab9687b2b59", 113 | "accessCode": "LDFK2GLKM6", 114 | "activatedAt": null, 115 | "entityAdminActivatedAt": null, 116 | "insuranceRequirementId": 2, 117 | "reasonRequirementId": 3, 118 | "paymentMean": false 119 | } 120 | } -------------------------------------------------------------------------------- /tests/fixtures/ordoclic/fetchslot-profile2.json: -------------------------------------------------------------------------------- 1 | { 2 | "profileSlug": "pharmacie-oceane-paris", 3 | "entityId": "03674d71-b200-4682-8e0a-3ab9687b2b59", 4 | "name": "Pharmacie Oceane", 5 | "type": "Pharmacie", 6 | "typeId": 1, 7 | "phone": "+33145788618", 8 | "address": "19 rue Lourmel ", 9 | "city": "Paris", 10 | "zip": "75015", 11 | "listAddress": [{ 12 | "id": "b7fd84f8-86d9-4bbe-b8f0-93b2191e7a72", 13 | "lat": 48.84922659999999, 14 | "lng": 2.2914519, 15 | "address": "19 rue Lourmel ", 16 | "city": "Paris", 17 | "zip": "75015", 18 | "createdAt": "2021-03-17T14:00:01.406087Z", 19 | "lastUpdate": "2021-04-14T08:29:23.138829Z", 20 | "activated": true, 21 | "isMainAddress": true 22 | }], 23 | "seoPublishedAt": "2021-03-17T14:00:19.998659Z", 24 | "imagesIds": [], 25 | "timeBlocks": [{ 26 | "id": 6422, 27 | "day": "1", 28 | "opening": "08:00:00", 29 | "closing": "20:00:00", 30 | "isFullDay": false 31 | }, { 32 | "id": 6423, 33 | "day": "2", 34 | "opening": "08:00:00", 35 | "closing": "20:00:00", 36 | "isFullDay": false 37 | }, { 38 | "id": 6424, 39 | "day": "3", 40 | "opening": "08:00:00", 41 | "closing": "20:00:00", 42 | "isFullDay": false 43 | }, { 44 | "id": 6425, 45 | "day": "4", 46 | "opening": "08:00:00", 47 | "closing": "20:00:00", 48 | "isFullDay": false 49 | }, { 50 | "id": 6426, 51 | "day": "5", 52 | "opening": "08:00:00", 53 | "closing": "20:00:00", 54 | "isFullDay": false 55 | }, { 56 | "id": 6427, 57 | "day": "6", 58 | "opening": "08:00:00", 59 | "closing": "20:00:00", 60 | "isFullDay": false 61 | }, { 62 | "id": 6428, 63 | "day": "7", 64 | "opening": "09:00:00", 65 | "closing": "19:00:00", 66 | "isFullDay": false 67 | }], 68 | "publicProfessionals": [{ 69 | "id": "5c8fc562-d836-4aed-84d6-225e5af8075e", 70 | "fullName": "Laurent HALWANI", 71 | "firstName": "Laurent", 72 | "lastName": "HALWANI", 73 | "title": "Monsieur", 74 | "jobId": 1, 75 | "job": "Pharmacien", 76 | "specialty": "", 77 | "address": "", 78 | "city": "", 79 | "zip": "", 80 | "activatedAt": "2021-03-11T10:11:48.656562Z", 81 | "entities": "Pharmacie Oceane", 82 | "publicProfileSlug": "laurent-halwani" 83 | }], 84 | "attributeValues": [{ 85 | "label": "introduction", 86 | "value": "/!\\ Vaccin actuellement disponible : ASTRAZENECA /!\\ \nNOTE IMPORTANTE : La Pharmacie se réserve le droit de modifier ou d'annuler les rendez-vous selon les disponibilités d'approvisionnement\n \n" 87 | }, { 88 | "label": "service_price", 89 | "value": [] 90 | }, { 91 | "label": "keywords", 92 | "value": ["Rendez-Vous", "Covid-19"] 93 | }, { 94 | "label": "public_pro", 95 | "value": ["5c8fc562-d836-4aed-84d6-225e5af8075e"] 96 | }, { 97 | "label": "waiting_room_link", 98 | "value": { 99 | "url": "", 100 | "enabled": true 101 | } 102 | }, { 103 | "label": "booking_settings", 104 | "value": { 105 | "option": "any" 106 | } 107 | }], 108 | "showPublicProfileDocument": true, 109 | "partnerId": "b0d54363-04ce-4c43-88d4-7f61889f6c02", 110 | "lobbyRoom": { 111 | "id": "615d47f8-108b-41bf-82e4-0dfd10c6a511", 112 | "entityId": "03674d71-b200-4682-8e0a-3ab9687b2b59", 113 | "accessCode": "LDFK2GLKM6", 114 | "activatedAt": null, 115 | "entityAdminActivatedAt": null, 116 | "insuranceRequirementId": 2, 117 | "reasonRequirementId": 3, 118 | "paymentMean": false 119 | } 120 | } -------------------------------------------------------------------------------- /tests/fixtures/ordoclic/nextavailable_slots.json: -------------------------------------------------------------------------------- 1 | { 2 | "slots": [], 3 | "nextAvailableSlotDate": "2021-06-12T10:30:00Z" 4 | } -------------------------------------------------------------------------------- /tests/fixtures/ordoclic/reasons.schema: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "object", 4 | "properties": { 5 | "reasons": { 6 | "type": "array", 7 | "items": { 8 | "type": "object", 9 | "properties": { 10 | "id": { 11 | "type": "string" 12 | }, 13 | "name": { 14 | "type": "string" 15 | }, 16 | "duration": { 17 | "type": "integer" 18 | }, 19 | "color": { 20 | "type": "string" 21 | }, 22 | "canBookOnline": { 23 | "type": "boolean" 24 | }, 25 | "professionalId": { 26 | "type": "string" 27 | }, 28 | "reasonTypeId": { 29 | "type": "integer" 30 | }, 31 | "reasonType": { 32 | "type": "string" 33 | }, 34 | "isAcceptNewPatient": { 35 | "type": "boolean" 36 | }, 37 | "advice": { 38 | "type": "string" 39 | }, 40 | "entityId": { 41 | "type": "null" 42 | }, 43 | "isTcsMode": { 44 | "type": "boolean" 45 | }, 46 | "defaultDocuments": { 47 | "type": "array", 48 | "items": { 49 | "type": "object", 50 | "properties": { 51 | "documentId": { 52 | "type": "string" 53 | }, 54 | "typeId": { 55 | "type": "integer" 56 | }, 57 | "favoriteLabel": { 58 | "type": "string" 59 | }, 60 | "permanentAccessCode": { 61 | "type": "string" 62 | }, 63 | "notifiedAt": { 64 | "type": "null" 65 | }, 66 | "reasonId": { 67 | "type": "null" 68 | }, 69 | "documentNotificationCondition": { 70 | "type": "integer" 71 | }, 72 | "documentNotificationTimeValueInHours": { 73 | "type": "integer" 74 | }, 75 | "multiFormType": { 76 | "type": "string" 77 | }, 78 | "needPatientSignature": { 79 | "type": "boolean" 80 | } 81 | }, 82 | "required": [ 83 | "documentId", 84 | "typeId", 85 | "favoriteLabel", 86 | "permanentAccessCode", 87 | "notifiedAt", 88 | "reasonId", 89 | "documentNotificationCondition", 90 | "documentNotificationTimeValueInHours", 91 | "multiFormType", 92 | "needPatientSignature" 93 | ] 94 | } 95 | }, 96 | "documentNotificationCondition": { 97 | "type": "integer" 98 | }, 99 | "documentNotificationTimeValueInHours": { 100 | "type": "integer" 101 | } 102 | }, 103 | "required": [ 104 | "id", 105 | "name", 106 | "duration", 107 | "color", 108 | "canBookOnline", 109 | "professionalId", 110 | "reasonTypeId", 111 | "reasonType", 112 | "isAcceptNewPatient", 113 | "advice", 114 | "entityId", 115 | "isTcsMode", 116 | "defaultDocuments", 117 | "documentNotificationCondition", 118 | "documentNotificationTimeValueInHours" 119 | ] 120 | } 121 | }, 122 | "limitedMode": { 123 | "type": "boolean" 124 | } 125 | }, 126 | "required": [ 127 | "reasons", 128 | "limitedMode" 129 | ] 130 | } 131 | -------------------------------------------------------------------------------- /tests/fixtures/utils/info_centres.json: -------------------------------------------------------------------------------- 1 | { 2 | "01": { 3 | "version": 1, 4 | "last_updated": "2021-04-16T18:44:43.303624+02:00", 5 | "centres_disponibles": [ 6 | { 7 | "departement": "01", 8 | "nom": "Centre 1", 9 | "url": "https://example1.fr", 10 | "location": { 11 | "longitude": 5.601759, 12 | "latitude": 45.977509, 13 | "city": "Plateau d'Hauteville" 14 | }, 15 | "metadata": { 16 | "address": "Rue de la R\u00e9publique, 01110 Plateau d'Hauteville", 17 | "business_hours": { 18 | "lundi": null, 19 | "mardi": null, 20 | "mercredi": null, 21 | "jeudi": null, 22 | "vendredi": null, 23 | "samedi": null, 24 | "dimanche": null 25 | } 26 | }, 27 | "prochain_rdv": "2021-05-14T12:30:00.000+02:00", 28 | "plateforme": "Doctolib", 29 | "type": "vaccination-center", 30 | "appointment_count": 35, 31 | "internal_id": "264639[]", 32 | "vaccine_type": [ 33 | "Pfizer-BioNTech" 34 | ], 35 | "erreur": null, 36 | "gid": "d264639", 37 | "last_scan_with_availabilities": "2021-04-04T00:00:00", 38 | "appointment_schedules": [ 39 | { 40 | "name": "chronodose", 41 | "from": "2021-05-10T00:00:00+02:00", 42 | "to": "2021-05-11T23:59:59+02:00", 43 | "total": 0 44 | }, 45 | { 46 | "name": "1_days", 47 | "from": "2021-05-10T00:00:00+02:00", 48 | "to": "2021-05-10T23:59:59+02:00", 49 | "total": 0 50 | }, 51 | { 52 | "name": "2_days", 53 | "from": "2021-05-10T00:00:00+02:00", 54 | "to": "2021-05-11T23:59:59+02:00", 55 | "total": 7 56 | }, 57 | { 58 | "name": "7_days", 59 | "from": "2021-05-10T00:00:00+02:00", 60 | "to": "2021-05-16T23:59:59+02:00", 61 | "total": 7 62 | }, 63 | { 64 | "name": "28_days", 65 | "from": "2021-05-10T00:00:00+02:00", 66 | "to": "2021-06-06T23:59:59+02:00", 67 | "total": 7 68 | }, 69 | { 70 | "name": "49_days", 71 | "from": "2021-05-10T00:00:00+02:00", 72 | "to": "2021-06-27T23:59:59+02:00", 73 | "total": 7 74 | } 75 | ] 76 | } 77 | ], 78 | "centres_indisponibles": [ 79 | { 80 | "departement": "01", 81 | "nom": "Centre 2", 82 | "url": "https://example2.fr", 83 | "location": { 84 | "longitude": 5.606802999999999, 85 | "latitude": 46.153488, 86 | "city": "nantua" 87 | }, 88 | "metadata": { 89 | "address": "19 rue du Coll\u00e8ge, 01130 Nantua", 90 | "phone_number": "+33474750042", 91 | "business_hours": null 92 | }, 93 | "prochain_rdv": "2021-04-19T09:00:00+00:00", 94 | "plateforme": "Ordoclic", 95 | "type": "drugstore", 96 | "appointment_count": 0, 97 | "internal_id": null, 98 | "vaccine_type": null, 99 | "erreur": null, 100 | "gid": "feb094ba", 101 | "last_scan_with_availabilities": null 102 | }, 103 | { 104 | "departement": "01", 105 | "nom": "Centre 3", 106 | "url": "https://example3.fr", 107 | "location": { 108 | "longitude": 5.606802999999999, 109 | "latitude": 46.153488, 110 | "city": "nantua" 111 | }, 112 | "metadata": { 113 | "address": "19 rue du Coll\u00e8ge, 01130 Nantua", 114 | "phone_number": "+33474750042", 115 | "business_hours": null 116 | }, 117 | "prochain_rdv": "2021-04-19T09:00:00+00:00", 118 | "plateforme": "Ordoclic", 119 | "type": "drugstore", 120 | "appointment_count": 46, 121 | "internal_id": null, 122 | "vaccine_type": null, 123 | "erreur": null, 124 | "gid": "feb094ba", 125 | "last_scan_with_availabilities": "2021-03-03T00:00:00" 126 | } 127 | ] 128 | } 129 | } -------------------------------------------------------------------------------- /tests/fixtures/valwin/slots_unavailable.json: -------------------------------------------------------------------------------- 1 | {"links":{"next":null,"total":0,"prev":null,"pageSize":60,"currentPage":1,"nbPages":0},"result":[]} -------------------------------------------------------------------------------- /tests/fixtures/valwin/valwin_center_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "departement": "69", 3 | "nom": "Grande Pharmacie du Plateau", 4 | "url": "https://grandepharmacie-du-plateau-lyon.pharmabest.com", 5 | "location": { 6 | "longitude": 4.795371, 7 | "latitude": 45.786598, 8 | "city": "Lyon", 9 | "cp": "69009" 10 | }, 11 | "metadata": { 12 | "address": "7, place Abb\u00e9 Pierre, 69009 Lyon", 13 | "business_hours": null 14 | }, 15 | "prochain_rdv": null, 16 | "plateforme": "Valwin", 17 | "type": "drugstore", 18 | "appointment_count": 0, 19 | "internal_id": "Valwinpharmabest75-plateau-lyon", 20 | "vaccine_type": [], 21 | "appointment_by_phone_only": false, 22 | "erreur": null, 23 | "last_scan_with_availabilities": null, 24 | "request_counts": null 25 | } -------------------------------------------------------------------------------- /tests/fixtures/valwin/valwin_centers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "departement": "28", 4 | "nom": "Pharmacie de Combray", 5 | "url": "https://pharmaciedecombray.epharmacie.pro/animation-details/a77026ad-050d-4885-97db-1939ff32cce7/1/60", 6 | "location": { 7 | "longitude": 1.25814, 8 | "latitude": 48.3043, 9 | "city": "Illiers-Combray", 10 | "cp": "28120" 11 | }, 12 | "metadata": { 13 | "address": "Avenue Marcel Proust, 28120 Illiers-Combray", 14 | "business_hours": null 15 | }, 16 | "prochain_rdv": "2021-09-14T17:05:00", 17 | "plateforme": "Valwin", 18 | "type": "drugstore", 19 | "appointment_count": 12, 20 | "internal_id": "Valwinreseausante46-combray", 21 | "vaccine_type": [ 22 | "Moderna" 23 | ], 24 | "appointment_by_phone_only": false, 25 | "erreur": null, 26 | "last_scan_with_availabilities": null, 27 | "request_counts": null 28 | }, 29 | { 30 | "departement": "51", 31 | "nom": "Pharmacie de Reims", 32 | "url": "https://pharmacie-de-reims.fr/animation-details/a77026ad-050d-4885-97db-1939ff32cce7/1/60", 33 | "location": { 34 | "longitude": 4.024, 35 | "latitude": 49.267829, 36 | "city": "Reims", 37 | "cp": "51100" 38 | }, 39 | "metadata": { 40 | "address": "153 - 155, avenue de Laon, 51100 Reims", 41 | "business_hours": null 42 | }, 43 | "prochain_rdv": "2021-09-15T13:00:00", 44 | "plateforme": "Valwin", 45 | "type": "drugstore", 46 | "appointment_count": 9, 47 | "internal_id": "Valwinph34-reims", 48 | "vaccine_type": [ 49 | "Moderna" 50 | ], 51 | "appointment_by_phone_only": false, 52 | "erreur": null, 53 | "last_scan_with_availabilities": null, 54 | "request_counts": null 55 | }, 56 | { 57 | "departement": "95", 58 | "nom": "Pharmacie du Château", 59 | "url": "https://pharmacie-beaumontsuroise.com/animation-details/a77026ad-050d-4885-97db-1939ff32cce7/1/60", 60 | "location": { 61 | "longitude": 2.28665, 62 | "latitude": 49.142929, 63 | "city": "Beaumont-sur-Oise", 64 | "cp": "95260" 65 | }, 66 | "metadata": { 67 | "address": "2, rue Albert 1er, 95260 Beaumont-sur-Oise", 68 | "business_hours": null 69 | }, 70 | "prochain_rdv": "2021-09-16T18:10:00", 71 | "plateforme": "Valwin", 72 | "type": "drugstore", 73 | "appointment_count": 26, 74 | "internal_id": "Valwinph54-chateau-beaumont-oise", 75 | "vaccine_type": [ 76 | "Moderna" 77 | ], 78 | "appointment_by_phone_only": false, 79 | "erreur": null, 80 | "last_scan_with_availabilities": null, 81 | "request_counts": null 82 | } 83 | ] -------------------------------------------------------------------------------- /tests/stats_generation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CovidTrackerFr/vitemadose/d3ddf8c65723213bf60c340291c4b136803771e2/tests/stats_generation/__init__.py -------------------------------------------------------------------------------- /tests/stats_generation/test_by_vaccine.py: -------------------------------------------------------------------------------- 1 | from functools import reduce 2 | from stats_generation import by_vaccine 3 | 4 | 5 | def test_merge(): 6 | data = {} 7 | by_vaccine.merge(data, ("Astra", 10)) 8 | assert data == {"Astra": 10} 9 | 10 | by_vaccine.merge(data, ("Astra", 10)) 11 | assert data == {"Astra": 20} 12 | 13 | by_vaccine.merge(data, ("Pfizer", 100)) 14 | assert data == {"Astra": 20, "Pfizer": 100} 15 | 16 | 17 | def test_flatten_vaccine_types(): 18 | data = { 19 | "version": 1, 20 | "last_updated": "2021-07-19T23:02:28+02:00", 21 | "centres_disponibles": [ 22 | { 23 | "departement": "75", 24 | "nom": "Pharmacie Bassereau", 25 | "url": "https://www.maiia.com/pharmacie/75017-paris/pharmacie-bassereau?centerid=604b186665d8f5139d42dc21", 26 | "location": {"longitude": 2.317913, "latitude": 48.886224, "city": "Paris", "cp": "75017"}, 27 | "metadata": { 28 | "address": "70 Rue Legendre 75017 Paris", 29 | "business_hours": { 30 | "Lundi": "09:20-20:00", 31 | "Mardi": "09:20-20:00", 32 | "Mercredi": "09:20-20:00", 33 | "Jeudi": "09:20-20:00", 34 | "Vendredi": "09:20-20:00", 35 | "Samedi": "09:20-20:00", 36 | "Dimanche": "09:20-20:00", 37 | }, 38 | }, 39 | "prochain_rdv": "2021-07-20T07:00:00+00:00", 40 | "plateforme": "Maiia", 41 | "type": "drugstore", 42 | "appointment_count": 7, 43 | "internal_id": "maiia604b1866", 44 | "vaccine_type": ["Janssen"], 45 | "appointment_by_phone_only": False, 46 | "erreur": None, 47 | "last_scan_with_availabilities": None, 48 | "request_counts": None, 49 | }, 50 | { 51 | "departement": "94", 52 | "nom": "Centre de vaccination - CHI de Villeneuve-Saint-Georges", 53 | "url": "https://www.maiia.com/centre-de-vaccination/94190-villeneuve-saint-georges/centre-de-vaccination---chi-de-villeneuve-saint-georges?centerid=6001704008fa3a60d6d1b0aa", 54 | "location": { 55 | "longitude": 2.450239, 56 | "latitude": 48.723306, 57 | "city": "Villeneuve-Saint-Georges", 58 | "cp": "94190", 59 | }, 60 | "metadata": { 61 | "address": "40 Allée de la Source 94190 Villeneuve-Saint-Georges", 62 | "business_hours": { 63 | "Lundi": "09:00-16:30", 64 | "Mardi": "09:00-16:30", 65 | "Mercredi": "09:00-16:30", 66 | "Jeudi": "09:00-16:30", 67 | "Vendredi": "09:00-16:30", 68 | "Samedi": "09:00-16:30", 69 | "Dimanche": "09:00-16:30", 70 | }, 71 | "phone_number": "+33143862150", 72 | }, 73 | "prochain_rdv": "2021-07-20T07:10:00+00:00", 74 | "plateforme": "Maiia", 75 | "type": "vaccination-center", 76 | "appointment_count": 650, 77 | "internal_id": "maiia60017040", 78 | "vaccine_type": ["Pfizer-BioNTech"], 79 | "appointment_by_phone_only": False, 80 | "erreur": None, 81 | "last_scan_with_availabilities": None, 82 | "request_counts": None, 83 | }, 84 | ], 85 | } 86 | flattened = list(by_vaccine.flatten_vaccine_types_schedules(data)) 87 | assert flattened == [("Janssen", 1), ("Pfizer-BioNTech", 1)] 88 | assert reduce(by_vaccine.merge, flattened, {}) == {"Janssen": 1, "Pfizer-BioNTech": 1} 89 | -------------------------------------------------------------------------------- /tests/test_center_location.py: -------------------------------------------------------------------------------- 1 | from scraper.pattern.center_location import convert_csv_data_to_location 2 | 3 | 4 | def test_location_working(): 5 | dict = {"long_coor1": 1.231, "lat_coor1": -42.839, "com_nom": "Rennes"} 6 | center_location = convert_csv_data_to_location(dict) 7 | assert center_location 8 | assert center_location.longitude == 1.231 9 | assert center_location.latitude == -42.839 10 | assert center_location.city == "Rennes" 11 | 12 | 13 | def test_location_issue(): 14 | dict = {"long_coor31": 1.231, "lat_coor13": -42.839, "com_nom": "Rennes"} 15 | center_location = convert_csv_data_to_location(dict) 16 | assert center_location is None 17 | 18 | 19 | def test_location_parse_address(): 20 | dict = {"long_coor1": 1.231, "lat_coor1": -42.839, "address": "39 Rue de la Fraise, 35000 Foobar"} 21 | center_location = convert_csv_data_to_location(dict) 22 | assert center_location.city == "Foobar" 23 | 24 | 25 | def test_location_bad_values(): 26 | dict = {"long_coor1": "1,231Foo", "lat_coor1": -1.23, "address": "39 Rue de la Fraise, 35000 Foobar"} 27 | center_location = convert_csv_data_to_location(dict) 28 | assert center_location is None 29 | 30 | 31 | def test_location_callback(): 32 | dict = {"long_coor1": "1.231", "lat_coor1": -1.23, "address": "39 Rue de la Fraise, 35000 Foo2bar"} 33 | center_location = convert_csv_data_to_location(dict) 34 | assert center_location.default() == {"longitude": 1.231, "latitude": -1.23, "city": "Foo2bar", "cp": "35000"} 35 | -------------------------------------------------------------------------------- /tests/test_geo_api.py: -------------------------------------------------------------------------------- 1 | from utils.vmd_geo_api import get_location_from_address, get_location_from_coordinates, Location, Coordinates 2 | 3 | location1: Location = { 4 | "full_address": "389 avenue mal de lattre de tassigny 71000 Mâcon", 5 | "number_street": "389 avenue mal de lattre de tassigny", 6 | "com_name": "Mâcon", 7 | "com_zipcode": "71000", 8 | "com_insee": "71270", 9 | "departement": "71", 10 | "longitude": 4.840267, 11 | "latitude": 46.316225, 12 | } 13 | 14 | 15 | location2: Location = { 16 | "full_address": "4 Rue des Hibiscus 97200 Fort-de-France", 17 | "number_street": "4 Rue des Hibiscus", 18 | "com_name": "Fort-de-France", 19 | "com_zipcode": "97200", 20 | "com_insee": "97209", 21 | "departement": "972", 22 | "longitude": -61.078206, 23 | "latitude": 14.611228, 24 | } 25 | 26 | 27 | location3: Location = { 28 | "full_address": "Rue du Grand But (Lomme) 59000 Lille", 29 | "number_street": "Rue du Grand But (Lomme)", 30 | "com_name": "Lille", 31 | "com_zipcode": "59000", 32 | "com_insee": "59350", 33 | "departement": "59", 34 | "longitude": 2.974304, 35 | "latitude": 50.649991, 36 | } 37 | 38 | 39 | # This test actually calls to the API Adresse via Internet 40 | # Slight updates in their result might break the assertion 41 | # while not being an actual problem 42 | # it's not that frequent 43 | 44 | def test_get_location_from_address(): 45 | # Common address 46 | address: str = "389 Avenue Maréchal de Lattre de Tassigny" 47 | inseecode: str = "71270" # Varennes-lès-Mâcon 48 | zipcode: str = "71000" 49 | 50 | assert get_location_from_address(address) != location1 # Too generic, can't find with more input 51 | assert get_location_from_address(address, zipcode=zipcode) == location1 52 | assert get_location_from_address(address, inseecode=inseecode) == location1 53 | 54 | # Specific address, in DOM-TOM 55 | address: str = "4 rue des Hibiscus\n97200 Fort-de-France" 56 | assert get_location_from_address(address) == location2 57 | 58 | # Specific address with CEDEX code 59 | address: str = "Rue du Grand But - BP 249, 59462Cedex Lomme" 60 | cedexcode: str = "59462" 61 | inseecode: str = "59350" 62 | 63 | assert get_location_from_address(address) == location3 64 | assert get_location_from_address(address, zipcode=cedexcode) == None # API Adresse does not handle CEDEX codes 65 | assert get_location_from_address(address, inseecode=inseecode) == location3 66 | 67 | # Check cache mechanism 68 | get_location_from_address.cache_clear() 69 | get_location_from_address(address) 70 | get_location_from_address(address) 71 | assert get_location_from_address.cache_info().hits == 1 72 | assert get_location_from_address.cache_info().misses == 1 73 | 74 | 75 | def test_get_location_from_coordinates(): 76 | coordinates: Coordinates = Coordinates(4.8405438, 46.3165338) 77 | 78 | assert get_location_from_coordinates(coordinates) == location1 79 | 80 | # Check cache mechanism 81 | get_location_from_coordinates.cache_clear() 82 | get_location_from_coordinates(coordinates) 83 | get_location_from_coordinates(coordinates) 84 | assert get_location_from_coordinates.cache_info().hits == 1 85 | assert get_location_from_coordinates.cache_info().misses == 1 86 | -------------------------------------------------------------------------------- /tests/test_keldoc_center_scrap.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from scraper.keldoc.keldoc_center_scrap import parse_keldoc_resource_url, KeldocCenterScraper 3 | from tests.test_keldoc import get_test_data 4 | 5 | TEST_CENTERS = [ 6 | { 7 | "item": { 8 | "url": "https://keldoc.com/centre-de-vaccination/62800-lievin/centre-de-vaccination-lievin-pays-dartois" 9 | }, 10 | "result": "https://booking.keldoc.com/api/patients/v2/searches/resource?type=centre-de-vaccination&location=62800-lievin&slug=centre-de-vaccination-lievin-pays-dartois", 11 | }, 12 | { 13 | "item": { 14 | "url": "https://www.keldoc.com/centre-hospitalier/melun-cedex-77011/groupe-hospitalier-sud-ile-de-france/centre-de-vaccination-ghsif-site-de-brie-comte-robert" 15 | }, 16 | "result": "https://booking.keldoc.com/api/patients/v2/searches/resource?type=centre-hospitalier&location=melun-cedex-77011&slug=groupe-hospitalier-sud-ile-de-france&cabinet=centre-de-vaccination-ghsif-site-de-brie-comte-robert", 17 | }, 18 | ] 19 | 20 | API_MOCKS = { 21 | "/api/patients/v2/searches/resource": "resource-ain", 22 | "/api/patients/v2/searches/geo_location": "department-ain", 23 | "/api/patients/v2/clinics/2737/specialties/144/cabinets/17136/motive_categories": "motives-ain", 24 | } 25 | 26 | 27 | def test_keldoc_center_scraper(): 28 | def app(request: httpx.Request) -> httpx.Response: 29 | if request.url.path in API_MOCKS: 30 | return httpx.Response(200, json=get_test_data(API_MOCKS[request.url.path])) 31 | return httpx.Response(200, json={}) 32 | 33 | client = httpx.Client(transport=httpx.MockTransport(app)) 34 | print(client._base_url) 35 | scraper = KeldocCenterScraper(session=client) 36 | result = scraper.run_departement_scrap("ain") 37 | print(result) 38 | assert result == get_test_data("result-ain") 39 | 40 | 41 | def test_parse_keldoc_resource_url(): 42 | for test_center in TEST_CENTERS: 43 | assert parse_keldoc_resource_url(test_center["item"]["url"]) == test_center["result"] 44 | 45 | 46 | def test_keldoc_requests(): 47 | # Timeout test 48 | def app_timeout(request: httpx.Request) -> httpx.Response: 49 | raise httpx.TimeoutException(message="Timeout", request=request) 50 | 51 | client = httpx.Client(transport=httpx.MockTransport(app_timeout)) 52 | scraper = KeldocCenterScraper(session=client) 53 | assert not scraper.send_keldoc_request("https://keldoc.com") 54 | 55 | # Status test 56 | def app_status(request: httpx.Request) -> httpx.Response: 57 | res = httpx.Response(403, json={}) 58 | raise httpx.HTTPStatusError(message="status error", request=request, response=res) 59 | 60 | client = httpx.Client(transport=httpx.MockTransport(app_status)) 61 | scraper = KeldocCenterScraper(session=client) 62 | assert not scraper.send_keldoc_request("https://keldoc.com") 63 | 64 | # Remote error test 65 | def app_remote_error(request: httpx.Request) -> httpx.Response: 66 | res = httpx.Response(403, json={}) 67 | raise httpx.RemoteProtocolError(message="status error", request=request) 68 | 69 | client = httpx.Client(transport=httpx.MockTransport(app_remote_error)) 70 | scraper = KeldocCenterScraper(session=client) 71 | assert not scraper.send_keldoc_request("https://keldoc.com") 72 | -------------------------------------------------------------------------------- /tests/test_mesoigner.py: -------------------------------------------------------------------------------- 1 | import json 2 | from scraper.pattern.scraper_request import ScraperRequest 3 | from scraper.pattern.center_location import CenterLocation 4 | from scraper.pattern.center_info import CenterInfo 5 | import httpx 6 | from pathlib import Path 7 | from jsonschema import validate 8 | from jsonschema.exceptions import ValidationError 9 | from datetime import datetime 10 | from dateutil.tz import tzutc 11 | import io 12 | import scraper.mesoigner.mesoigner as mesoigner 13 | from scraper.pattern.vaccine import Vaccine 14 | from utils.vmd_config import get_conf_platform 15 | 16 | 17 | MESOIGNER_CONF = get_conf_platform("mesoigner") 18 | MESOIGNER_APIs = MESOIGNER_CONF.get("api", "") 19 | 20 | 21 | TEST_CENTRE_INFO = Path("tests", "fixtures", "mesoigner", "mesoigner_center_info.json") 22 | 23 | 24 | def test_get_appointments(): 25 | 26 | """get_appointments should return first available appointment date""" 27 | 28 | center_data = dict() 29 | center_data = json.load(io.open(TEST_CENTRE_INFO, "r", encoding="utf-8-sig")) 30 | 31 | # This center has availabilities and should return a date, non null appointment_count and vaccines 32 | request = ScraperRequest( 33 | "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription", 34 | "2021-06-16", 35 | center_data, 36 | ) 37 | center_with_availability = mesoigner.MesoignerSlots() 38 | slots = json.load( 39 | io.open(Path("tests", "fixtures", "mesoigner", "slots_available.json"), "r", encoding="utf-8-sig") 40 | ) 41 | assert center_with_availability.get_appointments(request, slots_api=slots) == "2021-06-16T14:50:00+02:00" 42 | assert request.appointment_count == 4 43 | assert request.vaccine_type == [Vaccine.MODERNA, Vaccine.ASTRAZENECA] 44 | 45 | # This one should return no date, neither appointment_count nor vaccine. 46 | request = ScraperRequest( 47 | "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription", 48 | "2021-07-16", 49 | center_data, 50 | ) 51 | center_without_availability = mesoigner.MesoignerSlots() 52 | slots = json.load( 53 | io.open(Path("tests", "fixtures", "mesoigner", "slots_unavailable.json"), "r", encoding="utf-8-sig") 54 | ) 55 | assert center_without_availability.get_appointments(request, slots_api=slots) == None 56 | assert request.appointment_count == 0 57 | assert request.vaccine_type == None 58 | 59 | 60 | from unittest.mock import patch 61 | 62 | # On se place dans le cas où la plateforme est désactivée 63 | def test_fetch_slots(): 64 | mesoigner.PLATFORM_ENABLED = False 65 | center_data = dict() 66 | center_data = json.load(io.open(TEST_CENTRE_INFO, "r", encoding="utf-8-sig")) 67 | 68 | request = ScraperRequest( 69 | "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription", 70 | "2021-06-16", 71 | center_data, 72 | ) 73 | response = mesoigner.fetch_slots(request) 74 | 75 | # On devrait trouver None puisque la plateforme est désactivée 76 | assert response == None 77 | 78 | 79 | def test_fetch(): 80 | mesoigner.PLATFORM_ENABLED = True 81 | 82 | center_data = dict() 83 | center_data = json.load(io.open(TEST_CENTRE_INFO, "r", encoding="utf-8-sig")) 84 | 85 | center_info = CenterInfo.from_csv_data(center_data) 86 | 87 | # This center has availabilities and should return a date, non null appointment_count and vaccines 88 | request = ScraperRequest( 89 | "https://pharmacie-des-pyrenees.pharmaxv.fr/rendez-vous/vaccination/269-vaccination-covid-19/pre-inscription", 90 | "2021-06-16", 91 | center_info, 92 | ) 93 | slots = json.load( 94 | io.open(Path("tests", "fixtures", "mesoigner", "slots_available.json"), "r", encoding="utf-8-sig") 95 | ) 96 | 97 | def app(requested: httpx.Request) -> httpx.Response: 98 | assert "User-Agent" in requested.headers 99 | 100 | return httpx.Response(200, json=slots) 101 | 102 | client = httpx.Client(transport=httpx.MockTransport(app)) 103 | 104 | center_with_availability = mesoigner.MesoignerSlots(client=client) 105 | 106 | response = center_with_availability.fetch(request) 107 | assert response == "2021-06-16T14:50:00+02:00" 108 | 109 | 110 | def test_center_iterator(): 111 | result = mesoigner.center_iterator() 112 | if mesoigner.PLATFORM_ENABLED == False: 113 | assert result == None 114 | 115 | 116 | def test_center_iterator(): 117 | def app(request: httpx.Request) -> httpx.Response: 118 | print(request.url.path) 119 | path = Path("tests/fixtures/mesoigner/mesoigner_centers.json") 120 | return httpx.Response(200, json=json.loads(path.read_text(encoding="utf8"))) 121 | 122 | client = httpx.Client(transport=httpx.MockTransport(app)) 123 | centres = [centre for centre in mesoigner.center_iterator(client)] 124 | assert len(centres) == 4 125 | -------------------------------------------------------------------------------- /tests/test_scraper.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import json 3 | 4 | from scraper.pattern.center_info import CenterInfo 5 | from scraper.pattern.scraper_result import GENERAL_PRACTITIONER, ScraperResult 6 | from scraper.pattern.vaccine import Vaccine, get_vaccine_name 7 | from utils.vmd_utils import departementUtils 8 | from scraper.scraper import fetch_centre_slots 9 | from scraper.pattern.scraper_request import ScraperRequest 10 | from scraper.error import Blocked403 11 | from .utils import mock_datetime_now 12 | from utils.vmd_utils import DummyQueue 13 | from scraper.pattern.center_info import CenterInfo 14 | 15 | 16 | def test_get_vaccine_name(): 17 | assert get_vaccine_name("Vaccination Covid -55ans suite à une première injection d'AZ (ARNm)") == Vaccine.ARNM 18 | assert get_vaccine_name("Vaccination ARN suite à une 1ere injection Astra Zeneca") == Vaccine.ARNM 19 | assert ( 20 | get_vaccine_name("Vaccination Covid de moins de 55ans (vaccin ARNm) suite à une 1ère injection d'AZ") 21 | == Vaccine.ARNM 22 | ) 23 | assert get_vaccine_name("Vaccination Covid +55ans AZ") == Vaccine.ASTRAZENECA 24 | assert get_vaccine_name("Vaccination Covid Pfizer") == Vaccine.PFIZER 25 | assert get_vaccine_name("Vaccination Covid Moderna") == Vaccine.MODERNA 26 | 27 | 28 | def test_fetch_centre_slots(): 29 | """ 30 | We detect which implementation to use based on the visit URL. 31 | """ 32 | 33 | def fake_doctolib_fetch_slots(request: ScraperRequest, sdate, **kwargs): 34 | return "2021-04-04" 35 | 36 | def fake_keldoc_fetch_slots(request: ScraperRequest, sdate, **kwargs): 37 | return "2021-04-05" 38 | 39 | def fake_maiia_fetch_slots(request: ScraperRequest, sdate, **kwargs): 40 | return "2021-04-06" 41 | 42 | fetch_map = { 43 | "Doctolib": { 44 | "urls": ["https://partners.doctolib.fr", "https://www.doctolib.fr"], 45 | "scraper_ptr": fake_doctolib_fetch_slots, 46 | }, 47 | "Keldoc": { 48 | "urls": ["https://vaccination-covid.keldoc.com", "https://keldoc.com"], 49 | "scraper_ptr": fake_keldoc_fetch_slots, 50 | }, 51 | "Maiia": {"urls": ["https://www.maiia.com"], "scraper_ptr": fake_maiia_fetch_slots}, 52 | } 53 | 54 | start_date = "2021-04-03" 55 | center_info = CenterInfo(departement="08", nom="Mon Centre", url="https://some.url/") 56 | 57 | # Doctolib 58 | url = "https://partners.doctolib.fr/blabla" 59 | res = fetch_centre_slots( 60 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info 61 | ) 62 | assert res.platform == "Doctolib" 63 | assert res.next_availability == "2021-04-04" 64 | 65 | # Doctolib (old) 66 | url = "https://www.doctolib.fr/blabla" 67 | res = fetch_centre_slots( 68 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info 69 | ) 70 | assert res.platform == "Doctolib" 71 | assert res.next_availability == "2021-04-04" 72 | 73 | # Keldoc 74 | url = "https://vaccination-covid.keldoc.com/blabla" 75 | res = fetch_centre_slots( 76 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info 77 | ) 78 | assert res.platform == "Keldoc" 79 | assert res.next_availability == "2021-04-05" 80 | 81 | # Maiia 82 | url = "https://www.maiia.com/blabla" 83 | res = fetch_centre_slots( 84 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info 85 | ) 86 | assert res.platform == "Maiia" 87 | assert res.next_availability == "2021-04-06" 88 | 89 | # Default / unknown 90 | url = "https://www.example.com" 91 | res = fetch_centre_slots( 92 | url, None, start_date, fetch_map=fetch_map, creneau_q=DummyQueue(), center_info=center_info 93 | ) 94 | assert res.platform == "Autre" 95 | assert res.next_availability is None 96 | 97 | 98 | def test_scraper_request(): 99 | request = ScraperRequest("https://doctolib.fr/center/center-test", "2021-04-14") 100 | 101 | request.update_internal_id("d739") 102 | request.update_practitioner_type(GENERAL_PRACTITIONER) 103 | request.update_appointment_count(42) 104 | request.add_vaccine_type(get_vaccine_name("Injection pfizer 1ère dose")) 105 | 106 | assert request is not None 107 | assert request.internal_id == "d739" 108 | assert request.appointment_count == 42 109 | assert request.vaccine_type == [Vaccine.PFIZER] 110 | 111 | result = ScraperResult(request, "Doctolib", "2021-04-14T14:00:00.0000") 112 | assert result.default() == { 113 | "next_availability": "2021-04-14T14:00:00.0000", 114 | "platform": "Doctolib", 115 | "request": request, 116 | } 117 | -------------------------------------------------------------------------------- /tests/test_stats.py: -------------------------------------------------------------------------------- 1 | # -- Tests des statistiques -- 2 | import json 3 | import os 4 | 5 | from pathlib import Path 6 | from stats_generation.stats_available_centers import export_centres_stats 7 | 8 | 9 | def test_stat_count(): 10 | output_file_name = "stats_test.json" 11 | center_data = Path("tests", "fixtures", "stats", "info-centres.json") 12 | export_centres_stats(center_data, output_file_name) 13 | 14 | assert os.path.exists(f"{output_file_name}") 15 | 16 | output_file = open(f"{output_file_name}", "r") 17 | generated_content = output_file.read() 18 | output_file.close() 19 | 20 | stats = json.loads(generated_content) 21 | assert stats["tout_departement"]["disponibles"] == 2 22 | assert stats["tout_departement"]["total"] == 4 23 | os.remove(f"{output_file_name}") 24 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | from utils.vmd_utils import format_phone_number, get_last_scans, append_date_days, department_urlify 4 | from .utils import mock_datetime_now 5 | from scraper.pattern.center_info import CenterInfo 6 | 7 | 8 | def test_format_phone_number(): 9 | 10 | phone_number = "+331204312" 11 | assert format_phone_number(phone_number) == "+331204312" 12 | 13 | phone_number = "+569492392" 14 | assert format_phone_number(phone_number) == "+569492392" 15 | 16 | phone_number = "0123456789" 17 | assert format_phone_number(phone_number) == "+33123456789" 18 | 19 | phone_number = "01.20.43.12" 20 | assert format_phone_number(phone_number) == "+331204312" 21 | 22 | phone_number = "3975" 23 | assert format_phone_number(phone_number) == "+333975" 24 | 25 | phone_number = "0033146871340" 26 | assert format_phone_number(phone_number) == "+33146871340" 27 | 28 | 29 | def test_get_last_scans(): 30 | 31 | center_info1 = CenterInfo("01", "Centre 1", "https://example1.fr") 32 | center_info2 = CenterInfo("01", "Centre 2", "https://example2.fr") 33 | 34 | center_info2.prochain_rdv = "2021-06-06T00:00:00" 35 | 36 | centres_cherchés = [center_info1, center_info2] 37 | 38 | fake_now = dt.datetime(2021, 5, 5) 39 | with mock_datetime_now(fake_now): 40 | centres_cherchés = get_last_scans(centres_cherchés) 41 | 42 | assert centres_cherchés[0].last_scan_with_availabilities == None 43 | assert centres_cherchés[1].last_scan_with_availabilities == "2021-05-05T00:00:00" 44 | 45 | 46 | def test_department_urlify(): 47 | url = "FooBar 42" 48 | assert department_urlify(url) == "foobar-42" 49 | 50 | 51 | TEST_DATES = [ 52 | {"item": ("2021-04-21", 0), "result": "2021-04-21T00:00:00+02:00"}, 53 | {"item": ("2021-04-21", 3), "result": "2021-04-24T00:00:00+02:00"}, 54 | ] 55 | 56 | 57 | def test_append_days_date(): 58 | for test_date in TEST_DATES: 59 | item = test_date["item"] 60 | assert append_date_days(item[0], item[1]) == test_date["result"] 61 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from contextlib import contextmanager 3 | from unittest.mock import patch 4 | 5 | 6 | @contextmanager 7 | def mock_datetime_now(now): 8 | class MockedDatetime(dt.datetime): 9 | @classmethod 10 | def now(cls, *args, **kwargs): 11 | return now 12 | 13 | with patch("datetime.datetime", MockedDatetime): 14 | yield 15 | -------------------------------------------------------------------------------- /utils/vmd_blocklist.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from scraper.pattern.center_info import CenterInfo 4 | from utils.vmd_config import get_conf_inputs 5 | 6 | 7 | def is_in_blocklist(center: CenterInfo, blocklist_urls) -> bool: 8 | return center.url in blocklist_urls 9 | 10 | 11 | def get_blocklist_urls() -> set: 12 | path_blocklist = get_conf_inputs().get("from_main_branch").get("blocklist") 13 | centers_blocklist_urls = set([center["url"] for center in json.load(open(path_blocklist))["centers_not_displayed"]]) 14 | return centers_blocklist_urls 15 | -------------------------------------------------------------------------------- /utils/vmd_center_sort.py: -------------------------------------------------------------------------------- 1 | def sort_center(center: dict) -> str: 2 | return center.get("prochain_rdv", "-") if center else "-" 3 | -------------------------------------------------------------------------------- /utils/vmd_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from utils.vmd_logger import get_logger 6 | 7 | CONFIG_DATA = {} 8 | 9 | logger = get_logger() 10 | 11 | 12 | def get_config() -> dict: 13 | global CONFIG_DATA 14 | if not CONFIG_DATA: 15 | try: 16 | CONFIG_DATA = json.loads(Path("config.json").read_text(encoding="utf8")) 17 | except (OSError, ValueError): 18 | logger.exception("Unable to load configuration file.") 19 | return CONFIG_DATA 20 | 21 | 22 | def get_conf_inputs() -> Optional[dict]: 23 | return get_config().get("inputs", {}) 24 | 25 | 26 | def get_conf_outputs() -> Optional[dict]: 27 | return get_config().get("outputs", {}) 28 | 29 | 30 | def get_conf_outstats() -> Optional[dict]: 31 | return get_conf_outputs().get("stats", {}) 32 | 33 | 34 | def get_conf_platform(platform: str) -> dict: 35 | if not get_config().get("platforms"): 36 | logger.error("Unknown ’platforms’ key in configuration file.") 37 | exit(1) 38 | platform_conf = get_config().get("platforms").get(platform) 39 | if not platform_conf: 40 | logger.error(f"Unknown ’{platform}’ platform in configuration file.") 41 | exit(1) 42 | return platform_conf 43 | -------------------------------------------------------------------------------- /utils/vmd_duplicated.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | 3 | from utils.vmd_utils import departementUtils 4 | 5 | 6 | def deduplicates_names(departement_centers): 7 | """ 8 | Removes unique names by appending city name 9 | in par_departement 10 | 11 | see https://github.com/CovidTrackerFr/vitemadose/issues/173 12 | """ 13 | deduplicated_centers = [] 14 | departement_center_names_count = Counter([center["nom"] for center in departement_centers]) 15 | names_to_remove = { 16 | departement for departement in departement_center_names_count if departement_center_names_count[departement] > 1 17 | } 18 | 19 | for center in departement_centers: 20 | if center["nom"] in names_to_remove: 21 | center["nom"] = f"{center['nom']} - {departementUtils.get_city(center['metadata']['address'])}" 22 | deduplicated_centers.append(center) 23 | return deduplicated_centers 24 | -------------------------------------------------------------------------------- /utils/vmd_geo_api.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict, Optional, NamedTuple 2 | import requests 3 | from utils.vmd_logger import get_logger 4 | from functools import lru_cache 5 | 6 | logger = get_logger() 7 | 8 | 9 | class Location(TypedDict): 10 | full_address: str 11 | number_street: str 12 | com_name: str 13 | com_zipcode: str 14 | com_insee: str 15 | departement: str 16 | latitude: float 17 | longitude: float 18 | 19 | 20 | class Coordinates(NamedTuple): 21 | longitude: float 22 | latitude: float 23 | 24 | 25 | @lru_cache 26 | def get_location_from_address( 27 | address: str, 28 | zipcode: Optional[str] = None, 29 | inseecode: Optional[str] = None, 30 | ) -> Optional[Location]: 31 | params = {"q": address, "limit": 1} 32 | 33 | if zipcode: 34 | params["postcode"] = zipcode 35 | elif inseecode: 36 | params["citycode"] = inseecode 37 | 38 | r = requests.get("https://api-adresse.data.gouv.fr/search/", params=params) 39 | 40 | return _parse_geojson(r.json()) 41 | 42 | 43 | @lru_cache 44 | def get_location_from_coordinates(coordinates: Coordinates) -> Optional[Location]: 45 | params = {"lon": getattr(coordinates, "longitude"), "lat": getattr(coordinates, "latitude")} 46 | 47 | r = requests.get("https://api-adresse.data.gouv.fr/reverse/", params=params) 48 | 49 | return _parse_geojson(r.json()) 50 | 51 | 52 | def _parse_geojson(geojson: str) -> Location: 53 | if not geojson["features"]: 54 | return None 55 | 56 | result = geojson["features"][0] 57 | prop = result["properties"] 58 | geometry = result["geometry"] 59 | 60 | if prop["type"] != "housenumber": 61 | logger.warning("GeoJSON imprecise, could not get a house number location.") 62 | 63 | return { 64 | "full_address": prop["label"], 65 | "number_street": prop["name"], 66 | "com_name": prop["city"], 67 | "com_zipcode": prop["postcode"], 68 | "com_insee": prop["citycode"], 69 | "departement": prop["context"].split(",")[0], 70 | "longitude": geometry["coordinates"][0], 71 | "latitude": geometry["coordinates"][1], 72 | } 73 | -------------------------------------------------------------------------------- /utils/vmd_opendata.py: -------------------------------------------------------------------------------- 1 | def copy_omit_keys(d, omit_keys): 2 | return {k: d[k] for k in set(list(d.keys())) - set(omit_keys)} 3 | --------------------------------------------------------------------------------