From be7a423d5926d3d1c623734e9247d9586053b7f2 Mon Sep 17 00:00:00 2001 From: galela Date: Fri, 11 Sep 2026 16:58:06 +0200 Subject: [PATCH 1/6] Add MIT licence --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdae60a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016-2026 Woosmap + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From d9db6edef057a5e6b83720b88755f9292c3cff46 Mon Sep 17 00:00:00 2001 From: galela Date: Fri, 11 Sep 2026 17:29:20 +0200 Subject: [PATCH 2/6] Restructure samples by use case Replace the language-first layout and its Python 2 era scripts with one folder per use case: import, sync and export of stores, opening hours conversion, batch geocoding, async distance matrix, isochrone search, datasets, static map and IP geolocation. Python everywhere, Node for the three most used, the Java client kept. Every script reads the key from the environment, retries on 429 and 5xx, ships its tests, and CI runs them with a live smoke job when a key is configured. --- .github/dependabot.yml | 16 + .github/workflows/ci.yml | 97 ++ .gitignore | 12 + README.md | 68 +- batch-geocoding/README.md | 24 + batch-geocoding/python/geocode_csv.py | 190 +++ batch-geocoding/python/requirements.txt | 1 + batch-geocoding/python/test_geocode_csv.py | 130 ++ .../addresses_au.csv | 0 data/closures.csv | 2 + data/coordinates.csv | 19 + .../csv_to_woosmap => data}/foodmarkets.csv | 2 +- .../foodmarkets.json | 84 +- .../foodmarkets.xlsx | Bin data/opening_hours.csv | 6 + data/special_hours.csv | 4 + datasets/README.md | 38 + datasets/python/manage_dataset.py | 186 ++ datasets/python/requirements.txt | 1 + datasets/python/test_manage_dataset.py | 113 ++ distance-matrix/README.md | 35 + distance-matrix/java/README.md | 11 + .../java}/pom.xml | 2 +- .../com/example/WoosmapDistanceApiClient.java | 6 +- distance-matrix/node/async-matrix.mjs | 138 ++ distance-matrix/node/async-matrix.test.mjs | 60 + distance-matrix/node/package.json | 9 + distance-matrix/python/async_matrix.py | 159 ++ distance-matrix/python/requirements.txt | 1 + distance-matrix/python/test_async_matrix.py | 155 ++ geolocation-stores/README.md | 15 + .../python/nearest_stores_by_ip.py | 82 + geolocation-stores/python/requirements.txt | 1 + .../python/test_nearest_stores_by_ip.py | 57 + isochrone-stores/README.md | 22 + isochrone-stores/python/requirements.txt | 1 + .../python/stores_within_isochrone.py | 201 +++ .../python/test_stores_within_isochrone.py | 122 ++ java-samples/distance-api-client/README.md | 44 - jsfiddle-samples/driving-directions/README.MD | 5 - jsfiddle-samples/driving-directions/demo.css | 571 ------- .../driving-directions/demo.details | 6 - jsfiddle-samples/driving-directions/demo.html | 124 -- jsfiddle-samples/driving-directions/demo.js | 257 --- jsfiddle-samples/geolocation/README.MD | 5 - jsfiddle-samples/geolocation/demo.css | 109 -- jsfiddle-samples/geolocation/demo.details | 6 - jsfiddle-samples/geolocation/demo.html | 44 - jsfiddle-samples/geolocation/demo.js | 118 -- jsfiddle-samples/locator-map/README.MD | 5 - jsfiddle-samples/locator-map/demo.css | 141 -- jsfiddle-samples/locator-map/demo.details | 6 - jsfiddle-samples/locator-map/demo.html | 10 - jsfiddle-samples/locator-map/demo.js | 58 - jsfiddle-samples/map-infowindow/README.MD | 5 - jsfiddle-samples/map-infowindow/demo.css | 30 - jsfiddle-samples/map-infowindow/demo.details | 6 - jsfiddle-samples/map-infowindow/demo.html | 2 - jsfiddle-samples/map-infowindow/demo.js | 50 - jsfiddle-samples/map-tiled-view/README.MD | 5 - jsfiddle-samples/map-tiled-view/demo.css | 21 - jsfiddle-samples/map-tiled-view/demo.details | 6 - jsfiddle-samples/map-tiled-view/demo.html | 5 - jsfiddle-samples/map-tiled-view/demo.js | 77 - jsfiddle-samples/search-location/README.MD | 5 - jsfiddle-samples/search-location/demo.css | 176 -- jsfiddle-samples/search-location/demo.details | 6 - jsfiddle-samples/search-location/demo.html | 13 - jsfiddle-samples/search-location/demo.js | 92 - jsfiddle-samples/search-places/README.MD | 5 - jsfiddle-samples/search-places/demo.css | 176 -- jsfiddle-samples/search-places/demo.details | 6 - jsfiddle-samples/search-places/demo.html | 13 - jsfiddle-samples/search-places/demo.js | 123 -- jsfiddle-samples/search-query/README.MD | 5 - jsfiddle-samples/search-query/demo.css | 224 --- jsfiddle-samples/search-query/demo.details | 6 - jsfiddle-samples/search-query/demo.html | 60 - jsfiddle-samples/search-query/demo.js | 124 -- opening-hours/README.md | 40 + opening-hours/python/opening_hours.py | 203 +++ opening-hours/python/test_opening_hours.py | 149 ++ python-samples/batchgeocoding/README.md | 57 - .../batchgeocoding/google_batch_geocoder.py | 160 -- .../woosmap_localities_batch_geocoder.py | 166 -- python-samples/batchimport/README.md | 7 - .../batchimport/batch_import_locations.py | 114 -- .../batchimport/france_museum_geocoded.csv | 1517 ----------------- .../csv_to_woosmap/csv_to_woosmap.py | 168 -- .../excel_to_woosmap/excel_to_woosmap.py | 173 -- .../googlemybusiness_to_woosmap.py | 307 ---- .../googleplaces_to_woosmap.py | 158 -- .../googleplaces_to_woosmap/search_data.json | 10 - .../googlesheet_to_woosmap.py | 198 --- .../foodmarkets.json | 366 ---- .../woosmap_jsonschema_validation.py | 56 - .../woosmap_to_geojson/woosmap_to_geojson.py | 46 - python-samples/woosmap_to_woosmap/README.md | 10 - .../woosmap_to_woosmap/woosmap_to_woosmap.py | 103 -- python-samples/woosmapjson_import/README.md | 9 - .../woosmapjson_import/woosmapjson_import.py | 64 - requirements-dev.txt | 3 + ruff.toml | 12 + static-map/README.md | 19 + static-map/python/requirements.txt | 1 + static-map/python/static_map.py | 99 ++ static-map/python/test_static_map.py | 67 + stores-export/README.md | 16 + stores-export/python/export_stores.py | 119 ++ stores-export/python/requirements.txt | 1 + stores-export/python/test_export_stores.py | 65 + stores-import/README.md | 62 + stores-import/node/import-stores.mjs | 187 ++ stores-import/node/import-stores.test.mjs | 85 + stores-import/node/package.json | 9 + stores-import/node/spreadsheet.mjs | 74 + stores-import/node/spreadsheet.test.mjs | 47 + stores-import/python/import_stores.py | 267 +++ stores-import/python/requirements.txt | 2 + stores-import/python/spreadsheet.py | 72 + stores-import/python/test_import_stores.py | 128 ++ stores-import/python/test_spreadsheet.py | 80 + stores-sync/README.md | 38 + stores-sync/node/package.json | 9 + stores-sync/node/sync-stores.mjs | 187 ++ stores-sync/node/sync-stores.test.mjs | 101 ++ stores-sync/python/requirements.txt | 1 + stores-sync/python/sync_stores.py | 215 +++ stores-sync/python/test_sync_stores.py | 142 ++ 129 files changed, 4524 insertions(+), 6485 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 batch-geocoding/README.md create mode 100644 batch-geocoding/python/geocode_csv.py create mode 100644 batch-geocoding/python/requirements.txt create mode 100644 batch-geocoding/python/test_geocode_csv.py rename python-samples/batchgeocoding/hairdresser_sample_addresses.csv => data/addresses_au.csv (100%) create mode 100644 data/closures.csv create mode 100644 data/coordinates.csv rename {python-samples/csv_to_woosmap => data}/foodmarkets.csv (96%) rename {python-samples/woosmapjson_import => data}/foodmarkets.json (92%) rename {python-samples/excel_to_woosmap => data}/foodmarkets.xlsx (100%) create mode 100644 data/opening_hours.csv create mode 100644 data/special_hours.csv create mode 100644 datasets/README.md create mode 100644 datasets/python/manage_dataset.py create mode 100644 datasets/python/requirements.txt create mode 100644 datasets/python/test_manage_dataset.py create mode 100644 distance-matrix/README.md create mode 100644 distance-matrix/java/README.md rename {java-samples/distance-api-client => distance-matrix/java}/pom.xml (97%) rename {java-samples/distance-api-client => distance-matrix/java}/src/main/java/com/example/WoosmapDistanceApiClient.java (94%) create mode 100644 distance-matrix/node/async-matrix.mjs create mode 100644 distance-matrix/node/async-matrix.test.mjs create mode 100644 distance-matrix/node/package.json create mode 100644 distance-matrix/python/async_matrix.py create mode 100644 distance-matrix/python/requirements.txt create mode 100644 distance-matrix/python/test_async_matrix.py create mode 100644 geolocation-stores/README.md create mode 100644 geolocation-stores/python/nearest_stores_by_ip.py create mode 100644 geolocation-stores/python/requirements.txt create mode 100644 geolocation-stores/python/test_nearest_stores_by_ip.py create mode 100644 isochrone-stores/README.md create mode 100644 isochrone-stores/python/requirements.txt create mode 100644 isochrone-stores/python/stores_within_isochrone.py create mode 100644 isochrone-stores/python/test_stores_within_isochrone.py delete mode 100644 java-samples/distance-api-client/README.md delete mode 100644 jsfiddle-samples/driving-directions/README.MD delete mode 100755 jsfiddle-samples/driving-directions/demo.css delete mode 100755 jsfiddle-samples/driving-directions/demo.details delete mode 100755 jsfiddle-samples/driving-directions/demo.html delete mode 100755 jsfiddle-samples/driving-directions/demo.js delete mode 100644 jsfiddle-samples/geolocation/README.MD delete mode 100755 jsfiddle-samples/geolocation/demo.css delete mode 100755 jsfiddle-samples/geolocation/demo.details delete mode 100755 jsfiddle-samples/geolocation/demo.html delete mode 100755 jsfiddle-samples/geolocation/demo.js delete mode 100644 jsfiddle-samples/locator-map/README.MD delete mode 100755 jsfiddle-samples/locator-map/demo.css delete mode 100755 jsfiddle-samples/locator-map/demo.details delete mode 100755 jsfiddle-samples/locator-map/demo.html delete mode 100755 jsfiddle-samples/locator-map/demo.js delete mode 100644 jsfiddle-samples/map-infowindow/README.MD delete mode 100755 jsfiddle-samples/map-infowindow/demo.css delete mode 100755 jsfiddle-samples/map-infowindow/demo.details delete mode 100755 jsfiddle-samples/map-infowindow/demo.html delete mode 100755 jsfiddle-samples/map-infowindow/demo.js delete mode 100644 jsfiddle-samples/map-tiled-view/README.MD delete mode 100755 jsfiddle-samples/map-tiled-view/demo.css delete mode 100755 jsfiddle-samples/map-tiled-view/demo.details delete mode 100755 jsfiddle-samples/map-tiled-view/demo.html delete mode 100755 jsfiddle-samples/map-tiled-view/demo.js delete mode 100644 jsfiddle-samples/search-location/README.MD delete mode 100755 jsfiddle-samples/search-location/demo.css delete mode 100755 jsfiddle-samples/search-location/demo.details delete mode 100755 jsfiddle-samples/search-location/demo.html delete mode 100755 jsfiddle-samples/search-location/demo.js delete mode 100644 jsfiddle-samples/search-places/README.MD delete mode 100755 jsfiddle-samples/search-places/demo.css delete mode 100755 jsfiddle-samples/search-places/demo.details delete mode 100755 jsfiddle-samples/search-places/demo.html delete mode 100755 jsfiddle-samples/search-places/demo.js delete mode 100644 jsfiddle-samples/search-query/README.MD delete mode 100755 jsfiddle-samples/search-query/demo.css delete mode 100755 jsfiddle-samples/search-query/demo.details delete mode 100755 jsfiddle-samples/search-query/demo.html delete mode 100755 jsfiddle-samples/search-query/demo.js create mode 100644 opening-hours/README.md create mode 100644 opening-hours/python/opening_hours.py create mode 100644 opening-hours/python/test_opening_hours.py delete mode 100644 python-samples/batchgeocoding/README.md delete mode 100644 python-samples/batchgeocoding/google_batch_geocoder.py delete mode 100644 python-samples/batchgeocoding_woosmap_localities/woosmap_localities_batch_geocoder.py delete mode 100644 python-samples/batchimport/README.md delete mode 100755 python-samples/batchimport/batch_import_locations.py delete mode 100644 python-samples/batchimport/france_museum_geocoded.csv delete mode 100644 python-samples/csv_to_woosmap/csv_to_woosmap.py delete mode 100644 python-samples/excel_to_woosmap/excel_to_woosmap.py delete mode 100644 python-samples/googlemybusiness_to_woosmap/googlemybusiness_to_woosmap.py delete mode 100644 python-samples/googleplaces_to_woosmap/googleplaces_to_woosmap.py delete mode 100644 python-samples/googleplaces_to_woosmap/search_data.json delete mode 100644 python-samples/googlesheet_to_woosmap/googlesheet_to_woosmap.py delete mode 100644 python-samples/woosmap_jsonschema_validation/foodmarkets.json delete mode 100644 python-samples/woosmap_jsonschema_validation/woosmap_jsonschema_validation.py delete mode 100644 python-samples/woosmap_to_geojson/woosmap_to_geojson.py delete mode 100644 python-samples/woosmap_to_woosmap/README.md delete mode 100644 python-samples/woosmap_to_woosmap/woosmap_to_woosmap.py delete mode 100644 python-samples/woosmapjson_import/README.md delete mode 100644 python-samples/woosmapjson_import/woosmapjson_import.py create mode 100644 requirements-dev.txt create mode 100644 ruff.toml create mode 100644 static-map/README.md create mode 100644 static-map/python/requirements.txt create mode 100644 static-map/python/static_map.py create mode 100644 static-map/python/test_static_map.py create mode 100644 stores-export/README.md create mode 100644 stores-export/python/export_stores.py create mode 100644 stores-export/python/requirements.txt create mode 100644 stores-export/python/test_export_stores.py create mode 100644 stores-import/README.md create mode 100644 stores-import/node/import-stores.mjs create mode 100644 stores-import/node/import-stores.test.mjs create mode 100644 stores-import/node/package.json create mode 100644 stores-import/node/spreadsheet.mjs create mode 100644 stores-import/node/spreadsheet.test.mjs create mode 100644 stores-import/python/import_stores.py create mode 100644 stores-import/python/requirements.txt create mode 100644 stores-import/python/spreadsheet.py create mode 100644 stores-import/python/test_import_stores.py create mode 100644 stores-import/python/test_spreadsheet.py create mode 100644 stores-sync/README.md create mode 100644 stores-sync/node/package.json create mode 100644 stores-sync/node/sync-stores.mjs create mode 100644 stores-sync/node/sync-stores.test.mjs create mode 100644 stores-sync/python/requirements.txt create mode 100644 stores-sync/python/sync_stores.py create mode 100644 stores-sync/python/test_sync_stores.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..97726df --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: pip + directories: + - "/" + - "/*/python" + schedule: + interval: monthly + - package-ecosystem: maven + directory: "/distance-matrix/java" + schedule: + interval: monthly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f742449 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + python: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sample: + - stores-import + - stores-sync + - stores-export + - batch-geocoding + - distance-matrix + - opening-hours + - datasets + - isochrone-stores + - static-map + - geolocation-stores + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install + working-directory: ${{ matrix.sample }}/python + run: | + pip install --quiet -r ../../requirements-dev.txt + if [ -f requirements.txt ]; then pip install --quiet -r requirements.txt; fi + - name: Lint + working-directory: ${{ matrix.sample }}/python + run: | + ruff check . + ruff format --check . + - name: Test + working-directory: ${{ matrix.sample }}/python + run: python -m pytest -q + + node: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sample: [stores-import, stores-sync, distance-matrix] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Test + working-directory: ${{ matrix.sample }}/node + run: node --test + + java: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Compile + working-directory: distance-matrix/java + run: mvn --quiet --batch-mode compile + + # Runs the read-only samples against a real project when the secret is configured. + live: + if: github.event_name == 'push' + runs-on: ubuntu-latest + needs: [python] + env: + WOOSMAP_PRIVATE_KEY: ${{ secrets.WOOSMAP_PRIVATE_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Skip without a key + if: env.WOOSMAP_PRIVATE_KEY == '' + run: echo "WOOSMAP_PRIVATE_KEY secret not set, skipping live checks" + - name: Export stores + if: env.WOOSMAP_PRIVATE_KEY != '' + run: | + pip install --quiet -r stores-export/python/requirements.txt + python stores-export/python/export_stores.py --output /tmp/stores.json + - name: Geocode two rows + if: env.WOOSMAP_PRIVATE_KEY != '' + run: | + head -3 data/addresses_au.csv > /tmp/two.csv + python batch-geocoding/python/geocode_csv.py /tmp/two.csv /tmp/two.out.csv \ + --address-columns addressline1,postalcode,town --country-column IsoCode + grep -c ROOFTOP /tmp/two.out.csv || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09b89b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.venv/ +venv/ +node_modules/ +target/ +.idea/ +.vscode/ +.DS_Store +*.pyc +.env diff --git a/README.md b/README.md index 607c6b9..6f9981b 100644 --- a/README.md +++ b/README.md @@ -1 +1,67 @@ -Useful samples to work with woosmap APIs \ No newline at end of file +# Woosmap samples + +Server-side scripts that show how to do one job with the [Woosmap APIs](https://developers.woosmap.com). +Each folder is a use case. Inside, one folder per language. Copy the folder you need, every script stands alone. + +| Use case | What it does | Python | Node | +| --- | --- | --- | --- | +| [stores-import](stores-import/) | Load a CSV, XLSX or Google Sheet into a project with one atomic replace | ✓ | ✓ (CSV, Sheets) | +| [stores-sync](stores-sync/) | Nightly sync: create, update and delete only the stores that changed | ✓ | ✓ | +| [stores-export](stores-export/) | Dump a project as re-importable Woosmap JSON or GeoJSON | ✓ | | +| [opening-hours](opening-hours/) | Turn a weekday-per-column spreadsheet into the `openingHours` object | ✓ | | +| [batch-geocoding](batch-geocoding/) | Geocode or reverse geocode a CSV with Localities | ✓ | | +| [distance-matrix](distance-matrix/) | Large matrices with the async endpoint, small ones in Java | ✓ | ✓ | +| [isochrone-stores](isochrone-stores/) | Which stores are within N minutes of an address | ✓ | | +| [datasets](datasets/) | Declare, import and query a Datasets API dataset | ✓ | | +| [static-map](static-map/) | Render a map image server-side for an e-mail or a PDF | ✓ | | +| [geolocation-stores](geolocation-stores/) | Nearest stores from a visitor's IP address | ✓ | | + +Front-end samples for Map JS live in [js-samples](https://github.com/Woosmap/js-samples). + +## Running a sample + +Every script reads the private key from the `WOOSMAP_PRIVATE_KEY` environment variable and never +writes it to disk. Get one from the Console, on a project you can afford to overwrite. + +```sh +export WOOSMAP_PRIVATE_KEY=... +cd stores-import/python +pip install -r requirements.txt +python import_stores.py ../../data/foodmarkets.csv --dry-run +``` + +Node samples need Node 20 or later and no dependency: + +```sh +cd stores-import/node +node import-stores.mjs ../../data/foodmarkets.csv --dry-run +``` + +Test data lives in [data/](data/). The food markets set is small enough to import into any project. + +## Conventions + +- Python 3.10+, type hints everywhere, `requests` as the only HTTP dependency. +- Node 20+, ES modules, the built-in `fetch`, no dependency. +- One retry policy: 429 is retried after `Retry-After`, anything else fails with the response body. +- Write operations use `/stores/replace` or explicit create, update and delete, never delete-then-post. +- Each sample ships its tests. `pytest` and `node --test` run offline against mocked responses. +- Where a sample needs plumbing that is not about Woosmap, it sits in its own module next to the main one. + +## Contributing + +Run the checks before opening a pull request: + +```sh +pip install -r requirements-dev.txt +ruff check . && ruff format --check . +(cd stores-import/python && python -m pytest) +(cd stores-import/node && node --test) +``` + +CI runs the same for every sample, plus a compile of the Java client. When the repository secret +`WOOSMAP_PRIVATE_KEY` is set, it also runs the read-only samples against a real project. + +## Licence + +[MIT](LICENSE). diff --git a/batch-geocoding/README.md b/batch-geocoding/README.md new file mode 100644 index 0000000..d21f933 --- /dev/null +++ b/batch-geocoding/README.md @@ -0,0 +1,24 @@ +# Geocode a CSV + +Add coordinates to a file of addresses, or addresses to a file of coordinates, with the +[Localities geocode endpoint](https://developers.woosmap.com/products/localities/features/geocoding/). +Every input column is kept; six `geocode_*` columns are appended: lat, lng, formatted address, location +type (`ROOFTOP`, `GEOMETRIC_CENTER`, `APPROXIMATE`), public id and error. + +```sh +pip install -r python/requirements.txt + +# forward: pick the columns that make up the address, and the country when you know it +python python/geocode_csv.py ../data/addresses_au.csv out.csv \ + --address-columns addressline1,postalcode,town --country-column IsoCode + +# reverse +python python/geocode_csv.py ../data/coordinates.csv out.csv --reverse --lat-column lat --lng-column lng +``` + +Pass `--country fr` for a single country, `--language` for the output language and `--delay 0.1` to +pace requests. Rows that fail keep their input and get the reason in `geocode_error`, the run continues. +Always restrict the country when you can, it is the single biggest accuracy lever. + +Check `geocode_location_type` before trusting a result: `GEOMETRIC_CENTER` on a street-level input means +the house number was not found. diff --git a/batch-geocoding/python/geocode_csv.py b/batch-geocoding/python/geocode_csv.py new file mode 100644 index 0000000..8159e26 --- /dev/null +++ b/batch-geocoding/python/geocode_csv.py @@ -0,0 +1,190 @@ +"""Geocode (or reverse geocode) every row of a CSV file with the Woosmap Localities API.""" + +from __future__ import annotations + +import argparse +import csv +import io +import os +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com/localities/geocode/" +OUTPUT_COLUMNS = [ + "geocode_lat", + "geocode_lng", + "geocode_formatted_address", + "geocode_location_type", + "geocode_public_id", + "geocode_error", +] + +Row = dict[str, str] + + +@dataclass(frozen=True) +class Options: + address_columns: list[str] + country_column: str | None + country: str | None + reverse: bool + lat_column: str + lng_column: str + language: str | None + delay: float + + +def address_from(row: Row, columns: list[str]) -> str: + return ", ".join(part for part in (row.get(column, "").strip() for column in columns) if part) + + +def components_for(row: Row, options: Options) -> str | None: + country = row.get(options.country_column, "") if options.country_column else options.country + return f"country:{country.strip().lower()}" if country and country.strip() else None + + +def params_for(row: Row, options: Options) -> dict[str, str]: + params: dict[str, str] = {} + if options.reverse: + params["latlng"] = f"{row[options.lat_column].strip()},{row[options.lng_column].strip()}" + else: + params["address"] = address_from(row, options.address_columns) + components = components_for(row, options) + if components: + params["components"] = components + if options.language: + params["language"] = options.language + return params + + +class Geocoder: + def __init__(self, private_key: str, session: requests.Session | None = None) -> None: + self.private_key = private_key + self.session = session or requests.Session() + + def call(self, params: dict[str, str]) -> dict[str, Any]: + for attempt in range(3): + response = self.session.get( + API_URL, params={"private_key": self.private_key, **params}, timeout=30 + ) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + if response.status_code >= 400: + raise RuntimeError(f"HTTP {response.status_code}: {response.text[:200]}") + return response.json() + + +def first_result(body: dict[str, Any]) -> dict[str, str]: + results = body.get("results") or [] + if not results: + return {"geocode_error": "ZERO_RESULTS"} + best = results[0] + location = (best.get("geometry") or {}).get("location") or {} + return { + "geocode_lat": str(location.get("lat", "")), + "geocode_lng": str(location.get("lng", "")), + "geocode_formatted_address": best.get("formatted_address", ""), + "geocode_location_type": (best.get("geometry") or {}).get("location_type", ""), + "geocode_public_id": best.get("public_id", ""), + "geocode_error": "", + } + + +def geocode_row(geocoder: Geocoder, row: Row, options: Options) -> Row: + try: + params = params_for(row, options) + if not params.get("address") and not params.get("latlng"): + raise ValueError("empty address") + result = first_result(geocoder.call(params)) + except (KeyError, ValueError, RuntimeError, requests.RequestException) as error: + result = {"geocode_error": str(error)} + return {**row, **{column: result.get(column, "") for column in OUTPUT_COLUMNS}} + + +def read_rows(path: Path) -> tuple[list[str], list[Row]]: + text = path.read_text(encoding="utf-8-sig") + try: + dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t") + except csv.Error: + dialect = csv.excel + reader = csv.DictReader(io.StringIO(text), dialect=dialect) + return list(reader.fieldnames or []), list(reader) + + +def process(geocoder: Geocoder, rows: list[Row], options: Options) -> list[Row]: + output: list[Row] = [] + for index, row in enumerate(rows, start=1): + geocoded = geocode_row(geocoder, row, options) + status = geocoded["geocode_error"] or geocoded["geocode_location_type"] + print(f"{index}/{len(rows)} {status}", file=sys.stderr) + output.append(geocoded) + if options.delay: + time.sleep(options.delay) + return output + + +def write_rows(path: Path, fieldnames: list[str], rows: list[Row]) -> None: + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames + OUTPUT_COLUMNS, lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument( + "--address-columns", + default="address", + help="comma-separated columns joined into the address (forward mode)", + ) + parser.add_argument("--country-column", help="column holding an ISO 3166-1 country code") + parser.add_argument("--country", help="fixed ISO 3166-1 country code for every row") + parser.add_argument("--reverse", action="store_true", help="reverse geocode lat/lng columns") + parser.add_argument("--lat-column", default="lat") + parser.add_argument("--lng-column", default="lng") + parser.add_argument("--language", help="response language, e.g. fr") + parser.add_argument("--delay", type=float, default=0.0, help="seconds to wait between rows") + return parser + + +def options_from(args: argparse.Namespace) -> Options: + return Options( + address_columns=[c.strip() for c in args.address_columns.split(",") if c.strip()], + country_column=args.country_column, + country=args.country, + reverse=args.reverse, + lat_column=args.lat_column, + lng_column=args.lng_column, + language=args.language, + delay=args.delay, + ) + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + fieldnames, rows = read_rows(args.input) + geocoded = process(Geocoder(private_key_from_env()), rows, options_from(args)) + write_rows(args.output, fieldnames, geocoded) + failed = sum(1 for row in geocoded if row["geocode_error"]) + print(f"{len(geocoded) - failed} geocoded, {failed} failed", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/batch-geocoding/python/requirements.txt b/batch-geocoding/python/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/batch-geocoding/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/batch-geocoding/python/test_geocode_csv.py b/batch-geocoding/python/test_geocode_csv.py new file mode 100644 index 0000000..6249d12 --- /dev/null +++ b/batch-geocoding/python/test_geocode_csv.py @@ -0,0 +1,130 @@ +import csv +from pathlib import Path + +import geocode_csv as mod +import responses + +DATA = Path(__file__).resolve().parents[2] / "data" + + +def options(**overrides): + base = dict( + address_columns=["addressline1", "postalcode", "town"], + country_column="IsoCode", + country=None, + reverse=False, + lat_column="lat", + lng_column="lng", + language=None, + delay=0.0, + ) + return mod.Options(**{**base, **overrides}) + + +def geocode_body(lat=48.8, lng=2.3, location_type="ROOFTOP"): + return { + "results": [ + { + "public_id": "abc", + "types": ["address"], + "formatted_address": "1 Rue de Rivoli, 75001 Paris", + "geometry": {"location": {"lat": lat, "lng": lng}, "location_type": location_type}, + } + ] + } + + +def test_address_joins_non_empty_columns_in_order(): + row = {"addressline1": "20 Jull Street", "postalcode": "", "town": "Armadale"} + assert ( + mod.address_from(row, ["addressline1", "postalcode", "town"]) == "20 Jull Street, Armadale" + ) + + +def test_forward_params_include_country_component_from_column(): + params = mod.params_for({"addressline1": "x", "IsoCode": "AU"}, options()) + assert params == {"address": "x", "components": "country:au"} + + +def test_fixed_country_and_language_are_passed(): + params = mod.params_for( + {"addressline1": "x"}, options(country_column=None, country="FR", language="fr") + ) + assert params == {"address": "x", "components": "country:fr", "language": "fr"} + + +def test_reverse_params_use_latlng(): + params = mod.params_for({"lat": "48.8", "lng": "2.3"}, options(reverse=True)) + assert params == {"latlng": "48.8,2.3"} + + +def test_first_result_extracts_the_output_columns(): + result = mod.first_result(geocode_body()) + assert result["geocode_lat"] == "48.8" + assert result["geocode_location_type"] == "ROOFTOP" + assert result["geocode_public_id"] == "abc" + assert result["geocode_error"] == "" + + +def test_empty_results_flag_zero_results(): + assert mod.first_result({"results": []}) == {"geocode_error": "ZERO_RESULTS"} + + +def test_empty_address_is_reported_without_calling_the_api(): + row = mod.geocode_row(mod.Geocoder("k"), {"addressline1": ""}, options(country_column=None)) + assert row["geocode_error"] == "empty address" + + +@responses.activate +def test_geocode_row_appends_result_columns(): + responses.get(mod.API_URL, json=geocode_body()) + row = mod.geocode_row( + mod.Geocoder("k"), {"addressline1": "1 rue de Rivoli", "IsoCode": "FR"}, options() + ) + assert row["geocode_formatted_address"] == "1 Rue de Rivoli, 75001 Paris" + assert responses.calls[0].request.params["private_key"] == "k" + assert responses.calls[0].request.params["components"] == "country:fr" + + +@responses.activate +def test_http_errors_land_in_the_error_column(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + for _ in range(3): + responses.get(mod.API_URL, status=429, body="slow down") + row = mod.geocode_row(mod.Geocoder("k"), {"addressline1": "x"}, options(country_column=None)) + assert row["geocode_error"].startswith("HTTP 429") + assert len(responses.calls) == 3 + + +@responses.activate +def test_server_errors_are_not_retried(): + responses.get(mod.API_URL, status=502, body="gateway") + row = mod.geocode_row(mod.Geocoder("k"), {"addressline1": "x"}, options(country_column=None)) + assert row["geocode_error"].startswith("HTTP 502") + assert len(responses.calls) == 1 + + +def test_reads_semicolon_fixture_with_header(): + fieldnames, rows = mod.read_rows(DATA / "addresses_au.csv") + assert fieldnames[:2] == ["country", "name"] + assert rows[0]["town"] == "Ipswich" + + +@responses.activate +def test_main_writes_input_columns_plus_geocode_columns(tmp_path, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + responses.get(mod.API_URL, json=geocode_body()) + output = tmp_path / "out.csv" + code = mod.main( + [ + str(DATA / "coordinates.csv"), + str(output), + "--reverse", + ] + ) + assert code == 0 + with output.open(encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + assert rows[0]["id"] == "markthalrotterdam" + assert rows[0]["geocode_lat"] == "48.8" + assert responses.calls[0].request.params["latlng"] == "51.919948,4.486843" diff --git a/python-samples/batchgeocoding/hairdresser_sample_addresses.csv b/data/addresses_au.csv similarity index 100% rename from python-samples/batchgeocoding/hairdresser_sample_addresses.csv rename to data/addresses_au.csv diff --git a/data/closures.csv b/data/closures.csv new file mode 100644 index 0000000..f4965cd --- /dev/null +++ b/data/closures.csv @@ -0,0 +1,2 @@ +store_id,start,end +testacciomarket,2026-08-10,2026-08-24 diff --git a/data/coordinates.csv b/data/coordinates.csv new file mode 100644 index 0000000..2ee5253 --- /dev/null +++ b/data/coordinates.csv @@ -0,0 +1,19 @@ +id,lat,lng +markthalrotterdam,51.919948,4.486843 +testacciomarket,41.877657,12.473909 +naschmarktvienna,48.199044,16.364234 +mercadodesanmiguel,40.415261,-3.708944 +mercadodelaboqueria,41.381635,2.171596 +courssaleya,43.69553,7.275492 +marchedaligre,48.849021,2.3777 +mercatocentraledisanlorenzo,43.77654,11.253133 +torvehallerne,55.684025,12.569469 +boroughmarket,51.505046,-0.090679 +victualsmarket,48.135105,11.576246 +hallesdesete,43.40214,3.69545 +marcheduzes,44.011821,4.418889 +marchedapt,43.876503,5.393588 +marchedelaflotte,46.187465,-1.327086 +edimburghfarmersmarket,55.947817,-3.203562 +stgeorgesmarket,54.596073,-5.921654 +hallesdewazemmes,50.62671,3.049325 diff --git a/python-samples/csv_to_woosmap/foodmarkets.csv b/data/foodmarkets.csv similarity index 96% rename from python-samples/csv_to_woosmap/foodmarkets.csv rename to data/foodmarkets.csv index 2a0a2f8..c0e4a03 100644 --- a/python-samples/csv_to_woosmap/foodmarkets.csv +++ b/data/foodmarkets.csv @@ -1,4 +1,4 @@ -Type,Latitude,Longitude,Name,Address Line,City,Zipcode,Website,Contact Phone,Contact Email +Type,Latitude,Longitude,Name,Address Line,City,Zipcode,Website,Contact Phone,Contact Email covered,51.919948,4.486843,Markthal Rotterdam,Dominee Jan Scharpstraat 298,Rotterdam,3011 GZ,http://markthalrotterdam.nl/,+31 (0)30 234 64 64,info@markthalrotterdam.nl covered,41.877657,12.473909,Testaccio Market,Via Galvani/Via Alessandro Volta,Roma,00118,http://www.mercatotestaccio.com/,+39 06 578 0638,info@mercatotestaccio.ocm covered,48.199044,16.364234,Naschmarkt Vienna,Via Galvani/Via Alessandro Volta,Vienna,1060,http://www.naschmarkt-vienna.com/,+43 1 240555,contact@naschmarkt-vienna.com diff --git a/python-samples/woosmapjson_import/foodmarkets.json b/data/foodmarkets.json similarity index 92% rename from python-samples/woosmapjson_import/foodmarkets.json rename to data/foodmarkets.json index 0e03dd7..64d3f2b 100644 --- a/python-samples/woosmapjson_import/foodmarkets.json +++ b/data/foodmarkets.json @@ -14,9 +14,9 @@ "lines": [ "Dominee Jan Scharpstraat 298" ], - "country": "Netherlands", "city": "Rotterdam", - "zipcode": "3011 GZ" + "zipcode": "3011 GZ", + "countryCode": "NL" }, "contact": { "website": "http://markthalrotterdam.nl/", @@ -50,9 +50,9 @@ "lines": [ "Via Galvani/Via Alessandro Volta" ], - "country": "Italy", "city": "Roma", - "zipcode": "00118" + "zipcode": "00118", + "countryCode": "IT" }, "contact": { "website": "http://www.mercatotestaccio.com/", @@ -84,9 +84,9 @@ "lines": [ "Via Galvani/Via Alessandro Volta" ], - "country": "Austria", "city": "Vienna", - "zipcode": "1060" + "zipcode": "1060", + "countryCode": "AT" }, "contact": { "website": "http://www.naschmarkt-vienna.com/", @@ -126,9 +126,9 @@ "lines": [ "Plaza de San Miguel" ], - "country": "Spain", "city": "Madrid", - "zipcode": "28005" + "zipcode": "28005", + "countryCode": "ES" }, "contact": { "website": "http://www.mercadodesanmiguel.es/en", @@ -179,9 +179,9 @@ "lines": [ "La Rambla, 91" ], - "country": "Spain", "city": "Barcelona", - "zipcode": "08001" + "zipcode": "08001", + "countryCode": "ES" }, "contact": { "website": "http://www.boqueria.info/", @@ -206,7 +206,7 @@ "covered" ], "location": { - "lat": 43.695530, + "lat": 43.69553, "lng": 7.275492 }, "storeId": "courssaleya", @@ -215,9 +215,9 @@ "lines": [ "Place Charles Félix" ], - "country": "France", "city": "Nice", - "zipcode": "06300" + "zipcode": "06300", + "countryCode": "FR" }, "contact": { "website": "http://leblogduvieuxnice.nicematin.com/.services/blog/6a0120a864ed46970b0162fd89fc17970d/search?filter.q=marché" @@ -247,7 +247,7 @@ ], "location": { "lat": 48.849021, - "lng": 2.377700 + "lng": 2.3777 }, "storeId": "marchedaligre", "name": "Marché d’Aligre", @@ -255,9 +255,9 @@ "lines": [ "Place d’Aligre" ], - "country": "France", "city": "Paris", - "zipcode": "75012" + "zipcode": "75012", + "countryCode": "FR" }, "contact": { "website": "http://equipement.paris.fr/marche-couvert-beauvau-marche-d-aligre-5480", @@ -300,7 +300,7 @@ "covered" ], "location": { - "lat": 43.776540, + "lat": 43.77654, "lng": 11.253133 }, "storeId": "mercatocentraledisanlorenzo", @@ -310,9 +310,9 @@ "Piazza del Mercato Centrale", "Via dell'Ariento" ], - "country": "Italy", "city": "Firenze", - "zipcode": "50123" + "zipcode": "50123", + "countryCode": "IT" }, "contact": { "website": "http://www.mercatocentrale.it", @@ -345,9 +345,9 @@ "lines": [ "Frederiksborggade 21" ], - "country": "Denmark", "city": "Copenhagen", - "zipcode": "1360" + "zipcode": "1360", + "countryCode": "DK" }, "contact": { "website": "http://torvehallernekbh.dk", @@ -398,9 +398,9 @@ "lines": [ "8 Southwark St" ], - "country": "United Kingdom", "city": "London", - "zipcode": "SE1 1TL" + "zipcode": "SE1 1TL", + "countryCode": "GB" }, "contact": { "website": "http://boroughmarket.org.uk", @@ -439,9 +439,9 @@ "lines": [ "Viktualienmarkt 3" ], - "country": "Germany", "city": "München", - "zipcode": "80881" + "zipcode": "80881", + "countryCode": "DE" }, "contact": { "website": "http://www.muenchen.de/int/en/shopping/markets/viktualienmarkt.html", @@ -480,9 +480,9 @@ "lines": [ "Rue Gambetta" ], - "country": "France", "city": "Sète", - "zipcode": "34200" + "zipcode": "34200", + "countryCode": "FR" }, "contact": { "website": "http://www.halles-sete.com/", @@ -515,9 +515,9 @@ "lines": [ "Place aux Herbes" ], - "country": "France", "city": "Uzès", - "zipcode": "30700" + "zipcode": "30700", + "countryCode": "FR" }, "contact": { "website": "http://www.uzes.fr/Calendrier-des-marches-brocantes-et-foires_a126.html", @@ -554,9 +554,9 @@ "lines": [ "Place de la Bouquerie" ], - "country": "France", "city": "Apt", - "zipcode": "84400" + "zipcode": "84400", + "countryCode": "FR" }, "contact": { "website": "http://www.luberon-apt.fr/index.php/fr/sortir/les-marches", @@ -595,9 +595,9 @@ "lines": [ "Rue du Marché" ], - "country": "France", "city": "La Flotte", - "zipcode": "17630" + "zipcode": "17630", + "countryCode": "FR" }, "contact": { "website": "http://laflotte.fr/index.php/Vie-quotidienne/les-marches.html", @@ -630,9 +630,9 @@ "lines": [ "Castle Terrace" ], - "country": "Scotland", "city": "Edimburgh", - "zipcode": "EH1 UK" + "zipcode": "EH1 UK", + "countryCode": "GB" }, "contact": { "website": "http://www.edinburghfarmersmarket.co.uk/", @@ -665,9 +665,9 @@ "lines": [ "12 - 20 East Bridge Street" ], - "country": "Ireland", "city": "Belfast", - "zipcode": "BT1 3NQ" + "zipcode": "BT1 3NQ", + "countryCode": "IE" }, "contact": { "website": "http://www.belfastcity.gov.uk/tourism-venues/stgeorgesmarket/stgeorgesmarket-index.aspx", @@ -704,7 +704,7 @@ "covered" ], "location": { - "lat": 50.626710, + "lat": 50.62671, "lng": 3.049325 }, "storeId": "hallesdewazemmes", @@ -713,9 +713,9 @@ "lines": [ "Place de la nouvelle aventure" ], - "country": "France", "city": "Lille", - "zipcode": "59000" + "zipcode": "59000", + "countryCode": "FR" }, "contact": { "website": "http://www.halles-wazemmes.com/" @@ -729,7 +729,7 @@ "end": "14:00" } ], - "1":[], + "1": [], "5": [ { "start": "08:00", @@ -752,4 +752,4 @@ } } ] -} \ No newline at end of file +} diff --git a/python-samples/excel_to_woosmap/foodmarkets.xlsx b/data/foodmarkets.xlsx similarity index 100% rename from python-samples/excel_to_woosmap/foodmarkets.xlsx rename to data/foodmarkets.xlsx diff --git a/data/opening_hours.csv b/data/opening_hours.csv new file mode 100644 index 0000000..3bde5e2 --- /dev/null +++ b/data/opening_hours.csv @@ -0,0 +1,6 @@ +store_id,timezone,monday,tuesday,wednesday,thursday,friday,saturday,sunday +markthalrotterdam,Europe/Amsterdam,closed,10:00-20:00,10:00-20:00,10:00-20:00,10:00-20:00,10:00-20:00,10:00-20:00 +testacciomarket,Europe/Rome,07:00-15:30,07:00-15:30,07:00-15:30,07:00-15:30,07:00-15:30,07:00-15:30,closed +nightmarket,Europe/London,,,,18:00-02:00,18:00-02:00,18:00-02:00, +allhours,Europe/Paris,24/7,24/7,24/7,24/7,24/7,24/7,24/7 +bistro,Europe/Paris,"11:30-14:00, 18:00-23:00","11:30-14:00, 18:00-23:00","11:30-14:00, 18:00-23:00","11:30-14:00, 18:00-23:00","11:30-14:00, 18:00-23:59",18:00-23:59,closed diff --git a/data/special_hours.csv b/data/special_hours.csv new file mode 100644 index 0000000..a5fc5e4 --- /dev/null +++ b/data/special_hours.csv @@ -0,0 +1,4 @@ +store_id,date,hours +markthalrotterdam,2026-12-24,10:00-16:00 +markthalrotterdam,2026-12-25,closed +bistro,2026-12-31,18:00-01:00 diff --git a/datasets/README.md b/datasets/README.md new file mode 100644 index 0000000..706691f --- /dev/null +++ b/datasets/README.md @@ -0,0 +1,38 @@ +# Datasets API, end to end + +The [Datasets API](https://developers.woosmap.com/products/datasets-api/get-started/) stores your own +polygons, lines and points and answers spatial questions about them. It is activated per organisation, ask +support first. Data is loaded from a zipped Shapefile that you host on a URL Woosmap can fetch. + +```sh +pip install -r python/requirements.txt + +python python/manage_dataset.py create --name countries --url https://naciscdn.org/naturalearth/110m/cultural/ne_110m_admin_0_countries.zip --title-key NAME +python python/manage_dataset.py import --wait +python python/manage_dataset.py status +python python/manage_dataset.py list + +python python/manage_dataset.py query --operator contains --geometry "48.8566,2.3522" +python python/manage_dataset.py query --operator within --geometry @paris.geojson --where "population:>1000" +python python/manage_dataset.py query --operator intersects --geometry "LINESTRING(2.3 48.8, 2.4 48.9)" --buffer 200 +``` + +`import --wait` polls the status endpoint, treating the 404 the API returns until the worker has picked +the job up as "not started yet", and prints each step (`fetch`, `import`) until success or failure; the +exit code follows. Import triggers are rate limited to one per 90 seconds per dataset, status checks to one +per 5 seconds, so keep `--poll-interval` at 5 or more. + +Geometries can be WKT, a bare `lat,lng`, or `@file.geojson` holding a Feature or a geometry. A bare point +is sent as WKT `POINT(lng lat)`: the spec lists `lat,lng` as accepted, but the API matches nothing with it. +Results are paginated twenty per page by the API and gathered into one list. + +`nearby` has no distance cut-off: it returns every feature of the dataset sorted by distance to the +geometry, and `--buffer` does not apply to it. Take the first results rather than expecting a radius. +`--buffer` widens the input geometry for `intersects` only; the other operators ignore it. + +Each result is `{id, attributes, geometry}`. Attributes are the Shapefile fields as loaded. The geometry in +search results is the feature's bounding box; fetch `GET /datasets/{dataset_id}/features/{feature_id}` for +the full shape. + +To refresh on a schedule, keep the `reimport_key` returned by `create` and call +`POST /datasets/hooks/reimport/{reimport_key}` from your pipeline. diff --git a/datasets/python/manage_dataset.py b/datasets/python/manage_dataset.py new file mode 100644 index 0000000..c36327c --- /dev/null +++ b/datasets/python/manage_dataset.py @@ -0,0 +1,186 @@ +"""Declare, import and query a Woosmap dataset built from a hosted zipped Shapefile.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com/datasets/" +FINAL_STATUSES = {"success", "failed"} +PAGE_SIZE = 20 # per_page maximum +OPERATORS = ("within", "intersects", "contains", "nearby") + + +class Datasets: + def __init__(self, private_key: str, session: requests.Session | None = None) -> None: + self.private_key = private_key + self.session = session or requests.Session() + + def call(self, method: str, path: str = "", **kwargs: Any) -> dict[str, Any]: + params = {"private_key": self.private_key, **kwargs.pop("params", {})} + response = self.session.request( + method, f"{API_URL}{path}", params=params, timeout=60, **kwargs + ) + if response.status_code >= 400: + raise RuntimeError( + f"{method} {path or '/'} failed ({response.status_code}): {response.text}" + ) + return response.json() if response.content else {} + + def create(self, name: str, url: str, title_key: str | None) -> dict[str, Any]: + body: dict[str, Any] = {"name": name, "url": url} + if title_key: + body["schema_mapping"] = [{"schema_key": "title", "data_key": title_key}] + return self.call("POST", json=body) + + def list(self) -> list[dict[str, Any]]: + return self.call("GET").get("datasets", []) + + def trigger_import(self, dataset_id: str) -> None: + # rate limited to one import per dataset every 90 seconds + self.call("POST", f"{dataset_id}/import") + + def status(self, dataset_id: str) -> dict[str, Any]: + # 404 "No dataset status available" for a few seconds after the import is triggered + response = self.session.get( + f"{API_URL}{dataset_id}/status", params={"private_key": self.private_key}, timeout=60 + ) + if response.status_code == 404: + return {"status": "pending", "steps": []} + if response.status_code >= 400: + raise RuntimeError( + f"GET {dataset_id}/status failed ({response.status_code}): {response.text}" + ) + return response.json() + + def wait(self, dataset_id: str, interval: float, timeout: float) -> dict[str, Any]: + deadline = time.monotonic() + timeout + while True: + status = self.status(dataset_id) + print(describe_status(status), file=sys.stderr) + if status.get("status") in FINAL_STATUSES: + return status + if time.monotonic() >= deadline: + raise TimeoutError( + f"dataset {dataset_id} still {status.get('status')} after {timeout}s" + ) + time.sleep(interval) + + def query( + self, dataset_id: str, operator: str, geometry: Any, where: str | None, buffer: float | None + ) -> list[dict[str, Any]]: + body: dict[str, Any] = {"geometry": geometry} + if where: + body["where"] = where + if buffer is not None: + body["buffer"] = buffer + features: list[dict[str, Any]] = [] + page: int | None = 1 + while page: + result = self.call( + "POST", + f"{dataset_id}/features/{operator}/", + params={"page": page, "per_page": PAGE_SIZE}, + json=body, + ) + features.extend(result.get("features", [])) + page = (result.get("pagination") or {}).get("next") + return features + + +def describe_status(status: dict[str, Any]) -> str: + steps = ", ".join(f"{step['name']}={step.get('status')}" for step in status.get("steps", [])) + return f"{status.get('status')} [{steps}]" + + +def load_geometry(text: str) -> Any: + if text.startswith("@"): + document = json.loads(Path(text[1:]).read_text(encoding="utf-8")) + return document.get("geometry", document) + # the spec accepts a bare "lat,lng" but the API matches nothing with it, WKT does + parts = text.split(",") + if len(parts) == 2 and all(is_number(part) for part in parts): + return f"POINT({parts[1].strip()} {parts[0].strip()})" + return text + + +def is_number(text: str) -> bool: + try: + float(text) + except ValueError: + return False + return True + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + create = commands.add_parser("create", help="declare a dataset from a hosted zipped Shapefile") + create.add_argument("--name", required=True) + create.add_argument("--url", required=True, help="public URL of the .zip Shapefile") + create.add_argument("--title-key", help="attribute to expose as the feature title") + commands.add_parser("list", help="list datasets of the project") + imp = commands.add_parser("import", help="trigger an import and optionally wait for it") + imp.add_argument("dataset_id") + imp.add_argument("--wait", action="store_true") + imp.add_argument("--poll-interval", type=float, default=10.0) + imp.add_argument("--timeout", type=float, default=1800.0) + status = commands.add_parser("status", help="show the last import status") + status.add_argument("dataset_id") + query = commands.add_parser("query", help="run a spatial query") + query.add_argument("dataset_id") + query.add_argument("--operator", choices=OPERATORS, default="within") + query.add_argument( + "--geometry", + required=True, + help="WKT, lat,lng or @file.geojson holding a Feature or a geometry", + ) + query.add_argument("--where", help="attribute filter, e.g. population:>1000") + query.add_argument("--buffer", type=float, help="buffer applied to the geometry, in metres") + return parser + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def run(api: Datasets, args: argparse.Namespace) -> Any: + if args.command == "create": + return api.create(args.name, args.url, args.title_key) + if args.command == "list": + return api.list() + if args.command == "status": + return api.status(args.dataset_id) + if args.command == "import": + api.trigger_import(args.dataset_id) + return ( + api.wait(args.dataset_id, args.poll_interval, args.timeout) + if args.wait + else {"scheduled": True} + ) + return api.query( + args.dataset_id, args.operator, load_geometry(args.geometry), args.where, args.buffer + ) + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + result = run(Datasets(private_key_from_env()), args) + print(json.dumps(result, indent=2, ensure_ascii=False)) + if args.command == "import" and args.wait and result.get("status") != "success": + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/datasets/python/requirements.txt b/datasets/python/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/datasets/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/datasets/python/test_manage_dataset.py b/datasets/python/test_manage_dataset.py new file mode 100644 index 0000000..53bf3c0 --- /dev/null +++ b/datasets/python/test_manage_dataset.py @@ -0,0 +1,113 @@ +import json + +import manage_dataset as mod +import pytest +import responses + +DATASET = "11111111-2222-3333-4444-555555555555" + + +@responses.activate +def test_create_sends_name_url_and_title_mapping(): + responses.post(mod.API_URL, json={"id": DATASET, "name": "zones"}) + created = mod.Datasets("k").create("zones", "https://files.example/zones.zip", "ZONE_NAME") + assert created["id"] == DATASET + body = json.loads(responses.calls[0].request.body) + assert body == { + "name": "zones", + "url": "https://files.example/zones.zip", + "schema_mapping": [{"schema_key": "title", "data_key": "ZONE_NAME"}], + } + + +@responses.activate +def test_import_then_wait_until_success(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + responses.post(f"{mod.API_URL}{DATASET}/import", json={"dataset_id": DATASET}) + status_url = f"{mod.API_URL}{DATASET}/status" + responses.get( + status_url, + json={"status": "in_progress", "steps": [{"name": "fetch", "status": "success"}]}, + ) + responses.get(status_url, json={"status": "success", "steps": []}) + api = mod.Datasets("k") + api.trigger_import(DATASET) + assert api.wait(DATASET, interval=0, timeout=60)["status"] == "success" + assert len(responses.calls) == 3 + + +@responses.activate +def test_wait_times_out(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + clock = iter([0.0, 5000.0]) + monkeypatch.setattr(mod.time, "monotonic", lambda: next(clock)) + responses.get(f"{mod.API_URL}{DATASET}/status", json={"status": "in_progress", "steps": []}) + with pytest.raises(TimeoutError): + mod.Datasets("k").wait(DATASET, interval=0, timeout=10) + + +@responses.activate +def test_status_is_pending_while_the_api_answers_404(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + status_url = f"{mod.API_URL}{DATASET}/status" + responses.get(status_url, status=404, json={"detail": "No dataset status available."}) + responses.get(status_url, json={"status": "success", "steps": []}) + assert mod.Datasets("k").wait(DATASET, interval=0, timeout=60)["status"] == "success" + assert len(responses.calls) == 2 + + +def test_describe_status_lists_steps(): + status = { + "status": "in_progress", + "steps": [{"name": "fetch", "status": "success"}, {"name": "import"}], + } + assert mod.describe_status(status) == "in_progress [fetch=success, import=None]" + + +@responses.activate +def test_query_follows_next_pages_and_sends_filters(): + url = f"{mod.API_URL}{DATASET}/features/within/" + responses.post(url, json={"features": [{"id": "1"}], "pagination": {"page": 1, "next": 2}}) + responses.post(url, json={"features": [{"id": "2"}], "pagination": {"page": 2, "next": None}}) + features = mod.Datasets("k").query( + DATASET, "within", "POLYGON((0 0,1 0,1 1,0 0))", "pop:>10", 50.0 + ) + assert [f["id"] for f in features] == ["1", "2"] + body = json.loads(responses.calls[0].request.body) + assert body == {"geometry": "POLYGON((0 0,1 0,1 1,0 0))", "where": "pop:>10", "buffer": 50.0} + assert responses.calls[1].request.params["page"] == "2" + assert responses.calls[1].request.params["per_page"] == "20" + + +def test_load_geometry_reads_geojson_feature_files(tmp_path): + path = tmp_path / "zone.geojson" + path.write_text( + json.dumps({"type": "Feature", "geometry": {"type": "Point", "coordinates": [2, 48]}}) + ) + assert mod.load_geometry(f"@{path}") == {"type": "Point", "coordinates": [2, 48]} + assert mod.load_geometry("48.8,2.3") == "POINT(2.3 48.8)" + assert mod.load_geometry("POLYGON((0 0,1 0,1 1,0 0))") == "POLYGON((0 0,1 0,1 1,0 0))" + + +@responses.activate +def test_errors_include_the_api_body(): + responses.get(mod.API_URL, status=403, body='{"detail":"not activated"}') + with pytest.raises(RuntimeError, match="not activated"): + mod.Datasets("k").list() + + +@responses.activate +def test_main_import_wait_returns_1_on_failure(monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + responses.post(f"{mod.API_URL}{DATASET}/import", json={"dataset_id": DATASET}) + responses.get(f"{mod.API_URL}{DATASET}/status", json={"status": "failed", "steps": []}) + assert mod.main(["import", DATASET, "--wait"]) == 1 + + +@responses.activate +def test_main_list_prints_json(capsys, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + responses.get(mod.API_URL, json={"datasets": [{"id": DATASET}], "pagination": {"page": 1}}) + assert mod.main(["list"]) == 0 + assert DATASET in capsys.readouterr().out diff --git a/distance-matrix/README.md b/distance-matrix/README.md new file mode 100644 index 0000000..59ce83c --- /dev/null +++ b/distance-matrix/README.md @@ -0,0 +1,35 @@ +# Distance matrices + +## Large matrices, asynchronously (Python, Node) + +For hundreds or thousands of origin-destination pairs, use the +[async endpoints](https://developers.woosmap.com/products/distance-api/features/matrix_async/): submit, +poll the status, fetch the result. Two CSV files in, one CSV out with a row per pair. + +```sh +pip install -r python/requirements.txt +python python/async_matrix.py matrix.csv --origins origins.csv --destinations destinations.csv --mode driving +python python/async_matrix.py matrix.csv --matrix-id 74b4265e-c102-4178-b483-9111b2342443 + +node node/async-matrix.mjs matrix.csv --origins origins.csv --destinations destinations.csv +``` + +The job runs on Woosmap's side and its result stays available after the script exits: the submitted +`matrix_id` is printed, and `--matrix-id` resumes the polling without submitting, and paying for, a +second job. Input files hold one `lat,lng` per line, a header row is skipped. Output columns: `origin_index`, +`destination_index`, `status`, `distance_m`, `duration_s`. + +The result endpoint answers `303` to a signed, gzipped file that both runtimes follow and decompress +transparently. Its body is not the synchronous `rows/elements` shape: it carries `matrix.travelTimes` +and `matrix.distances` as flat row-major arrays, plus `matrix.errorCodes` when some pairs failed. The +scripts unfold them into pairs for you. + +## Small matrices, synchronously (Java) + +`java/` is a minimal Java 11 client for `GET /distance/distancematrix/json`, fit for a handful of +destinations in a request-response flow. It reads the key from `WOOSMAP_PRIVATE_KEY`. + +```sh +cd java +mvn clean compile exec:java -Dexec.mainClass="com.example.WoosmapDistanceApiClient" +``` diff --git a/distance-matrix/java/README.md b/distance-matrix/java/README.md new file mode 100644 index 0000000..5cb9767 --- /dev/null +++ b/distance-matrix/java/README.md @@ -0,0 +1,11 @@ +# Woosmap Distance API Java client + +A single-file Java 11 client for the synchronous Distance Matrix endpoint. Jackson parses the response. + +```sh +export WOOSMAP_PRIVATE_KEY=... +mvn clean compile exec:java -Dexec.mainClass="com.example.WoosmapDistanceApiClient" +``` + +The example computes driving distance and duration from one origin in Paris to three destinations and +prints them. For matrices beyond a few hundred elements, use the asynchronous samples in the parent folder. diff --git a/java-samples/distance-api-client/pom.xml b/distance-matrix/java/pom.xml similarity index 97% rename from java-samples/distance-api-client/pom.xml rename to distance-matrix/java/pom.xml index a71dac9..0a06be8 100644 --- a/java-samples/distance-api-client/pom.xml +++ b/distance-matrix/java/pom.xml @@ -9,7 +9,7 @@ com.fasterxml.jackson.core jackson-databind - 2.12.3 + 2.17.2 diff --git a/java-samples/distance-api-client/src/main/java/com/example/WoosmapDistanceApiClient.java b/distance-matrix/java/src/main/java/com/example/WoosmapDistanceApiClient.java similarity index 94% rename from java-samples/distance-api-client/src/main/java/com/example/WoosmapDistanceApiClient.java rename to distance-matrix/java/src/main/java/com/example/WoosmapDistanceApiClient.java index 3b53b3a..ad99810 100644 --- a/java-samples/distance-api-client/src/main/java/com/example/WoosmapDistanceApiClient.java +++ b/distance-matrix/java/src/main/java/com/example/WoosmapDistanceApiClient.java @@ -11,10 +11,14 @@ public class WoosmapDistanceApiClient { - private static final String API_KEY = "YOUR_WOOSMAP_API_KEY"; + private static final String API_KEY = System.getenv("WOOSMAP_PRIVATE_KEY"); private static final String BASE_URL = "https://api.woosmap.com/distance/distancematrix/json"; public static void main(String[] args) { + if (API_KEY == null || API_KEY.isBlank()) { + System.err.println("set WOOSMAP_PRIVATE_KEY in the environment"); + System.exit(1); + } try { String origins = "48.836,2.237"; // Example: Paris coordinates String destinations = "48.709,2.403|48.768,2.338|49.987,2.223"; // Example multiple destinations diff --git a/distance-matrix/node/async-matrix.mjs b/distance-matrix/node/async-matrix.mjs new file mode 100644 index 0000000..11ce0e1 --- /dev/null +++ b/distance-matrix/node/async-matrix.mjs @@ -0,0 +1,138 @@ +// Compute a large distance matrix with the asynchronous Distance API and save it as CSV. +import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +export const API_URL = "https://api.woosmap.com/distance/matrix/async/"; +const FINAL_STATUSES = new Set(["completed", "timeout", "error"]); +const OUTPUT_COLUMNS = ["origin_index", "destination_index", "status", "distance_m", "duration_s"]; + +const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + +export function parsePoints(text) { + const points = []; + for (const line of text.replace(/^/, "").split(/\r?\n/)) { + const [lat, lng] = line.replace(/;/g, ",").split(",").map((c) => c.trim()); + if (lat && lng && !Number.isNaN(Number(lat)) && !Number.isNaN(Number(lng))) points.push(`${lat},${lng}`); + } + if (!points.length) throw new Error("no coordinates found"); + return points; +} + +export class AsyncMatrix { + constructor(privateKey, fetchImpl = fetch, sleepImpl = sleep, now = () => Date.now() / 1000) { + this.privateKey = privateKey; + this.fetch = fetchImpl; + this.sleep = sleepImpl; + this.now = now; + } + + async call(method, url, body) { + const target = `${url}?private_key=${encodeURIComponent(this.privateKey)}`; + let response; + for (let attempt = 0; attempt < 3; attempt += 1) { + response = await this.fetch(target, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + redirect: "follow", + }); + if (response.status !== 429 || attempt === 2) break; + // 429 is the only status the API asks to retry, and Retry-After says when + await this.sleep(Number(response.headers.get("Retry-After") ?? 2 ** attempt)); + } + if (!response.ok) throw new Error(`${method} ${url} failed (${response.status}): ${await response.text()}`); + return response.json(); + } + + async submit(origins, destinations, options = {}) { + const body = { origins: origins.join("|"), destinations: destinations.join("|"), ...options }; + return (await this.call("POST", API_URL, body)).matrix_id; + } + + status = async (matrixId) => (await this.call("GET", `${API_URL}${matrixId}/status`)).status; + result = (matrixId) => this.call("GET", `${API_URL}${matrixId}`); + + async wait(matrixId, interval, timeout) { + const deadline = this.now() + timeout; + for (;;) { + const status = await this.status(matrixId); + console.error(`${matrixId}: ${status}`); + if (FINAL_STATUSES.has(status)) return status; + if (this.now() >= deadline) throw new Error(`matrix ${matrixId} still ${status} after ${timeout}s`); + await this.sleep(interval); + } + } +} + +export function flatten(result) { + // The async result is not the synchronous rows/elements shape: travelTimes and distances + // are flat row-major arrays, errorCodes is present only when some pairs failed. + const matrix = result.matrix ?? {}; + const destinations = matrix.numDestinations ?? 0; + const { travelTimes = [], distances = [], errorCodes = [] } = matrix; + const rows = []; + for (let index = 0; index < (matrix.numOrigins ?? 0) * destinations; index += 1) { + const error = errorCodes[index] ?? 0; + rows.push({ + origin_index: Math.floor(index / destinations), + destination_index: index % destinations, + status: error ? `ERROR_${error}` : "OK", + distance_m: error ? "" : distances[index] ?? "", + duration_s: error ? "" : travelTimes[index] ?? "", + }); + } + return rows; +} + +export const toCsv = (rows) => + [OUTPUT_COLUMNS.join(","), ...rows.map((row) => OUTPUT_COLUMNS.map((c) => row[c]).join(","))].join("\n") + "\n"; + +async function submitFromFiles(api, values) { + if (!values.origins || !values.destinations) throw new Error("pass --origins and --destinations, or --matrix-id to resume a job"); + const origins = parsePoints(await readFile(values.origins, "utf8")); + const destinations = parsePoints(await readFile(values.destinations, "utf8")); + const matrixId = await api.submit(origins, destinations, { mode: values.mode, method: values.method, elements: values.elements }); + // the job outlives this process: keep the id to resume with --matrix-id if polling is cut + console.error(`submitted ${origins.length}x${destinations.length} matrix ${matrixId}`); + return matrixId; +} + +export async function main(argv) { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + mode: { type: "string", default: "driving" }, + method: { type: "string", default: "time" }, + elements: { type: "string", default: "duration_distance" }, + "poll-interval": { type: "string", default: "5" }, + timeout: { type: "string", default: "1800" }, + origins: { type: "string" }, + destinations: { type: "string" }, + "matrix-id": { type: "string" }, + }, + }); + const [outputPath] = positionals; + if (!outputPath) throw new Error("usage: node async-matrix.mjs --origins o.csv --destinations d.csv | --matrix-id ID"); + const privateKey = process.env.WOOSMAP_PRIVATE_KEY; + if (!privateKey) throw new Error("set WOOSMAP_PRIVATE_KEY in the environment"); + const api = new AsyncMatrix(privateKey); + const matrixId = values["matrix-id"] ?? (await submitFromFiles(api, values)); + const status = await api.wait(matrixId, Number(values["poll-interval"]), Number(values.timeout)); + if (status !== "completed") { + console.error(`matrix ${matrixId} ended with status ${status}`); + return 1; + } + const rows = flatten(await api.result(matrixId)); + await writeFile(outputPath, toCsv(rows)); + console.error(`wrote ${rows.length} elements to ${outputPath}`); + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then((code) => process.exit(code), (error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/distance-matrix/node/async-matrix.test.mjs b/distance-matrix/node/async-matrix.test.mjs new file mode 100644 index 0000000..eae6887 --- /dev/null +++ b/distance-matrix/node/async-matrix.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { API_URL, AsyncMatrix, flatten, parsePoints, toCsv } from "./async-matrix.mjs"; + +function fakeFetch(responses) { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, init }); + const next = responses.shift() ?? {}; + return new Response(JSON.stringify(next.body ?? {}), { status: next.status ?? 200, headers: next.headers }); + }; + return { fetchImpl, calls }; +} + +test("parsePoints skips headers, blanks and accepts semicolons", () => { + assert.deepEqual(parsePoints("lat,lng\n48.8,2.3\n\n48.7;2.4\n"), ["48.8,2.3", "48.7,2.4"]); + assert.throws(() => parsePoints("lat,lng\n")); +}); + +test("submit posts pipe-separated points with the private key", async () => { + const { fetchImpl, calls } = fakeFetch([{ body: { matrix_id: "m1", status: "accepted" } }]); + const id = await new AsyncMatrix("k", fetchImpl).submit(["1,2", "3,4"], ["5,6"], { mode: "driving" }); + assert.equal(id, "m1"); + assert.equal(calls[0].url, `${API_URL}?private_key=k`); + assert.deepEqual(JSON.parse(calls[0].init.body), { origins: "1,2|3,4", destinations: "5,6", mode: "driving" }); +}); + +test("wait polls until a final status", async () => { + const { fetchImpl, calls } = fakeFetch([ + { body: { status: "accepted" } }, + { body: { status: "inProgress" } }, + { body: { status: "completed" } }, + ]); + const api = new AsyncMatrix("k", fetchImpl, async () => {}, () => 0); + assert.equal(await api.wait("m1", 0, 60), "completed"); + assert.equal(calls.length, 3); +}); + +test("wait gives up after the timeout", async () => { + const { fetchImpl } = fakeFetch([{ body: { status: "inProgress" } }, { body: { status: "inProgress" } }]); + let clock = 0; + const api = new AsyncMatrix("k", fetchImpl, async () => {}, () => (clock += 100)); + await assert.rejects(api.wait("m1", 0, 50), /still inProgress/); +}); + +test("flatten maps row-major arrays to origin/destination pairs and toCsv renders it", () => { + const rows = flatten({ + matrix: { numOrigins: 2, numDestinations: 2, travelTimes: [100, 200, 300, 400], distances: [1000, 2000, 3000, 4000], errorCodes: [0, 0, 3, 0] }, + }); + assert.deepEqual(rows[1], { origin_index: 0, destination_index: 1, status: "OK", distance_m: 2000, duration_s: 200 }); + assert.deepEqual(rows[2], { origin_index: 1, destination_index: 0, status: "ERROR_3", distance_m: "", duration_s: "" }); + assert.equal(toCsv(rows).split("\n")[1], "0,0,OK,1000,100"); +}); + +test("errors fail immediately and carry the body", async () => { + const { fetchImpl, calls } = fakeFetch([{ status: 502, body: { detail: "gateway" } }]); + await assert.rejects(new AsyncMatrix("k", fetchImpl, async () => {}).status("m1"), /gateway/); + assert.equal(calls.length, 1); +}); diff --git a/distance-matrix/node/package.json b/distance-matrix/node/package.json new file mode 100644 index 0000000..6f07010 --- /dev/null +++ b/distance-matrix/node/package.json @@ -0,0 +1,9 @@ +{ + "name": "woosmap-distance-matrix-async", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "test": "node --test" + } +} diff --git a/distance-matrix/python/async_matrix.py b/distance-matrix/python/async_matrix.py new file mode 100644 index 0000000..65980e3 --- /dev/null +++ b/distance-matrix/python/async_matrix.py @@ -0,0 +1,159 @@ +"""Compute a large distance matrix with the asynchronous Distance API and save it as CSV.""" + +from __future__ import annotations + +import argparse +import csv +import os +import sys +import time +from pathlib import Path +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com/distance/matrix/async/" +FINAL_STATUSES = {"completed", "timeout", "error"} +OUTPUT_COLUMNS = ["origin_index", "destination_index", "status", "distance_m", "duration_s"] + + +def read_points(path: Path) -> list[str]: + points: list[str] = [] + for line in path.read_text(encoding="utf-8-sig").splitlines(): + cells = [cell.strip() for cell in line.replace(";", ",").split(",")] + if len(cells) < 2 or not cells[0] or not cells[1]: + continue + try: + float(cells[0]), float(cells[1]) + except ValueError: + continue + points.append(f"{cells[0]},{cells[1]}") + if not points: + raise ValueError(f"no coordinates found in {path}") + return points + + +class AsyncMatrix: + def __init__(self, private_key: str, session: requests.Session | None = None) -> None: + self.private_key = private_key + self.session = session or requests.Session() + + def _call(self, method: str, url: str, **kwargs: Any) -> requests.Response: + for attempt in range(3): + response = self.session.request( + method, url, params={"private_key": self.private_key}, timeout=60, **kwargs + ) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + if response.status_code >= 400: + raise RuntimeError(f"{method} {url} failed ({response.status_code}): {response.text}") + return response + + def submit(self, origins: list[str], destinations: list[str], **options: str) -> str: + body = {"origins": "|".join(origins), "destinations": "|".join(destinations), **options} + return self._call("POST", API_URL, json=body).json()["matrix_id"] + + def status(self, matrix_id: str) -> str: + return self._call("GET", f"{API_URL}{matrix_id}/status").json()["status"] + + def result(self, matrix_id: str) -> dict[str, Any]: + return self._call("GET", f"{API_URL}{matrix_id}").json() + + def wait(self, matrix_id: str, interval: float, timeout: float) -> str: + deadline = time.monotonic() + timeout + while True: + status = self.status(matrix_id) + print(f"{matrix_id}: {status}", file=sys.stderr) + if status in FINAL_STATUSES: + return status + if time.monotonic() >= deadline: + raise TimeoutError(f"matrix {matrix_id} still {status} after {timeout}s") + time.sleep(interval) + + +def flatten(result: dict[str, Any]) -> list[dict[str, Any]]: + # The async result is not the synchronous rows/elements shape: travelTimes and distances + # are flat row-major arrays, errorCodes is present only when some pairs failed. + matrix = result.get("matrix") or {} + destinations = matrix.get("numDestinations") or 0 + times, distances = matrix.get("travelTimes") or [], matrix.get("distances") or [] + errors = matrix.get("errorCodes") or [] + rows: list[dict[str, Any]] = [] + for index in range(matrix.get("numOrigins", 0) * destinations): + error = errors[index] if index < len(errors) else 0 + rows.append( + { + "origin_index": index // destinations, + "destination_index": index % destinations, + "status": "OK" if not error else f"ERROR_{error}", + "distance_m": distances[index] if index < len(distances) and not error else "", + "duration_s": times[index] if index < len(times) and not error else "", + } + ) + return rows + + +def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=OUTPUT_COLUMNS, lineterminator="\n") + writer.writeheader() + writer.writerows(rows) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "output", type=Path, help="CSV to write, one row per origin/destination pair" + ) + parser.add_argument("--origins", type=Path, help="CSV with one lat,lng per line") + parser.add_argument("--destinations", type=Path, help="CSV with one lat,lng per line") + parser.add_argument("--matrix-id", help="resume polling a job submitted earlier instead") + parser.add_argument( + "--mode", default="driving", help="driving (default), walking, cycling or truck" + ) + parser.add_argument("--method", choices=("time", "distance"), default="time") + parser.add_argument("--elements", default="duration_distance") + parser.add_argument( + "--poll-interval", type=float, default=5.0, help="seconds between status checks" + ) + parser.add_argument("--timeout", type=float, default=1800.0, help="seconds before giving up") + return parser + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def submit_from_files(api: AsyncMatrix, args: argparse.Namespace) -> str: + if not (args.origins and args.destinations): + raise SystemExit("pass --origins and --destinations, or --matrix-id to resume a job") + origins, destinations = read_points(args.origins), read_points(args.destinations) + matrix_id = api.submit( + origins, destinations, mode=args.mode, method=args.method, elements=args.elements + ) + # the job outlives this process: keep the id to resume with --matrix-id if polling is cut + print(f"submitted {len(origins)}x{len(destinations)} matrix {matrix_id}", file=sys.stderr) + return matrix_id + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + api = AsyncMatrix(private_key_from_env()) + matrix_id = args.matrix_id or submit_from_files(api, args) + status = api.wait(matrix_id, args.poll_interval, args.timeout) + if status != "completed": + print(f"matrix {matrix_id} ended with status {status}", file=sys.stderr) + return 1 + rows = flatten(api.result(matrix_id)) + write_csv(args.output, rows) + print(f"wrote {len(rows)} elements to {args.output}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/distance-matrix/python/requirements.txt b/distance-matrix/python/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/distance-matrix/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/distance-matrix/python/test_async_matrix.py b/distance-matrix/python/test_async_matrix.py new file mode 100644 index 0000000..7ba866f --- /dev/null +++ b/distance-matrix/python/test_async_matrix.py @@ -0,0 +1,155 @@ +import csv + +import async_matrix as mod +import pytest +import responses + + +def test_read_points_skips_header_and_blank_lines(tmp_path): + path = tmp_path / "p.csv" + path.write_text("lat,lng\n48.8,2.3\n\n48.7;2.4\n") + assert mod.read_points(path) == ["48.8,2.3", "48.7,2.4"] + + +def test_read_points_rejects_empty_file(tmp_path): + path = tmp_path / "p.csv" + path.write_text("lat,lng\n") + with pytest.raises(ValueError): + mod.read_points(path) + + +@responses.activate +def test_submit_posts_pipe_separated_points(): + responses.post(mod.API_URL, json={"matrix_id": "m1", "status": "accepted"}) + matrix_id = mod.AsyncMatrix("k").submit(["1,2", "3,4"], ["5,6"], mode="driving") + assert matrix_id == "m1" + body = responses.calls[0].request.body + assert b'"origins": "1,2|3,4"' in body + assert b'"destinations": "5,6"' in body + assert responses.calls[0].request.params["private_key"] == "k" + + +@responses.activate +def test_wait_polls_until_completed(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + url = f"{mod.API_URL}m1/status" + responses.get(url, json={"status": "accepted"}) + responses.get(url, json={"status": "inProgress"}) + responses.get(url, json={"status": "completed"}) + assert mod.AsyncMatrix("k").wait("m1", interval=0, timeout=60) == "completed" + assert len(responses.calls) == 3 + + +@responses.activate +def test_wait_gives_up_after_timeout(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + clock = iter([0.0, 100.0, 200.0]) + monkeypatch.setattr(mod.time, "monotonic", lambda: next(clock)) + responses.get(f"{mod.API_URL}m1/status", json={"status": "inProgress"}) + with pytest.raises(TimeoutError): + mod.AsyncMatrix("k").wait("m1", interval=0, timeout=50) + + +@responses.activate +def test_result_follows_the_303_redirect(): + responses.get( + f"{mod.API_URL}m1", status=303, headers={"Location": "https://files.example/m1.json"} + ) + responses.get("https://files.example/m1.json", json={"matrix": {}}) + assert mod.AsyncMatrix("k").result("m1") == {"matrix": {}} + + +def test_flatten_maps_row_major_arrays_to_pairs(): + result = { + "matrix": { + "numOrigins": 2, + "numDestinations": 2, + "travelTimes": [100, 200, 300, 400], + "distances": [1000, 2000, 3000, 4000], + "errorCodes": [0, 0, 3, 0], + } + } + rows = mod.flatten(result) + assert rows[1] == { + "origin_index": 0, + "destination_index": 1, + "status": "OK", + "distance_m": 2000, + "duration_s": 200, + } + assert rows[2] == { + "origin_index": 1, + "destination_index": 0, + "status": "ERROR_3", + "distance_m": "", + "duration_s": "", + } + assert rows[3]["origin_index"] == 1 and rows[3]["destination_index"] == 1 + + +def test_flatten_without_error_codes(): + result = { + "matrix": {"numOrigins": 1, "numDestinations": 1, "travelTimes": [5], "distances": [9]} + } + assert mod.flatten(result)[0]["status"] == "OK" + + +@responses.activate +def test_main_end_to_end(tmp_path, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + origins, destinations, output = tmp_path / "o.csv", tmp_path / "d.csv", tmp_path / "out.csv" + origins.write_text("48.8,2.3\n") + destinations.write_text("48.7,2.4\n48.6,2.5\n") + responses.post(mod.API_URL, json={"matrix_id": "m1", "status": "accepted"}) + responses.get(f"{mod.API_URL}m1/status", json={"status": "completed"}) + responses.get( + f"{mod.API_URL}m1", + json={ + "matrix": { + "numOrigins": 1, + "numDestinations": 2, + "travelTimes": [2, 2], + "distances": [1, 1], + } + }, + ) + args = [str(output), "--origins", str(origins), "--destinations", str(destinations)] + assert mod.main(args) == 0 + with output.open() as handle: + rows = list(csv.DictReader(handle)) + assert [r["destination_index"] for r in rows] == ["0", "1"] + assert rows[0]["distance_m"] == "1" + + +@responses.activate +def test_main_reports_failed_matrix(tmp_path, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + points = tmp_path / "p.csv" + points.write_text("48.8,2.3\n") + responses.post(mod.API_URL, json={"matrix_id": "m1", "status": "accepted"}) + responses.get(f"{mod.API_URL}m1/status", json={"status": "error"}) + args = [str(tmp_path / "out.csv"), "--origins", str(points), "--destinations", str(points)] + assert mod.main(args) == 1 + + +@responses.activate +def test_main_resumes_an_existing_job(tmp_path, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + responses.get(f"{mod.API_URL}m9/status", json={"status": "completed"}) + responses.get( + f"{mod.API_URL}m9", + json={ + "matrix": {"numOrigins": 1, "numDestinations": 1, "travelTimes": [1], "distances": [2]} + }, + ) + output = tmp_path / "out.csv" + assert mod.main([str(output), "--matrix-id", "m9"]) == 0 + assert "0,0,OK,2,1" in output.read_text() + assert all(c.request.method == "GET" for c in responses.calls) + + +def test_main_requires_inputs_or_matrix_id(tmp_path, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + with pytest.raises(SystemExit): + mod.main([str(tmp_path / "out.csv")]) diff --git a/geolocation-stores/README.md b/geolocation-stores/README.md new file mode 100644 index 0000000..760ea1d --- /dev/null +++ b/geolocation-stores/README.md @@ -0,0 +1,15 @@ +# Nearest stores from an IP address + +Pre-fill "your store" on a landing page or route a support ticket to the right shop, before the visitor +has typed anything. The [Geolocation stores endpoint](https://developers.woosmap.com/products/geolocation-api/stores/) +locates the IP and runs a Stores Search around it in one call. + +```sh +pip install -r python/requirements.txt +python python/nearest_stores_by_ip.py 145.94.1.1 --limit 3 --radius 50000 +python python/nearest_stores_by_ip.py 2001:db8::1 --query 'type:"grocery"' --json +``` + +Prints the resolved city and accuracy, then one line per store with its distance in metres. Stores are +only returned when the IP resolves to within 20 km accuracy, so a datacentre or mobile-carrier IP often +yields a location without stores. Treat the result as a suggestion the visitor can correct. diff --git a/geolocation-stores/python/nearest_stores_by_ip.py b/geolocation-stores/python/nearest_stores_by_ip.py new file mode 100644 index 0000000..db34f2e --- /dev/null +++ b/geolocation-stores/python/nearest_stores_by_ip.py @@ -0,0 +1,82 @@ +"""Find the stores closest to a visitor from their IP address, server-side.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com/geolocation/stores" + + +def locate(session: requests.Session, private_key: str, ip: str, **params: Any) -> dict[str, Any]: + response = session.get( + API_URL, + params={ + "private_key": private_key, + "ip_address": ip, + **{k: v for k, v in params.items() if v}, + }, + timeout=30, + ) + if response.status_code >= 400: + raise RuntimeError(f"geolocation failed ({response.status_code}): {response.text}") + return response.json() + + +def describe_location(body: dict[str, Any]) -> str: + place = ", ".join(part for part in (body.get("city"), body.get("country_name")) if part) + accuracy = body.get("accuracy") + return f"{place or 'unknown location'} (accuracy {accuracy} km)" if accuracy else place + + +def store_lines(body: dict[str, Any]) -> list[str]: + features = (body.get("stores") or {}).get("features") or [] + return [ + f"{f['properties'].get('store_id')}\t{f['properties'].get('name')}\t{f['properties'].get('distance')}" + for f in features + ] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ip", help="public IPv4 or IPv6 address of the visitor") + parser.add_argument("--limit", type=int, default=3, help="number of stores to return") + parser.add_argument("--radius", type=int, help="search radius in metres") + parser.add_argument("--query", help='optional Stores API query, e.g. type:"grocery"') + parser.add_argument("--json", action="store_true", help="print the raw response") + return parser + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + body = locate( + requests.Session(), + private_key_from_env(), + args.ip, + limit=args.limit, + radius=args.radius, + query=args.query, + ) + if args.json: + print(json.dumps(body, indent=2)) + return 0 + print(describe_location(body)) + lines = store_lines(body) + print("\n".join(lines) if lines else "no store found near this IP") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/geolocation-stores/python/requirements.txt b/geolocation-stores/python/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/geolocation-stores/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/geolocation-stores/python/test_nearest_stores_by_ip.py b/geolocation-stores/python/test_nearest_stores_by_ip.py new file mode 100644 index 0000000..407e2de --- /dev/null +++ b/geolocation-stores/python/test_nearest_stores_by_ip.py @@ -0,0 +1,57 @@ +import nearest_stores_by_ip as mod +import pytest +import requests +import responses + + +def body(**extra): + return { + "city": "Paris", + "country_name": "France", + "accuracy": 5, + "stores": { + "features": [ + {"properties": {"store_id": "a", "name": "Shop A", "distance": 1200}}, + ] + }, + **extra, + } + + +@responses.activate +def test_locate_passes_ip_and_optional_params_only_when_set(): + responses.get(mod.API_URL, json=body()) + mod.locate(requests.Session(), "k", "1.2.3.4", limit=3, radius=None, query=None) + params = responses.calls[0].request.params + assert params["ip_address"] == "1.2.3.4" + assert params["limit"] == "3" + assert "radius" not in params + + +@responses.activate +def test_locate_raises_on_error(): + responses.get(mod.API_URL, status=403, body='{"detail":"denied"}') + with pytest.raises(RuntimeError, match="denied"): + mod.locate(requests.Session(), "k", "1.2.3.4") + + +def test_describe_location_includes_city_country_and_accuracy(): + assert mod.describe_location(body()) == "Paris, France (accuracy 5 km)" + + +def test_store_lines_are_tab_separated(): + assert mod.store_lines(body()) == ["a\tShop A\t1200"] + + +def test_store_lines_empty_when_no_stores_key(): + assert mod.store_lines({"city": "Paris"}) == [] + + +@responses.activate +def test_main_prints_location_and_stores(capsys, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + responses.get(mod.API_URL, json=body()) + assert mod.main(["1.2.3.4", "--limit", "1"]) == 0 + out = capsys.readouterr().out + assert "Paris, France" in out + assert "Shop A" in out diff --git a/isochrone-stores/README.md b/isochrone-stores/README.md new file mode 100644 index 0000000..90b4424 --- /dev/null +++ b/isochrone-stores/README.md @@ -0,0 +1,22 @@ +# Stores within a travel time + +"Which stores can deliver within twenty minutes of this address?" Three API calls chained server-side: + +1. Localities geocode turns the address into coordinates (skipped with `--origin lat,lng`). +2. The [Isochrone endpoint](https://developers.woosmap.com/products/distance-api/features/isochrone/) returns + the reachable area as an encoded polyline. +3. Stores Search fetches candidates in a circle covering that area, and a point-in-polygon test keeps the + ones inside. + +```sh +pip install -r python/requirements.txt +python python/stores_within_isochrone.py --address "Piazza Testaccio, Roma" --country it --value 15 +python python/stores_within_isochrone.py --origin 51.92,4.48 --value 30 --mode cycling --query 'type:"covered"' +python python/stores_within_isochrone.py --origin 51.92,4.48 --value 10 --method distance --json +``` + +`--value` is minutes with `--method time` (default) and kilometres with `--method distance`. The plain +output is one line per store, sorted by road-free distance from the origin; `--json` prints the matching +GeoJSON features. + +The polyline decoder and the point-in-polygon test are in the script, so no geospatial dependency is needed. diff --git a/isochrone-stores/python/requirements.txt b/isochrone-stores/python/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/isochrone-stores/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/isochrone-stores/python/stores_within_isochrone.py b/isochrone-stores/python/stores_within_isochrone.py new file mode 100644 index 0000000..7235270 --- /dev/null +++ b/isochrone-stores/python/stores_within_isochrone.py @@ -0,0 +1,201 @@ +"""List the stores reachable within a travel time, combining Isochrone and Stores Search.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com" +PAGE_SIZE = 300 # stores_by_page maximum +EARTH_RADIUS_M = 6_371_000 + +LatLng = tuple[float, float] + + +def decode_polyline(encoded: str, precision: int = 5) -> list[LatLng]: + factor = 10**precision + points: list[LatLng] = [] + index, lat, lng = 0, 0, 0 + while index < len(encoded): + for coordinate in ("lat", "lng"): + shift, result = 0, 0 + while True: + byte = ord(encoded[index]) - 63 + index += 1 + result |= (byte & 0x1F) << shift + shift += 5 + if byte < 0x20: + break + delta = ~(result >> 1) if result & 1 else result >> 1 + if coordinate == "lat": + lat += delta + else: + lng += delta + points.append((lat / factor, lng / factor)) + return points + + +def haversine_m(a: LatLng, b: LatLng) -> float: + lat1, lng1, lat2, lng2 = map(math.radians, (*a, *b)) + h = ( + math.sin((lat2 - lat1) / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin((lng2 - lng1) / 2) ** 2 + ) + return 2 * EARTH_RADIUS_M * math.asin(math.sqrt(h)) + + +def point_in_polygon(point: LatLng, polygon: list[LatLng]) -> bool: + lat, lng = point + inside = False + previous = polygon[-1] + for current in polygon: + (lat1, lng1), (lat2, lng2) = previous, current + crosses = (lat1 > lat) != (lat2 > lat) + if crosses and lng < (lng2 - lng1) * (lat - lat1) / (lat2 - lat1) + lng1: + inside = not inside + previous = current + return inside + + +def covering_radius_m(origin: LatLng, polygon: list[LatLng]) -> int: + return int(max(haversine_m(origin, vertex) for vertex in polygon) * 1.05) + 100 + + +class Woosmap: + def __init__(self, private_key: str, session: requests.Session | None = None) -> None: + self.private_key = private_key + self.session = session or requests.Session() + + def get(self, path: str, **params: Any) -> dict[str, Any]: + response = self.session.get( + f"{API_URL}{path}", params={"private_key": self.private_key, **params}, timeout=60 + ) + if response.status_code >= 400: + raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}") + body = response.json() + # the Distance API reports errors in `status` with HTTP 200, unlike the other APIs + status = body.get("status") + if status not in (None, "OK"): + detail = body.get("message") or body.get("error_message") or "" + raise RuntimeError(f"GET {path} returned {status}: {detail}".strip()) + return body + + def geocode(self, address: str, country: str | None) -> LatLng: + params = {"address": address} + if country: + params["components"] = f"country:{country.lower()}" + results = self.get("/localities/geocode/", **params).get("results") or [] + if not results: + raise RuntimeError(f"no geocoding result for {address!r}") + location = results[0]["geometry"]["location"] + return location["lat"], location["lng"] + + def isochrone(self, origin: LatLng, value: int, method: str, mode: str) -> list[LatLng]: + body = self.get( + "/distance/isochrone/json/", + origin=f"{origin[0]},{origin[1]}", + value=value, + method=method, + mode=mode, + ) + # the isoline is an encoded polyline, not GeoJSON + return decode_polyline(body["isoline"]["geometry"]) + + def stores_around( + self, origin: LatLng, radius_m: int, query: str | None + ) -> list[dict[str, Any]]: + features: list[dict[str, Any]] = [] + page = 1 + while True: + params: dict[str, Any] = { + "lat": origin[0], + "lng": origin[1], + "radius": radius_m, + "stores_by_page": PAGE_SIZE, + "page": page, + } + if query: + params["query"] = query + body = self.get("/stores/search", **params) + features.extend(body.get("features", [])) + if page >= body.get("pagination", {}).get("pageCount", 1): + return features + page += 1 + + +def store_point(feature: dict[str, Any]) -> LatLng: + lng, lat = feature["geometry"]["coordinates"] + return lat, lng + + +# Stores Search filters by circle, polyline or zone, not by polygon: fetch a covering +# circle, then clip to the isochrone locally. +def stores_within( + api: Woosmap, origin: LatLng, polygon: list[LatLng], query: str | None +) -> list[dict[str, Any]]: + candidates = api.stores_around(origin, covering_radius_m(origin, polygon), query) + return [feature for feature in candidates if point_in_polygon(store_point(feature), polygon)] + + +def summarise(feature: dict[str, Any]) -> dict[str, Any]: + props = feature["properties"] + return { + "storeId": props.get("store_id"), + "name": props.get("name"), + "city": (props.get("address") or {}).get("city"), + "distance_m": props.get("distance"), + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + origin = parser.add_mutually_exclusive_group(required=True) + origin.add_argument("--origin", help="lat,lng") + origin.add_argument("--address", help="address to geocode with Localities") + parser.add_argument("--country", help="ISO country code to restrict geocoding, e.g. fr") + parser.add_argument( + "--value", type=int, default=20, help="minutes (method time) or km (method distance)" + ) + parser.add_argument("--method", choices=("time", "distance"), default="time") + parser.add_argument("--mode", default="driving", help="driving, walking or cycling") + parser.add_argument("--query", help='optional Stores API query, e.g. type:"grocery"') + parser.add_argument("--json", action="store_true", help="print the matching GeoJSON features") + return parser + + +def parse_latlng(text: str) -> LatLng: + lat, lng = (float(part) for part in text.split(",")) + return lat, lng + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + api = Woosmap(private_key_from_env()) + origin = parse_latlng(args.origin) if args.origin else api.geocode(args.address, args.country) + polygon = api.isochrone(origin, args.value, args.method, args.mode) + matches = stores_within(api, origin, polygon, args.query) + matches.sort(key=lambda f: f["properties"].get("distance", 0)) + if args.json: + print(json.dumps({"type": "FeatureCollection", "features": matches}, indent=2)) + else: + for store in map(summarise, matches): + print(f"{store['storeId']}\t{store['name']}\t{store['city']}\t{store['distance_m']}") + print(f"{len(matches)} stores within the isochrone", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/isochrone-stores/python/test_stores_within_isochrone.py b/isochrone-stores/python/test_stores_within_isochrone.py new file mode 100644 index 0000000..2f19029 --- /dev/null +++ b/isochrone-stores/python/test_stores_within_isochrone.py @@ -0,0 +1,122 @@ +import pytest +import responses +import stores_within_isochrone as mod + +# Google's documented polyline example +EXAMPLE_POLYLINE = "_p~iF~ps|U_ulLnnqC_mqNvxq`@" +SQUARE = [(48.0, 2.0), (48.0, 3.0), (49.0, 3.0), (49.0, 2.0)] + + +def test_decode_polyline_matches_reference_points(): + assert mod.decode_polyline(EXAMPLE_POLYLINE) == [ + (38.5, -120.2), + (40.7, -120.95), + (43.252, -126.453), + ] + + +def test_haversine_paris_to_london_is_about_344_km(): + assert mod.haversine_m((48.8566, 2.3522), (51.5074, -0.1278)) == pytest.approx( + 343_500, rel=0.01 + ) + + +def test_point_in_polygon_inside_and_outside(): + assert mod.point_in_polygon((48.5, 2.5), SQUARE) + assert not mod.point_in_polygon((47.5, 2.5), SQUARE) + assert not mod.point_in_polygon((48.5, 3.5), SQUARE) + + +def test_covering_radius_wraps_the_farthest_vertex(): + origin = (48.5, 2.5) + radius = mod.covering_radius_m(origin, SQUARE) + farthest = max(mod.haversine_m(origin, v) for v in SQUARE) + assert radius > farthest + + +def feature(store_id, lat, lng, distance=0): + return { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [lng, lat]}, + "properties": {"store_id": store_id, "name": store_id, "distance": distance}, + } + + +@responses.activate +def test_stores_within_keeps_only_features_inside_the_polygon(): + responses.get( + f"{mod.API_URL}/stores/search", + json={"features": [feature("in", 48.5, 2.5), feature("out", 47.0, 2.5)], "pagination": {}}, + ) + matches = mod.stores_within(mod.Woosmap("k"), (48.5, 2.5), SQUARE, 'type:"grocery"') + assert [f["properties"]["store_id"] for f in matches] == ["in"] + params = responses.calls[0].request.params + assert params["query"] == 'type:"grocery"' + assert params["stores_by_page"] == "300" + + +@responses.activate +def test_geocode_returns_first_result_location(): + responses.get( + f"{mod.API_URL}/localities/geocode/", + json={"results": [{"geometry": {"location": {"lat": 48.8, "lng": 2.3}}}]}, + ) + assert mod.Woosmap("k").geocode("Paris", "fr") == (48.8, 2.3) + assert responses.calls[0].request.params["components"] == "country:fr" + + +@responses.activate +def test_geocode_without_results_raises(): + responses.get(f"{mod.API_URL}/localities/geocode/", json={"results": []}) + with pytest.raises(RuntimeError, match="no geocoding result"): + mod.Woosmap("k").geocode("nowhere", None) + + +@responses.activate +def test_distance_api_errors_carry_the_status_despite_http_200(): + responses.get( + f"{mod.API_URL}/distance/isochrone/json/", + json={ + "status": "INVALID_REQUEST", + "message": "value: Input should be less than or equal to 120", + }, + ) + with pytest.raises(RuntimeError, match="INVALID_REQUEST: value"): + mod.Woosmap("k").isochrone((48.8, 2.3), 300, "time", "driving") + + +@responses.activate +def test_stores_search_has_no_status_field_and_still_works(): + responses.get(f"{mod.API_URL}/stores/search", json={"features": [], "pagination": {}}) + assert mod.Woosmap("k").stores_around((48.8, 2.3), 1000, None) == [] + + +@responses.activate +def test_isochrone_decodes_the_isoline_geometry(): + responses.get( + f"{mod.API_URL}/distance/isochrone/json/", + json={"status": "OK", "isoline": {"geometry": EXAMPLE_POLYLINE}}, + ) + polygon = mod.Woosmap("k").isochrone((38.5, -120.2), 20, "time", "driving") + assert polygon[0] == (38.5, -120.2) + assert responses.calls[0].request.params["origin"] == "38.5,-120.2" + + +@responses.activate +def test_main_prints_matching_stores(capsys, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + responses.get( + f"{mod.API_URL}/distance/isochrone/json/", + json={"isoline": {"geometry": EXAMPLE_POLYLINE}}, + ) + responses.get( + f"{mod.API_URL}/stores/search", + json={ + "features": [feature("far", 0.0, 0.0, 10), feature("near", 40.0, -122.0, 5)], + "pagination": {}, + }, + ) + assert mod.main(["--origin", "40.0,-122.0", "--value", "30"]) == 0 + out = capsys.readouterr().out + assert out.startswith("near\t") + assert "far" not in out diff --git a/java-samples/distance-api-client/README.md b/java-samples/distance-api-client/README.md deleted file mode 100644 index 3e7fa19..0000000 --- a/java-samples/distance-api-client/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Woosmap Distance API Client - -This is a simple Java client to interact with the Woosmap Distance API. - -## Prerequisites - -- Java 11 or later -- Maven - -## Setup - -1. Clone the repository. -2. Replace `YOUR_WOOSMAP_API_KEY` in `WoosmapDistanceApiClient.java` with your actual Woosmap Private API key. - -## Build and Run - -```sh -mvn clean install -mvn exec:java -Dexec.mainClass="com.example.WoosmapDistanceApiClient" -``` - -## Example - -The client sends a request to the Woosmap Distance API to calculate the distance and duration between Paris and multiple -destinations. Example of output: - -```shell -[INFO] --- exec:3.0.0:java (default-cli) @ woosmap-distance-api-client --- -Origin 1: - Destination 1: - Distance: 24090.0 meters (24.1 km) - Duration: 1652.0 seconds (28 mins) - Destination 2: - Distance: 15880.0 meters (15.9 km) - Duration: 1095.0 seconds (18 mins) - Destination 3: - Distance: 153263.0 meters (153 km) - Duration: 7331.0 seconds (2 hours 2 mins) -[INFO] ------------------------------------------------------------------------ -``` - -## Dependencies - -- Jackson Databind for JSON parsing \ No newline at end of file diff --git a/jsfiddle-samples/driving-directions/README.MD b/jsfiddle-samples/driving-directions/README.MD deleted file mode 100644 index ffdd1be..0000000 --- a/jsfiddle-samples/driving-directions/README.MD +++ /dev/null @@ -1,5 +0,0 @@ - - In order to view this demo on JSFiddle, open this URL: - https://fiddle.jshell.net/gh/get/library/pure/woosmap/samples/tree/master/jsfiddle-samples/driving-directions/ - - \ No newline at end of file diff --git a/jsfiddle-samples/driving-directions/demo.css b/jsfiddle-samples/driving-directions/demo.css deleted file mode 100755 index ef2211a..0000000 --- a/jsfiddle-samples/driving-directions/demo.css +++ /dev/null @@ -1,571 +0,0 @@ -#my-map { - height: 500px; -} - -* { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -input { - font-size: inherit; - line-height: normal; -} - -#directions-box { - position: absolute; - max-height: 100%; - top: 10px; - left: 10px; - max-width: 350px; - min-width: 250px; - width: 40%; -} - -#inputs, -#errors, -#directions { - width: 100% -} - -#inputs { - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); - display: none; - background-color: white; -} - -#directions { - display: none; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); - background-color: white; - margin-top: 4px; - max-height: 470px; -} - -#directions-travel-mode-selector { - position: relative; - height: 40px; -} - -#directions-travel-mode-selector .woosmap-form-label { - background-color: #F7F7F7; -} - -#errors { - z-index: 8; - opacity: 0; - padding: 10px; - border-radius: 0 0 3px 3px; - background: rgba(0, 0, 0, .25); - top: 90px; - left: 10px; -} - -/* Basics */ - -.woosmap-directions-inputs, -.woosmap-directions-errors, -.woosmap-directions-routes, -.woosmap-directions-instructions { - font: 300 15px/20px 'Helvetica Neue', Arial, Helvetica, sans-serif; -} - -/* Inputs */ - -.woosmap-directions-origin, -.woosmap-directions-destination { - /*background-color: white;*/ - position: relative; -} - -.woosmap-form-label { - cursor: pointer; - position: absolute; - left: 0; - top: 0; - background: #444; - color: rgba(0, 0, 0, .75); - font-weight: bold; - text-align: center; - padding: 10px; - line-height: 20px; -} - -.woosmap-directions-origin .woosmap-form-label { - background-color: #1badee; -} - -.woosmap-travel-mode-option { - cursor: pointer; - top: 0; - color: rgba(0, 0, 0, .75); - text-align: center; - padding: 10px; - line-height: 20px; -} - -.woosmap-directions-inputs input { - width: 100%; - border: 0; - background-color: transparent; - height: 40px; - margin: 0; - color: rgba(0, 0, 0, .5); - padding: 10px 10px 10px 50px; - font-weight: 300; -} - -.woosmap-directions-inputs input:focus { - color: rgba(0, 0, 0, .75); - outline: 0; - box-shadow: none; - outline: thin dotted; -} - -.woosmap-directions-origin input { - border-top: 1px solid rgba(0, 0, 0, .1); -} - -.woosmap-directions-destination input { - border-top: 1px solid rgba(0, 0, 0, .1); -} - -.woosmap-directions-reverse-input { - position: absolute; - background: white; - left: 50px; - top: 70px; - cursor: pointer; -} - -.woosmap-directions-inputs .woosmap-close-icon { - opacity: .5; - position: absolute; - right: 5px; - top: 10px; - cursor: pointer; -} - -input:not(:valid) + .woosmap-close-icon { - display: none; -} - -.woosmap-close-icon:hover { - opacity: .75; -} - -/* Errors */ - -.woosmap-directions-error { - color: white; - display: inline-block; - padding: 0 5px; -} - -/* Routes */ - -.woosmap-route-container { - padding: 6px; - box-shadow: -1px 5px 10px -4px #aaa6a0; -} - -.woosmap-route-info-container { - border-bottom: 1px solid rgb(230, 230, 230); -} - -.woosmap-route-info-container.hide { - border-bottom: none; -} - -.woosmap-route-short-duration { - color: rgb(152, 152, 152); - font-size: small -} - -.woosmap-route-traffic-duration { - color: #1badee; -} - -.woosmap-route-details { - font-size: small; - color: #3b8bba; - margin-bottom: 10px; -} - -.woosmap-route-details:hover { - text-decoration: underline; - cursor: pointer; -} - -.route-summary-upper-panel.hide { - cursor: pointer; -} - -/* Mail View */ - -.woosmap-mail-container { - padding: 6px; - box-shadow: -1px 5px 10px -4px #aaa6a0; -} - -.woosmap-mobile-form-menu { - cursor: pointer; - margin: 2px 0px 3px -3px; -} - -.woosmap-mobile-icon { - background-image: url('https://developers.woosmap.com/img/mobile-icon.png'); - -webkit-background-size: 20px 20px; - background-size: 20px 20px; - background-repeat: no-repeat; - content: ''; - display: inline-block; - vertical-align: top; - width: 20px; - height: 20px; - cursor: pointer; - margin-top: 1px; - background-color: white; -} - -.woosmap-mobile-link-container { - float: right; - margin: 3px; -} - -/* Instructions */ - -.instructions-header { - background-color: #1badee; -} - -#instructions-steps { - overflow-y: scroll; - overflow-x: hidden; - max-height: 445px; - padding-left: 5px; - padding-right: 5px; - font-size: 13px; -} - -.woosmap-instructions-title { - vertical-align: middle; - padding-left: 10px; - color: white; -} - -.close-instructions-button { - background-image: url('https://developers.woosmap.com/img/close-x.png'); - cursor: pointer; - background-color: #1badee; - width: 24px; - height: 24px; - float: right; -} - -.woosmap-directions-steps { - position: relative; - list-style: none; - margin: 0; - padding: 0; -} - -.woosmap-directions-step { - position: relative; - color: rgba(255, 255, 255, .75); - cursor: pointer; - padding: 20px 20px 20px 40px; - font-size: 20px; - line-height: 25px; -} - -.woosmap-directions-step-distance { - color: rgba(255, 255, 255, .5); - position: absolute; - padding: 5px 10px; - font-size: 12px; - left: 30px; - bottom: -15px; -} - -.woosmap-directions-step:hover { - color: white; -} - -.woosmap-directions-step:after { - content: ""; - position: absolute; - top: 50px; - bottom: -20px; - border-left: 2px dotted rgba(255, 255, 255, .2); - left: 20px; -} - -.woosmap-directions-step:last-child:after, -.woosmap-directions-step:last-child .woosmap-directions-step-distance { - display: none; -} - -/* icons */ - -.woosmap-geolocation-icon { - background-image: url('https://developers.woosmap.com/img/location.png'); - -webkit-background-size: 280px 20px; - background-size: 20px 20px; - background-repeat: no-repeat; - margin: 0; - content: ''; - display: inline-block; - vertical-align: top; - width: 20px; - height: 20px; -} - -.woosmap-travel-mode { - padding: 0 0 0 40px; - background: white; - text-align: center; -} - -.woosmap-directions-icon { - background-image: url('https://developers.woosmap.com/img/woosmap.directions.png'); - -webkit-background-size: 280px 20px; - background-size: 280px 20px; - background-repeat: no-repeat; - margin: 0; - content: ''; - display: inline-block; - vertical-align: top; - width: 20px; - height: 20px; -} - -.woosmap-directions-instructions .woosmap-directions-icon { - position: absolute; - left: 10px; - top: 25px; - margin: auto; -} - -.woosmap-depart-icon { - background-position: -160px 0; -} - -.woosmap-arrive-icon { - background-position: -200px 0; -} - -.woosmap-close-icon { - background-position: -220px 0; -} - -.woosmap-reverse-icon { - background-position: -240px 0; -} - -.woosmap-travel-mode-option { - background-color: white; -} - -.woosmap-travel-mode-icon { - background-image: url('https://developers.woosmap.com/img/driving-sprite.png'); - -webkit-background-size: 20px 276px; - background-size: 20px 276px; - background-repeat: no-repeat; - margin: 0; - content: ''; - display: inline-block; - vertical-align: top; - width: 20px; - height: 20px; -} - -.woosmap-driving-icon { - background-position: 0 -40px; -} - -.woosmap-walking-icon { - background-position: 0 -120px; -} - -.woosmap-bicycling-icon { - background-position: 0 -160px; -} - -.selected .woosmap-driving-icon { - background-position: 0 -60px; -} - -.selected .woosmap-walking-icon { - background-position: 0 -140px; -} - -.selected .woosmap-bicycling-icon { - background-position: 0 -180px; -} - -.woosmap-travel-mode-option.selected { - box-shadow: inset 0 -2px 0px 0px #3983de; -} - -/*------------override google maps style----------*/ -.adp-warnbox { - display: none; -} - -.adp-placemark { - border: none; - background: #FFF; -} - -#adp-placemark, .adp-placemark { - font-weight: bold !important; -} - -.adp-substep { - max-width: 153px; -} - -#directions .adp-placemark img { - width: 30px; - height: 44px; - margin-right: 5px; -} - -#directions #adp-placemark img { - content: url('https://developers.woosmap.com/img/markers/start.png') !important; -} - -#directions .adp-placemark:last-child img { - content: url('https://developers.woosmap.com/img/markers/end.png') !important; -} - -#map-container { - position: relative; -} - -/*grids*/ -.pure-g { - letter-spacing: -.31em; - text-rendering: optimizespeed; - display: -webkit-flex; - -webkit-flex-flow: row wrap; - display: -ms-flexbox; - -ms-flex-flow: row wrap; - -ms-align-content: flex-start; - -webkit-align-content: flex-start; - align-content: flex-start; -} - -.pure-g [class *="pure-u"] { - font-family: "Open Sans", "Helvetica Neue", Arial, Helvetica, Verdana, sans-serif; - font-weight: normal; - letter-spacing: 0.01em; -} - -.pure-u-1, .pure-u-1-3 { - display: inline-block; - zoom: 1; - letter-spacing: normal; - word-spacing: normal; - vertical-align: top; - text-rendering: auto; -} - -.pure-u-1 { - width: 100%; -} - -.pure-u-1-3 { - width: 33.3333%; - *width: 33.3023% -} - -.pure-form input[type=text], .pure-form input[type=email] { - padding: .5em .6em; - display: inline-block; - border: 1px solid #ccc; - box-shadow: inset 0 1px 3px #ddd; - border-radius: 4px; - vertical-align: middle; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box -} - -.pure-form input:not([type]) { - padding: .5em .6em; - display: inline-block; - border: 1px solid #ccc; - box-shadow: inset 0 1px 3px #ddd; - border-radius: 4px; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box -} - -.pure-form input[type=text]:focus, .pure-form input[type=email]:focus { - outline: 0; - border-color: #129FEA -} - -.pure-form input:not([type]):focus { - outline: 0; - border-color: #129FEA -} - -.pure-form input[type=text][disabled], .pure-form input[type=email][disabled] { - cursor: not-allowed; - background-color: #eaeded; - color: #cad2d3 -} - -.pure-form input:not([type])[disabled] { - cursor: not-allowed; - background-color: #eaeded; - color: #cad2d3 -} - -.pure-form input[readonly] { - background-color: #eee; - color: #777; - border-color: #ccc -} - -.pure-form input:focus:invalid { - color: #b94a48; - border-color: #e9322d -} - -.pure-form label { - margin: .5em 0 .2em -} - -@media only screen and (max-width: 480px) { - .pure-form button[type=submit] { - margin: .7em 0 0 - } - - .pure-form input:not([type]), .pure-form input[type=text], .pure-form input[type=email], .pure-form label { - margin-bottom: .3em; - display: block - } - - .pure-group input:not([type]), .pure-group input[type=text], .pure-group input[type=email] { - margin-bottom: 0 - } -} - -::-webkit-scrollbar { - width: 5px; - height: 5px; -} - -::-webkit-scrollbar-thumb { - background-color: #d1d1d1; -} - -::-webkit-scrollbar-track { - background-color: #F7F7F7; -} \ No newline at end of file diff --git a/jsfiddle-samples/driving-directions/demo.details b/jsfiddle-samples/driving-directions/demo.details deleted file mode 100755 index 2c8529e..0000000 --- a/jsfiddle-samples/driving-directions/demo.details +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: DrivingDirections - Woosmap Javascript API Driving Directions Demo -description: jsFiddle demo that allow the use of Google maps Driving Directions using Woosmap Javascript API. -authors: - - Woosmap DevTeam -... \ No newline at end of file diff --git a/jsfiddle-samples/driving-directions/demo.html b/jsfiddle-samples/driving-directions/demo.html deleted file mode 100755 index 78acc02..0000000 --- a/jsfiddle-samples/driving-directions/demo.html +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - -
-
-
-
-
\ No newline at end of file diff --git a/jsfiddle-samples/driving-directions/demo.js b/jsfiddle-samples/driving-directions/demo.js deleted file mode 100755 index 927fba9..0000000 --- a/jsfiddle-samples/driving-directions/demo.js +++ /dev/null @@ -1,257 +0,0 @@ -var projectKey = '12345678'; -var markersStyle = { - rules: [ - { - type: 'drive', - icon: {url: 'https://images.woosmap.com/marker_drive.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_drive_selected.svg', scaledSize: {width: 46, height: 60}} - } - ], - default: { - icon: {url: 'https://images.woosmap.com/marker_default.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_selected.svg', scaledSize: {width: 46, height: 60}} - } -}; -var tilesStyle = { - color: '#383838', - size: 11, - minSize: 6, - typeRules: [{ - type: 'drive', - color: '#82a859' - }] -}; - -//this function is called when loader finished the API loading -function woosmap_main() { - var loader = new woosmap.MapsLoader(); - var dataSource = new woosmap.DataSource(); - loader.load(function () { - - /******** usefull function ********/ - function closeRouteContainer() { - woosmap.$('.route-summary-upper-panel').addClass('hide'); - woosmap.$('.route-summary-lower-panel').hide(); - woosmap.$('.woosmap-route-details').hide(); - woosmap.$('.woosmap-route-info-container').addClass('hide'); - } - - function displayRouteContainer(container) { - var $container = woosmap.$(container); - $container.find('.route-summary-lower-panel').show(); - $container.find('.woosmap-route-details').show(); - $container.find('.woosmap-route-info-container').removeClass('hide'); - $container.find('.route-summary-upper-panel').removeClass('hide'); - } - - function makeMarker(position, icon, title) { - directionsMarkers.push(new google.maps.Marker({ - position: position, - map: map, - icon: icon, - title: title - })); - } - - function cleanMarker() { - woosmap.$.each(directionsMarkers, function (index, marker) { - marker.setMap(null); - }); - directionsMarkers = []; - } - - /*********************************/ - - var map = new google.maps.Map(woosmap.$('#my-map')[0], { - center: { - lat: 45, - lng: 2 - }, - zoom: 5, - disableDefaultUI: true - }); - - var mapView = new woosmap.TiledView(map, { - style: markersStyle, - tileStyle: tilesStyle - }); - - - /**** directions renderers options ****/ - var newPolylineOption = { - strokeColor: '#1badee', - strokeOpacity: 1.0, - strokeWeight: 4, - icons: ['https://developers.woosmap.com/img/markers/marker.png'] - }; - - // Start/Finish icons - var icons = { - start: 'https://developers.woosmap.com/img/markers/start.png', - end: 'https://developers.woosmap.com/img/markers/end.png' - }; - var directionRendererOptions = { - suppressMarkers: true, - suppressInfoWindows: true, - polylineOptions: newPolylineOption - }; - - var googleDirectionsRequestOptions = { - provideRouteAlternatives: true, - durationInTraffic: true - }; - /***************************************/ - - var directionsMarkers = []; - var navigatorGeolocation = new woosmap.location.LocationProvider(); - var travelModeSelector = new woosmap.ui.TravelModeSelector(woosmap.$('#travel-mode-selector-template').html()); - var originDestinationInput = new woosmap.ui.OriginDestinationInput(woosmap.$('#directions-origin-destination-template').html(), { - 'geolocText': 'Ma Position' - }); - var directionsProvider = new woosmap.location.DirectionsProvider(directionRendererOptions, googleDirectionsRequestOptions); - var mailView = new woosmap.ui.MailView(woosmap.$('#directions-mail-input-template').html()); - var locationProvider = new woosmap.location.LocationProvider(); - var store_id = ''; - var directionsRestorer = new woosmap.utils.MVCObject(); - directionsRestorer.location = null; - directionsRestorer.location_changed = function () { - var self = this; - if (store_id) { - dataSource.getStoreById(store_id, function (data) { - originDestinationInput.set('selectedStore', data); - originDestinationInput.set('location', self.get('location')); - }); - } - }; - - var directionsResultsDisplayer = new woosmap.ui.DirectionsResultsDisplayer(map, woosmap.$('#directions-summary-template').html(), - function () { - //this function is called when directionResultsDisplayer finished to display renderers - directionsResultsDisplayer.displayRouteOnMap(0); - directionsResultsDisplayer.displayRouteSteps(0); - woosmap.$("#directions").show(); - var computedDirections = directionsResultsDisplayer.get("directionsRenderers")[0].getDirections(); - var leg = computedDirections.routes[0].legs[0]; - cleanMarker(); - makeMarker(leg.start_location, icons.start, "Start"); - makeMarker(leg.end_location, icons.end, 'End'); - - closeRouteContainer(); - displayRouteContainer(woosmap.$('.woosmap-route-container')[0]); - - woosmap.$('.woosmap-route-container').click(function () { - closeRouteContainer(); - displayRouteContainer(this); - directionsResultsDisplayer.cleanMapFromRoutes(); - directionsResultsDisplayer.cleanRouteSteps(); - directionsResultsDisplayer.displayRouteOnMap(woosmap.$(this).find('.woosmap-show-steps').data('renderer-index')); - directionsResultsDisplayer.displayRouteSteps(woosmap.$(this).find('.woosmap-show-steps').data('renderer-index')); - }); - - woosmap.$('.woosmap-route-details').click(function () { - woosmap.$('#instructions').show(); - woosmap.$('#routes').hide(); - woosmap.$('#inputs').hide(); - woosmap.$('#instructions-mail').hide(); - woosmap.$('#directions').css('top', '5px'); - woosmap.$('#directions').css('bottom', '5px'); - }); - - woosmap.$('#close-instructions-button').click(function () { - woosmap.$('#instructions').hide(); - woosmap.$('#routes').show(); - woosmap.$('#inputs').show(); - woosmap.$('#instructions-mail').show(); - woosmap.$('#directions').css('top', '135px'); - woosmap.$('#directions').css('bottom', ''); - }); - } - ); - - originDestinationInput.bindTo('selectedStore', mapView); - directionsProvider.bindTo('selectedTravelMode', travelModeSelector); - directionsProvider.bindTo('originDestination', originDestinationInput); - directionsResultsDisplayer.bindTo('directionsSummaries', directionsProvider); - directionsResultsDisplayer.bindTo('directionsRenderers', directionsProvider); - directionsResultsDisplayer.bindTo('directionsLink', directionsProvider); - originDestinationInput.bindTo('location', navigatorGeolocation); - mailView.bindTo('selectedStore', mapView); - directionsRestorer.bindTo('location', locationProvider); - - function _update_mail_status(text, color) { - var $mailStatusDiv = woosmap.$('#mail-status'); - $mailStatusDiv.html(text).css('color', color); - $mailStatusDiv.show(); - setTimeout(function () { - $mailStatusDiv.hide(1000); - }, 3000); - } - - mailView.delegate = { - mailSent: function () { - _update_mail_status('Email envoyé', 'green'); - woosmap.$('.woosmap-mail-input').val(""); - }, - mailError: function () { - _update_mail_status('Erreur', 'red'); - }, - mailSending: function () { - _update_mail_status('Envoi en cours', '#1badee'); - } - }; - - woosmap.$('#map-container').append(originDestinationInput.getODContainer()); - woosmap.$('#routes').append(directionsResultsDisplayer.getRoutesContainer()); - woosmap.$('#instructions-steps').append(directionsResultsDisplayer.getStepsContainer()); - woosmap.$('#directions-travel-mode-selector').append(travelModeSelector.getSelectorContainer()); - - woosmap.$('#instructions-mail').empty().append(mailView.getContainer()); - - if (new woosmap.DeviceDetector().getDeviceType() == 'mobile') { - woosmap.$('.woosmap-mail-container').hide(); - } else { - woosmap.$('.woosmap-mobile-form-menu').click(function () { - woosmap.$('.woosmap-mobile-form').toggle(); - }); - } - - woosmap.$(".woosmap-directions-origin .woosmap-close-icon").click(function () { - woosmap.$("#woosmap-directions-origin-input").val(''); - }); - - woosmap.$(".woosmap-directions-destination .woosmap-close-icon").click(function () { - woosmap.$("#woosmap-directions-destination-input").val(''); - }); - - woosmap.$('#directions-travel-mode-selector .woosmap-travel-mode-option').click(function () { - woosmap.$('#directions-travel-mode-selector .woosmap-travel-mode-option').removeClass('selected'); - woosmap.$(this).addClass('selected'); - }); - - woosmap.$('.geolocation-button').click(function () { - navigatorGeolocation.askForLocation(navigator.geolocation); - }); - - google.maps.event.addListener(map, 'click', function (event) { - originDestinationInput.set('location', { - 'lat': event.latLng.lat(), - 'lng': event.latLng.lng() - }); - }); - window.setTimeout(function () { - store_id = top.location.search.split('store_id=')[1] ? top.location.search.split('store_id=')[1].replace('&', '') : ''; - if (store_id) { - dataSource.getStoreById(store_id, function (data) { - originDestinationInput.set('selectedStore', data); - originDestinationInput.set('location', self.get('location')); - }); - locationProvider.askForLocation(navigator.geolocation); - } - }, 1000); - woosmap.$("#inputs").show(); - }); -} - -document.addEventListener("DOMContentLoaded", function (event) { - WoosmapLoader.load('latest', projectKey, woosmap_main); -}); diff --git a/jsfiddle-samples/geolocation/README.MD b/jsfiddle-samples/geolocation/README.MD deleted file mode 100644 index a6f2de3..0000000 --- a/jsfiddle-samples/geolocation/README.MD +++ /dev/null @@ -1,5 +0,0 @@ - - In order to view this demo on JSFiddle, open this URL: - https://fiddle.jshell.net/gh/get/library/pure/woosmap/samples/tree/master/jsfiddle-samples/geolocation/ - - \ No newline at end of file diff --git a/jsfiddle-samples/geolocation/demo.css b/jsfiddle-samples/geolocation/demo.css deleted file mode 100755 index abdc8fb..0000000 --- a/jsfiddle-samples/geolocation/demo.css +++ /dev/null @@ -1,109 +0,0 @@ -.bg { - background: white; - padding: 5px; -} - -.nearest-store-map { - height: 250px; - width: 76%; - float: left -} - -.nearest-store-button { - width: 100%; -} - -.nearest-store-button-div { - float: right; - width: 23%; -} - -#closest-store-info { - border: 1px solid grey; - display: none; - width: 50%; -} - -#html5-geolocation-button { - width: 100%; - margin-top: 10px; -} - -#store-info { - margin-top: 52px; -} - -::-webkit-scrollbar { - width: 5px; - height: 5px; -} - -::-webkit-scrollbar-thumb { - background-color: #d1d1d1; -} - -::-webkit-scrollbar-track { - background-color: #F7F7F7; -} - -/*pure-css*/ - -.pure-button { - /* Structure */ - display: inline-block; - zoom: 1; - line-height: normal; - white-space: nowrap; - vertical-align: middle; - text-align: center; - cursor: pointer; - -webkit-user-drag: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -.pure-button { - font-family: inherit; - font-size: 100%; - padding: 0.5em 1em; - color: #444; /* rgba not supported (IE 8) */ - color: rgba(0, 0, 0, 0.80); /* rgba supported */ - border: 1px solid #999; /*IE 6/7/8*/ - border: none rgba(0, 0, 0, 0); /*IE9 + everything else*/ - background-color: #E6E6E6; - text-decoration: none; - border-radius: 2px; -} - -.pure-button-hover, -.pure-button:hover, -.pure-button:focus { - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#1a000000', GradientType=0); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(transparent), color-stop(40%, rgba(0, 0, 0, 0.05)), to(rgba(0, 0, 0, 0.10))); - background-image: -webkit-linear-gradient(transparent, rgba(0, 0, 0, 0.05) 40%, rgba(0, 0, 0, 0.10)); - background-image: -moz-linear-gradient(top, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0.10)); - background-image: -o-linear-gradient(transparent, rgba(0, 0, 0, 0.05) 40%, rgba(0, 0, 0, 0.10)); - background-image: linear-gradient(transparent, rgba(0, 0, 0, 0.05) 40%, rgba(0, 0, 0, 0.10)); -} - -.pure-button:focus { - outline: 0; -} - -.pure-button-active, -.pure-button:active { - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15) inset, 0 0 6px rgba(0, 0, 0, 0.20) inset; - border-color: #000 \9; -} - -hr { - border: 0; - height: 0; - border-top: 1px solid rgba(0, 0, 0, 0.1); - border-bottom: 1px solid rgba(255, 255, 255, 0.3); -} diff --git a/jsfiddle-samples/geolocation/demo.details b/jsfiddle-samples/geolocation/demo.details deleted file mode 100755 index 576b233..0000000 --- a/jsfiddle-samples/geolocation/demo.details +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: Geolocation - Woosmap Javascript API Geolocation Demo -description: jsFiddle demo that allow the geolocation of a user using Woosmap Javascript API. -authors: - - Woosmap DevTeam -... \ No newline at end of file diff --git a/jsfiddle-samples/geolocation/demo.html b/jsfiddle-samples/geolocation/demo.html deleted file mode 100755 index 5aa4ba1..0000000 --- a/jsfiddle-samples/geolocation/demo.html +++ /dev/null @@ -1,44 +0,0 @@ - - -
- -
-
-
-
- -
-
- -
-
-
-
-
-
- -

- - - ... -

-
- - -

- - -
\ No newline at end of file diff --git a/jsfiddle-samples/geolocation/demo.js b/jsfiddle-samples/geolocation/demo.js deleted file mode 100755 index ca99025..0000000 --- a/jsfiddle-samples/geolocation/demo.js +++ /dev/null @@ -1,118 +0,0 @@ -var projectKey = '12345678'; -var markersStyle = { - rules: [ - { - type: 'drive', - icon: {url: 'https://images.woosmap.com/marker_drive.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_drive_selected.svg', scaledSize: {width: 46, height: 60}} - } - ], - default: { - icon: {url: 'https://images.woosmap.com/marker_default.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_selected.svg', scaledSize: {width: 46, height: 60}} - } -}; -var tilesStyle = { - color: '#383838', - size: 11, - minSize: 6, - typeRules: [{ - type: 'drive', - color: '#82a859' - }] -}; - -//this function is called when loader finished the API loading -function woosmap_main() { - var loader = new woosmap.MapsLoader(); - var dataSource = new woosmap.DataSource(); - loader.load(function () { - var map = new google.maps.Map(woosmap.$('#nearest-store-map')[0], { - center: {lat: 46, lng: 3}, - zoom: 5 - }); - var mapView = new woosmap.TiledView(map, {style: markersStyle, tileStyle: tilesStyle}); - - var locationProvider = new woosmap.location.LocationProvider(); - var locationProviderMap = new woosmap.location.LocationProvider(); - var zipCodeProvider = new woosmap.ZipCodeProvider(); - var zipCodeWatcher = new woosmap.utils.MVCObject(); - - var template = "{{name}}
{{address.zipcode}} {{address.city}}
{{contact.phone}}
{{distance}} km"; - var storesInformationTemplateRenderer = new woosmap.TemplateRenderer(template); - var storesInformationDisplayer = new woosmap.utils.MVCObject(); - storesInformationDisplayer.stores = null; - storesInformationDisplayer.stores_changed = function () { - var properties = this.get('stores')[0].properties; - properties.distance = Math.round(properties.distance / 1000); - woosmap.$('#store-info').html(storesInformationTemplateRenderer.render(properties)); - }; - storesInformationDisplayer.bindTo('stores', mapView); - - var nearbyStoresSource = new woosmap.location.NearbyStoresSource(dataSource, 1); - nearbyStoresSource.bindTo('stores', mapView); - mapView.bindTo('location', locationProviderMap); - - mapView.marker.setOptions({ - draggable: true, - icon: {url: 'https://developers.woosmap.com/img/markers/geolocated.png'} - }); - - nearbyStoresSource.bindTo('location', locationProviderMap); - zipCodeProvider.bindTo('location', locationProvider); - zipCodeWatcher.bindTo('zipcode', zipCodeProvider); - - zipCodeWatcher.zipcode_changed = function () { - woosmap.$('#geoloc-zipcode-result').html(zipCodeProvider.getZipCode()); - }; - - woosmap.$("#geoloc-zipcode-ip").click(function () { - woosmap.$('#geoloc-zipcode-result').html('looking for ...'); - woosmap.$('#geoloc-zipcode-result').html(zipCodeProvider.getZipCode()); - }); - woosmap.$("#geoloc-zipcode-optin").click(function () { - locationProvider.askForLocation(navigator.geolocation); - }); - - woosmap.$("#ip-geolocation-button").click(function () { - locationProviderMap.askForLocation(); - }); - - woosmap.$("#html5-geolocation-button").click(function () { - locationProviderMap.askForLocation(navigator.geolocation); - }); - - /*---------- DistanceProvider --------------*/ - var template = woosmap.$('#closest-store-template').html(); - var anotherLocationProvider = new woosmap.location.LocationProvider(); - var anotherNearbyStoresSource = new woosmap.location.NearbyStoresSource(dataSource, 10); - var distanceProvider = new woosmap.location.DistanceProvider(); - var closestStoreTemplateRenderer = new woosmap.TemplateRenderer(template); - var closestStoreDisplayer = new woosmap.utils.MVCObject(); - closestStoreDisplayer.stores = null; - closestStoreDisplayer.stores_changed = function () { - distanceProvider.updateStoresDistanceWithGoogle(this.get('stores'), function (updated_stores) { - var $storesDiv = woosmap.$('#closest-store-info'); - var store_properties = updated_stores[0].properties; - store_properties.distance = store_properties.distance / 1000; - store_properties.duration = Math.round(store_properties.duration / 60); - $storesDiv.html(closestStoreTemplateRenderer.render(store_properties)); - $storesDiv.show(); - }, 'duration'); - }; - - - closestStoreDisplayer.bindTo('stores', anotherNearbyStoresSource); - anotherNearbyStoresSource.bindTo('location', anotherLocationProvider); - distanceProvider.bindTo('location', anotherLocationProvider); - woosmap.$('#update-stores-distance').click(function () { - anotherLocationProvider.askForLocation(navigator.geolocation); - }); - - - }); -} - -document.addEventListener("DOMContentLoaded", function (event) { - WoosmapLoader.load('latest', projectKey, woosmap_main); -}); diff --git a/jsfiddle-samples/locator-map/README.MD b/jsfiddle-samples/locator-map/README.MD deleted file mode 100644 index edacb11..0000000 --- a/jsfiddle-samples/locator-map/README.MD +++ /dev/null @@ -1,5 +0,0 @@ - - In order to view this demo on JSFiddle, open this URL: - https://fiddle.jshell.net/gh/get/library/pure/woosmap/samples/tree/master/jsfiddle-samples/locator-map/ - - \ No newline at end of file diff --git a/jsfiddle-samples/locator-map/demo.css b/jsfiddle-samples/locator-map/demo.css deleted file mode 100755 index 5dc374d..0000000 --- a/jsfiddle-samples/locator-map/demo.css +++ /dev/null @@ -1,141 +0,0 @@ -#my-map { - height: 500px; -} - -.locator-container { - font-size: 15px; - line-height: 1.5em; - color: #555; -} - -.sidebar { - height: 500px; - overflow: hidden; - border: 1px solid #EEE; - border-right: none; -} - -.locator-heading { - background: #fff; - border-bottom: 1px solid #eee; - height: 36px; - line-height: 36px; - padding: 0 10px; -} - -.locator-heading h2 { - font-size: 20px; - margin: 0; - font-weight: 400; - color: #333; -} - -.listings { - height: 460px; - padding-bottom: 36px; - background-color: #FFF; - color:#555; -} - -.woosmap-tableview-container, .card_container { - max-height: 100%; - overflow-y: scroll; - overflow-x: hidden; -} - -.item { - display: block; - border-bottom: 1px solid #eee; - padding: 10px; - text-decoration: none; - cursor: pointer; -} - -.item .title { - display: block; - color: #4d9da9; - font-weight: 500; -} - -.item .title small { - font-weight: 300; -} - -.quiet { - color: #888; -} -small { - font-size: 80%; -} - -.selected_card .item .title, -.item .title:hover { - color: #1badee; -} - -.item.active { - background-color: #f8f8f8; -} - -.selected_card { - background-color: #f8f8f8; -} - -.selected_card:hover { - background-color: #f8f8f8; -} - -::-webkit-scrollbar { - width: 5px; - height: 5px; -} - -::-webkit-scrollbar-thumb { - background-color: #d1d1d1; -} - -::-webkit-scrollbar-track { - background-color: #F7F7F7; -} - -/*grids*/ -.pure-g { - letter-spacing: -.31em; - text-rendering: optimizespeed; - display: -webkit-flex; - -webkit-flex-flow: row wrap; - display: -ms-flexbox; - -ms-flex-flow: row wrap; - -ms-align-content: flex-start; - -webkit-align-content: flex-start; - align-content: flex-start; -} - -.pure-g [class *="pure-u"] { - font-family: "Open Sans", "Helvetica Neue", Arial, Helvetica, Verdana, sans-serif; - font-weight: normal; - letter-spacing: 0.01em; -} - -.pure-u-1, .u-sm-1-3, .u-sm-2-3 { - display: inline-block; - zoom: 1; - letter-spacing: normal; - word-spacing: normal; - vertical-align: top; - text-rendering: auto; -} - -.pure-u-1 { - width: 100%; -} - -@media screen and (min-width: 35.5em) { - .u-sm-1-3 { - width: 33.3333%; - } - - .u-sm-2-3 { - width: 66.5%; - } -} \ No newline at end of file diff --git a/jsfiddle-samples/locator-map/demo.details b/jsfiddle-samples/locator-map/demo.details deleted file mode 100755 index 4eba68a..0000000 --- a/jsfiddle-samples/locator-map/demo.details +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: LocatorMap - Woosmap Javascript API Locator Map Demo -description: jsFiddle demo that display a more complete store locator map using Woosmap Javascript API. -authors: - - Woosmap DevTeam -... \ No newline at end of file diff --git a/jsfiddle-samples/locator-map/demo.html b/jsfiddle-samples/locator-map/demo.html deleted file mode 100755 index 5c7d7e8..0000000 --- a/jsfiddle-samples/locator-map/demo.html +++ /dev/null @@ -1,10 +0,0 @@ - -
- -
-
\ No newline at end of file diff --git a/jsfiddle-samples/locator-map/demo.js b/jsfiddle-samples/locator-map/demo.js deleted file mode 100755 index 80bda6a..0000000 --- a/jsfiddle-samples/locator-map/demo.js +++ /dev/null @@ -1,58 +0,0 @@ -var projectKey = '12345678'; -var markersStyle = { - rules: [ - { - type: 'drive', - icon: {url: 'https://developers.woosmap.com/img/markers/marker_drive.png', scaledSize: {width: 46/2, height: 58/2}}, - selectedIcon: {url: 'https://developers.woosmap.com/img/markers/marker_selected.png', scaledSize: {width: 46, height: 58}} - } - ], - default: { - icon: {url: 'https://developers.woosmap.com/img/markers/marker_default.png', scaledSize: {width: 46/2, height: 58/2}}, - selectedIcon: {url: 'https://developers.woosmap.com/img/markers/marker_selected.png', scaledSize: {width: 46, height: 58}} - } -}; -var tilesStyle = { - color: '#383838', - size: 11, - minSize: 6, - typeRules: [{ - type: 'drive', - color: '#82a859' - }] -}; - - -/*----- Init and display a Map with a TiledLayer-----*/ -function woosmap_main() { - var self = this; - var loader = new woosmap.MapsLoader(); - var dataSource = new woosmap.DataSource(); - loader.load(function () { - var tableview = new woosmap.ui.TableView({ - cell: '
' + - '{{name}}
{{address.city}}
' + - '
{{address.lines}} {{address.city}} {{address.zip}}
' - }); - - var listings = woosmap.$('#listings'); - listings.append(tableview.getContainer()); - self.tableview = tableview; - var map = new google.maps.Map(woosmap.$('#my-map')[0], { - center: {lat: 46, lng: 3}, - zoom: 5 - }); - var mapView = new woosmap.TiledView(map, {style: markersStyle, tileStyle: tilesStyle}); - mapView.bindTo('stores', tableview, 'stores', false); - mapView.bindTo('selectedStore', tableview, 'selectedStore', false); - - dataSource.getAllStores(function (stores) { - tableview.set('stores', stores.features); - }); - }); - -} - -document.addEventListener("DOMContentLoaded", function (event) { - WoosmapLoader.load('1.2', projectKey, woosmap_main); -}); \ No newline at end of file diff --git a/jsfiddle-samples/map-infowindow/README.MD b/jsfiddle-samples/map-infowindow/README.MD deleted file mode 100644 index 622c76f..0000000 --- a/jsfiddle-samples/map-infowindow/README.MD +++ /dev/null @@ -1,5 +0,0 @@ - - In order to view this demo on JSFiddle, open this URL: - https://fiddle.jshell.net/gh/get/library/pure/woosmap/samples/tree/master/jsfiddle-samples/map-infowindow/ - - \ No newline at end of file diff --git a/jsfiddle-samples/map-infowindow/demo.css b/jsfiddle-samples/map-infowindow/demo.css deleted file mode 100755 index db1ac67..0000000 --- a/jsfiddle-samples/map-infowindow/demo.css +++ /dev/null @@ -1,30 +0,0 @@ -#my-map { - height: 400px; - width: 100%; -} - -.info-item { - line-height: 2; - display: block; - overflow: hidden; - white-space: nowrap; - text-decoration: none; - color: #555; -} - -.info-item .title { - display: block; - color: #4d9da9; - font-weight: 500; - background: url('https://developers.woosmap.com/img/Punaise_WGS_V3.png') no-repeat top left; - background-size: auto 58px; - padding-left: 70px; -} - -.quiet { - color: #888; -} - -small { - font-size: 80%; -} \ No newline at end of file diff --git a/jsfiddle-samples/map-infowindow/demo.details b/jsfiddle-samples/map-infowindow/demo.details deleted file mode 100755 index 59fb249..0000000 --- a/jsfiddle-samples/map-infowindow/demo.details +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: MapInfowindow - Woosmap Javascript API MapInfowindow Demo -description: jsFiddle demo that display a TiledView of a sample datasource using Woosmap Javascript API and allow to click location to open InfoWindow. -authors: - - Woosmap DevTeam -... \ No newline at end of file diff --git a/jsfiddle-samples/map-infowindow/demo.html b/jsfiddle-samples/map-infowindow/demo.html deleted file mode 100755 index 7b9a542..0000000 --- a/jsfiddle-samples/map-infowindow/demo.html +++ /dev/null @@ -1,2 +0,0 @@ - -
diff --git a/jsfiddle-samples/map-infowindow/demo.js b/jsfiddle-samples/map-infowindow/demo.js deleted file mode 100755 index 30ae2ca..0000000 --- a/jsfiddle-samples/map-infowindow/demo.js +++ /dev/null @@ -1,50 +0,0 @@ -var projectKey = '12345678'; -var markersStyle = { - rules: [ - { - type: 'drive', - icon: {url: 'https://images.woosmap.com/marker_drive.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_drive_selected.svg', scaledSize: {width: 46, height: 60}} - } - ], - default: { - icon: {url: 'https://images.woosmap.com/marker_default.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_selected.svg', scaledSize: {width: 46, height: 60}} - } -}; -var tilesStyle = { - color: '#383838', - size: 11, - minSize: 6, - typeRules: [{ - type: 'drive', - color: '#82a859' - }] -}; - -/*----- Init and display a Map with a TiledLayer-----*/ -function woosmap_main() { - var loader = new woosmap.MapsLoader(); - var dataSource = new woosmap.DataSource(); - loader.load(function () { - var map = new google.maps.Map(woosmap.$('#my-map')[0], { - center: {lat: 46, lng: 3}, - zoom: 5 - }); - var template = '
' + - '{{name}}
{{address.city}}
' + - '
{{address.lines}} {{address.city}} {{address.zip}}
' + - '
'; - - var renderer = new woosmap.TemplateRenderer(template); - var win = new woosmap.LocatorWindow(map, renderer); - - var mapView = new woosmap.TiledView(map, {style: markersStyle, tileStyle: tilesStyle}); - win.bindTo('selectedStore', mapView); - }); - -} - -document.addEventListener("DOMContentLoaded", function (event) { - WoosmapLoader.load('1.2', projectKey, woosmap_main); -}); diff --git a/jsfiddle-samples/map-tiled-view/README.MD b/jsfiddle-samples/map-tiled-view/README.MD deleted file mode 100644 index 82a243a..0000000 --- a/jsfiddle-samples/map-tiled-view/README.MD +++ /dev/null @@ -1,5 +0,0 @@ - - In order to view this demo on JSFiddle, open this URL: - https://fiddle.jshell.net/gh/get/library/pure/woosmap/samples/tree/master/jsfiddle-samples/map-tiled-view/ - - \ No newline at end of file diff --git a/jsfiddle-samples/map-tiled-view/demo.css b/jsfiddle-samples/map-tiled-view/demo.css deleted file mode 100755 index e59dbad..0000000 --- a/jsfiddle-samples/map-tiled-view/demo.css +++ /dev/null @@ -1,21 +0,0 @@ -#my-map { - height: 400px; - width: 100%; -} - -#go-to-paris { - padding: 8px; - border-style: none; - border-radius: 2px; - box-shadow: rgba(0, 0, 0, 0.298039) 0 1px 4px -1px; - background-color: rgb(255, 255, 255); - cursor: pointer; -} - -.btn-container { - margin: 18px; - z-index: 0; - position: absolute; - right: 0; - top: 0; -} diff --git a/jsfiddle-samples/map-tiled-view/demo.details b/jsfiddle-samples/map-tiled-view/demo.details deleted file mode 100755 index c39ecf0..0000000 --- a/jsfiddle-samples/map-tiled-view/demo.details +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: BasicTiledView - Woosmap Javascript API TiledView Demo -description: jsFiddle demo that display a TiledView of a sample datasource using Woosmap Javascript API. -authors: - - Woosmap DevTeam -... \ No newline at end of file diff --git a/jsfiddle-samples/map-tiled-view/demo.html b/jsfiddle-samples/map-tiled-view/demo.html deleted file mode 100755 index 1b66a33..0000000 --- a/jsfiddle-samples/map-tiled-view/demo.html +++ /dev/null @@ -1,5 +0,0 @@ - -
-
- -
diff --git a/jsfiddle-samples/map-tiled-view/demo.js b/jsfiddle-samples/map-tiled-view/demo.js deleted file mode 100755 index f5db2f5..0000000 --- a/jsfiddle-samples/map-tiled-view/demo.js +++ /dev/null @@ -1,77 +0,0 @@ -var projectKey = '12345678'; -var markersStyle = { - rules: [ - { - type: 'drive', - icon: {url: 'https://developers.woosmap.com/img/markers/marker_drive.png', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://developers.woosmap.com/img/markers/marker_selected.png', scaledSize: {width: 46, height: 60}} - } - ], - default: { - icon: {url: 'https://developers.woosmap.com/img/markers/marker_default.png', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://developers.woosmap.com/img/markers/marker_selected.png', scaledSize: {width: 46, height: 60}} - } -}; -var tilesStyle = { - color: '#383838', - size: 11, - minSize: 6, - typeRules: [{ - type: 'drive', - color: '#82a859' - }] -}; - -/*----- Handle store selection -----*/ -function registerLocationClickEvent(mapView) { - var selectedStoreObserver = new woosmap.utils.MVCObject(); - selectedStoreObserver.selectedStore = null; - selectedStoreObserver.selectedStore_changed = function () { - var selectedStore = this.get('selectedStore'); - alert(selectedStore.properties.name); - }; - selectedStoreObserver.bindTo('selectedStore', mapView); -} - -/*----- Store by Location, with distance -----*/ -function registerNearbyClickEvent(mapView, dataSource) { - var MAX_STORE = 10; - var MAX_DISTANCE_FROM_LOCATION = 150000; //150km - var nearbyStoreSource = new woosmap.location.NearbyStoresSource(dataSource, MAX_STORE, MAX_DISTANCE_FROM_LOCATION); - nearbyStoreSource.bindTo('location', mapView); - nearbyStoreSource.bindTo('stores', mapView); - - woosmap.$('#go-to-paris').on('click', function () { - mapView.set('location', { - lat: 48.85, - lng: 2.27 - }); - }); -} - -function registerDraggableMarker(mapView) { - mapView.marker.setOptions({ - draggable: true, - icon: {url: 'https://developers.woosmap.com/img/markers/geolocated.png'} - }); -} -/*----- Init and display a Map with a TiledLayer-----*/ -function woosmap_main() { - var loader = new woosmap.MapsLoader(); - var dataSource = new woosmap.DataSource(); - loader.load(function () { - var map = new google.maps.Map(woosmap.$('#my-map')[0], { - center: {lat: 46, lng: 3}, - zoom: 5 - }); - var mapView = new woosmap.TiledView(map, {style: markersStyle, tileStyle: tilesStyle}); - registerNearbyClickEvent(mapView, dataSource); - registerLocationClickEvent(mapView); - registerDraggableMarker(mapView); - }); - -} - -document.addEventListener("DOMContentLoaded", function (event) { - WoosmapLoader.load('1.2', projectKey, woosmap_main); -}); diff --git a/jsfiddle-samples/search-location/README.MD b/jsfiddle-samples/search-location/README.MD deleted file mode 100644 index 15b9560..0000000 --- a/jsfiddle-samples/search-location/README.MD +++ /dev/null @@ -1,5 +0,0 @@ - - In order to view this demo on JSFiddle, open this URL: - https://fiddle.jshell.net/gh/get/library/pure/woosmap/samples/tree/master/jsfiddle-samples/search-location/ - - \ No newline at end of file diff --git a/jsfiddle-samples/search-location/demo.css b/jsfiddle-samples/search-location/demo.css deleted file mode 100755 index 0841aa7..0000000 --- a/jsfiddle-samples/search-location/demo.css +++ /dev/null @@ -1,176 +0,0 @@ -#my-map { - height: 500px; -} - -.locator-container { - font-size: 15px; - line-height: 1.5em; - color: #555; - background: #FFF; -} - -.sidebar { - height: 500px; - overflow: hidden; - border: 1px solid #EEE; - border-right: none; -} - -.listings { - height: 460px; - padding-bottom: 36px; - background-color: #FFF; - color: #555; -} - -.woosmap-tableview-container, .card_container { - max-height: 100%; - overflow-y: scroll; - overflow-x: hidden; -} - -.item { - display: block; - border-bottom: 1px solid #eee; - padding: 10px; - text-decoration: none; - cursor: pointer; -} - -.item .title { - display: block; - color: #4d9da9; - font-weight: 500; -} - -.item .title small { - font-weight: 300; -} - -.quiet { - color: #888; -} - -small { - font-size: 80%; -} - -.selected_card .item .title, -.item .title:hover { - color: #1badee; -} - -.item.active { - background-color: #f8f8f8; -} - -.selected_card { - background-color: #f8f8f8; -} - -.selected_card:hover { - background-color: #f8f8f8; -} - -.search_container { - margin: 5px; - border: 1px solid #1badee; - border-radius: 2px; - box-sizing: border-box; - -moz-box-sizing: border-box; - height: 32px; - outline: none; - padding: 0 7px; - width: 96%; - vertical-align: top; - position: relative; -} - -.search_input { - border: none; - padding: 0; - height: 1.25em; - width: 100%; - z-index: 6; - outline: none; - background: #FFF; - margin-top: 7px; - float: left; - font-size: 1em; - color: #555; -} - -.search_clear { - float: right; - background: white url('https://developers.woosmap.com/img/close.png') no-repeat left top; - position: absolute; - right: 5px; - top: 8px; - padding: 7px; - font-size: 14px; - cursor: pointer; - display: none; -} - -.search_clear:hover { - background: white url('https://developers.woosmap.com/img/close-hover.png') no-repeat left top; -} - -.woosmap-tableview-highlighted-cell { - background-color: #f8f8f8; -} - -::-webkit-scrollbar { - width: 5px; - height: 5px; -} - -::-webkit-scrollbar-thumb { - background-color: #d1d1d1; -} - -::-webkit-scrollbar-track { - background-color: #F7F7F7; -} - -/*grids*/ -.pure-g { - letter-spacing: -.31em; - text-rendering: optimizespeed; - display: -webkit-flex; - -webkit-flex-flow: row wrap; - display: -ms-flexbox; - -ms-flex-flow: row wrap; - -ms-align-content: flex-start; - -webkit-align-content: flex-start; - align-content: flex-start; -} - -.pure-g [class *="pure-u"] { - font-family: "Open Sans", "Helvetica Neue", Arial, Helvetica, Verdana, sans-serif; - font-weight: normal; - letter-spacing: 0.01em; -} - -.pure-u-1, .u-sm-1-3, .u-sm-2-3 { - display: inline-block; - zoom: 1; - letter-spacing: normal; - word-spacing: normal; - vertical-align: top; - text-rendering: auto; -} - -.pure-u-1 { - width: 100%; -} - -@media screen and (min-width: 35.5em) { - .u-sm-1-3 { - width: 33.3333%; - } - - .u-sm-2-3 { - width: 66.5%; - } -} \ No newline at end of file diff --git a/jsfiddle-samples/search-location/demo.details b/jsfiddle-samples/search-location/demo.details deleted file mode 100755 index 1a1ef0b..0000000 --- a/jsfiddle-samples/search-location/demo.details +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: SearchLocation - Woosmap Javascript API Search Location Demo -description: jsFiddle demo that allow the use of Google maps basic geocoding using Woosmap Javascript API. -authors: - - Woosmap DevTeam -... \ No newline at end of file diff --git a/jsfiddle-samples/search-location/demo.html b/jsfiddle-samples/search-location/demo.html deleted file mode 100755 index 35db2b2..0000000 --- a/jsfiddle-samples/search-location/demo.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
- -
-
diff --git a/jsfiddle-samples/search-location/demo.js b/jsfiddle-samples/search-location/demo.js deleted file mode 100755 index 59d5ff5..0000000 --- a/jsfiddle-samples/search-location/demo.js +++ /dev/null @@ -1,92 +0,0 @@ -var projectKey = '12345678'; -var markersStyle = { - rules: [ - { - type: 'drive', - icon: {url: 'https://images.woosmap.com/marker_drive.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_drive_selected.svg', scaledSize: {width: 46, height: 60}}, - numberedIcon: {url: 'https://images.woosmap.com/marker_drive_selected.svg', scaledSize: {width: 46, height: 60}} - } - ], - default: { - icon: {url: 'https://images.woosmap.com/marker_default.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_selected.svg', scaledSize: {width: 46, height: 60}} - } -}; -var tilesStyle = { - color: '#383838', - size: 11, - minSize: 6, - typeRules: [{ - type: 'drive', - color: '#82a859' - }] -}; - -function registerDraggableMarker(mapView) { - mapView.marker.setOptions({ - draggable: true, - icon: {url: 'https://developers.woosmap.com/img/markers/geolocated.png'} - }); -} - -/*----- Init and display a Map with a TiledLayer-----*/ -function woosmap_main() { - var self = this; - var loader = new woosmap.MapsLoader(); - var dataSource = new woosmap.DataSource(); - loader.load(function () { - var tableview = new woosmap.ui.TableView({ - cell_store: '
' + - '{{name}}
{{address.city}}
' + - '
{{address.lines}} {{address.city}} {{address.zip}}
', - cell_place: '' - }); - var geocoder = new woosmap.location.GeocoderSearchSource(); - var searchview = new woosmap.ui.SearchView(woosmap.$('#search_template').text()); - var nearbyStoresSource = new woosmap.location.NearbyStoresSource(dataSource, 5); - - nearbyStoresSource.bindTo('location', geocoder, 'location', false); - tableview.bindTo('stores', nearbyStoresSource); - geocoder.bindTo('query', searchview, 'query', false); - - var listings = woosmap.$('#listings'); - var sidebar = woosmap.$('.sidebar'); - - sidebar.prepend(searchview.getContainer()); - listings.append(tableview.getContainer()); - - self.tableview = tableview; - var defaultStores = null; - var map = new google.maps.Map(woosmap.$('#my-map')[0], { - center: {lat: 46, lng: 3}, - zoom: 5 - }); - - var mapView = new woosmap.TiledView(map, {style: markersStyle, tileStyle: tilesStyle}); - mapView.bindTo('stores', tableview, 'stores', false); - mapView.bindTo('selectedStore', tableview, 'selectedStore', false); - mapView.bindTo('location', geocoder); - mapView.delegate = { - 'didLocationMarkerDragEnd': function () { - searchview.$searchInput.val(''); - } - }; - - registerDraggableMarker(mapView); - - searchview.delegate = { - didClearSearch: function () { - tableview.set('stores', defaultStores); - mapView.set('selectedStore', null); - mapView.set('location', {}) - } - }; - - }); - -} - -document.addEventListener("DOMContentLoaded", function (event) { - WoosmapLoader.load('latest', projectKey, woosmap_main); -}); \ No newline at end of file diff --git a/jsfiddle-samples/search-places/README.MD b/jsfiddle-samples/search-places/README.MD deleted file mode 100644 index 183b1c6..0000000 --- a/jsfiddle-samples/search-places/README.MD +++ /dev/null @@ -1,5 +0,0 @@ - - In order to view this demo on JSFiddle, open this URL: - https://fiddle.jshell.net/gh/get/library/pure/woosmap/samples/tree/master/jsfiddle-samples/search-places/ - - \ No newline at end of file diff --git a/jsfiddle-samples/search-places/demo.css b/jsfiddle-samples/search-places/demo.css deleted file mode 100755 index 0841aa7..0000000 --- a/jsfiddle-samples/search-places/demo.css +++ /dev/null @@ -1,176 +0,0 @@ -#my-map { - height: 500px; -} - -.locator-container { - font-size: 15px; - line-height: 1.5em; - color: #555; - background: #FFF; -} - -.sidebar { - height: 500px; - overflow: hidden; - border: 1px solid #EEE; - border-right: none; -} - -.listings { - height: 460px; - padding-bottom: 36px; - background-color: #FFF; - color: #555; -} - -.woosmap-tableview-container, .card_container { - max-height: 100%; - overflow-y: scroll; - overflow-x: hidden; -} - -.item { - display: block; - border-bottom: 1px solid #eee; - padding: 10px; - text-decoration: none; - cursor: pointer; -} - -.item .title { - display: block; - color: #4d9da9; - font-weight: 500; -} - -.item .title small { - font-weight: 300; -} - -.quiet { - color: #888; -} - -small { - font-size: 80%; -} - -.selected_card .item .title, -.item .title:hover { - color: #1badee; -} - -.item.active { - background-color: #f8f8f8; -} - -.selected_card { - background-color: #f8f8f8; -} - -.selected_card:hover { - background-color: #f8f8f8; -} - -.search_container { - margin: 5px; - border: 1px solid #1badee; - border-radius: 2px; - box-sizing: border-box; - -moz-box-sizing: border-box; - height: 32px; - outline: none; - padding: 0 7px; - width: 96%; - vertical-align: top; - position: relative; -} - -.search_input { - border: none; - padding: 0; - height: 1.25em; - width: 100%; - z-index: 6; - outline: none; - background: #FFF; - margin-top: 7px; - float: left; - font-size: 1em; - color: #555; -} - -.search_clear { - float: right; - background: white url('https://developers.woosmap.com/img/close.png') no-repeat left top; - position: absolute; - right: 5px; - top: 8px; - padding: 7px; - font-size: 14px; - cursor: pointer; - display: none; -} - -.search_clear:hover { - background: white url('https://developers.woosmap.com/img/close-hover.png') no-repeat left top; -} - -.woosmap-tableview-highlighted-cell { - background-color: #f8f8f8; -} - -::-webkit-scrollbar { - width: 5px; - height: 5px; -} - -::-webkit-scrollbar-thumb { - background-color: #d1d1d1; -} - -::-webkit-scrollbar-track { - background-color: #F7F7F7; -} - -/*grids*/ -.pure-g { - letter-spacing: -.31em; - text-rendering: optimizespeed; - display: -webkit-flex; - -webkit-flex-flow: row wrap; - display: -ms-flexbox; - -ms-flex-flow: row wrap; - -ms-align-content: flex-start; - -webkit-align-content: flex-start; - align-content: flex-start; -} - -.pure-g [class *="pure-u"] { - font-family: "Open Sans", "Helvetica Neue", Arial, Helvetica, Verdana, sans-serif; - font-weight: normal; - letter-spacing: 0.01em; -} - -.pure-u-1, .u-sm-1-3, .u-sm-2-3 { - display: inline-block; - zoom: 1; - letter-spacing: normal; - word-spacing: normal; - vertical-align: top; - text-rendering: auto; -} - -.pure-u-1 { - width: 100%; -} - -@media screen and (min-width: 35.5em) { - .u-sm-1-3 { - width: 33.3333%; - } - - .u-sm-2-3 { - width: 66.5%; - } -} \ No newline at end of file diff --git a/jsfiddle-samples/search-places/demo.details b/jsfiddle-samples/search-places/demo.details deleted file mode 100755 index f81f2b9..0000000 --- a/jsfiddle-samples/search-places/demo.details +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: SearchPlaces - Woosmap Javascript API Search Places Demo -description: jsFiddle demo that allow the use of Google maps Autocomplete using Woosmap Javascript API. -authors: - - Woosmap DevTeam -... \ No newline at end of file diff --git a/jsfiddle-samples/search-places/demo.html b/jsfiddle-samples/search-places/demo.html deleted file mode 100755 index dbf52d1..0000000 --- a/jsfiddle-samples/search-places/demo.html +++ /dev/null @@ -1,13 +0,0 @@ - - -
- -
-
\ No newline at end of file diff --git a/jsfiddle-samples/search-places/demo.js b/jsfiddle-samples/search-places/demo.js deleted file mode 100755 index f997c5d..0000000 --- a/jsfiddle-samples/search-places/demo.js +++ /dev/null @@ -1,123 +0,0 @@ -var projectKey = '12345678'; -var markersStyle = { - rules: [{ - type: 'drive', - icon: { - url: 'https://images.woosmap.com/marker_drive.svg', - scaledSize: { - width: 36, - height: 48 - } - }, - selectedIcon: { - url: 'https://images.woosmap.com/marker_drive_selected.svg', - scaledSize: { - width: 46, - height: 60 - } - } - }], - default: { - icon: { - url: 'https://images.woosmap.com/marker_default.svg', - scaledSize: { - width: 36, - height: 48 - } - }, - selectedIcon: { - url: 'https://images.woosmap.com/marker_selected.svg', - scaledSize: { - width: 46, - height: 60 - } - } - } -}; -var tilesStyle = { - color: '#383838', - size: 11, - minSize: 6, - typeRules: [{ - type: 'drive', - color: '#82a859' - }] -}; - -function registerDraggableMarker(mapView) { - mapView.marker.setOptions({ - draggable: true, - icon: {url: 'https://developers.woosmap.com/img/markers/geolocated.png'} - }); -} - -/*----- Init and display a Map with a TiledLayer-----*/ -function woosmap_main() { - var self = this; - var loader = new woosmap.MapsLoader("", ['places']); - var dataSource = new woosmap.DataSource(); - loader.load(function () { - - var searchBounds = new google.maps.LatLngBounds( - new google.maps.LatLng(-5, 42), - new google.maps.LatLng(9, 52) - ); - var googlePlaceAutocompleteOptions = { - bounds: searchBounds, - types: ['geocode'], - componentRestrictions: {country: 'fr'} - }; - var tableview = new woosmap.ui.TableView({ - cell_store: '
' + - '{{name}}
{{address.city}}
' + - '
{{address.lines}} {{address.city}} {{address.zip}}
', - cell_place: '' - }); - var searchview = new woosmap.ui.SearchView(woosmap.$('#search_template').text()); - var nearbyStoresSource = new woosmap.location.NearbyStoresSource(dataSource, 5); - var placesSearchSource = new woosmap.location.PlacesSearchSource(googlePlaceAutocompleteOptions); - - tableview.bindTo('stores', nearbyStoresSource); - tableview.bindTo('predictions', placesSearchSource); - - placesSearchSource.bindTo('autocomplete_query', searchview, false); - - var listings = woosmap.$('#listings'); - var sidebar = woosmap.$('.sidebar'); - - sidebar.prepend(searchview.getContainer()); - listings.append(tableview.getContainer()); - - self.tableview = tableview; - - var map = new google.maps.Map(woosmap.$('#my-map')[0], { - center: {lat: 46, lng: 3}, - zoom: 5 - }); - - var mapView = new woosmap.TiledView(map, {style: markersStyle, tileStyle: tilesStyle}); - tableview.bindTo('location', mapView); - nearbyStoresSource.bindTo('location', mapView); - mapView.bindTo('stores', tableview, 'stores', false); - mapView.bindTo('selectedStore', tableview, 'selectedStore', false); - - mapView.marker.setOptions({ - draggable: true - }); - searchview.delegate = { - didClearSearch: function () { - tableview.set('stores', []); - tableview.set('predictions', []); - mapView.set('selectedStore', null); - mapView.set('location', {}) - } - }; - - registerDraggableMarker(mapView); - }); - -} - -document.addEventListener("DOMContentLoaded", function (event) { - WoosmapLoader.load('1.2', projectKey, woosmap_main); -}); \ No newline at end of file diff --git a/jsfiddle-samples/search-query/README.MD b/jsfiddle-samples/search-query/README.MD deleted file mode 100644 index c9b79f9..0000000 --- a/jsfiddle-samples/search-query/README.MD +++ /dev/null @@ -1,5 +0,0 @@ - - In order to view this demo on JSFiddle, open this URL: - https://fiddle.jshell.net/gh/get/library/pure/woosmap/samples/tree/master/jsfiddle-samples/search-query/ - - \ No newline at end of file diff --git a/jsfiddle-samples/search-query/demo.css b/jsfiddle-samples/search-query/demo.css deleted file mode 100755 index 862948c..0000000 --- a/jsfiddle-samples/search-query/demo.css +++ /dev/null @@ -1,224 +0,0 @@ -#my-map { - height: 600px; -} - -.locator-container { - font-size: 15px; - line-height: 1.5em; - color: #555; - background: #FFF; -} - -.sidebar { - height: 600px; - overflow: hidden; - border: 1px solid #EEE; - border-right: none; -} - -.listings { - - padding-bottom: 36px; - background-color: #FFF; - color: #555; -} - -.woosmap-tableview-container, .card_container { - max-height: 100%; - overflow-y: scroll; - overflow-x: hidden; -} - -.item { - display: block; - border-bottom: 1px solid #eee; - padding: 10px; - text-decoration: none; - cursor: pointer; -} - -.item .title { - display: block; - color: #4d9da9; - font-weight: 500; -} - -.item .title small { - font-weight: 300; -} - -.quiet { - color: #888; -} - -small { - font-size: 80%; -} - -.selected_card .item .title, -.item .title:hover { - color: #1badee; -} - -.item.active { - background-color: #f8f8f8; -} - -.selected_card { - background-color: #f8f8f8; -} - -.selected_card:hover { - background-color: #f8f8f8; -} - -.search_container { - margin: 5px; - border: 1px solid #1badee; - border-radius: 2px; - box-sizing: border-box; - -moz-box-sizing: border-box; - height: 32px; - outline: none; - padding: 0 7px; - width: 96%; - vertical-align: top; - position: relative; -} - -.search_input { - border: none; - padding: 0; - height: 1.25em; - width: 100%; - z-index: 6; - outline: none; - background: #FFF; - margin-top: 7px; - float: left; - font-size: 1em; - color: #555; -} - -.search_clear { - float: right; - background: white url('https://developers.woosmap.com/img/close.png') no-repeat left top; - position: absolute; - right: 5px; - top: 8px; - padding: 7px; - font-size: 14px; - cursor: pointer; - display: none; -} - -.search_clear:hover { - background: white url('https://developers.woosmap.com/img/close-hover.png') no-repeat left top; -} - -.woosmap-tableview-highlighted-cell { - background-color: #f8f8f8; -} - -::-webkit-scrollbar { - width: 5px; - height: 5px; -} - -::-webkit-scrollbar-thumb { - background-color: #d1d1d1; -} - -::-webkit-scrollbar-track { - background-color: #F7F7F7; -} - -/* attribute search page */ - -.attributes-search-container { - border-bottom: solid 1px #eee; - -} - -.attribute-filter-container { - margin: 5px; - border: 1px solid rgba(27, 173, 238, 0.29); -} - -.attribute-filter-container:hover { - margin: 5px; - border: 1px solid #1badee; -} - -.attribute-filter-title { - cursor: pointer; - text-align: center; -} - -.attribute-filter-title.active { - border-bottom: solid 1px #eee; -} - -.attribute-filter-body { - margin-left: 5px; -} - -.checkbox-input-attribute { - margin-right: 5px; -} - -.search-attributes-listings { - height: 320px; - padding-bottom: 36px; -} - -.woosmap-tableview-highlighted-cell .title { - color: #1badee; -} - -.woosmap-tableview-selected-cell { - color: #1badee; - background-color: rgba(235, 235, 235, 0.29); -} - -/*grids*/ -.pure-g { - letter-spacing: -.31em; - text-rendering: optimizespeed; - display: -webkit-flex; - -webkit-flex-flow: row wrap; - display: -ms-flexbox; - -ms-flex-flow: row wrap; - -ms-align-content: flex-start; - -webkit-align-content: flex-start; - align-content: flex-start; -} - -.pure-g [class *="pure-u"] { - font-family: "Open Sans", "Helvetica Neue", Arial, Helvetica, Verdana, sans-serif; - font-weight: normal; - letter-spacing: 0.01em; -} - -.pure-u-1, .u-sm-1-3, .u-sm-2-3 { - display: inline-block; - zoom: 1; - letter-spacing: normal; - word-spacing: normal; - vertical-align: top; - text-rendering: auto; -} - -.pure-u-1 { - width: 100%; -} - -@media screen and (min-width: 35.5em) { - .u-sm-1-3 { - width: 33.3333%; - } - - .u-sm-2-3 { - width: 66.5%; - } -} \ No newline at end of file diff --git a/jsfiddle-samples/search-query/demo.details b/jsfiddle-samples/search-query/demo.details deleted file mode 100755 index fc866e5..0000000 --- a/jsfiddle-samples/search-query/demo.details +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: SearchAttributes - Woosmap Javascript API Search Attributes Demo -description: jsFiddle demo that allow you to search the data attributes using Woosmap Javascript API. -authors: - - Woosmap DevTeam -... \ No newline at end of file diff --git a/jsfiddle-samples/search-query/demo.html b/jsfiddle-samples/search-query/demo.html deleted file mode 100755 index f33957e..0000000 --- a/jsfiddle-samples/search-query/demo.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - -
- -
-
- diff --git a/jsfiddle-samples/search-query/demo.js b/jsfiddle-samples/search-query/demo.js deleted file mode 100755 index 7c09d66..0000000 --- a/jsfiddle-samples/search-query/demo.js +++ /dev/null @@ -1,124 +0,0 @@ -var projectKey = '12345678'; -var markersStyle = { - rules: [ - { - type: 'drive', - icon: {url: 'https://images.woosmap.com/marker_drive.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_drive_selected.svg', scaledSize: {width: 46, height: 60}} - } - ], - default: { - icon: {url: 'https://dimages.woosmap.com/marker_default.svg', scaledSize: {width: 36, height: 48}}, - selectedIcon: {url: 'https://images.woosmap.com/marker_selected.svg', scaledSize: {width: 46, height: 60}} - } -}; -var tilesStyle = { - color: '#383838', - size: 11, - minSize: 6, - typeRules: [{ - type: 'drive', - color: '#82a859' - }] -}; - -function registerDraggableMarker(mapView) { - mapView.marker.setOptions({ - draggable: true, - icon: { - url: 'https://developers.woosmap.com/img/markers/geolocated.png' - } - }); -} - -/*----- Init and display a Map with a TiledLayer-----*/ -function woosmap_main() { - var self = this; - var loader = new woosmap.MapsLoader("", ['places']); - var dataSource = new woosmap.DataSource(); - loader.load(function () { - - var map = new google.maps.Map(woosmap.$('#my-map')[0], { - center: { - lat: 46, - lng: 3 - }, - zoom: 5 - }); - - var mapView = new woosmap.TiledView(map, { - style: markersStyle, - tileStyle: tilesStyle - }); - - var searchview = new woosmap.ui.SearchView(woosmap.$('#search_template').text()); - - var initialSearchTextOptions = { - name: woosmap.search.SearchQuery.OR, - city: woosmap.search.SearchQuery.OR - }; - var searchQuery = new woosmap.search.SearchQuery(initialSearchTextOptions); - - var searchTextOptionsRenderer = new woosmap.TemplateRenderer(woosmap.$("#text-search-options-template").html()); - var tagsRenderer = new woosmap.TemplateRenderer(woosmap.$("#tags-selector-template").html()); - var typesRenderer = new woosmap.TemplateRenderer(woosmap.$("#types-selector-template").html()); - var attributeSearchContainer = woosmap.$('
'); - attributeSearchContainer.append(searchview.getContainer()); - attributeSearchContainer.append(searchTextOptionsRenderer.render()); - attributeSearchContainer.append(tagsRenderer.render()); - attributeSearchContainer.append(typesRenderer.render()); - var sidebar = woosmap.$('.sidebar'); - sidebar.prepend(attributeSearchContainer); - - function typeChanged() { - searchQuery.types = []; - woosmap.$.each(woosmap.$('.woosmap-available-type:checked'), function (index, object) { - searchQuery.addTypeFilter(woosmap.$(object).val(), woosmap.search.SearchQuery.AND); - }); - mapView.setSearchQuery(searchQuery); - } - - function tagChanged() { - searchQuery.tags = []; - woosmap.$.each(woosmap.$('.woosmap-available-tag:checked'), function (index, object) { - searchQuery.addTagFilter(woosmap.$(object).val(), woosmap.search.SearchQuery.AND); - }); - mapView.setSearchQuery(searchQuery); - } - - woosmap.$('.woosmap-available-tag').click(function () { - tagChanged(); - }); - - woosmap.$('.woosmap-available-type').click(function () { - typeChanged(); - }); - - woosmap.$('.woosmap-text-search-param').click(function () { - if (this.checked) { - searchQuery.addQueryOption(woosmap.$(this).val(), woosmap.search.SearchQuery.OR) - } else { - searchQuery.removeQueryOption(woosmap.$(this).val()) - } - mapView.setSearchQuery(searchQuery); - }); - - woosmap.$('.search_input').on("change paste keyup", function () { - searchQuery.setQuery(woosmap.$(this).val()); - mapView.setSearchQuery(searchQuery); - }); - - searchview.delegate = { - didClearSearch: function () { - searchQuery.setQuery(''); - mapView.setSearchQuery(searchQuery); - } - }; - }); - -} - -document.addEventListener("DOMContentLoaded", function (event) { - WoosmapLoader.load('1.2', projectKey, woosmap_main); -}); - diff --git a/opening-hours/README.md b/opening-hours/README.md new file mode 100644 index 0000000..22ae90e --- /dev/null +++ b/opening-hours/README.md @@ -0,0 +1,40 @@ +# Convert opening hours + +Retailers keep hours in spreadsheets with one column per weekday. The Stores API wants an +[`openingHours` object](https://developers.woosmap.com/products/stores-api/concepts/opening-hours/) with +numeric day keys, time slices, special dates and temporary closures. This script does the conversion and +validates as it goes. + +## Input + +`hours.csv`: `store_id`, optional `timezone`, then `monday` … `sunday`. A cell can be: + +| Cell | Result | +| --- | --- | +| empty, `closed`, `-` | closed that day | +| `24/7`, `all-day`, `24h` | `[{"all-day": true}]` | +| `09:00-12:00, 14:00-19:00` | two slices, `;` or `,` between them, `9h00` accepted | +| `22:00-02:00` | one slice crossing midnight, kept as is, the API handles it | + +Optional `special.csv` (`store_id`, `date`, `hours`, same cell grammar, ISO dates) and +`closures.csv` (`store_id`, `start`, `end`, inclusive ISO dates). + +When all seven days are identical the output uses the `default` key. Closures that have already ended are +accepted but the API discards them on import. + +## Usage + +```sh +python python/opening_hours.py ../data/opening_hours.csv \ + --special ../data/special_hours.csv --closures ../data/closures.csv \ + --output hours.json + +python python/opening_hours.py hours.csv --timezone Europe/Paris \ + --merge stores.json --output stores_with_hours.json +``` + +The first form writes `{store_id: openingHours}`. The second injects the hours into a Woosmap JSON file +so [stores-sync](../stores-sync/) can push them. Invalid rows are reported and skipped; `--strict` fails +instead. No dependency beyond the standard library. + +Timezones are not derived from coordinates here; put them in the CSV or pass a single `--timezone`. diff --git a/opening-hours/python/opening_hours.py b/opening-hours/python/opening_hours.py new file mode 100644 index 0000000..7179491 --- /dev/null +++ b/opening-hours/python/opening_hours.py @@ -0,0 +1,203 @@ +"""Convert tabular opening hours (one column per weekday) into the Woosmap openingHours object.""" + +from __future__ import annotations + +import argparse +import csv +import json +import re +import sys +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path +from typing import Any + +DAY_COLUMNS = { + "monday": "1", + "tuesday": "2", + "wednesday": "3", + "thursday": "4", + "friday": "5", + "saturday": "6", + "sunday": "7", +} +CLOSED_WORDS = {"", "closed", "close", "ferme", "fermé", "-", "x"} +ALL_DAY_WORDS = {"24/7", "all-day", "allday", "24h", "24h/24", "open 24 hours"} +SLICE_PATTERN = re.compile(r"^(\d{1,2})[:h](\d{2})\s*-\s*(\d{1,2})[:h](\d{2})$") + +Slices = list[dict[str, Any]] + + +class HoursError(ValueError): + pass + + +@dataclass +class Conversion: + hours: dict[str, dict[str, Any]] = field(default_factory=dict) + errors: list[str] = field(default_factory=list) + + +def parse_time(hours: str, minutes: str) -> str: + hour, minute = int(hours), int(minutes) + if hour > 23 or minute > 59: + raise HoursError(f"invalid time {hours}:{minutes}") + return f"{hour:02d}:{minute:02d}" + + +def parse_slice(text: str) -> dict[str, str]: + match = SLICE_PATTERN.match(text.strip()) + if not match: + raise HoursError(f"cannot read time slice {text!r}, expected HH:MM-HH:MM") + start, end = parse_time(match[1], match[2]), parse_time(match[3], match[4]) + # end before start is a slice crossing midnight, which the Stores API accepts as is + if start == end: + raise HoursError(f"slice {text!r} starts and ends at the same time") + return {"start": start, "end": end} + + +def parse_cell(text: str) -> Slices: + lowered = text.strip().lower() + if lowered in CLOSED_WORDS: + return [] + if lowered in ALL_DAY_WORDS: + return [{"all-day": True}] + return [parse_slice(part) for part in re.split(r"[,;]", text) if part.strip()] + + +def weekly_hours(row: dict[str, str]) -> dict[str, Slices]: + per_day = {key: parse_cell(row.get(column, "")) for column, key in DAY_COLUMNS.items()} + values = list(per_day.values()) + if all(value == values[0] for value in values): + return {"default": values[0]} + return per_day + + +def parse_iso_date(text: str) -> str: + try: + return date.fromisoformat(text.strip()).isoformat() + except ValueError as error: + raise HoursError(f"invalid date {text!r}, expected YYYY-MM-DD") from error + + +def parse_closure(row: dict[str, str]) -> dict[str, str]: + start, end = parse_iso_date(row["start"]), parse_iso_date(row["end"]) + if end < start: + raise HoursError(f"closure ends ({end}) before it starts ({start})") + return {"start": start, "end": end} + + +def convert( + hours_rows: list[dict[str, str]], + special_rows: list[dict[str, str]], + closure_rows: list[dict[str, str]], + default_timezone: str | None, +) -> Conversion: + result = Conversion() + for index, row in enumerate(hours_rows, start=2): + try: + result.hours[row["store_id"]] = base_hours(row, default_timezone) + except (HoursError, KeyError) as error: + result.errors.append(f"hours row {index}: {error}") + add_special(result, special_rows) + add_closures(result, closure_rows) + return result + + +def base_hours(row: dict[str, str], default_timezone: str | None) -> dict[str, Any]: + timezone = row.get("timezone", "").strip() or default_timezone + if not timezone: + raise HoursError("no timezone column and no --timezone default") + return {"timezone": timezone, "usual": weekly_hours(row)} + + +def add_special(result: Conversion, rows: list[dict[str, str]]) -> None: + for index, row in enumerate(rows, start=2): + hours = result.hours.get(row.get("store_id", "")) + if hours is None: + result.errors.append(f"special row {index}: unknown store_id {row.get('store_id')!r}") + continue + try: + hours.setdefault("special", {})[parse_iso_date(row["date"])] = parse_cell(row["hours"]) + except (HoursError, KeyError) as error: + result.errors.append(f"special row {index}: {error}") + + +def add_closures(result: Conversion, rows: list[dict[str, str]]) -> None: + for index, row in enumerate(rows, start=2): + hours = result.hours.get(row.get("store_id", "")) + if hours is None: + result.errors.append(f"closure row {index}: unknown store_id {row.get('store_id')!r}") + continue + try: + hours.setdefault("temporary_closure", []).append(parse_closure(row)) + except (HoursError, KeyError) as error: + result.errors.append(f"closure row {index}: {error}") + + +def merge_into_stores(stores: list[dict[str, Any]], hours: dict[str, dict[str, Any]]) -> int: + merged = 0 + for store in stores: + if store.get("storeId") in hours: + store["openingHours"] = hours[store["storeId"]] + merged += 1 + return merged + + +def read_csv(path: Path | None) -> list[dict[str, str]]: + if path is None: + return [] + with path.open(encoding="utf-8-sig", newline="") as handle: + return [ + {k.strip(): (v or "").strip() for k, v in row.items() if k} + for row in csv.DictReader(handle) + ] + + +def load_stores_document(path: Path) -> dict[str, Any]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise SystemExit(f"no such file: {path}") from None + except json.JSONDecodeError as error: + raise SystemExit(f"{path} is not valid JSON: {error}") from None + if not isinstance(document, dict) or not isinstance(document.get("stores"), list): + raise SystemExit(f'{path} must be a JSON object with a "stores" array') + return document + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("hours", type=Path, help="CSV: store_id, [timezone], monday … sunday") + parser.add_argument("--special", type=Path, help="CSV: store_id, date, hours") + parser.add_argument("--closures", type=Path, help="CSV: store_id, start, end") + parser.add_argument("--timezone", help="fallback IANA timezone when the CSV has none") + parser.add_argument("--merge", type=Path, help="Woosmap JSON file to inject openingHours into") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--strict", action="store_true", help="fail if any row is invalid") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + result = convert( + read_csv(args.hours), read_csv(args.special), read_csv(args.closures), args.timezone + ) + for error in result.errors: + print(error, file=sys.stderr) + if args.strict and result.errors: + return 1 + document: dict[str, Any] = result.hours + if args.merge: + document = load_stores_document(args.merge) + merged = merge_into_stores(document["stores"], result.hours) + print(f"opening hours set on {merged} of {len(document['stores'])} stores", file=sys.stderr) + args.output.write_text( + json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print(f"{len(result.hours)} stores converted, {len(result.errors)} errors", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/opening-hours/python/test_opening_hours.py b/opening-hours/python/test_opening_hours.py new file mode 100644 index 0000000..b2db270 --- /dev/null +++ b/opening-hours/python/test_opening_hours.py @@ -0,0 +1,149 @@ +import json +from pathlib import Path + +import opening_hours as mod +import pytest + +DATA = Path(__file__).resolve().parents[2] / "data" + + +@pytest.mark.parametrize("text", ["", "closed", "Fermé", "-"]) +def test_closed_words_give_an_empty_day(text): + assert mod.parse_cell(text) == [] + + +@pytest.mark.parametrize("text", ["24/7", "all-day", "24h"]) +def test_all_day_words(text): + assert mod.parse_cell(text) == [{"all-day": True}] + + +def test_two_slices_with_flexible_separators_and_hours_format(): + assert mod.parse_cell("9h00-12h00; 14:00 - 19:00") == [ + {"start": "09:00", "end": "12:00"}, + {"start": "14:00", "end": "19:00"}, + ] + + +def test_overnight_slice_is_kept_as_is(): + assert mod.parse_cell("22:00-02:00") == [{"start": "22:00", "end": "02:00"}] + + +@pytest.mark.parametrize("text", ["25:00-26:00", "10:60-11:00", "10:00-10:00", "morning"]) +def test_invalid_slices_raise(text): + with pytest.raises(mod.HoursError): + mod.parse_cell(text) + + +def test_identical_week_collapses_to_default(): + row = {day: "09:00-18:00" for day in mod.DAY_COLUMNS} + assert mod.weekly_hours(row) == {"default": [{"start": "09:00", "end": "18:00"}]} + + +def test_varying_week_lists_every_day_with_numeric_keys(): + row = {day: "09:00-18:00" for day in mod.DAY_COLUMNS} + row["sunday"] = "closed" + hours = mod.weekly_hours(row) + assert hours["7"] == [] + assert hours["1"] == [{"start": "09:00", "end": "18:00"}] + assert "default" not in hours + + +def test_convert_uses_timezone_column_then_default(): + rows = [ + {"store_id": "a", "timezone": "Europe/Rome", "monday": "24/7"}, + {"store_id": "b", "monday": "24/7"}, + ] + result = mod.convert(rows, [], [], "Europe/Paris") + assert result.hours["a"]["timezone"] == "Europe/Rome" + assert result.hours["b"]["timezone"] == "Europe/Paris" + + +def test_missing_timezone_is_an_error(): + result = mod.convert([{"store_id": "a", "monday": "24/7"}], [], [], None) + assert result.hours == {} + assert "no timezone" in result.errors[0] + + +def test_special_and_closures_attach_to_their_store(): + result = mod.convert( + [{"store_id": "a", "timezone": "Europe/Paris", "monday": "09:00-18:00"}], + [{"store_id": "a", "date": "2026-12-25", "hours": "closed"}], + [{"store_id": "a", "start": "2026-08-01", "end": "2026-08-15"}], + None, + ) + assert result.hours["a"]["special"] == {"2026-12-25": []} + assert result.hours["a"]["temporary_closure"] == [{"start": "2026-08-01", "end": "2026-08-15"}] + + +def test_special_for_unknown_store_and_bad_date_are_reported(): + result = mod.convert( + [{"store_id": "a", "timezone": "Europe/Paris"}], + [ + {"store_id": "zz", "date": "2026-12-25", "hours": ""}, + {"store_id": "a", "date": "25/12/2026", "hours": ""}, + ], + [], + None, + ) + assert len(result.errors) == 2 + assert "unknown store_id 'zz'" in result.errors[0] + assert "invalid date" in result.errors[1] + + +def test_closure_ending_before_start_is_rejected(): + result = mod.convert( + [{"store_id": "a", "timezone": "Europe/Paris"}], + [], + [{"store_id": "a", "start": "2026-08-15", "end": "2026-08-01"}], + None, + ) + assert "before it starts" in result.errors[0] + + +def test_fixture_converts_without_errors(): + result = mod.convert( + mod.read_csv(DATA / "opening_hours.csv"), + mod.read_csv(DATA / "special_hours.csv"), + mod.read_csv(DATA / "closures.csv"), + None, + ) + assert result.errors == [] + assert result.hours["allhours"]["usual"] == {"default": [{"all-day": True}]} + assert result.hours["nightmarket"]["usual"]["4"] == [{"start": "18:00", "end": "02:00"}] + assert result.hours["bistro"]["special"]["2026-12-31"] == [{"start": "18:00", "end": "01:00"}] + + +def test_merge_sets_opening_hours_on_matching_stores(): + stores = [{"storeId": "a"}, {"storeId": "b"}] + assert mod.merge_into_stores(stores, {"a": {"timezone": "Europe/Paris"}}) == 1 + assert stores[0]["openingHours"] == {"timezone": "Europe/Paris"} + assert "openingHours" not in stores[1] + + +def test_main_merges_into_the_food_markets_file(tmp_path): + output = tmp_path / "stores.json" + code = mod.main( + [ + str(DATA / "opening_hours.csv"), + "--merge", + str(DATA / "foodmarkets.json"), + "--output", + str(output), + ] + ) + assert code == 0 + stores = {s["storeId"]: s for s in json.loads(output.read_text())["stores"]} + assert stores["markthalrotterdam"]["openingHours"]["usual"]["1"] == [] + + +def test_merge_target_must_be_a_stores_document(tmp_path): + bare = tmp_path / "bare.json" + bare.write_text("[]") + with pytest.raises(SystemExit, match='"stores" array'): + mod.load_stores_document(bare) + + +def test_main_strict_fails_on_bad_rows(tmp_path): + hours = tmp_path / "h.csv" + hours.write_text("store_id,timezone,monday\na,Europe/Paris,noon\n") + assert mod.main([str(hours), "--output", str(tmp_path / "o.json"), "--strict"]) == 1 diff --git a/python-samples/batchgeocoding/README.md b/python-samples/batchgeocoding/README.md deleted file mode 100644 index a336389..0000000 --- a/python-samples/batchgeocoding/README.md +++ /dev/null @@ -1,57 +0,0 @@ -**[UPDATE 31/01/2020]** -The script has moved to a dedicated repository, [woosmap/geopy-googlemaps-batchgeocoder](https://github.com/woosmap/geopy-googlemaps-batchgeocoder), and updated to be compatible with Python3. ---- - -# Python Script to batch geocode your csv address file using geopy and GoogleV3 Geocoding service - -**input** : csv file with addresses you need to geocode - -**output** : same csv with appended following fields - -- Latitude -- Longitude -- Location_Type -- Formatted_Address -- Error (if needded, for failed geocoded addresses) - -sample usage: - - python google_batch_geocoder.py - - -**Mandatory parameters you have to set inside the python file** - -- ADDRESS_COLUMNS_NAME = ["name", "addressline1", "town"] -*used to set a google geocoding query by merging this value into one string with comma separated. it depends on your CSV Input File* - -- NEW_COLUMNS_NAME = ["Lat", "Long", "Error", "formatted_address", "location_type"] -*appended columns name to processed data csv* - -- DELIMITER = ";" -*delimiter for input csv file* - -- INPUT_CSV_FILE = "./hairdresser_sample_addresses_sample.csv" -*path and name for output csv file* - -- OUTPUT_CSV_FILE = "./processed.csv" -*path and name for output csv file* - -**optional parameters** - -- COMPONENTS_RESTRICTIONS_COLUMNS_NAME = {"country": "IsoCode"} -*used to define component restrictions for google geocoding* -*see [Google componentRestrictions doc](https://developers.google.com/maps/documentation/javascript/reference?hl=FR#GeocoderComponentRestrictions) for details* - -- GOOGLE_SECRET_KEY = "1Asfgsdf5vR12XE1A6sfRd7=" -*google secret key that allow you to geocode for Google API Premium accounts* - -- GOOGLE_CLIENT_ID = "gme-webgeoservicessa1" -*google client ID, used to track and analyse your requests for Google API Premium accounts* - - - -**useful links** - -- [geopy](https://github.com/geopy/geopy) : Geocoding library for Python. -- [Google Maps Geocoding API](https://developers.google.com/maps/documentation/geocoding/start) -- [Google Maps Geocoding API Usage Limits](https://developers.google.com/maps/documentation/geocoding/usage-limits) diff --git a/python-samples/batchgeocoding/google_batch_geocoder.py b/python-samples/batchgeocoding/google_batch_geocoder.py deleted file mode 100644 index e9c54e9..0000000 --- a/python-samples/batchgeocoding/google_batch_geocoder.py +++ /dev/null @@ -1,160 +0,0 @@ -import csv -import os -import time -from _csv import QUOTE_MINIMAL -from csv import Dialect -from geopy.geocoders import GoogleV3 -from geopy.exc import ( - GeocoderQueryError, - GeocoderQuotaExceeded, - ConfigurationError, - GeocoderParseError, - GeocoderTimedOut -) - -# used to set a google geocoding query by merging this value into one string with comma separated -ADDRESS_COLUMNS_NAME = ["name", "addressline1", "town"] - -# used to define component restrictions for google geocoding -COMPONENT_RESTRICTIONS_COLUMNS_NAME = {"country": "IsoCode"} - -# appended columns name to processed data csv -NEW_COLUMNS_NAME = ["Lat", "Long", "Error", "formatted_address", "location_type"] - -# delimiter for input csv file -DELIMITER = ";" - -# Automatically retry X times when GeocoderErrors occur (sometimes the API Service return intermittent failures). -RETRY_COUNTER_CONST = 5 - -dir = os.path.dirname(__file__) - -# path and name for output csv file -INPUT_CSV_FILE = os.path.join(dir, "hairdresser_sample_addresses.csv") - -# path and name for output csv file -OUTPUT_CSV_FILE = os.path.join(dir, "processed.csv") - -# google keys - see https://blog.woosmap.com for more details -GOOGLE_SECRET_KEY = "" # important !! this key must stay private. TODO : pass this variable as a parameter to script -GOOGLE_CLIENT_ID = "" # Only for Premium users so if used, you must also provide secret_key -GOOGLE_API_KEY = "" # it will become a mandatory parameter soon - - -# dialect to manage different format of CSV -class CustomDialect(Dialect): - delimiter = DELIMITER - quotechar = '"' - doublequote = True - skipinitialspace = False - lineterminator = '\n' - quoting = QUOTE_MINIMAL - - -csv.register_dialect('ga', CustomDialect) - - -def process_addresses_from_csv(): - geo_locator = GoogleV3(api_key=GOOGLE_API_KEY, - client_id=GOOGLE_CLIENT_ID, - secret_key=GOOGLE_SECRET_KEY) - - with open(INPUT_CSV_FILE, 'r') as csvinput: - with open(OUTPUT_CSV_FILE, 'w') as csvoutput: - - # new csv based on same dialect as input csv - writer = csv.writer(csvoutput, dialect="ga") - - reader = csv.DictReader(csvinput, dialect="ga") - - # 2-dimensional data variable used to write the new CSV - processed_data = [] - - # append new columns, to receive geocoded information, to the header of the new CSV - header = list(reader.fieldnames) - for column_name in NEW_COLUMNS_NAME: - header.append(column_name.strip()) - processed_data.append(header) - - # iterate through each row of input CSV - for record in reader: - # build a line address based on the merge of multiple field values to pass to Google Geocoder - line_address = ','.join( - str(val) for val in (record[column_name] for column_name in ADDRESS_COLUMNS_NAME)) - - # if you want to use componentRestrictions feature, - # build a matching dict {'googleComponentRestrictionField' : 'yourCSVFieldValue'} - # to pass to Google Geocoder - component_restrictions = {} - if COMPONENT_RESTRICTIONS_COLUMNS_NAME: - for key, value in COMPONENT_RESTRICTIONS_COLUMNS_NAME.items(): - component_restrictions[key] = record[value] - - # geocode the built line_address and passing optional componentRestrictions - location = geocode_address(geo_locator, line_address, component_restrictions) - - # build a new temp_row for each csv entry to append to process_data Array - # first, append existing fieldnames value to this temp_row - temp_row = [record[column_name] for column_name in reader.fieldnames] - - # then, append geocoded field value to this temp_row - for column_name in NEW_COLUMNS_NAME: - try: - if isinstance(location[column_name], str): - temp_row.append(location[column_name].encode('utf-8')) - else: - temp_row.append(location[column_name]) - except BaseException as error: - print(error) - temp_row.append('') - - # to manage more precisely errors, you could use csvwriter.writerow(item) - # instead of build the processed_data array - processed_data.append(temp_row) - - try: - # finally write all rows once a time to the output CSV. - writer.writerows(processed_data) - except BaseException as error: - print(error) - - -def geocode_address(geo_locator, line_address, component_restrictions=None, retry_counter=0): - try: - # if not using Google Map API For Work (Standard instead of Premium) you will raise an OVER_QUERY_LIMIT - # due to the quotas request per seconds. So we have to sleep 500 ms between each request to Geocoding Service. - if not GOOGLE_SECRET_KEY: - time.sleep(0.5) - - # the geopy GoogleV3 geocoding call - location = geo_locator.geocode(line_address, components=component_restrictions) - - if location is not None: - # build a dict to append to output CSV - location_result = {"Lat": location.latitude, "Long": location.longitude, "Error": "", - "formatted_address": location.raw['formatted_address'], - "location_type": location.raw['geometry']['location_type']} - else: - raise ValueError("None location found, please verify your address line") - - # To catch generic geocoder errors. TODO : Handle finer-grained exceptions - except (ValueError, GeocoderQuotaExceeded, ConfigurationError, GeocoderParseError) as error: - location_result = {"Lat": 0, "Long": 0, "Error": error.message, "formatted_address": "", "location_type": ""} - - # To retry because intermittent failures sometimes occurs - except (GeocoderQueryError, GeocoderTimedOut) as error: - if retry_counter < RETRY_COUNTER_CONST: - return geocode_address(geo_locator, line_address, component_restrictions, retry_counter + 1) - else: - location_result = {"Lat": 0, "Long": 0, "Error": error.message, "formatted_address": "", - "location_type": ""} - - print("address line : %s" % line_address) - print("geocoded address : %s" % location_result["formatted_address"]) - print("location type : %s" % location_result["location_type"]) - - return location_result - - -if __name__ == '__main__': - process_addresses_from_csv() diff --git a/python-samples/batchgeocoding_woosmap_localities/woosmap_localities_batch_geocoder.py b/python-samples/batchgeocoding_woosmap_localities/woosmap_localities_batch_geocoder.py deleted file mode 100644 index 1e785c1..0000000 --- a/python-samples/batchgeocoding_woosmap_localities/woosmap_localities_batch_geocoder.py +++ /dev/null @@ -1,166 +0,0 @@ -import csv -import os -import requests -from _csv import QUOTE_MINIMAL -from csv import Dialect - -# used to set a google geocoding query by merging this value into one string with comma separated -ADDRESS_COLUMNS_NAME = ["name", "AddressLine1", "AddressLine2", "AddressLine3", "City", "Postcode"] - -# used to define component restrictions for google geocoding -COMPONENT_RESTRICTIONS_COLUMNS_NAME = {} - -# appended columns name to processed data csv -NEW_COLUMNS_NAME = ["Lat", "Long", "Error", "formatted_address", "location_type"] - -# delimiter for input csv file -DELIMITER = "," - -# Automatically retry X times when GeocoderErrors occur (sometimes the API Service return intermittent failures). -RETRY_COUNTER_CONST = 5 - -dir = os.path.dirname(__file__) - -# path and name for output csv file -INPUT_CSV_FILE = os.path.join(dir, "input1.csv") - -# path and name for output csv file -OUTPUT_CSV_FILE = os.path.join(dir, "processed_test.csv") - -# Add your Woosmap Private Key here -WOOSMAP_PRIVATE_API_KEY = "XXXX" - - -# dialect to manage different format of CSV -class CustomDialect(Dialect): - delimiter = DELIMITER - quotechar = '"' - doublequote = True - skipinitialspace = False - lineterminator = '\n' - quoting = QUOTE_MINIMAL - - -csv.register_dialect('ga', CustomDialect) - - -class WoosmapLocalities: - """A wrapper around the Woosmap Localities API.""" - - WOOSMAP_API_HOSTNAME = 'api.woosmap.com' - - def __init__(self): - self.session = requests.Session() - - def get_details(self, public_id): - return self.session.get( - 'https://{hostname}/localities/details/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY, - 'public_id': public_id}).json() - - def autocomplete(self, text_input): - return self.session.get( - 'https://{hostname}/localities/autocomplete/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY, - 'input': text_input, - 'types': 'address'}).json() - - def end(self): - self.session.close() - - -def process_addresses_from_csv(): - woosmap_localities = WoosmapLocalities() - with open(INPUT_CSV_FILE, 'r') as csvinput: - with open(OUTPUT_CSV_FILE, 'w') as csvoutput: - - # new csv based on same dialect as input csv - writer = csv.writer(csvoutput, dialect="ga") - - # create a proper header with stripped fieldnames for new CSV - # header = [h.strip() for h in next(csvinput).split(DELIMITER)] - - # read Input CSV as Dict of Dict - reader = csv.DictReader(csvinput, dialect="ga") - - # 2-dimensional data variable used to write the new CSV - processed_data = [] - - # append new columns, to receive geocoded information, to the header of the new CSV - header = list(reader.fieldnames) - for column_name in NEW_COLUMNS_NAME: - header.append(column_name.strip()) - processed_data.append(header) - - # iterate through each row of input CSV - for record in reader: - # build a line address based on the merge of multiple field values to pass to Google Geocoder - line_address = ','.join( - str(val) for val in (record[column_name] for column_name in ADDRESS_COLUMNS_NAME)) - - # if you want to use componentRestrictions feature, - # build a matching dict {'googleComponentRestrictionField' : 'yourCSVFieldValue'} - # to pass to Google Geocoder - component_restrictions = {} - if COMPONENT_RESTRICTIONS_COLUMNS_NAME: - for key, value in COMPONENT_RESTRICTIONS_COLUMNS_NAME.items(): - component_restrictions[key] = record[value] - - # geocode the built line_address and passing optional componentRestrictions - location = geocode_address(woosmap_localities, line_address, component_restrictions) - - # build a new temp_row for each csv entry to append to process_data Array - # first, append existing fieldnames value to this temp_row - temp_row = [record[column_name] for column_name in reader.fieldnames] - - # then, append geocoded field value to this temp_row - for column_name in NEW_COLUMNS_NAME: - try: - temp_row.append(location[column_name]) - except BaseException as error: - print(error) - temp_row.append('') - - # to manage more precisely errors, you could use csvwriter.writerow(item) - # instead of build the processed_data array - processed_data.append(temp_row) - - woosmap_localities.end() - - try: - # finally write all rows once a time to the output CSV. - writer.writerows(processed_data) - except BaseException as error: - print(error) - - -def geocode_address(woosmap_localities, line_address, component_restrictions=None, retry_counter=0): - try: - suggestions = woosmap_localities.autocomplete(line_address) - if suggestions is not None: - location = woosmap_localities.get_details(suggestions['localities'][0].get('public_id', ''))['result'] - if location is not None: - location_geom = location.get('geometry') - # build a dict to append to output CSV - location_result = {"Lat": location['geometry']['location']['lat'], - "Long": location['geometry']['location']['lng'], - "Error": "", - "formatted_address": location['formatted_address'], - "location_type": location['geometry']['accuracy']} - else: - raise ValueError("None location found, please verify your address line") - - # To catch generic geocoder errors. TODO : Handle finer-grained exceptions - except (ValueError) as error: - location_result = {"Lat": 0, "Long": 0, "Error": error.message, "formatted_address": "", "location_type": ""} - - print("address line : %s" % line_address) - print("geocoded address : %s" % location_result["formatted_address"]) - print("location type : %s" % location_result["location_type"]) - print("--------------------------------------------------------") - - return location_result - - -if __name__ == '__main__': - process_addresses_from_csv() diff --git a/python-samples/batchimport/README.md b/python-samples/batchimport/README.md deleted file mode 100644 index 2570e98..0000000 --- a/python-samples/batchimport/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Python Script to batch import your location to Woosmap Database. - -sample usage: - - python batch_import_locations.py - -The sample CSV File represent all the bars in Paris extracted from OpenStreetMap \ No newline at end of file diff --git a/python-samples/batchimport/batch_import_locations.py b/python-samples/batchimport/batch_import_locations.py deleted file mode 100755 index c12559b..0000000 --- a/python-samples/batchimport/batch_import_locations.py +++ /dev/null @@ -1,114 +0,0 @@ -import csv -from _csv import QUOTE_ALL -from csv import Dialect -import requests - -endpoint_csv = 'france_museum_geocoded.csv' -private_key = '' #your private key here -endpoint_api = 'http://api.woosmap.com/stores' -stores_batch_size = 100 -update_location = False - - -class InvalidGeometry(Exception): - pass - - -def get_geometry(store): - return { - 'lat': store['latitude'], - 'lng': store['longitude'] - } - - -def get_contact(store): - return { - 'website': store['SITWEB'] - } - - -def get_address(store): - return { - 'lines': [store['ADR']], - 'city': store['VILLE'], - 'zipcode': store['CP'], - } - - -def datagov2woosmap(store, id): - geometry = get_geometry(store) - address = get_address(store) - contact = get_contact(store) - return { - 'storeId': id, - 'name': store['NOM DU MUSEE'], - 'address': address, - 'contact': contact, - 'location': geometry - } - - -class data_gov_dialect(Dialect): - delimiter = ',' - quotechar = '"' - doublequote = True - skipinitialspace = False - lineterminator = '\n' - quoting = QUOTE_ALL - - -csv.register_dialect('dg', data_gov_dialect) - - -def import_batch(batch, use_put=False): - print('--> Importing batch (%d) locations' % len(batch)) - if use_put: - response = session.put(endpoint_api, - params={'private_key': private_key}, - json={'stores': batch}) - else: - response = session.post(endpoint_api, - params={'private_key': private_key}, - json={'stores': batch}) - - print('<-- Total time:', response.elapsed.total_seconds()) - if response.status_code >= 400: - print('Failed batch') - print(response.text) - return False - - return True - - -if __name__ == '__main__': - with open(endpoint_csv, 'r') as f: - reader = csv.DictReader(f, dialect="dg") - - failed = [] - session = requests.Session() - if not update_location: - response = session.delete(endpoint_api, params={'private_key': private_key}) - - if response.status_code >= 400: - print(response.text) - exit(-1) - - batch = [] - batch_size = stores_batch_size - id = 0 - for location in reader: - id += 1 - try: - woosmap_location = datagov2woosmap(location, "ID" + str(id)) - - batch.append(woosmap_location) - if len(batch) == batch_size: - batch_result = import_batch(batch, use_put=update_location) - batch = [] - - except InvalidGeometry: - pass - - if batch: - batch_result = import_batch(batch, use_put=update_location) - batch = [] diff --git a/python-samples/batchimport/france_museum_geocoded.csv b/python-samples/batchimport/france_museum_geocoded.csv deleted file mode 100644 index 02e00f9..0000000 --- a/python-samples/batchimport/france_museum_geocoded.csv +++ /dev/null @@ -1,1517 +0,0 @@ -NOM DU MUSEE,ADR,CP,VILLE,SITWEB,latitude,longitude -Musée de la Folie Marco,"30, Rue du Dr Sultzer",67140,BARR,http://www.barr.fr,48.410188,7.45112 -Musée de la Poterie,"2, rue de Kuhlendorf",67660,BETSCHDORF,http://www.betschdorf.fr/tourisme/visiter_betschdrof/musee_poterie.php,48.900331,7.914441 -Musée de Bouxwiller et du Pays de Hanau,"2, Place du Château - -Halle aux Blés",67330,BOUXWILLER,http://www.musee-pays-hanau.webmuseo.com,48.824975,7.482776 -Musée Alsacien,"1, place Joseph Thierry",67500,HAGUENAU,http://www.ville-haguenau.fr/pages/culture/musee.htm,48.814611,7.789527 -Musée Historique,"9, Rue du Maréchal Foch - -B.P. 40261",67504,HAGUENAU Cedex,http://www.ville-haguenau.fr/pages/culture/musee.htm ou www.museumspass.com/dn_musees-haguenau/,48.812831,7.791479 -Musée de la Chartreuse,"4, Cour des Chartreux",67120,MOLSHEIM,http://www.chartreuse-molsheim.info,48.54278,7.49023 -Maison de l'Archéologie des Vosges du Nord,"44, Avenue Foch",67110,NIEDERBRONN-LES-BAINS,http://www.musee-niederbronn.fr,48.947443,7.650429 -Musée de l'Image Populaire de Pfaffenhoffen,"24, rue du Docteur Albert Schweitzer",67350,PFAFFENHOFFEN,http://www.pfaffenhoffen.org ou http://www.musee-image-populaire.webmuseo.com/,48.844809,7.611262 -Musée du Fer,"9, rue Jeanne d'Arc",67110,REICHSHOFFEN,musee-reischshoffen.webmuseo.com ou www.lacastine.com,48.932379,7.665286 -Musée du Château des Rohan,"Château des Rohans - -Place du Général de Gaulle",67700,SAVERNE,http://www.louise weiss.org,48.741895,7.362216 -Musée des Arts Décoratifs,"Palais Rohan - -2, Place du Château",67000,STRASBOURG,http://www.musees-strasbourg.eu,48.581323,7.751359 -Musée Tomi Ungerer,"Villa Greiner - -2, avenue de la Marseillaise",67000,STRASBOURG,http://www.musees-strasbourg.eu,48.585556,7.755619 -Musée des Beaux-Arts,"Palais Rohan - -2, Place du Château",67000,STRASBOURG,http://www.musees.strasbourg.eu,48.581323,7.751359 -Musée Historique,"2, Rue du Vieux Marché aux Poissons",67000,STRASBOURG,http://www.musees-strasbourg.eu,48.580496,7.74967 -Musée Alsacien,"23-25, Quai Saint-Nicolas",67000,STRASBOURG,http://www.musees-strasbourg.eu,48.57862,7.748812 -Musée Archéologique,"Palais Rohan - -2, Place du Château",67000,STRASBOURG,http://www.musees.strasbourg.eu,48.581323,7.751359 -Musée de l'Œuvre Notre-Dame,"3, Place du Château",67000,STRASBOURG,http://www.musees-strasbourg.eu,48.580997,7.751064 -Cabinet des Estampes et des Dessins,"5, Place du Château",67000,STRASBOURG,http://www.musees-strasbourg.eu,48.581108,7.750893 -Musée d'Art Moderne et Contemporain de Strasbourg,1 place Hans Jean Arp,67000,STRASBOURG,http://www.musees-strasbourg.eu,48.580058,7.736546 -Musée Zoologique de l'Université Louis Pasteur et de la Ville de Strasbourg,"29, Boulevard de la Victoire",67000,STRASBOURG,http://www.musees.strasbourg.eu,48.582685,7.764696 -Musée J.F. Oberlin,"25, Montée Oberlin",67130,WALDERSBACH,http://www.musee-oberlin.com,48.414761,7.214894 -Musée Lalique,40 rue du Hochberg,67290,WINGEN-SUR-MODER,http://www.musee-lalique.com,48.927033,7.361922 -Musée Westercamp,"3, rue du Musée",67160,WISSEMBOURG,http://www.musee-wissembourg.webmuseo.com ou www.ville-wissembourg.eu,49.038815,7.944344 -Musée de la Bataille du 6 Août 1870,"2, Rue du Moulin",67360,WOERTH,http://www.woerth-en-alsace.com,48.938618,7.747992 -Musée Sundgauvien d'Altkirch,"1, Rue de l'Hôtel de Ville",68130,ALTKIRCH,,47.623494,7.23739 -Musée Gallo-Romain,Place de la Mairie,68600,BIESHEIM,http://www.ville-biesheim.fr/museegr.html,48.041378,7.547923 -Musée de la Porte de Thann,"1, Rue de Thann",68700,CERNAY,http://histoire-cernay.perso.sfr.fr ou www.musees-alsace.org,47.809504,7.174289 -Musée d'Unterlinden,"1, rue Unterlinden",68000,COLMAR,http://www.musee-unterlinden.com,48.079733,7.354974 -Musée Bartholdi,"30, Rue des Marchands",68000,COLMAR,http://www.musee-bartholdi.com ou www.colmar.fr ou www.culture.fr,48.076747,7.357746 -Musée d'Histoire Naturelle et d'Ethnographie de Colmar,"11, Rue Turenne",68000,COLMAR,http://www.museumcolmar.org,48.073594,7.35872 -Musée Théodore Deck et des Pays du Florival,"1, Rue du Quatre Février",68500,GUEBWILLER,http://www.ville-guebwiller.fr/musee-deck/,47.905522,7.214504 -Musée Historique et Militaire de Huningue,"6, rue des Boulangers",68330,HUNINGE,,47.591513,7.584985 -Musée d'Histoire Local,"64, avenue du Général de Gaulle",68240,KAYSERSBERG,http://www.ville-kaysersberg.fr,48.139072,7.261441 -Cité du Train,"2, rue Alfred de Glehn",68200,MULHOUSE,http://www.citedutrain.com,47.751091,7.293676 -Musée Historique,"4, Rue des Archives",68100,MULHOUSE,http://www.musees-mulhouse.fr/musee-historique/collections.html,47.746535,7.339544 -Musée du Sapeur Pompier,"Association des Amis du Musée - -4, Boulevard de la Marseillaise",68100,MULHOUSE,http://musee.spmulhouse.free.fr,47.757665,7.335541 -Musée des Beaux-Arts,"4, Place Guillaume-Tell",68100,MULHOUSE,http://www.musees-mulhouse.fr/musee-des-beaux-arts/collections.html,47.745774,7.338271 -Musée de l'Impression sur Etoffes,"14, rue Jean-Jacques Henner - -B.P. 1468",68072,MULHOUSE CEDEX,http://www.musee-impression.com,47.74415,7.343298 -Musée National de l'Automobile - Collection Schlumpf,"192, Avenue de Colmar - -CS 91096",68051,MULHOUSE Cedex,http://www.collection-schlumpf.com,47.760209,7.327993 -Musée EDF Electropolis,"55, rue du Pâturage - -B.P. 52463",68057,MULHOUSE Cedex,electropolis.edf.com,47.749347,7.295182 -Musée Minéralogique de la Société Industrielle de Mulhouse,"3, Avenue Alfred-Werner",68093,MULHOUSE Cedex,,47.732773,7.312173 -Musée Vauban,"7, Place de la Porte de Belfort",68600,NEUF-BRISACH,http://www.neuf-brisach.fr,48.016059,7.525831 -Maison du Sundgau - Musée Paysan,"10, rue Principale",68480,OLTINGUE,http://musee.paysan.free.fr/,47.492827,7.391275 -Musée du Dolder,"57, Rue du Général de Gaulle",68340,RIQUEWIHR,Site des musées d'Alsace,48.16694,7.297357 -Musée du Papier Peint,"La Commanderie 28, rue Zuber - -B.P. 41",68171,RIXHEIM Cedex,http://www.museepapierpeint.org,47.74438,7.397815 -Musée des Amis de Thann,"24, Rue Saint Thiébaut",68800,THANN,http://www.ville-thann.fr - http://www.genealogiethaznn.org,47.812283,7.101737 -Musée d'Anthropologie du Tabac d'Intérêt National,"Maison Peyrarède - -Place du Feu",24100,BERGERAC,http://www.bergerac.fr/EspCulture/sitetabac/index.htm,44.849787,0.483514 -Château de Bourdeilles,Bourdeilles,24310,BOURDEILLES,Site des musées aquitains,45.316682,0.565763 -Musée Fernand Desmoulin,Boulevard Charlemagne,24310,BRANTOME,Site des musées aquitains ou http://www.perigord-dronne-belle.fr,45.364629,0.647342 -Musée-Aquarium,Site Bella-Riva,24100,CREYSSE,,44.854577,0.564797 -Musée Paul Reclus,Place de la Halle,24250,DOMME,,44.803002,1.214222 -Musée Eugène Le Roy et des Vieux Métiers,Place Bertran de Borne,24290,MONTIGNAC,,45.063871,1.163492 -Musée des Arts et Traditions Populaires du Périgord,"2, Rue Raoul Grassin",24400,MUSSIDAN,http://www.museevoulgre.cjb.net,45.037552,0.362147 -Musée Départemental de la Poupée et du Jouet,Avenue du Général Leclerc,24300,NONTRON,Site des musées aquitains,45.527035,0.660711 -Musée d'Art et d'Archéologie du Périgord (MAAP),"22, Cours Tourny",24000,PERIGUEUX,http://www.perigueux-maap.fr/,45.186039,0.72395 -Musée Gallo-Romain de Vesunna,"Parc de Vésone - -20, Rue du 26ème Régiment d'Infanterie",24000,PERIGUEUX,http://www.vesunna.fr ou http://www.perigueux-vesunna.fr/,45.179999,0.71269 -Musée Militaire - Souvenir du Périgord,"32, Rue des Farges",24000,PERIGUEUX,http://www.museemilitaire-perigord.fr,45.183285,0.720943 -Musée du Site Médiéval,,24440,SAINT-AVIT-SENIEUR,,44.756834,0.842382 -Musée Municipal de Blasimon,Mairie de Blasimon,33540,BLASIMON,,44.747676,-0.092939 -Musée d'Art et d'Histoire du Pays Blayais,Citadelle de Blaye,33390,BLAYE,Site des musées aquitains,45.126379,-0.663606 -Centre National Jean Moulin,"Direction des établissements culturels - -48, rue Vital Carles",33000,BORDEAUX,Site de la ville : www.bordeaux.fr,44.838651,-0.577869 -CAPC Musée d'Art Contemporain de Bordeaux,"Entrepôt - -7, Rue Ferrère",33000,BORDEAUX,http://www.capc-bordeaux.fr ou www.bordeaux.fr,44.848186,-0.572137 -Musée d'Aquitaine,"20, Cours Pasteur",33000,BORDEAUX,http://www.musee-aquitaine-bordeaux.fr,44.835543,-0.575205 -Musée des Beaux-Arts,"20, Cours D'Albret",33000,BORDEAUX,http://www.musba-bordeaux.fr,44.838056,-0.579413 -Musée des Arts Décoratifs,"Hôtel de Lalande - -39, Rue Bouffard",33000,BORDEAUX,Site de la ville - www.mairie-bordeaux.fr,44.838956,-0.579686 -Muséum d'Histoire Naturelle de Bordeaux,"5, Place Bardineau",33000,BORDEAUX,http://www.mairie-bordeaux.fr,44.848154,-0.580085 -Musée Goupil,"Conservatoire de l'Image Industrielle - -20 Cours Pasteur",33000,BORDEAUX,Site de la ville,44.835543,-0.575205 -Musée de la Voiture à Cheval,"3, Place de la Citadelle",33710,BOURG-SUR-GIRONDE,Site des musées d'aquitaine,45.039708,-0.560791 -Musée Municipal,Esplanade Charles de Gaulle,33190,LA REOLE,,44.581128,-0.041074 -Musée des Beaux-Arts et d'Archéologie,"42, Place Abel Surchamp",33500,LIBOURNE,http://www.musees-libourne.org,44.914487,-0.245068 -Musée Historique ou du Vieux Libourne (Robin),,33500,LIBOURNE,,44.91759,-0.244124 -Musée Historique de l'Hydravion,"332, Avenue Louis Breguet",40600,BISCARROSSE,http://www.hydravions-biscarrosse.com,44.388887,-1.182563 -Musée de Borda,"Mairie de Dax Service du musée Borda - -14, Rue Saint Pierre - -B.P. 50344",40107,DAX Cédex,http://www.dax.fr/musee-de-borda - Site des musées aquitains,43.708837,-1.051437 -Musée Départemental de Préhistoire,Abbaye d'Arthous,40300,HASTINGUES,http://www.culture-commune.fr - www.arthous.landes.org/,43.524706,-1.121479 -Atelier-Musée (Ecomusée de la Grande Landes),"101, Rue Jacques Desert",40043,LUXEY,Site du parc - www.parc-landes-de-gascogne.fr,44.263529,-0.518812 -Musée Despiau-Wlérick,"6, Place Marguerite de Navarre",40000,MONT-DE-MARSAN,http://www.mont-de-marsan.org/decouvrir/decouv_muse.html ou Site des musées aquitains,43.892599,-0.498893 -Musée de la Chalosse,"Domaine de Carcher - -480, Chemin du Sala - -B.P. 24",40380,MONTFORT-EN-CHALOSSE,http://www.museedelachalosse.fr,43.715749,-0.833916 -Ecomusée de la Grande Lande,Route de Soférino,40630,SABRES,http://www.parc-landes-de-gascogne.fr,44.149054,-0.750737 -Musée Municipal,Rue du Général Lamarque,40500,SAINT-SEVER,http://www.saint-sever.fr/culture_musee.htm,43.758576,-0.572238 -Musée Départemental de la Faïence et des Arts de la table,"2378, Route d'Hagetmau",40320,SAMADET,http://www.museesamadet.landes.org,43.641006,-0.510832 -Musée Municipal du Lac,"112, Place de la Mairie",40460,SANGUINET,http://www.ville-sanguinet.fr,44.483691,-1.075968 -Musée des Beaux-Arts,Place du Dr Pierre Esquirol,47916,AGEN Cedex 9,http://www.agen.fr/musee,44.202967,0.615493 -Musée Municipal Albert Marzelles,"15, Rue Abel Boyé",47200,MARMANDE,Site des musées aquitains et site de la ville,44.498918,0.166067 -Musée du Liège et du Bouchon,Rue du Puits Saint Côme,47170,MEZIN,http://www.cg47.fr,44.056911,0.25639 -Musée du Château Henri IV,Rue Henri IV,47600,NERAC,Site des musées aquitains ou www.nerac.fr,44.135138,0.33973 -Musée de Préhistoire Mésolithique Laurent Coulonges,Le Bourg,47500,SAUVETERRE-LA-LEMANCE,http://www.sauveterre-prehistoire.fr ou www.tourisme-fumelois.fr,44.597508,1.016896 -Musée Municipal (Moulin de Gajac),"2, Rue du Jardins",47300,VILLENEUVE-SUR-LOT,Site de la ville,44.405095,0.707815 -Musée Municipal,Rue de l'Eglise,64260,ARUDY,http://www.museearudy.com ou www.ot-arudy.fr,43.105938,-0.42719 -Musée Bonnat-Helleu - Musée des Beaux-Arts de Bayonne,"5, Rue Jacques-Laffitte",64100,BAYONNE,http://www.museebonnat.bayonne.fr,43.49213,-1.472317 -Musée Basque et de l'Histoire de Bayonne,"37, Quai des Corsaires",64100,BAYONNE,http://www.musee-basque.com,43.491074,-1.473873 -Muséum d'Histoire Naturelle,"Hôtel de ville - -1, Avenue Maréchal Leclerc",64100,BAYONNE,http://www.museum.bayonne.fr,43.493388,-1.48047 -Musée Municipal de Guéthary,"Parc municipal André Narbaits - -117, Avenue du Général de Gaulle",64210,GUETHARY,http://www.musee-de-guethary.fr/ OU Site des musées aquitains,43.421516,-1.607796 -Musée Municipal des Beaux-Arts,Rue Mathieu Lalanne,64000,PAU,http://musee.ville-pau.fr,43.297273,-0.364361 -Musée National du Château,,64000,PAU,http://www.musee-chateau-pau.fr,43.301752,-0.369218 -Musée Béarnais,"Association Régionaliste de Pyrénées - -7 avenue Henri Faisans",64000,PAU,,43.299954,-0.361253 -Musée Bernadotte,"8, Rue Tran",64000,PAU,site de la ville,43.297209,-0.372526 -Musée Charles-Louis Philippe,"5, Rue Charles-Louis Philippe",03350,CERILLY,http://www.mairie-cerilly.fr,46.617913,2.823245 -Musée Municipal de Gannat,Place Rantian,03800,GANNAT,http://www.bassin-gannat.com/,46.102147,3.19633 -Musée Rural de la Sologne Bourbonnaise,"Michel LABONNE - -Secrétaire des Amis du Muséee - -149, Route de Bourgogne",03400,IZEURE,,46.57337,3.359803 -Musée des Musiques Populaires - MuPop,"3, Rue Notre Dame",03100,MONTLUCON,http://www.mupop.fr,46.34088,2.605558 -Musée Anne de Beaujeu,"1, Avenue Victor Hugo - -B.P. 1669",03016,MOULINS Cedex,http://www.mab.allier.fr,46.568527,3.333844 -Musée Municipal de Souvigny,Place Aristide Briand,03210,SOUVIGNY,site de la ville www.ville-souvigny.com,46.534768,3.192475 -Musée des Arts D'Afrique et D'Asie,"11, Rue Mounin",03200,VICHY,http://www.musee-aaa.com,46.128546,3.420555 -Musée des Sciences,Château Saint Etienne,15000,AURILLAC,http://www.aurillac.fr,44.933328,2.444943 -Musée d'Art et d'Archéologie,"Centre Culturel Pierre Mendès France - -37, rue des Carmes",15012,AURILLAC Cedex,http://www.ville-aurillac.fr,44.925394,2.441947 -Ecomusée de Margeride-Haute-Auvergne,"La Tour - -Jardin de Saint-Martin",15320,RUYNES-EN-MARGERIDE,http://www.saint-flour.fr/culture/musees/ecomusee-margeride.php,45.000522,3.224427 -Musée d'art et d'histoire Alfred Douët,"Maison consulaire - -17, Place d'Armes",15100,SAINT-FLOUR,Site de la ville,45.034016,3.095067 -Musée de la Haute Auvergne,"Palais épiscopal - -1, Place d'Armes",15100,SAINT-FLOUR,http://www.auvergne-centrefrance.com/geotouring/musees/detail/mus154.htm,45.033137,3.094858 -Musée de Paléontologie,Place de l'église,43380,CHILHAC,http://www.museechilhac.fr,45.158926,3.432601 -Musée des Arts et Traditions Populaires de Haute Loire,,43100,LAVAUDIEU,Site des amis de Lavaudieu - www.abbayedelavaudieu.fr,45.274699,3.434854 -Musée des Manufactures de Dentelles,"14, avenue de la Gare",43130,RETOURNAC,http://www.ville-retournac.fr/musee/francais/indexfr.htm,45.202545,4.035302 -Musée de la Ferblanterie,,43300,SAINT ARCONS D'ALLIER,,45.090036,3.574618 -Musée de la Dentelle,"53, Route Nationale",63220,ARLANC,http://www.arlanc.com/musee_dentelle.htm,45.414373,3.724421 -Musée d'Archéologie Bargoin,"45, rue Ballainvilliers",63000,CLERMONT-FERRAND,http://www.clermont-ferrand.fr/-Musee-Bargoin-.html,45.774229,3.0869 -Muséum d'Histoire Naturelle Henri-Lecoq,"15, rue Bardoux",63000,CLERMONT-FERRAND,http://www.museelecoq.clermont-ferrand.fr ou www.clermont-ferrand.fr/-Museum-Henri-Lecoq-.html,45.774138,3.088299 -Musée Départemental de la Céramique Gallo-Romaine,"39, Rue de la République - -B.P. 30",63190,LEZOUX,"www.puydedome.fr rubrique ""découvrir le Puy-de-Dôme""",46.632156,1.066913 -Musée des Pénitents Blancs,Place de l'Eglise,63940,MARSAC-EN-LIVRADOIS,http://www.livradois.com/marsac/penitent/index.htm,45.478841,3.728706 -Musée Paléontologique,Le Bourg,63560,MENAT,,46.103787,2.904038 -Musée Lapidaire,Place de l'Abbaye,63200,MOZAC,http://www.riom-communaute.fr,45.89205,3.094625 -Musée Municipal,Parc du Prélong,63790,MUROL,http://www.musee-murol.net/Musee.htm,45.574781,2.943256 -Musée Régional d'Auvergne,"10 bis, Rue Delile",63200,RIOM,http://www.riom-communaute.fr,45.895037,3.116254 -Musée Francisque Mandet,"14, Rue de l'Hôtel de Ville",63200,RIOM,http://www.riom-communaute.fr,45.893874,3.116508 -Musée Municipal de la Coutellerie,"58, Rue de la Coutellerie",63300,THIERS,http://www.ville-thiers.fr/Musee-de-la-Coutellerie,45.852379,3.548141 -Musée Municipal Marcel Sahut,"2, rue des Ecoles",63530,VOLVIC,http://www.volvic-tourisme.com/HTML/sites/site_musee.htm,45.860585,3.024306 -Muséoparc - Alésia,"1, Route des Trois Ormeaux",21150,ALISE-SAINTE-REINE,http://www.alesia.com,47.536592,4.469595 -Musée des Beaux-Arts,"Porte Marie de Bourgogne - -6, boulevard Perpreuil",21200,BEAUNE,http://www.musees-bourgogne.org ou www.beaune.fr,47.02097,4.839394 -Musée E.J Marey,Hôtel de Ville,21200,BEAUNE,http://www.musees-bourgogne.org,47.026213,4.839367 -Musée du Vin de Bourgogne,"Hôtel des Ducs - -Rue d'Enfer",21200,BEAUNE,http://www.musees-bourgogne.org ou www.beaune.fr,47.023506,4.836339 -Musée de la Sidérurgie Grande Forge,La Grande Forge,21500,BUFFON,,47.649618,4.260887 -Musée du Pays Châtillonnais - Trésor de Vix,"14, Rue de la Libération",21400,CHATILLON-SUR-SEINE,http://www.musee-vix.fr,47.863812,4.574628 -Musée Magnin,"4, rue des Bons Enfants",21000,DIJON,http://www.musee-magnin.fr,47.320958,5.042188 -"Musée de la Vie Bourguignonne, Perrin de Puycousin","Monastère des Bernardines - -15-17, rue Sainte-Anne",21000,DIJON,http://www.musees-bourgogne.org,47.317415,5.037131 -Musée François Rude,"8, Rue Vaillant",21000,DIJON,http://mba.dijon.fr ou www.ville-dijon.fr,47.321222,5.04395 -Musée d'Art Sacré,"Monastère des Bernardines - -15-17, rue Sainte-Anne",21000,DIJON,Site des musées de Bourgogne,47.317415,5.037131 -Muséum - Jardin des Sciences,"Parc de l'Arquebuse - -14, Rue Jehan de Marville - 1 Avenue Albert 1er",21000,DIJON,http://www.dijon.fr,47.322449,5.015052 -Musée Archéologique,"5, Rue du Docteur Maret - -CS 73310",21033,DIJON Cedex,Site des musées de Bourgogne - www.musees-bourgogne.org,47.32236,5.034429 -Musée Noisot,Mairie de Fixin,21220,FIXIN,Site des musée de Bourgogne,47.243639,4.989806 -Musée des Beaux-Arts,"Chapelle des Ursulines - -Rue Piron",21500,MONTBARD,http://www.montbard.com/,47.624475,4.336854 -Musée Municipal,"12, Rue Camille Rodier",21700,NUITS-SAINT-GEORGES,Site des musées de Bourgogne,47.138661,4.950405 -Musée Municipal François Pompon,"3, Place du Docteur Roclore",21210,SAULIEU,Site des musées de Bourgogne ou www.saulieu.fr,47.279459,4.229714 -Musée Municipal,"3, Rue Jean-Jacques Collenot",21140,SEMUR-EN-AUXOIS,Site des musées de Bourgogne,47.490173,4.336447 -Musée du Septennat,"6, rue du Château",58120,CHATEAU-CHINON,http://www.cg58.fr,47.06721,3.934311 -Musée du Costume,"16-18, rue Saint Christophe",58120,CHATEAU-CHINON,http://www.cg58.fr/patrimoi/costu.htm,47.066701,3.933691 -Musée d'Art et d'Histoire Romain Rolland,Avenue de la République,58500,CLAMECY,http://www.musees-bourgogne.org,47.460717,3.520761 -Musée de la Loire de Cosne sur Loire,Place de la Résistance,58200,COSNE-SUR-LOIRE,http://www.mairie-cosnessurloire.fr/Services/MUSEEE/ACCUEIL.htm,47.410594,2.922716 -Musée Municipal,"33, Rue des Chapelains",58400,LA CHARITE-SUR-LOIRE,Site de l'OTSI ou des musées de Bourgogne,47.178733,3.015633 -Musée de la Mine,"1, Avenue de la République",58260,LA MACHINE,http://www.cc-loire-foret.fr ou www.cg58.fr et Site des musées de Bourgogne,46.889225,3.463514 -Musée de la Faïence Frédéric Blandin,"16, rue Saint-Genest",58000,NEVERS,http://www.musee-faience.nevers.fr,46.98629,3.155037 -Musée Ernest Guédon,"17, quai Jules-Pabiot",58150,POUILLY-SUR-LOIRE,,47.285947,2.949487 -Musée du Grès de Puisaye,Château de Saint-Amand,58310,SAINT-AMAND-EN-PUISAYE,http://www.cg58.fr - Site musées de Bourgogne,47.536865,3.067214 -Musée Auguste Grasset,Place de la Mairie,58210,VARZY,http://www.cg58.fr ou www.musees-bourgogne.org,47.359969,3.388063 -Museum d'Histoire Naturelle Jacques de La Comble,"14, Rue Saint-Antoine",71400,AUTUN,http://www.autun.com/tourisme/museum.php,46.947996,4.301812 -Musée Lapidaire Saint-Nicolas,Rue Saint Nicolas,71400,AUTUN,,46.955532,4.300124 -Musée Rolin,"5, rue des Bancs",71400,AUTUN,http://www.autun.com ou Site des musées de Bourgogne,46.961209,4.305784 -Musée Verger Tarin,Rue des Sous-Chantres,71400,AUTUN,,46.944133,4.299862 -Musée de la Mine,"34, rue du Bois Clair",71450,BLANZY,http://www.ecomusee-creusot-montceau.fr Site des musées de Bourgogne et de l'écomusée,46.701488,4.382962 -Musée Saint-Nazaire,Rue St Nazaire,71140,BOURBON-LANCY,Site des musées de Bourgogne ou www.bourbon-lancy.com/musees_fr_02_07_02.html,46.624461,3.770111 -Musée Nicéphore Niepce,"28, Quai des Messageries",71100,CHALON-SUR-SAONE,http://www.museeniepce.com,46.780045,4.855727 -Musée Denon,Place de l'hotel de ville,71100,CHALON-SUR-SAONE,site des musées de Bourgogne,46.78089,4.853022 -Musée du Prieuré de Charolles,Rue du Prieuré,71120,CHAROLLES,Site des musées de Bourgogne ou site de la ville - http://www.ville-charolles.fr/musee-du-prieure,46.432494,4.279233 -Musée René Davoine,"32, rue René Davoine",71120,CHAROLLES,http://www.institut-charolais.com (site de l'office du tourisme),46.4329,4.271817 -"L'Atelier d'un Journal"" de Louhans - Musée Municipal","29, Rue des Dodânes",71500,LOUHANS,http://www.ecomusee-de-la-bresse.com ou site des musées de Bourgogne,46.630156,5.224455 -Musée des Ursulines,"5, rue des Ursulines",71000,MACON,http://www.macon.fr/Culture-sports-et-loisirs/Les-Musees-de-Macon,46.307159,4.832892 -Musée Lamartine,"41, Rue Sigorgne",71000,MACON,http://www.macon.fr/Culture-sports-et-loisirs/Les-Musees-de-Macon,46.305187,4.832539 -Musée de la Tour du Moulin,"7-9, rue de la Tour",71110,MARCIGNY,http://www.marcigny.fr/musee-de-la-tour-du-moulin - http://tourdumoulin.blogspot.com,46.274545,4.043191 -Musée Eucharistique du Hiéron,"13, Rue de la Paix",71600,PARAY-LE-MONIAL,http://www.musee-hieron.fr,46.451822,4.123021 -Musée Municipal,Avenue Jean-Paul II,71600,PARAY-LE-MONIAL,,46.450634,4.119775 -Ecomusée de la Bresse Bourguignonne,Château départemental,71270,PIERRE-DE-BRESSE,http://www.ecomusee-de-la-bresse.com,46.88301,5.262064 -Musée Départemental du Compagnonnage,Le Bourg,71570,ROMANECHE-THORINS,http://www.cg71.fr/musee_compagnonnage/ ou http://musee-compagnonnage.cg71.fr,46.186127,4.741798 -Musée du Terroir,Le Champ Bressan,71470,ROMENAY,,46.501598,5.06602 -Musée Départemental de Préhistoire de Solutré,,71960,SOLUTRE-POUILLY,http://www.musees-bourgogne.org,46.294797,4.736366 -Hôtel Dieu - Musée Greuze,"21, rue de l'hôpital",71700,TOURNUS,Site des musées de Bourgogne,46.562137,4.910839 -Musée Bourguignon - Perrin de Puycousin,"8, Place de l'Abbaye",71700,TOURNUS,Site des musées de Bourgogne,46.566134,4.909274 -Maison du Blé et du Pain,"2, Rue de l'Egalité",71350,VERDUN-SUR-LE-DOUBS,http://www.verdunsurledoubs.fr rubrique culture ou www.ecomusee-de-la-bresse.com,46.899606,5.021369 -Musée Leblanc-Duvernois,"9 bis, Rue d'Egleny",89000,AUXERRE,Site des musées de Bourgogne,47.795393,3.566248 -Muséum - Maison de l'Eau,"5, Boulevard Vauban",89000,AUXERRE,Site des musées de Bourgogne,47.796936,3.564362 -Musée d'Art et d'Histoire,"Abbaye St-Germain - -2 bis, Place Saint-Germain",89000,AUXERRE,Site des musées de Bourgogne,47.800283,3.571975 -Musée de l'Avallonnais,"5, Rue du Collège - -B.P. 67",89206,AVALLON Cedex,http://www.museeavallonnais.fr- site des musées de Bourgogne - site ville,47.486968,3.906795 -Musée des Arts Naïfs et Populaires de Noyers sur Serein,"25, Rue de l'Eglise",89310,NOYERS-SUR-SEREIN,Site des musées de Bourgogne ou www.noyers-sur-serein.com/artnaif.htm,47.72104,3.966631 -Musée de l'Aventure du Son,Place de l'hôtel de ville,89170,SAINT-FARGEAU,http://www.aventureduson.fr,47.641201,3.070611 -Musée Colette,Château,89520,SAINT-SAUVEUR-EN-PUISAYE,http://www.centre-colette.com - http://www.yonne-89.net/MuseeColette.htm,47.617677,3.199325 -Musée Muncipal,Place de la Cathédrale,89100,SENS,Site des musées de Bourgogne,48.197826,3.282682 -Musée Municipal de Tonnerre,"22, Rue Rougemont",89700,TONNERRE,,47.855191,3.97484 -Musée Villeneuvien,"Porte de Joigny et 2, Rue Carnot",89500,VILLENEUVE-SUR-YONNE,Site de la ville,48.080297,3.294088 -Musée d'Art et d'Histoire du Puisaye,Rue Paul-Huillard,89130,VILLIERS-SAINT-BENOIT,,47.780167,3.207453 -Musée du Château de Dinan,Rue du Château,22100,DINAN,http://www.mairie-dinan.com,48.450306,-2.043857 -Musée National de la Marine,Château de Brest,29240,BREST,http://www.musee-marine.fr/brest,48.385372,-4.488366 -Musée des Beaux-Arts de Brest,"24, Rue Traverse",29200,BREST,http://www.musee-brest.com,48.385326,-4.490018 -Ecomusée des Monts d'Arrée - Moulins de Kerouat,Moulins de Kerouat,29450,COMMANA,http://www.ecomusee-monts-arree.fr,48.415821,-4.006179 -Musée de la Pêche,"Concarneau Cornouaille Agglomération - -3, Rue Vauban",29900,CONCARNEAU,http://www.concarneau-cornouaille.fr - www.musee-peche.fr,47.872103,-3.915485 -Le Port-Musée,Place de l'Enfer,29100,DOUARNENEZ,http://www.port-musee.org,48.092809,-4.332891 -Musée de Morlaix,Place des Jacobins,29600,MORLAIX,http://www.musee.ville.morlaix.fr,48.577109,-3.825372 -Musée des Phares et Balises,Le Créac'h,29242,OUESSANT,http://www.parc-naturel-armorique.fr,48.454477,-5.087351 -Ecomusée de l'Île d'Ouessant - Maison du Niou Huella,Le Niou Huella,29242,OUESSANT,http://www.parc-naturel-armorique.fr,48.460406,-5.110939 -Musée de Pont-Aven,Place de l'Hôtel de Ville,29930,PONT-AVEN,http://www.pontaven.fr,47.855413,-3.747175 -Musée Bigouden,Square de l'Europe,29120,PONT-L'ABBE,http://www.museebigouden.fr,47.862215,-4.236762 -Musée des Beaux-Arts,"40, Place Saint-Corentin",29000,QUIMPER,http//musee-beauxarts.quimper.fr,47.996206,-4.10238 -Musée Départemental Breton,"Palais des Evêques - -1, Rue du Roi Gradlon",29000,QUIMPER,http://www.cg29.fr/culture/mdb.htm - Site en cours,47.995125,-4.102602 -Musée de l'Ecole Rurale en Bretagne,Kergroas,29560,TREGARVAN,http://www.musee-ecole.fr,48.244346,-4.225059 -Ecomusée du Pays de Montfort,"2, Rue du Château",35160,MONTFORT-SUR-MEU,http://www.ecomusee-broceliande.com - http://ecomuseepaysmontfort.free.fr/,48.137765,-1.956378 -Musée de la Faucillonnaie,Manoir de la Faucillonnaie,35500,MONTREUIL-SOUS-PEROUSE,Site de l'office du tourisme,48.154961,-1.238899 -Musée des Beaux-Arts,"20, Quai Emile Zola",35100,RENNES,http://www.mbar.org,48.109834,-1.675861 -Musée de Bretagne,"46, boulevard Magenta - -CS 51138",35011,RENNES Cedex,http://www.musee-bretagne.fr,48.105242,-1.673896 -Musée Saint-Nicolas,"15, Rue Pasteur",35506,VITRE,site de l'office du tourisme,48.125622,-1.216099 -Musée des Rochers-Sévigné,Route d'Argentré du Plessis,35500,VITRE,site de l'office du tourisme,48.099994,-1.191075 -Musée du Château,Place du Château,35506,VITRE Cedex,site de l'office du tourisme,48.12432,-1.214152 -Ecomusée de Saint-Dégan,,56400,BREC'H,http://www.ecomusee-st-degan.fr,47.720736,-2.989176 -Musée de Préhistoire J. Miln- Z. Le-Rouzic,"10, Place de la chapelle",56340,CARNAC,http://www.museedecarnac.com,47.584531,-3.079164 -Ecomusée de l'Île de Groix,Port-Tudy,56590,ILE DE GROIX,http://ecomusee.groix.free.fr,47.63973,-3.444863 -Ecomusée Industriel des Forges,Mail François Giovannelli,56650,INZINZAC-LOCHRIST,http://www.inzinzac-lochrist.fr/Ecomusee-des-Forges.6681.0.html,47.824797,-3.252319 -Musée du Faouët,"1, Rue de Quimper",56320,LE FAOUET,http://www.museedufaouet.fr,48.031817,-3.490833 -Musée de la Compagnie des Indes,Citadelle de Port-Louis,56290,PORT-LOUIS,http://musee.lorient.fr,47.706409,-3.353649 -Musée National de la Marine,Citadelle de Port-Louis,56290,PORT-LOUIS,http://www.musee-marine.fr/port-louis,47.706409,-3.353649 -Musée de la Résistance Bretonne,Les Hardys-Behelec,56140,SAINT-MARCEL - MALESTROIT,http://www.resistance-bretonne.com,47.80168,-2.432061 -Musée du Château de Suscinio,,56370,SARZEAU,http://www.suscinio.info,47.48788,-2.798596 -Musée d'Histoire et d'Archéologie de Vannes,"Château Gaillard - -2, Rue Noé",56000,VANNES,,47.656705,-2.757726 -"La Cohue, Musée des Beaux-Arts de vannes",9 et 15 Place Saint-Pierre,56000,VANNES,,47.657693,-2.757556 -Musée des Meilleurs Ouvriers de France,Place Etienne Dolet,18000,BOURGES,Site de la ville www.ville-bourges.fr,47.082181,2.398248 -Musée d'Histoire Naturelle Gabriel Foucher,Parc Saint-Paul,18000,BOURGES,http://www.museum-bourges.net,47.069228,2.412463 -Musée du Berry,"Hôtel Cujas - -4, Rue des Arènes",18000,BOURGES,site de la ville www.ville-bourges.fr,47.08537,2.392969 -Musée Estève,"Hôtel des Echevins - -13, Rue Edouard Branly",18000,BOURGES,Site de la ville : www.ville-bourges.fr,47.085951,2.396195 -Musée des Arts Décoratifs,"Hôtel Lallemant - -5, Rue de l'Hôtel Lallemant",18000,BOURGES,Site de la ville www.ville-bourges.fr,47.084605,2.398378 -Musée Emile Chénon,"10-12, Rue de la Victoire",18370,CHATEAUMEILLANT,,46.561505,2.196409 -Musée du Château Charles VII,Place du Général Leclerc,18500,MEHUN-SUR-YEVRE,http://www.ville-mehun-sur-yevre.fr/Le-Château,47.143299,2.216615 -Musée Saint-Vic,Cours Manuel,18200,SAINT-AMAND-MONTROND,,46.720866,2.506685 -Musée Bonnevallais,Rue des Ecoles,28800,BONNEVAL,,48.183865,1.390081 -Muséum des Sciences Naturelles et de Préhistoire,"5 bis, Bd de la Courtille",28000,CHARTRES,site de la ville : www.ville-chartres.fr ou http://perso.wanadoo.fr/samnel.museum/,48.441822,1.490983 -Conservatoire du Machinisme et des Pratiques Agricoles,"1, rue de la République",28300,CHARTRES-MAINVILLIERS,http://www.lecompa.com,48.446888,1.476576 -Musée des Beaux-Arts et d'Histoire Naturelle,"3, rue Toufaire",28200,CHATEAUDUN,http://www.ville-chateaudun.fr ou www.musees.regioncentre.fr,48.070792,1.327905 -Musée d'Art et d'Histoire Marcel Dessal,"5-7, Place du musée",28100,DREUX,http://www.musees.regioncentre.fr - site de la région,48.733472,1.368066 -Musée Marcel Proust,"4, rue du Docteur Proust - -B.P. 20025",28120,ILLIERS-COMBRAY,http://perso.orange.fr/marcelproust/,48.29985,1.243452 -Musée du Château Saint-Jean,Rue du Château,28400,NOGENT-LE-ROTROU,http://www.ville-nogent-le-rotrou.fr/html/art_cult/musee/collec.htm,48.318117,0.823574 -Musée Farcot,Place Farcot,28700,SAINVILLE,mairie.perso.fr/sainville/index.htm,48.415949,1.879013 -Musée de la Chemiserie et de l'Elégance Masculine,"Rue Charles Brillaud - -BP 154",36200,ARGENTON-SUR-CREUSE,http://chemiserie.cc-argenton.fr,46.588407,1.518501 -Château Musée,31/33 Rue Hersent Luzarche,36290,AZAY-LE-FERRON,http://www.château-azay-le-ferron.com,46.851361,1.069524 -Musées de Châteauroux - Hôtel Bertrand,"2, Rue Descente-des-Cordeliers",36000,CHATEAUROUX,http://www.ville-chateauroux.fr - Site de la ville,46.813737,1.694262 -Musée de l'Hospice Saint-Roch,"23, Rue de l'Hospice Saint-Roch - -B.P. 150",36100,ISSOUDUN Cedex,http://www.issoudun.fr,46.945388,1.994497 -Musée George Sand et de la Vallée Noire,"71, Rue Venôse",36400,LA CHATRE,Site de la région ou www.pays-george-sand.com,46.581939,1.989964 -Musée de Géologie Régionale,,36600,LANGE,,47.064325,1.485945 -Musée Ornithologique (Ecomusée de la Brenne),Château Naillac,36300,LE BLANC,Site de la région centre,46.629212,1.061409 -Musée de la poste,"6, Rue Joyeuse",37400,AMBOISE,,47.411309,0.984736 -Musée de l'Hôtel de Ville,"60, Rue Concorde - -B.P. 247",37402,AMBOISE Cedex,Site de la ville www.ville-amboise.fr,47.413209,0.984493 -Musée du Vieux Chinon,"44, Rue Haute-Saint-Maurice",37500,CHINON,http://www.chinon-histoire.org,47.167125,0.23767 -"""Maison Musée"" René Descartes","29, Rue Descartes",37160,DESCARTES,http://www.ville-descartes.fr/pratique/f_pratique.htm,46.972623,0.699988 -Musée de Préhistoire du Grand Pressigny,Château du Grand-Pressigny,37350,LE GRAND PRESSIGNY,http://www.prehistoiregrandpressigny.fr,46.92224,0.804273 -Musée Lansyer,"1, rue Lansyer",37600,LOCHES,Site de la région centre ou http://www.lochesentouraine.com,47.126829,0.997457 -Musée du Terroir,,37600,LOCHES,,47.126959,0.989963 -Musée de l'Hôtel de Ville,"1, Place du Marché",37120,RICHELIEU,http://www.ville-richelieu.fr,47.012709,0.324356 -Musée Balzac,Château de Saché,37190,SACHE,"Site hébergé sur www.lysdanslavallee.fr,",47.245773,0.54394 -Musée Municipal,Place du Château,37800,SAINTE-MAURE-DE-TOURAINE,http://www.sainte-maure-de-touraine.fr,47.1115,0.619817 -Musée du Savignéen,"10, Faubourg Rue",37340,SAVIGNE-SUR-LATHAN,http://www.museedusavigneen.com,47.44581,0.319721 -Ecomusée du Véron,"80, Route de Candes",37420,SAVIGNY-EN-VERON,http://www.cc-veron.fr/ecomusee,47.209697,0.124427 -Musée Rabelais,Maison de la Devinière,37500,SEUILLY,http://www.musee-rabelais.fr ou site du conseil général,47.140565,0.179408 -Muséum d'Histoire Naturelle,"3, Rue du Président Merville",37000,TOURS,http://www.museum.tours.fr,47.394637,0.68388 -Musée des Beaux-Arts,"18, Place François Sicard",37000,TOURS,http://www.mba.tours.fr ou www.musees-regioncentre.fr,47.394698,0.694004 -Musée des Vins de Touraine,"Cellier Saint-Julien - -16, Rue Nationale",37000,TOURS,site de la ville,47.393831,0.687403 -Musée du Compagnonnage,"Cloître Saint-Julien - -8, Rue Nationale",37000,TOURS,http://www.museecompagnonnage.fr,47.396086,0.686726 -Musée de l'Hôtel Goüin,"25, Rue du Commerce - -B.P. 1105",37011,TOURS Cedex,Site du conseil général,47.395094,0.684809 -Musée de Minerve,Place du Musée,37290,YZEURES-SUR-CREUSE,,46.785891,0.870036 -Maison de la Magie,"1, Place du Château",41000,BLOIS,http://www.maisondelamagie.fr,47.586104,1.332126 -Château Musées de Blois,Château de Blois,41000,BLOIS,http://www.chateaudeblois.fr,47.586086,1.332121 -Muséum d'Histoire Naturelle,"""Les Jacobins"" - -Rue Anne de Bretagne",41000,BLOIS,http://www.ville-blois.fr,47.585156,1.332989 -Musée de la Corbillière,Château de la Corbillière,41500,MER,,47.696527,1.514337 -Atelier Musée Louis Leygue,"106, Rue des Venages",41100,NAVEIL,,47.794146,1.031198 -Musée Archéologique,Hôtel de Ville,41140,THESEE,http://www.musees.regioncentre,47.317703,1.323178 -Musée Municipal Théâtre Forain et Archéologique,Quartier du Paradis,45410,ARTENAY,http://www.musee-theatre-forain.fr,48.08201,1.879598 -Musée Daniel Vannier,"Hôtel de Ville - Service patrimoine - -20, Rue du Change",45190,BEAUGENCY,site de la région ou www.patrimoine-beaugency.fr,47.77814,1.630916 -Musée de la Marine de Loire,"Ecuries du Château - -1, Place Aristide Briand",45110,CHATEAUNEUF-SUR-LOIRE,http://www.chateauneuf-sur-loire.com ou www.musees-centre.com,47.865168,2.21918 -Musée d'Art et d'Archéologie,"2, Faubourg Puyrault",45230,CHATILLON-COLIGNY,http://www.ifrance.com/chatillon-coligny,47.820625,2.844906 -Musée Gaston Couté,"Espace culturel La Monnaye - -22 Rue des Remparts",45130,MEUNG-SUR-LOIRE,http://www.meung-sur-loire.com,47.825556,1.694889 -Musée Girodet,"2, rue de la Chaussée",45200,MONTARGIS,http://www.montargis.fr/musee.htm,47.997843,2.737468 -Musée du Gatinais,"7, Rue du Château",45200,MONTARGIS,,47.998314,2.729233 -Muséum des Sciences Naturelles,"6, Rue Marcel Proust",45000,ORLEANS,http://www.orleans.fr,47.90807,1.907907 -Musée des Beaux-Arts,"1, rue Fernand Rabier",45000,ORLEANS,Région : www.musees.regioncentre.fr ou www.orleans.fr (rubrique culture/musee),47.903147,1.909903 -Musée de la Maison de la Dernière Cartouche,12 Avenue de la Dernière Cartouche,08140,BAZEILLES,http://www.maisondeladernierecartouche.com,49.681811,4.975009 -Musée Arthur Rimbaud,Quai Arthur Rimbaud,08000,CHARLEVILLE-MEZIERES,http://www.mairie-charlevillemezieres.fr/htm/culture/rimbaud.htm,49.776058,4.720484 -Maison des Ailleurs - Arthur Rimbaud,Quai Rimbaud,08000,CHARLEVILLE-MEZIERES,Site de la ville,49.776058,4.720484 -Musée de l'Ardenne,"31, Place Ducale",08000,CHARLEVILLE-MEZIERES,http://www.mairie-charlevillemezieres.fr/htm/culture/rimbaud.htm,49.773621,4.721113 -Musée de Givet,"Mairie - -11, Place Carnot",08600,GIVET,,50.136341,4.8247 -Musée du Feutre,Mairie,08210,MOUZON,http://www.culture.gouv.fr/champagne-ardenne/2culture/musee_france/musee_mouzon.html,49.609209,5.075187 -Musée du Rethelois et du Porcien,Place Noiret-Chaigneau,08300,RETHEL,,49.506881,4.363521 -Musée du Château-Fort,BP 10322,08200,SEDAN,http://www.chateau-fort-sedan.fr,49.69468,4.920339 -Musée Napoléon Ier,"34, Rue de l'Ecole Militaire",10500,BRIENNE-LE-CHATEAU,http://www.musee-napoleon-brienne.fr,48.388915,4.528446 -Musée de la Résistance,Rue Boursault,10250,MUSSY-SUR-SEINE,Site de l'OT,47.977391,4.498921 -Musée des Beaux-Arts,"1, Rue Chrestien-de-Troyes",10000,TROYES,http://www.musees-troyes.com,48.300638,4.08003 -Musée Historique de Troyes et de la Champagne,"4, Rue Vauluisant",10000,TROYES,,48.294239,4.071824 -Pharmacie Musée de l'Hôtel-Dieu,Quai des Comtes-de-Champagne,10000,TROYES,,48.300768,4.077528 -Maison de l'Outil et de la Pensée Ouvrière,"7, Rue de La Trinité",10000,TROYES,http://www.maison-de-l-outil.com,48.294907,4.07261 -Musée de la Bonneterie,"Conservation des Musées - -2, rue Chrestien de Troyes",10000,TROYES,http://www.ville-troyes.fr,48.300945,4.0797 -Muséum d'Histoire Naturelle,"4, Rue Chrestiens de Troyes",10000,TROYES,Site de la ville,48.300722,4.07987 -Musée d'Art Moderne,"14, Place Saint-Pierre",10000,TROYES,Site de la ville,48.300046,4.080194 -Musée des Beaux-Arts et d'Archéologie,Place du Palais,52000,CHAUMONT,http://www.ville-chaumont.fr,48.112453,5.137406 -Musée de l'Affiche - médiathèque,"7/9, Avenue Foch",52000,CHAUMONT,,48.110208,5.129477 -Maison des Lumière Denis Diderot (ex Musée du Breuil de Saint-Germain),"1, Place Pierre Burelle",52200,LANGRES,http://www.maisondeslumieres.org,47.866417,5.331753 -Musée Municipal,"17, Rue de la Victoire",52100,SAINT-DIZIER,Site de la mairie,48.637908,4.9485 -Musée du Cloître de Notre-Dame-en-Vaux,Rue Nicolas Durant,51000,CHALONS-EN-CHAMPAGNE,,48.958112,4.362901 -Musée Garinet,"13, rue Pasteur",51000,CHALONS-EN-CHAMPAGNE,,48.955828,4.366495 -Musée Municipal,Place Alexandre Godart,51000,CHALONS-EN-CHAMPAGNE,,48.956869,4.363827 -Musée Municipal d'Epernay,"13, Avenue de Champagne",51200,EPERNAY,,49.042901,3.961379 -Musée de l'Ancien Collège des Jésuites,"Direction de la Culture de la ville de Reims - -1, place Museux",51100,REIMS,,49.253125,4.04484 -Musée Hôtel le Vergeur,"36, Place du Forum",51100,REIMS,http://www.museelevergeur.com,49.257013,4.034043 -Musée Saint-Rémi,"53, rue Simon",51100,REIMS,Site de la ville,49.243111,4.040351 -Musée des Beaux-Arts de Saint-Denis,"8, Rue Chanzy",51100,REIMS,site de la ville,49.253442,4.030921 -Musée de l'Argonne Viard-Morel,Place Général Leclerc,51800,SAINTE-MENEHOULD,,49.092431,4.898023 -Musée Fesch,"50-52, rue du Cardinal Fesch",20000,AJACCIO,http://www.musee-fesch.com,41.922392,8.738549 -Musée de la Maison Bonaparte,Rue Saint-Charles,20000,AJACCIO,http://www.musee-maisonbonaparte.fr - www.musees-nationaux-napoleoniens.org,41.917772,8.738068 -Musée Napoléonien,"Hotel de Ville - -Avenue Antoine Serafini",20000,AJACCIO,http://www.napoleon.org ou http://www.ajaccio.fr/,41.918813,8.738403 -Musée de Préhistoire Corse,Boulevard Jacques Nicolai,20100,SARTENE,,41.620393,8.97461 -Musée Départemental d'Archéologie d'Aléria,Fort de Matra,20270,ALERIA,http://www.cg2b.fr,42.103684,9.511502 -Musée de Bastia,"Mairie de Bastia - -Avenue Pierre Giudicelli",20410,BASTIA Cedex,http://www.musee-bastia.com,42.702212,9.451447 -Musée Archéologique de Mariana - Prince Rainier III de Monaco,Mairie de Lucciana,20290,LUCCIANA,"http://www.lucciana.fr/mariana,-site-historique-le-musee-archeologique-de-mariana-%20-%C2%AB-prince-rainier-iii-de-monaco-%C2%BB_202.html",42.542624,9.497059 -Musée Départemental Pascal Paoli,Hameau de la Stretta 20261,20218,MOROSAGLIA,http://www.cg2b.fr,42.437734,9.308812 -Musée des Beaux-Arts et d'Archéologie,"1, Place de la Révolution",25000,BESANCON,http://www.musee-arts-besancon.org,47.240051,6.022927 -Muséum de Besançon,"La Citadelle - -Rue des Fusillés de la Résistance",25000,BESANCON,http://www.citadelle.com,47.232614,6.032133 -Musée de la résistance et de la Déportation,"La Citadelle - -Rue des Fusillés",25000,BESANCON,http://www.citadelle.com,47.232614,6.032133 -Musée Comtois,"LA CITADELLE - -Rue des Fusillés",25000,BESANCON,http://www.citadelle.com,47.232614,6.032133 -Musée du Temps,"96, Grande rue",25000,BESANCON,http://www.besancon.fr/museedutemps,47.23602,6.026659 -Musée Beurnier-Rossel,"8, Place Saint-Martin - -B.P. 95287",25200,MONTBELIARD,http://www.montbeliard.fr,47.509957,6.797684 -Musée du Château des Ducs de Wurtemberg,B.P. 95287,25205,MONTBELIARD Cedex,http://www.montbeliard.fr,48.890388,5.555086 -Musée de Plein Air des Maisons Comtoises,Rue du Musée,25360,NANCRAY,http://www.maisons-comtoises.org,47.243044,6.189533 -Musée de Pontarlier,"2, Place d'Arçon",25300,PONTARLIER,Site de la ville,46.904221,6.354581 -Musée Départemental Albert Demard,Château de Champlitte,70600,CHAMPLITTE,http://www.cg70.fr/decouvertes/musee,47.661612,5.466123 -Ecomusée du Pays de la Cerise,"206, Le Petit Fahyr",70220,FOUGEROLLES,http://www.musees-des-techniques.org,47.907071,6.401161 -Musée Baron Martin,"6, rue Pigalle",70100,GRAY,http://www.ville-gray.fr,47.446411,5.592781 -Musée Départemental de la Montagne Albert Demard,Château-Lambert,70440,LE HAUT-DU-THEM,http://musees.cg70.fr,47.845927,6.746415 -Musée de la Tour des Echevins,"36, Rue Victor Genoux",70300,LUXEUIL-LES-BAINS,http://www.luxeuil.fr,47.816844,6.38053 -Musée Georges Garret,"1, Rue des Ursulines",70000,VESOUL,Site des musées de la région,47.623695,6.155772 -Musée Sarret de Grozon,Grande Rue,39600,ARBOIS,Site des musées de la région,46.903356,5.773137 -Musée Municipal d'Archéologie,"26, rue Baronne Delort",39300,CHAMPAGNOLE,http://www.tourisme.champagnole.com,46.74651,5.907547 -Musée des Beaux-Arts,"85, rue des Arènes",39100,DOLE,Site de la ville ou site des musées de la région,47.089059,5.488205 -Musée d'Archéologie,"7, Rue des Cordeliers",39000,LONS-LE-SAUNIER,Site de la ville,46.674433,5.55523 -Musée des Beaux-Arts,Place Philibert de Châlon,39000,LONS-LE-SAUNIER,Site des musées de la région,46.675825,5.553897 -Musée du Jouet,"5, Rue du Murgin",39260,MOIRANS-EN-MONTAGNE,http://www.musee-du-jouet.fr et Site des musées de la région,46.433004,5.726307 -Viséum-Musée de la Lunette,Place Jean Jaurès,39400,MOREZ,http://www.musee-lunette.fr ou www.musees-franchecomte.com,46.529788,6.020053 -Musée Municipal de Poligny,"Hôtel de Ville - -49, Grande Rue",39800,POLIGNY,http://www.musee-poligny.fr,46.836426,5.708802 -Musée de l'Abbaye/Donations Guy Bardone - René Genis,"3, Place de l'Abbaye",39200,SAINT-CLAUDE,Site des musées de la région,46.386209,5.865239 -Musée des Salines,Place des Salines,39110,SALINS-LES-BAINS,http://www.salinesdessalins.com ou www.musees-des-techniques.org,46.939895,5.877348 -Musée Max Claudet,Place des Salines,39110,SALINS-LES-BAINS,http://www.juramusees.fr/1/musee/musees/beaux_arts/musee_max_claudet_salins_les_bains.html,46.939895,5.877348 -Musée de l'Artisanat Jurassien,Abbaye de Baume-Les-Messieurs,39210,VOITEUR,,46.707619,5.64848 -Musée Frédéric Japy,"16, Rue Frédéric Japy",90500,BEAUCOURT,http://www.musees-des-techniques.org,47.485011,6.922453 -Forge-Musée,"2, Rue de Lamadeleine",90170,ETUEFFONT,http://www.musees-des-techniques.org ou www.cc-pays-sous-vosgien.fr,47.722575,6.920842 -Musée Précolombien Edgar Clerc,440 Route de la Rosette,97160,LE MOULE,http://www.cg971.fr/musees/clerc/index_edgar.htm,16.340101,-61.382285 -Musée Municipal Saint-John Perse,"9, rue de Nozière",97110,POINTE-A-PITRE,http://www.sjperse.org/museesjp.htm,16.236416,-61.535945 -Musée Schoelcher,"24, Rue Peynier",97110,POINTE-A-PITRE -GUADELOUPE,http://www.cg971.fr/musees/schoelcher/index_schoecher.htm,16.237697,-61.537737 -Musée des Cultures Guyanaises,"78, Rue Mme Payé",97300,CAYENNE,http://www.mcg973.org ou www.amazonian-museum-network.org,4.944611,-52.302857 -Musée Départemental Alexandre Franconie,"1, Avenue du Général-de-Gaulle",97300,CAYENNE - GUYANE,,4.938177,-52.334359 -Ecomusée Municipal d'Approuague-Kaw,"Rue Gaston Monnerville - -Le Bourg",97390,REGINA,,16.333767,-61.348355 -Musée Français de la Photographie,"78, Rue de Paris",91570,BIEVRES,http://www.museedelaphoto.fr,48.762826,2.225102 -Musée Dunoyer de Segonzac,"5, Place des Droits de l'Homme",91800,BOUSSY-SAINT-ANTOINE,,48.688925,2.530019 -Musée Municipal Robert Dubois-Corneau,"16, Rue du Réveillon",91800,BRUNOY,http://www.brunoy.fr,48.700303,2.500266 -Musée du Château,Place du Général de Gaulle,91410,DOURDAN,Site de la ville - www.mairie-dourdan.fr,48.529349,2.01149 -Musée Belmondo et de la Sculpture Figurative du XXè siècle,"Château Buchillot - -14, Rue de l'Abreuvoir",92100,BOULOGNE-BILLANCOURT,http://www.boulognebillancourt.com,48.847455,2.232701 -Musée-Jardin Paul Landowski,"14, Rue Max Blondat",92100,BOULOGNE-BILLANCOURT,,48.844261,2.246847 -Musée Départemental Albert-Kahn,"10-14, Rue du Port",92100,BOULOGNE-BILLANCOURT,http://www.albert-kahn.fr,48.841354,2.227488 -Musée des Années Trente,"Espace Landowski - -28, avenue André Morizet",92100,BOULOGNE-BILLANCOURT,Site de l'association des amis - www.annees30.com/,48.83591,2.239289 -Fondation Arp,"21, Rue des Châtaigniers",92140,CLAMART,http://www.fondationarp.org,48.803529,2.244319 -Musée Municipal d'Art et d'Histoire,"2, Rue Gabriel Péri",92700,COLOMBES,Site de la ville - www.mairie-colombes.fr,48.923321,2.252039 -Musée Roybet Fould,"Parc de Bécon - -178, Boulevard Saint Denis",92400,COURBEVOIE,,48.900998,2.271279 -Musée des Travaux Publics,Mairie de Courbevoie,92400,COURBEVOIE,Site de la ville,48.89371,2.258054 -Musée Français de la Carte à Jouer,"16, Rue Auguste Gervais",92130,ISSY-LES-MOULINEAUX,http://www.issy.com/musee,48.822771,2.273453 -Musée - Atelier Rodin,"19, Avenue Auguste Rodin",92190,MEUDON,http://www.musee-rodin.fr,48.813854,2.25249 -Musée d'Art et d'Histoire,"11, Rue des Pierres",92190,MEUDON,http://www.meudon.fr,48.806412,2.234894 -Musée des Automates,"Hôtel Arturo Lopez - -12, Rue du Centre",92200,NEUILLY-SUR-SEINE,,48.879608,2.252754 -Musée National des Châteaux de Malmaison et de Bois-Préau,Avenue du Château de Malmaison,92500,RUEIL-MALMAISON,http://www.chateau-malmaison.fr,48.872958,2.169032 -Musée Franco-Suisse,"13, Boulevard Foch",92501,RUEIL-MALMAISON Cedex,Site de la mairie : /www.mairie-rueilmalmaison.fr,48.878122,2.180317 -Musée d'Histoire Locale - Mémoire de la Ville,"13, Boulevard Foch",92501,RUEIL-MALMAISON Cedex,Site de la mairie : /www.mairie-rueilmalmaison.fr,48.878122,2.180317 -Musée Municipal de Saint-Cloud,"""Jardin des Avelines"" - -60, Rue Gounod",92210,SAINT-CLOUD,http://www.saint-cloud.fr,48.842497,2.208286 -Musée de l'Île de France,,92330,SCEAUX,http://www.chateau-sceaux.fr ou/www.domaine-de-sceaux.fr (site du conseil général) ou www.sceaux.fr,48.778555,2.288291 -Sèvres - Cité de la Céramique,"2, Place de la Manufacture",92310,SEVRES,http://www.sevresciteceramique.fr,46.575183,0.305931 -MUS - Musée d'Histoire Urbaine et Sociale,"1, Place de la gare de Suresnes-Longchamp",92150,SURESNES,http://www.ville-suresnes.fr ou http://webmuseo.com/ws/musee-suresnes/app/report/index.html,48.868319,2.221999 -Musée National de L'Orangerie des Tuileries,Jardin des Tuileries,75001,PARIS,http://www.musee-orangerie.fr,48.862968,2.323733 -Musée National Picasso,"Hôtel Salé - -5, Rue de Thorigny",75003,PARIS,http://www.musee-picasso.fr,48.8597,2.362644 -"Musée Cognacq-Jay, Musée du XVIIIe siècle de la ville de Paris","8, Rue Elzévir",75003,PARIS,http://www.museecognacqjay.paris.fr,48.858257,2.361524 -Musée Carnavalet-Histoire de Paris,"16, Rue de Sévigné",75003,PARIS,http://www.carnavalet.paris.fr,48.857573,2.363359 -Maison de Victor Hugo,"6, Place des Vosges",75004,PARIS,http://www.maisonsvictorhugo.paris.fr,48.854821,2.366126 -Musée National du Moyen Age-Thermes de Cluny,"6, Place Paul Painlevé",75005,PARIS,http://www.musee-moyenage.fr,48.850325,2.343921 -Galerie de Minéralogie et de Géologie (Muséum d'Histoire Naturelle),"Jardin des Plantes - -36, Rue Geoffroy Saint-Hilaire",75005,PARIS,http://www.mnhn.fr,48.841491,2.355989 -Grande Galerie de l'Evolution (Muséum National d'Histoire Naturelle),"Jardin des Plantes - -36, rue Geoffroy Saint-Hilaire",75005,PARIS,http://www.mnhn.fr,48.841491,2.355989 -Galerie de Botanique (Muséum National National d'Histoire Naturelle),,75005,PARIS,http://www.mnhn.fr,48.844107,2.351541 -Galerie d’entomologie (Muséum national d'histoire naturelle),"57, Rue Cuvier",75005,PARIS,http://www.mnhn.fr,48.843835,2.355201 -Musée de l'Assistance Publique - Hôpitaux de Paris,"10, Rue des Fossés Saint-Marcel",75005,PARIS,http://www.aphp.fr,48.838808,2.356014 -Crypte Archéologique du Parvis Notre-Dame,"7, Place Jean Paul II - -Parvis Notre-Dame",75004,PARIS,http://www.crypte/paris.fr,49.11925,6.174731 -Musée Nissim de Camondo (Les Arts Décoratifs),"63, Rue de Monceau",75008,PARIS,http://www.lesartsdecoratifs.fr/,48.878849,2.312855 -Musée d'Art et d'Histoire du Judaïsme,"Hôtel de Saint-Aignan - -71, Rue du Temple",75003,PARIS,http://www.mahj.org,48.86095,2.355608 -Galerie d'Anatomie Comparée et de Paléontologie (Muséum d'Histoire Naturelle),"Jardin des Plantes - -2, rue Buffon",75005,PARIS,http://www.mnhn.fr,48.842137,2.359895 -Musée de l'Armée,"Hôtel National des Invalides - -129, rue de Grenelle",75007,PARIS,/www.musee-armee.fr,48.858219,2.312885 -Établissement Public de la Porte Dorée - Musée de l’Histoire de l’Immigration,"Palais de la Porte Dorée - -293, avenue Daumesnil",75012,PARIS,http://www.histoire-immigration.fr,48.834887,2.408507 -Musée de la Chasse et de la Nature,"60, rue des Archives",75003,PARIS,http://www.chassenature.org/,48.861337,2.358433 -Musée National de la Marine,"17, Place du Trocadéro",75116,PARIS,http://www.musee-marine.fr,48.862492,2.287378 -Musée de la Publicité (Les Arts Décoratifs),"107, Rue de Rivoli",75010,PARIS,http://www.museedelapub.org et www.lesartsdecoratifs.fr/,48.863218,2.3333 -Musée Jean-Jacques Henner,"43, Avenue de Villiers",75017,PARIS,http://www.musee-henner.fr ou www.henner-intime.fr,48.883045,2.307689 -Musée de Montmartre,"12, Rue Cortot",75018,PARIS,http://www.museedemontmartre.fr,48.887709,2.340577 -Musée de l'Homme (Muséum National d'Histoire Naturelle),Place du Trocadéro,75116,PARIS,http://www.mnhn.fr,48.862507,2.282278 -Musée Zadkine,"100 bis, rue d'Assas",75006,PARIS,http://www.zadkine.paris.fr,48.843177,2.333969 -Palais Galliéra - Musée de la Mode de la ville de Paris,"10, Avenue Pierre 1er de Serbie",75116,PARIS,http://www.palaisgalliera.paris.fr,48.865846,2.296361 -Etablissement Public du Musée d'Orsay,"62, Rue de Lille",75007,PARIS,http://www.musee-orsay.fr,48.859754,2.325917 -Musée de la Musique,"221, Avenue Jean-Jaurès",75019,PARIS,http://www.citedelamusique.fr,48.889306,2.393807 -Musée Hébert,"Hôtel de Montmorency-Bours - -85, Rue du Cherche Midi",75006,PARIS,,48.847429,2.322709 -"Petit Palais, Musée des Beaux-Arts de la ville de Paris",Avenue Winston-Churchill,75008,PARIS,http://www.petitpalais.paris.fr,48.866084,2.313813 -Musée National Gustave Moreau,"14, Rue de la Rochefoucauld",75009,PARIS,http://www.musee-moreau.fr,48.877878,2.334383 -Musée de la Franc-Maçonnerie,"16, Rue Cadet",75009,PARIS,http://www.museefm.org,48.874952,2.343057 -Musée de la Vie Romantique,"16, Rue Chaptal",75009,PARIS,http://www.vie-romantique.paris.fr,48.880899,2.333278 -Etablissement public de la Porte Dorée - Aquarium Tropical,"293, avenue Daumesnil",75012,PARIS,http://www.aquarium-portedoree.fr,48.834887,2.408507 -"Musée Cernuschi, Musée des Arts de l'Asie de la ville de Paris","7, Avenue Velasquez",75008,PARIS,http://www.cernuschi.paris.fr,48.879693,2.312194 -Musées Arts Décoratifs Mode et du Textile,"107, Rue de Rivoli",75001,PARIS,http://www.lesartsdecoratifs.fr,48.863218,2.3333 -Musée National de la Légion d'Honneur et des Ordres de Chevalerie,"2, Rue de la Légion d'Honneur",75007,PARIS,http://www.musee-legiondhonneur.fr,48.860433,2.324889 -Musée du Louvre,"34, Quai du Louvre",75001,PARIS,http://www.louvre.fr,48.858905,2.340991 -Musée National du Sport,"93, avenue de France",75013,PARIS,http://www.museedusport.fr,48.829117,2.37785 -Musée National Eugène Delacroix,"6, Rue Furstenberg",75006,PARIS,http://www.musee-delacroix.fr,48.854437,2.335771 -Musée du Général Leclerc de Hauteclocque et de la Libération de Paris - Musée Jean Moulin,"23, Allée de la 2ème DB",75015,PARIS,http://www.ml-leclerc-moulin.paris.fr,48.840251,2.319293 -Musées des Arts Décoratifs,"107, Rue de Rivoli",75001,PARIS,http://www.lesartsdecoratifs.fr/,48.863218,2.3333 -Musée Bourdelle,"16, Rue Antoine Bourdelle",75015,PARIS,http://www.bourdelle.paris.fr,48.843017,2.31883 -Les Catacombes,"1, avenue du Colonel Henri Rol-Tanguy - -(Place Denfert-Rochereau)",75014,PARIS,http://www.catacombes.paris.fr,48.833784,2.331927 -Maison de Balzac,"47, rue Raynouard",75016,PARIS,http://www.balzac.paris.fr,48.85538,2.280755 -Musée National Auguste Rodin,"Hôtel Biron - -77, rue de Varenne",75007,PARIS,http://www.musee-rodin.fr,48.855868,2.31598 -Musée d'Ennery,"59, Avenue Foch",75116,PARIS,http://www.guimet.fr,48.871887,2.282381 -Etablissement Public du Musée des Arts Asiatiques Guimet,"6, Place d'Iéna",75116,PARIS,http://www.museeguimet.fr,48.865008,2.293674 -M.U.C.E.M. - Musée des ATP,"6, Avenue du Mahatma Gandhi",75116,PARIS,http://www.musee-europemediterranee.org,48.877026,2.267791 -Musée des Monuments Français,"Palais de Chaillot - -1, Place du Trocadéro et du 11 Novembre",75116,PARIS,http://www.citechaillot.fr,48.862887,2.287945 -Musée Bouchard,"25, Rue de l'Yvette",75016,PARIS,http://www.musee-bouchard.com,48.853611,2.265988 -Musée National des Techniques (Conservatoire National des Arts et Métiers),"292, rue Saint-Martin",75141,PARIS cedex 03,http://www.arts-et-metiers.net,48.866961,2.354634 -Musée National d'Art Moderne (Centre National d'Art et de Culture Georges Pompidou),Place Georges Pompidou,75191,PARIS Cedex 04,http://www.centrepompidou.fr,48.844444,2.271958 -Musée du Service de Santé des Armées du Val-de-Grâce,"1, Place Alphonse Laveran",75230,PARIS Cedex 05,http://www.valdegrace.org,48.840814,2.341091 -Institut du Monde Arabe,"1, Rue des Fossés Saint-Bernard - -Place Mohammed V",75236,PARIS Cedex 05,http://www.imarabe.org,48.849302,2.356099 -Musée des Monnaies et des Médailles,"11, Quai de Conti",75270,PARIS Cedex 06,http://www.monnaiedeparis.fr,48.857003,2.338699 -Musée du Quai Branly,"222, rue de l'Université",75343,PARIS cedex 07,http://www.quaibranly.fr,48.860136,2.297256 -L'Adresse Musée de la Poste,"34, Boulevard de Vaugirard",75731,PARIS Cedex 15,http://www.museedelaposte.fr ou www.laposte.fr/musee,48.841347,2.317414 -Musée Départemental de l'Ecole de Barbizon - Auberge Ganne,"92, Grande Rue",77630,BARBIZON,"www.seine-et-marne.fr (rubrique ""loisirs/musées départementaux)",48.446153,2.602548 -Musée Municipal de Chelles Alfred Bonno,Place de la République,77500,CHELLES,http://www.chelles.fr/Culture/Musee-Alfred-Bonno,48.877886,2.59188 -"Musée des Transports Urbains, Interurbains et Ruraux","1, Rue Gabriel de Mortillet",77500,CHELLES,http://www.amtuir.org,48.87682,2.603374 -Maison Natale de Louis Braille,"13, Rue Louis-Braille",77700,COUPVRAY,http://www.braillenet.org/louis_braille/maisnat.htm,48.894538,2.791986 -Musée Municipal,,77580,CRECY-LA-CHAPELLE,dgs@crecylachapelle.eu,48.858000,2.907321 -Musée d'Art et d'Histoire Militaire,"88, Rue St-Honoré",77300,FONTAINEBLEAU,http://www.napoleon.org/fr,48.406893,2.698781 -Musée du Château de Fontainebleau,Château de Fontainebleau,77300,FONTAINEBLEAU,http://www.châteaudefontainebleau.fr,48.401648,2.700536 -Musée Gatien Bonnet,"8, Cour Pierre Herbin",77405,LAGNY-SUR-MARNE,Site de la ville,48.879644,2.707767 -Musée Henri Chapu,"937, Rue Chapu",77350,LE MEE-SUR-SEINE,Site de la ville,48.534859,2.635235 -Musée Bossuet,"5, Place Charles de Gaulle",77100,MEAUX,site de la ville - www.ville-meaux.fr,48.96087,2.8784 -Musée de la Gendarmerie Nationale,Avenue du 13ème Dragons,77000,MELUN,http://www.gendarmerie.interieur.gouv.fr/musee,48.546473,2.651614 -Musée de Melun,"5, Rue du Franc Mûrier",77008,MELUN Cedex,Site de la ville : www.ville-melun.fr,48.536398,2.658972 -Musée Municipal,Place de Samois,77250,MORET-SUR-LOING,http://www.ville-moret-sur-loing.fr,48.373057,2.813651 -Château-musée de Nemours,Rue Gautier 1er,77140,NEMOURS,Site de la ville - www.ville-nemours.fr,48.265557,2.696198 -Musée Départemental de Préhistoire d'Île de France,"48, Avenue Etienne Dailly",77140,NEMOURS,http://www.musee-prehistoire-idf.fr,48.260788,2.714709 -Musée de Provins et du Provinois,"7, rue du Palais",77160,PROVINS,Site de la ville : www.mairie-provins.fr,48.561624,3.290374 -Musée des Pays de Seine-et-Marne,"17, Avenue de la Ferté-Sous-Jouarre",77750,SAINT-CYR-SUR-MORIN,"Site du conseil général - www.seine-et-marne.fr - rubrique ""loisirs""",48.90803,3.18169 -Musée départemental Stéphane Mallarmé,"Pont de Valvins - -4, Promenade Stéphane Mallarmé",77870,VULAINES-SUR-SEINE,"www.weine-et-marne.fr (rubrique ""loisirs/musées départementaux)",48.430323,2.746605 -Musée de l'Air et de l'Espace,"Aéroport de Paris - Le Bourget - -BP 173",93352,LE BOURGET Cedex,http://www.museeairespace.fr,48.943418,2.427417 -Musée Municipal,Avenue du Consul Général Nordling,93190,LIVRY-GARGAN,http://www.mairie-livrygargan.fr/rubrique culture loisirs,48.917561,2.534138 -Musée de l'Histoire Vivante,"31, Boulevard Théophile Sueur",93100,MONTREUIL-SOUS-BOIS,http://www.museehistoirevivante.com,48.866151,2.469481 -Musée d'Art et d'Histoire,"22 bis, rue Gabriel Péri",93200,SAINT-DENIS,http://www.musee-saint-denis.fr,48.932336,2.355796 -Musée Municipal,"12, Rue Albert Dhalenne",93400,SAINT-OUEN,,48.916536,2.330208 -Musée du Vieil Argenteuil,"5, rue Pierre-Guienne",95100,ARGENTEUIL,Site de la ville - www.argenteuil.fr/article.php3?id_article=529,48.94388,2.256737 -Musée de la Renaissance - Château d'Ecouen,Château,95440,ECOUEN,http://www.musee-renaissance.fr ou www.musee-château-ecouen.fr,49.015861,2.378418 -Musée Archéologique du Val d'Oise,Place du Château,95450,GUIRY-EN-VEXIN,http://www.valdoise.fr/content/content15651.html,49.107989,1.848496 -Musée d'Art et d'Histoire Louis Senlecq,"46, Grande Rue",95290,L'ISLE-ADAM,http://musee.ville-isle-adam.fr/index.html,49.112091,2.218832 -ARCHEA - Musée Intercommunal d'Histoire et d'Archéologie,"56, Rue de Paris",95380,LOUVRES,http://www.archea-roissyportedefrance.fr,49.041117,2.50695 -Musée jean-Jacques Rousseau,"5, Rue Jean-Jacques Rousseau",95160,MONTMORENCY,/www.ville-montmorency.fr,48.986892,2.320916 -Musée Tavet Delacour,"4, Rue Lemercier",95300,PONTOISE,Site de la ville - www.ville-Pontoise.fr,49.05093,2.099776 -Musée Camille Pissarro,"17, Rue du Château",95300,PONTOISE,http://www.ville-pontoise.fr,49.048398,2.099888 -Musée Adrien Mentienne,"1, Grande Rue Charles de Gaulle",94360,BRY-SUR-MARNE,http://www.bry94.fr/bry/71.htm,48.835224,2.519827 -Ecomusée du Val de Bièvre,"Ferme de Cottinville - -41, rue Maurice Ténine",94260,FRESNES,http://www.ecomusee-valdebievre.fr,48.75525,2.326389 -Musée de Saint-Maur - Villa Médicis,"5, Rue Saint-Hilaire",94210,LA VARENNE-SAINT-HILAIRE,http://www.saint-maur.com/musee/musee.htm,48.792802,2.514062 -Musée Fragonard,"Ecole Nationale Vétérinaire d'Alfort - -7, Avenue du Gal de Gaulle",94704,MAISONS-ALFORT Cedex,http://musee.vet-alfort.fr,48.814274,2.421122 -Musée de Nogent-Sur-Marne,"36, Boulevard Gallieni",94130,NOGENT-SUR-MARNE,http://www.musee-nogentsurmarne.fr,48.84026,2.484292 -Musée Emile Jean,"31, rue Louis-Lenoir",94350,VILLIERS-SUR-MARNE,http://www.mairie-villiers94.com/francais/vie_quoti/vie_quot_culture7.php,48.826701,2.543144 -Musée de la Batellerie,"Château du Prieuré - -3, Place Gévelot",78700,CONFLANS-SAINTE-HONORINE,http://www.mairie-conflans-sainte-honorine.fr,48.992445,2.095621 -Musée de la Toile de Jouy,"Château de l'Eglantine - -54, rue Charles de Gaulle",78350,JOUY-EN-JOSAS,http://www.museedelatoiledejouy.fr,48.768986,2.153216 -Musée National de Port Royal des Champs,Route des Granges,78114,MAGNY-LES-HAMEAUX,http://www.port-royal-des-champs.eu,48.748152,2.015991 -Musée de l'Hôtel-Dieu,"1, Rue Thiers",78200,MANTES-LA-JOLIE,http://musee.ville-mantes-la-jolie.com,48.990874,1.719901 -Musée Victor Aubert,"24, rue Quincampoix",78580,MAULE,http://museeaubertmaule.free.fr,48.908957,1.849122 -Musée Zola-Dreyfus,"26, Rue Pasteur",78670,MEDAN,http://www.maisonzola-museedreyfus.com,48.955066,1.995741 -Musée-Maison Maurice Ravel,"5, Rue Maurice Ravel",78490,MONTFORT-L'AMAURY,http://www.ville-montfort-l-amaury.fr,48.776114,1.805347 -Musée de la Ville,Quai François Truffaut,78180,MONTIGNY LE BRETONNEUX,http://www.museedelaville.agglo-sqy.fr,48.782574,2.042599 -Musée d'Art et d'Histoire,"12, Rue Saint-Louis",78300,POISSY,Site de la ville,48.930899,2.038803 -Musée du Jouet Pierre Pinel,"1, Enclos de l'Abbaye",78300,POISSY,site de la ville,48.928536,2.03784 -Musée Rambolitrain,"4, Place Jeanne d'Arc",78120,RAMBOUILLET,http://www.rambolitrain.fr,48.646025,1.823892 -Musée Municipal - Collections Paul et André Véra,"Espace Véra - -2, Rue Henri IV",78100,SAINT-GERMAIN-EN-LAYE,Site de la ville,48.896357,2.097575 -Musée d'Archéologie Nationale (des origines à l'an mille) - Château de st-Germain-en-Laye,"Château - -Place Charles de Gaulle",78105,SAINT-GERMAIN-EN-LAYE Cedex,http://www.musee-antiquitesnationales.fr -www.musee-archeologienationale.fr,48.897404,2.094962 -Musée Départemental Maurice Denis,"2 bis, Rue Maurice Denis - -B.P. 60222",78102,SAINT-GERMAIN-EN-LAYE Cedex,http://www.musee-mauricedenis.fr,48.892496,2.087541 -Collections de la Fondation de Coubertin,Domaine de Coubertin,78470,ST-REMY-LES-CHEVREUSE,http://www.coubertin.fr,48.701169,2.06008 -Musée Lambinet,"54, Boulevard de la Reine",78000,VERSAILLES,http://www.musee-lambinet.com,48.808966,2.130624 -Etablissement Public du Musée et du Domaine National de Versailles,R.P. 834,78008,VERSAILLES Cedex,http://www.chateauversailles.fr,50.27117,1.666476 -"Musée Municipal ""Eburomagus Musée Archéologique""","2, Avenue du Razès",11150,BRAM,http://www.eburomagus.com,43.243772,2.116173 -Musée des Beaux-Arts,"1, Rue de Verdun",11000,CARCASSONNE,Site de la ville,43.212595,2.35536 -Musée Municipal,Mairie,11600,LASTOURS,,43.329607,2.381615 -Musée Archéologique,Allée des Potiers,11590,SALLELES D'AUDE,http://culture.legrandnarbonne.com/421-amphoralis.html - www.amphoralis.com,43.268326,2.939055 -Musée des Corbières,Place de la Libération,11130,SIGEAN,http://www.sigean.fr - site de la ville,43.028708,2.979241 -Musée du Colombier,Rue Jean Mayodon,30100,ALES,site de la ville - www.ville-ales.fr,44.129374,4.080928 -Musée Albert André,"Hôtel de ville - -place Mallet",30200,BAGNOLS-SUR-CEZE,Site dela ville ou Site du conseil général,44.162044,4.619756 -Musée Léon Alègre,"24, avenue Paul Langevin",30200,BAGNOLS-SUR-CEZE,Site de la ville ou site du conseil général,44.164783,4.621031 -Musée Municipal de la Vignasse,"Mairie - -Rue de l'Hôtel de ville",30300,BEAUCAIRE,http://www.beaucaire.fr/spip.php?article65,43.806612,4.644317 -Musée Cévenol,"1, Rue des Calquières",30120,LE VIGAN,http://www.museecevenol.com,43.988992,3.606958 -Musée Archéologique,"13, Boulevard Amiral Courbet",30000,NIMES,http://www.nimes.fr/index.php?id=280,43.837616,4.362815 -Musée des Cultures Taurines,"6, Rue Alexandre Ducros",30000,NIMES,http://www.nimes.fr/index.php?id=282,43.833806,4.358623 -Musée du Vieux Nîmes,Place aux Herbes,30000,NIMES,http://www.nimes.fr/index.php?id=283,43.838287,4.359585 -Carré d'Art Musée d'Art Contemporain de Nîmes,Place de la Maison Carrée,30031,NIMES Cedex 1,http://www.carreartmusee.nimes.fr,43.838163,4.356136 -Musée des Beaux-Arts,Rue Cité Foulc,30033,NIMES Cedex 9,http://www.nimes.fr,43.831958,4.360886 -Musée d'Histoire Naturelle et de Préhistoire,"13 Bis, Bd Amiral Courbet",30033,NIMES Cedex 9,site de la ville - www.nimes.fr,43.837676,4.36298 -Musée Départemental d'Art Sacré,"Maison des Chevaliers - -2, Rue Saint-Jacques",30130,PONT-SAINT-ESPRIT,http://www.gard-provencal.com/musees/artsacre.htm,44.255204,4.650869 -Musée Paul Raymond,Place de l'Ancienne Mairie,30130,PONT-SAINT-ESPRIT,http://www.gard-provencal.com/musees/praymond.htm,44.255685,4.650898 -Musée des Vallées Cévenoles,B.P. 08,30270,SAINT-JEAN DU GARD,http://www.museedescevennes.com,48.890388,5.555086 -Musée Municipal Georges Borias,BP 103,30701,UZES Cedex,http://uzesmusee.blogspot.fr/ ou http://www.uzes-tourisme.com/sitefr/musees/borias.htm,44.016322,4.41047 -Musée Pierre de Luxembourg,"3, Rue de la République",30400,VILLENEUVE-LES-AVIGNON,Site du conseil général,43.965435,4.796389 -Musée Agathois,5 rue de la Fraternité,34300,AGDE,,43.311837,3.468587 -Musée des Beaux-Arts de Béziers,Place de la Révolution,34500,BEZIERS,http://www.ville-beziers.fr/culture/02.cfm,43.341552,3.210676 -Muséum d'Histoire Naturelle,15 Place Pierre Semard,34500,BEZIERS,Site de la ville,43.343241,3.211279 -Musée de l'Etang de Thau,Quai du Port de Pêche,34140,BOUZIGUES,http://www.bouzigues.fr/musee,43.446763,3.660714 -Musée Municipal,"4 bis, Rue Lucien Salette",34110,FRONTIGNAN-LA-PEYRADE,site de la ville - www.ville-frontignan.fr,43.446743,3.754546 -Site Archéologique Lattara - Musée Henri Prades de Montpellier Agglomération,"390, Avenue de Pérols",34970,LATTES Cédex,http://www.montpellier-agglo.com/museearcheo,43.566729,3.908384 -Musée Intercommunal du Pic Saint-Loup,"1, Rue du Musée",34270,LES MATELLES,http://www.cc-picsaintloup.fr,43.725407,3.80852 -Musée Archéologique,Place du Monument aux Morts,34210,MINERVE,,43.354336,2.746215 -Musée de l'Hôtel d'Espeyran,"6, bis rue Montpelliéret",34000,MONTPELLIER,http://www.montpellier-agglo.com,43.611158,3.880156 -Musée Fabre,"13, rue Montpellieret",34000,MONTPELLIER,http://www.montpellier-agglo.com/museefabre,43.61122,3.880192 -Musée Languedocien,"Hôtel des Trésoriers de France - -7, Rue Jacques-Cœur",34000,MONTPELLIER,http://www.musee-languedocien.com,43.609525,3.879151 -Musée du Vieux Montpellier,"Hôtel de Varennes - -2, Place Pétrarque",34000,MONTPELLIER,http://www.montpellier.fr/3803-manifestations-culturelles.htm,43.610763,3.878354 -Agropolis-Muséum,"951, Avenue Agropolis",34394,MONTPELLIER Cedex 5,http://www.museum.agropolis.fr,43.646297,3.868582 -Musée Municipal,"Hôtel de ville - -Rue des Lavoirs",34570,MURVIEL-LES-MONTPELLIER,http://www.ville-murviel-les-montpellier.fr,43.605501,3.737131 -Musée de Vulliod Saint-Germain,"3, Rue Albert-Paul Allies",34120,PEZENAS,Site de la ville,43.461352,3.422892 -Musée de Préhistoire Régionale et du Mégalithisme,"8, Grand'Rue",34220,SAINT-PONS-DE-THOMIERES,http://www.pays-saintponais.com,43.488771,2.758652 -Musée Paul Valéry,"148, Rue François Desnoyer",34200,SETE,http://www.museepaulvalery-sete.fr,43.395512,3.691204 -Musée Ignon Fabre,"3, Rue de l'Epine",48000,MENDE,,44.518396,3.499291 -Ecomusée du Mont Lozère,Route de Finiels,48220,PONT DE MONTVERT,http://www.mescevennes.com/decouvertes/ecomusee_loz.htm,44.36401,3.746557 -Muséum d'Histoire Naturelle,"12, Place Fontaine Neuve",66000,PERPIGNAN,http://www.mairie-perpignan.fr,42.697424,2.899718 -Musée Archéologique de Ruscino,Château Roussillon,66000,PERPIGNAN,,42.708796,2.946234 -Musée Joseph Puig,"42, Avenue de Grande-Bretagne - -B.P. 931",66931,PERPIGNAN Cedex,Site de la mairie - www.mairie-perpignan.fr,42.69945,2.883256 -Musée Hyacinthe Rigaud,"16, rue de l'Ange - -B.P. 931",66931,PERPIGNAN Cedex,Site de la mairie : www.mairie-perpignan.fr,42.69812,2.893588 -Musée d'Archéologie Sous-Marine,Mairie,66660,PORT-VENDRES,,42.522276,3.104792 -Musée de Cerdagne,Cal Mateu,66800,SAINTE-LEOCADIE,http://www.museedecerdagne.com,42.439388,2.00239 -Musée de Tautavel,Avenue Léon-Jean Grégory,66720,TAUTAVEL,http://www.450000ans.com ou www.cerptautavel.com,42.813782,2.747999 -Musée Labenche d'Art et d'Histoire,"26 bis, boulevard Jules Ferry",19100,BRIVE-LA-GAILLARDE,http://www.musee-labenche.com ou www.brive.net,45.158437,1.535971 -Musée du Président Jacques Chirac,,19800,SARRAN,http://www.museepresidentjchirac.fr,45.41758,1.922718 -Musée de la Mémoire et des Industries Tullistes,"1, Rue du 9 juin 1944",19000,TULLE,Site de la ville,45.260245,1.751701 -Musée du Pays d'Ussel,B.P. 63,19208,USSEL,http://www.ussel19.fr/culture-patrimoine/musee-du-pays-d-ussel.html,48.408029,6.860172 -Musée Départemental de la Tapisserie,"16, Avenue des Lissiers - -B.P. 89",23200,AUBUSSON,http://www.cite-tapisserie.fr,45.954471,2.1694 -Musée d'Art et d'Archéologie,"Hôtel de la Sénatorerie - -22, avenue de la Sénatorerie",23000,GUERET,http://www.ville-gueret.fr/culture/musee.php,46.166077,1.870637 -Musée René Baubérot,"1, place Saint-Thyrse",87290,CHATEAUPONSAC,http://www.museechateauponsac.com,46.13125,1.274322 -Musée Municipal des Beaux-Arts de Limoges - Palais de l'Evêché,"1, Place de l'Evêché",87000,LIMOGES,http://www.museebal.fr,45.828784,1.265445 -Musée National Adrien Dubouché,"8 bis, Place Winston Churchill",87000,LIMOGES,http://www.musee-adriendubouche.fr,45.831405,1.254207 -Musée Départemental d'Art Contemporain,Place du Château,87600,ROCHECHOUART,http://www.musee-rochechouart.com,45.821801,0.819893 -Les Sources d'Hercule,"1, place Jean-Marie Keyser",54120,DENEUVRE,http://www.museehercule.com/,48.446385,6.734719 -Musée de l'Histoire du Fer,"1, Avenue du Général de Gaulle - -B.P. 15",54140,JARVILLE-LA-MALGRANGE,http://www.grand-nancy.org,48.597477,6.488578 -Musée du Château de Montaigu,"167, Rue Lucien Galtier",54410,LANEUVEVILLE-DEVANT-NANCY,http://www.nancy.fr/transver/coord.htm#muslor - Site du Grand Nancy,48.665792,6.215554 -Musée Municipal des Emaux et Faïences,"Port de France - -Rue de la Manutention",54400,LONGWY,http://http//www.mairie-longwy.fr,49.522644,5.763706 -Musée du Château de Lunéville,Place de la Deuxième Division de Cavalerie,54300,LUNEVILLE,http://www.chateauluneville.cg54.fr - http://www.chateaudeslumieres.com,48.594721,6.489897 -Musée des Beaux-Arts de Nancy,"3, Place Stanislas - -B.P. 218",54004,NANCY,http://www.mairie-nancy.fr,48.693507,6.182435 -Musée de l'Ecole de Nancy,"36-38, rue du Sergent Blandan",54000,NANCY,http://www.ecole-de-nancy.com,48.680494,6.166351 -Musée de Zoologie - Aquarium Tropical,"34, Rue Sainte-Catherine",54000,NANCY,http://www.man.uhp-nancy.fr,48.695072,6.188467 -"Musée ""au Fil du Papier""","Hôtel de la Monnaie - -13, Rue Magot de Rogéville",54700,PONT-A-MOUSSON,Site de la ville,48.901615,6.056259 -Musée d'Art et d'Histoire de Toul,"25, Rue Gouvion Saint-Cyr",54200,TOUL,http://www.toul.fr,48.678544,5.89134 -Musée Barrois,Esplanade du Château,55000,BAR-LE-DUC,site de la ville - http://www.barleduc.fr,48.771797,5.156074 -Musée de la Céramique et de l'Ivoire,"7, Avenue Carcano",55200,COMMERCY,,48.763489,5.589497 -Musée d'Art Sacré de la Meuse,Rue du Palais de Justice,55300,SAINT-MIHIEL,http://otsisaintmihiel.e-monsite.com,48.888549,5.540884 -Musée Départemental Raymond Poincaré,Clos Raymond Poincaré,55300,SAMPIGNY,http://www.cg55.fr,48.824129,5.510779 -Musée de la Bière et du Pays de Stenay,Rue de la Citadelle,55700,STENAY,http://www.musee-de-la-biere.com,49.489009,5.187268 -Musée d'Argonne,"2, Rue Louis XVI",55270,VARENNES-EN-ARGONNE,site de la conservation : www.cg55.fr,49.226174,5.0333 -Musée Jeanne d'Arc,Hôtel de Ville,55140,VAUCOULEURS,,48.601979,5.666467 -Musée de la Princerie,"16, Rue de la Belle Vierge",55100,VERDUN,http://www.musee-princerie.fr,49.161059,5.382439 -Musée Départemental de la Guerre de 70,"11, Rue de Metz",57130,GRAVELOTTE,http://www.cg57.fr/vivrelamoselle/Pages/Tourisme/museesdepartementaux/MuseeGravelotte.aspx,49.112222,6.026282 -Musée Départemental du Sel,Porte de France,57630,MARSAL,Site du conseil général - www.mosellepassion.fr,48.788526,6.60656 -Maison du Verre et du Cristal,Place Robert Schumann,57960,MEISENTHAL,http://www.musee-verre.webmuseo.com/,48.964887,7.35253 -Musées de la Cour d'Or,"2, Rue du Haut Poirier",57000,METZ,http://www.musee.metzmetropole.fr,49.121051,6.177978 -Centre Pompidou de Metz,"1, Parvis des Droits-de-l'Homme - -CS 90490",57020,METZ Cedex 1,http://www.centrepompidou-metz.fr,43.550885,-0.98388 -Musée de la Mine - Carreau Wendel,Parc Explor Wendel,57540,PETITE-ROSSELLE,http://www.musee-les-mineurs.fr,49.21292,6.872911 -Musée Militaire et Erckmann-Chatrian,"Hôtel de Ville - -Place d’Armes",57370,PHALSBOURG,http://www.phalsbourg.fr/La_culture__les_loisirs/Le_Musee,48.767,7.258837 -Musée du Pays de Sarrebourg,Rue de la Paix,57400,SARREBOURG,http://www.ville-sarrebourg.fr/Culture/Musee,48.734998,7.055235 -"Jardin d'Hiver, Musée de La Faïence","15-17, Rue Poincaré",57200,SARREGUEMINES,http://www.sarreguemines-museum.com,49.109069,7.070473 -Moulin de la Blies - Musée des Techniques Faïencières,"125, Avenue de la Blies",57200,SARREGUEMINES,http://www.sarreguemines-museum.com,49.1265,7.080124 -La Tour aux Puces - Musée du Pays Thionvillois,"Cour du Château - -B.P. 30352",57125,THIONVILLE Cedex,http://www.tourauxpuces.com,48.589574,6.50558 -Musée de l'Image,42 quai de Dogneville,88000,EPINAL,http://www.museedelimage.fr,48.183628,6.445992 -Musée Départemental d'Art Ancien et Contemporain,"1, Place Lagarde - -B.P. 436",88011,EPINAL Cedex,Site du conseil général - www.vosges.fr,48.17326,6.446595 -Musée Louis Français,"30, Avenue Louis Français",88370,PLOMBIERES-LES-BAINS,site de la ville,47.964095,6.458494 -Musée Charles de Bruyères,"70, Rue Charles de Gaulle",88200,REMIREMONT,http://www.remiremont.fr/culture/musees.php,48.016142,6.593289 -Musée Charles Friry,"12, Rue du Général Humbert",88200,REMIREMONT,http://www.remiremont.fr/culture/musees.php,48.015174,6.590031 -Musée Pierre Noël - Musée de la Vie dans les Hautes-Vosges,"11, rue Saint-Charles",88100,SAINT-DIE-DES-VOSGES,Site de la ville - www.ville-saintdie.fr,48.288282,6.951464 -Musée Départemental d'Archéologie Précolombienne et de Préhistoire de la Martinique,"9, rue de la Liberté",97200,FORT-DE-FRANCE,http://www.cg972.fr/mdap,14.603311,-61.068677 -Musée Régional d'Histoire et d'Ethnologie,"10, Boulevard du Général de Gaulle",97200,FORT-DE-FRANCE - MARTINIQUE-,http://www.cr-martinique.fr/francais/institution/serv-region/musees/histoire_ethno.htm,14.606869,-61.06693 -Musée de La Canne,Quartier Vatable,97229,LES TROIS ILETS,http://www.zananas-martinique.com/martinique-patrimoine/maison-de-la-canne.html,14.533769,-61.029966 -Ecomusée de la Martinique,Anse Figuier,97211,RIVIERE-PILOTE,/www.cr-martinique.fr/francais/institution/serv-region/musees/ecomusee.htm,14.466475,-60.904035 -Musée Volcanologique Franck Arnold Perret,Rue Victor Hugo,97250,SAINT-PIERRE,http://www.saintpierre-martinique.fr,14.744214,-61.176234 -Musée Départemental de l'Ariège,Rue du Rocher,09000,FOIX,http://www.sesta.fr ou www.ariege.com/chateaufoix/index.html,42.965919,1.605311 -Musée du Textile et du Peigne en Corne,"65, Rue Jean Jaurès",09300,LAVELANET,http://www.paysdolmes.org - www.geocities.com/amtpc2000,42.929884,1.844322 -Musée de la Préhistoire,Place de l'Eglise,09290,LE MAS D'AZIL,http://www.grotte-masdazil.com,43.079919,1.36027 -Musée de la Forge,Route de Paris,09330,MONTGAILHARD,http://www.sesta.fr,42.940991,1.630394 -Musée d'Archéologie,"32, Village",09300,MONTSEGUR,Site de la ville : www.citaenet.com/montsegur,42.870856,1.832753 -Musée du Palais des Evêques,Route de Montjoie,09190,SAINT-LIZIER,Site du Service d'exploitation des sites touristiques de l'Ariège - www.sesta.fr ou www.grands-sites-ariege.fr,43.001426,1.144943 -Musée Régional de Géologie Pierre Vetter,Avenue Paul Ramadier,12300,DECAZEVILLE,http://www.decazeville.fr ou www.musees-midi-pyrenees.fr,44.555583,2.266179 -"Musée des Mœurs et Coutumes, Musée du Rouergue",Place Frontin,12500,ESPALION,http://www.aveyron-culture.com,44.520844,2.760388 -Musée Joseph Vaylet,"Ancienne église St. Jean Baptiste - -35, rue Droite",12500,ESPALION,http://www.tourisme-espalion.fr - http://www.museeduscaphandre.com,44.520788,2.762169 -Musée Municipal de Millau,"Hôtel de Pégayolles - -Place du Maréchal Foch",12100,MILLAU,http://www.millauculture.fr ou www.ot-millau.fr,44.0978,3.080769 -Musée des Beaux-Arts Denys Puech,Place Georges Clemenceau,12000,RODEZ,http://musee-denys-puech.grand-rodez.com/,44.349215,2.57811 -Musée Fenaille,"14, Place Eugène Raynaldy",12000,RODEZ,http://www.musee-fenaille.com ou www.grandrodez.com,44.349201,2.576408 -Musée Municipal d'archéologie,Rue Saint-Pierre,12250,ROQUEFORT-SUR-SOULZON,,43.975028,2.99165 -Musée des Arts et Métiers,,12330,SALLES-LA-SOURCE,http://www.musees-aveyron.fr,44.434095,2.487917 -Musée Municipal Urbain Cabrol,Place de la Fontaine,12200,VILLEFRANCHE-DE-ROUERGUE,http://www.villefranchederouergue.fr,44.351453,2.03681 -Musée des Jacobins,"4, Place Louis Blanc",32000,AUCH,http://www.musee-jacobins.auch.fr www.auch-tourisme.com ou site de la mairie,43.647171,0.587482 -Musée de l'Armagnac,"2, Rue Jules Ferry",32100,CONDOM,site du département : www.gers-gascogne.com,43.960712,0.372842 -Musée Archéologique,Place de la République,32180,EAUZE,site du département : www.gers-gascogne.com ou site de la mairie,43.861183,0.101447 -Musée Campanaire,Place de l'Hôtel de ville,32600,L'ISLE JOURDAIN,site du département : www.gers-gascogne.com,43.612994,1.081929 -Musée Joseph Abeilhe,"Mairie de Marciac - -19, Place de l'Hôtel de Ville",32230,MARCIAC,Site de la région : www.musees-midi-pyrenees.fr,43.524344,0.16097 -Musée des Beaux-Arts et des Arts Décoratifs,"13, rue de l'Evêché",32300,MIRANDE,site du département : www.gers-gascogne.com,43.515315,0.403452 -Musée de Préhistoire,Avenue de Benabarre,31420,AURIGNAC,http://www.musee-aurignacien.com,43.216167,0.88003 -Musée du Pays de Luchon,"18, Allée d'Etigny",31110,BAGNERES-DE-LUCHON,http://www.luchon.com ou www.mairies-luchon.fr -www.musees-midi-pyrenees.fr,42.78928,0.592262 -Musée Municipal,Place Henri Dulion,31220,MARTRES-TOLOSANE,http://www.tourisme-martres-tolosane.fr,43.1989,1.010796 -Musée Municipal d'Art et d'Histoire,"6, place de Mas Saint Pierre",31800,SAINT-GAUDENS,http://www.mairiestgaudens.fr,43.107637,0.725625 -Musée des Augustins,"21, rue de Metz",31000,TOULOUSE,http://www.augustins.org,43.600562,1.445904 -Musée Saint-Raymond,Place Saint Sernin,31000,TOULOUSE,http://www.SaintRaymond.toulouse.fr,43.608498,1.442139 -Musée des Transports et des Communications,"93, avenue Jules Julien",31400,TOULOUSE,http://asptuit.free.fr,43.577035,1.450597 -Musée du Vieux Toulouse,"Hôtel Dumay - -7, Rue du May",31000,TOULOUSE,En projet,43.602262,1.443143 -Musée Georges Labit,"17, rue du Japon",31400,TOULOUSE,,43.589735,1.458081 -Musée Paul Dupuy,"13, Rue de la Pléau",31000,TOULOUSE,Site des musées de la région : www.musees-midi-pyrenees.fr/,43.596898,1.446935 -"Les Abattoirs, Musée d'Art Moderne et Contemporain","76, allées Charles-de-Fitte",31300,TOULOUSE,http://www.lesabattoirs.org,43.600678,1.428998 -Muséum d'Histoire Naturelle,"35, allées Jules Guesde",31000,TOULOUSE,http://www.museum.toulouse.fr,43.594236,1.449092 -Musée Bigourdan du Vieux Moulin,Rue Hount-Blanque,65200,BAGNERES-DE-BIGORRE,,43.065592,0.15365 -Musée Salies,Place des Thermes,65200,BAGNERES-DE-BIGORRE,http://www.museesbagneres.fr/,43.062205,0.147936 -Muséum d'Histoire Naturelle,"7, Place des Thermes de Salut",65200,BAGNERES-DE-BIGORRE,http://www.museesbagneres.fr/,43.062516,0.146987 -Musée Pyrénéen,Château fort,65100,LOURDES,http://www.chateaufort-lourdes.fr ou site de la ville - http://www.lourdes-visite.com/,43.095693,-0.048489 -Musée Municipal,Presbytère,65120,LUZ-SAINT-SAUVEUR,,42.865782,0.001324 -Musée-château Gaston Phébus,Château-Fort de Mauvezin,65130,MAUVEZIN,http://www.chateaudemauvezin.com,43.116342,0.278587 -Musée Henri Martin,"792, Rue Emile Zola",46000,CAHORS,http://www.mairie-cahors.fr/musee,44.448506,1.438286 -Musée d'Histoire de Figeac,"Rue Victor Delbos - -Cour du Puy",46100,FIGEAC,http://www.musee-champollion.fr/,44.610842,2.035622 -Musée Champollion - Les Ecriture du Monde,"4, Rue des Frères Champollion",46100,FIGEAC,http://www.musee-champollion.fr et site de la ville,44.609716,2.034747 -Musée Murat,Place de Tolentino,46240,LABASTIDE MURAT,site de l'association des amis du musée,44.648438,1.568714 -"Musée Archéologique ""Armand-Viré""",Rue de la Ville,46140,LUZECH,http://www.ville-luzech.fr/musee-archeologique-armand-vire-luzech,44.479699,1.285863 -Musée Départemental de Cuzals,,46330,SAULIAC-SUR-CELE,http://www.museeduquercy.com,44.506456,1.706642 -Musée de l'Automate,Place de l'Abbaye,46200,SOUILLAC,http://www.musee-automate.fr,44.894537,1.468386 -Musée Municipal,Mairie,46110,VAYRAC,http://www.vayrac.fr/uxellodunum.php?lg=fr,44.942216,1.708576 -Musée Eugénie et Maurice Guérin,Château de Cayla,81140,ANDILLAC,http://musee-cayla.tarn.fr/,44.009953,1.899242 -Musée Jean Jaurès,"2, Place Pélisson",81100,CASTRES,http://www.ville-castres.fr ou www.musees-midi-pyrenees.fr/musees/centre-national-et-musee-jean-jaures/,43.607135,2.240474 -Musée Charles Portal - Histoire et Patrimoine,"Porte des Ormeaux - -1, Rue Saint-Michel",81170,CORDES-SUR-CIEL, http://musee-charles-portal.asso-web.com,44.063752,1.94934 -Musée du protestantisme en Haut-Languedoc,"""La Ramade""",81260,FERRIERES,http://www.mpehl.org et www.musees-midi-pyrenees.fr,43.661945,2.448171 -Musée de l'Abbaye Saint-Michel,Place Saint-Michel,81600,GAILLAC,site de la ville,43.897879,1.895779 -Muséum d'histoire naturelle Philadelphe Thomas,Place Philadelphe Thomas,81600,GAILLAC,Site de la ville : www.ville-gaillac.fr,43.89944,1.894093 -Musée des Beaux-Arts,Avenue Dom-Vayssette,81600,GAILLAC,Site de la ville www.ville-gaillac.fr,43.894259,1.90185 -Musée Départemental du Textile,Rue de la Rive,81270,LA-BASTIDE-ROUAIROUX,http://musee-textile.tarn.fr,43.476806,2.638362 -Musée du Pays Vaurais,"1, rue Jouxaygues",81500,LAVAUR Cedex,http://www.musees-midi-pyrenees.fr/musees/musee-du-pays-vaurais,43.698317,1.82076 -Musée Raymond Lafage,"10, Rue Victor Mazies",81310,LISLE-SUR-TARN,http://www.ville-lisle-sur-tarn.fr - http://musee.lislesurtarn.over-blog.fr/,43.852891,1.811372 -Musée du Pays Rabastinois,"Mairie de Rabastens - -2, Rue Amédée Clausade",81800,RABASTENS,Site de la région : www.musees-midi-pyrenees.fr/musees/musee-du-pays-rabastinois,43.820412,1.724788 -Musée du Vieil Auvillar,"Mairie - -Place de la Halle",82340,AUVILLAR,http://www.auvillar.com,44.070483,0.899587 -Musée Théodore Calbet,"15, Rue Jean de Comère",82170,GRISOLLES,http://www.museecalbet.com,43.827651,1.294738 -Musée des arts et traditions populaires,"4, Rue de l'Abbaye",82200,MOISSAC,,44.105697,1.085296 -Musée d'Histoire Naturelle Victor Brun,"2, Place Antoine Bourdelle",82000,MONTAUBAN,http://www.museum.montauban.com/,44.017099,1.351385 -Musée Ingres,BP 752,82013,MONTAUBAN Cedex,http://www.montauban.com,48.832951,2.296328 -Musée de Préhistoire,"Hôtel de Ville - -Place de la Mairie",82140,SAINT-ANTONIN-NOBLE-VAL,Site de la ville : www.saint-antonin-noble-val.com,44.150801,1.755919 -Musée Théophile Jouglet,"215, Avenue Anatole-France",59410,ANZIN,site de la ville ou www.mediatheque-anzin.fr,50.372495,3.501834 -Musée d'Histoire et d'Archéologie,BP 90,59440,AVESNES-SUR-HELPE,http://www.annuaire-mairie.fr/musee-de-la-societe-archeologique-d-avesnes-sur-helpe.html#musee,50.121812,3.92658 -Musée Benoît de Puydt,"24, Rue du Musée",59270,BAILLEUL,Site de la ville,50.740715,2.734399 -Musée du Mont-de-Piété,"1, Rue du Mont-de-Piété",59380,BERGUES,http://www.musee-bergues.fr,50.969087,2.430391 -Musée de l'Ostrevant,"192, rue Edouard Lalo",59111,BOUCHAIN,http://museedostrevant.canalblog.com/,50.284651,3.311321 -Musée Municipal de Cambrai,"15, Rue de l'Epée",59400,CAMBRAI,http://www.villedecambrai.com/culture/musee.html,50.17347,3.229635 -Musée Diocésain d'Art Sacré,"Service de la conservation du patrimoine culturel - -11, Rue du Grand Séminaire - CS 80149",59403,CAMBRAI Cedex,http://www.liturgiecatholique.fr/Musee-diocesain-d-Art-Sacre-du.html ou http://archives.cathocambrai.com/,50.17298,3.231369 -Musée d'Archéologie et d'Histoire Locale,"9, Place Wilson",59220,DENAIN,Site de la ville - www.ville-denain.fr,50.327328,3.400252 -Musée de la Chartreuse,"130, Rue des Chartreux",59500,DOUAI,http://www.museedelachartreuse.fr,50.374404,3.07548 -Musée Portuaire,"Entrepôt des Tabacs - -9, Quai de la Citadelle",59140,DUNKERQUE,http://www.museeportuaire.fr,51.037914,2.372174 -Musée Municipal d'Escaudain,Rue Paul Bert,59124,ESCAUDAIN,http//clec.free.fr,50.327774,3.34373 -Musée du Dessin et de l'Estampe Originale,"Château Arsenal - -Place Charles Valentin",59820,GRAVELINES,http://www.ville-gravelines.fr,50.988054,2.128816 -Musée Départemental Matisse,Palais Fénelon,59360,LE CATEAU-CAMBRESIS,http://www.cg59.fr ou www.tourisme-lecateau.fr/museematisse,50.103244,3.537306 -Musée d'Histoire Naturelle et de Géologie,"19, rue de Bruxelles",59000,LILLE,http://www.mairie-lille.fr,50.626639,3.066546 -Musée de l'Hospice Comtesse,"32, Rue de la Monnaie",59800,LILLE,http://www.mairie-lille.fr ou http://www.pba-lille.fr/spip.php?article167,50.641054,3.06294 -Palais des Beaux-Arts de Lille,"18, bis rue de Valmy",59000,LILLE,http://www.pba-lille.fr ou site de la mairie http://www.mairie-lille.fr,50.630545,3.064941 -Musée d'Histoire Locale de Marchiennes,Rue Corbineau,59870,MARCHIENNES,http://www.officedetourismemarchiennes.fr,50.407479,3.281263 -Musée Henri Boez,"Hôtel de ville - -BP 269",59607,MAUBEUGE Cedex,http://www.musenor.com,50.623845,3.071417 -La Piscine - Musée d'Art et d'Industrie André Diligent,"24, rue des Champs",59100,ROUBAIX,http://www.roubaix-lapiscine.com,50.692479,3.166607 -Musée Municipal,"Tour Abbatiale - -Grand- Place",59230,SAINT-AMAND-LES-EAUX,Site de la ville,50.448798,3.427859 -Musée-Atelier du Verre,"1, Rue du général de Gaulle - -B.P. 2",59216,SARS-POTERIES,site du département - www.nordmag.com,47.52711,-2.769245 -MUba Eugène Leroy/Tourcoing,"2, Rue Paul Doumer",59200,TOURCOING,http://www.muba-tourcoing.fr/,50.724554,3.162819 -Musée des Beaux-Arts,Boulevard Watteau,59300,VALENCIENNES,http://www.valenciennes.fr(culture/musee),50.35607,3.530995 -LAM - Lille Métropole Musée d'Art Moderne d'Art Contemporain et d'Art Brut,"1, Allée du Musée",59650,VILLENEUVE-D'ASCQ,http://www.musee-lam.fr,50.637497,3.148587 -Musée des Beaux-Arts,"Ancienne Abbaye Saint-Vaast - -22, Rue Paul Doumer",62000,ARRAS,http://www.musenor.org ou www.arras.fr/culture/musee-des-beaux-arts.html,50.291624,2.77326 -Musée Communautaire Opale-Sud,"60, Rue de l'Impératrice",62600,BERCK-SUR-MER,http://www.opale-sud.com,50.405092,1.567687 -Musée Régional d'Ethnologie,"211, avenue Kennedy",62400,BETHUNE,Site de la ville,50.536456,2.648235 -Château-Musée de Boulogne-Sur-Mer,Rue de Bernet,62200,BOULOGNE-SUR-MER,http://www.ville-boulogne-sur-mer.fr/château-musee ou musenor,50.72557,1.616458 -Muséum d'Histoire Naturelle,"115, Boulevard Eurvin",62317,BOULOGNE-SUR-MER,,50.723398,1.615809 -Musée des Beaux-Arts et de la Dentelle,"25, rue Richelieu",62100,CALAIS,http://www.musee.calais.fr,50.956724,1.851871 -Musée Quentovic,"8, Place du Général de Gaulle",62630,ETAPLES-SUR-MER,http://www.musee-quentovic.fr/,50.513895,1.637621 -Musée d'Histoire et d'Archéologie,"50, rue André Deprez",62440,HARNES,Site de la ville : www.ville-harnes.fr/Patrimoine,50.447577,2.91073 -Musée Municipal,Avenue du Golf,62520,LE TOUQUET-PARIS-PLAGE,http://www.letouquet.com ou http://www.letouquet-musee.com/,50.506399,1.602007 -Musée d'Art et d'Histoire Roger Rodière,Hôtel Saint-Walloy,62170,MONTREUIL-SUR-MER,http://www.2p2m.org,50.462757,1.76407 -Musée de l'Hôtel Sandelin,"14, Rue Carnot",62500,SAINT-OMER,http://www.musees-ville-saint-omer.com,50.749007,2.254521 -Musée Henri Dupuis,"9, Rue Henri Dupuis",62500,SAINT-OMER,m3.dnsalias.com/sandelin - http://www.musenor.com/Les-Musees/Saint-Omer-Musee-Henri-Dupuis,50.748349,2.251197 -Musée Jean-Charles Cazin,"84, Grand' Place Foch",62830,SAMER,site de la ville - www.villesamer.fr,50.63954,1.745547 -Musée du Débarquement,Place du 6 Juin,14117,ARROMANCHES,http://www.musee-arromanches.fr,49.340234,-0.622108 -Musée d'Art et d'Histoire Baron Gérard,37 rue du Bienvenu,14400,BAYEUX,http://www.bayeuxmuseum.com/mahb.html,49.276158,-0.703671 -Musée Langlois,"Mairie - -13, rue du Paradis",14950,BEAUMONT-EN-AUGE,,49.278234,0.110532 -Musée de la Poste et des Techniques de communication,"52, Rue Saint-Pierre",14000,CAEN,http://www.caen.fr/museedelaposte/,49.183359,-0.363538 -Musée de la Société des Antiquaires,,14000,CAEN,,49.185369,-0.349611 -Musée de Normandie,Le Château,14000,CAEN,http://www.musee-de-normandie.eu,49.16202,-0.345097 -Musée des Beaux-Arts,Le Château,14000,CAEN,http://www.mba.caen.fr,49.16202,-0.345097 -Musée de la Mine,Rue de la Fosse Frandemiche,14330,LE MOLAY-LITTRY,http://www.ville-molay-littry.fr,49.24317,-0.87738 -Musée de la Meunerie - Moulin de Marcy,Moulin de Marcy,14330,LE MOLAY-LITTRY,http://www.ville-molay-littry.fr,49.227596,-0.9048 -Musée d'Art et d'Histoire de Lisieux,"38, Boulevard Pasteur",14100,LISIEUX,http://www.ville-lisieux.fr/23-Musee-d-Art-et-d-Histoire.html,49.144562,0.219323 -Musée Municipal,"107, rue Grande",14290,ORBEC,site de la mairie - http://www.mairie-orbec.fr,49.022096,0.406804 -Musée du Château,Château de Pontécoulant,14110,PONTECOULANT,http://www.condeintercom.fr,48.896917,-0.588433 -Musée des Techniques Fromagères,Rue Saint Benoît,14170,SAINT-PIERRE-SUR-DIVES,,49.019523,-0.033256 -Musée de Trouville - Villa Montebello,"64, Rue du Général Leclerc",14360,TROUVILLE-SUR-MER,http://www.trouville.fr/pages/03maville/culture.html,49.37135,0.083538 -Musée et Sites archéologiques de Vieux-la-Romaine,"13, Chemin Haussé",14930,VIEUX,Site du conseil général,49.105688,-0.437702 -Musée Municipal,"2, Place Sainte-Anne - -B.P. 62",14502,VIRE Cedex 02,http://www.cc-vire.fr/web/le_musee.html,48.835996,-0.888245 -Musée Municipal d'Avranches,Place Jean de Saint Avit,50300,AVRANCHES,,48.678874,-1.350061 -Maison de la Pomme et de la Poire,La Logeraie,50720,BARENTON,http://www.parc-naturel-normandie-maine.fr ou www.musees-basse-normandie.fr,48.587967,-0.806027 -Musée du Vieux Château,Place de la Mairie,50260,BRICQUEBEC,http://www.mairie-bricquebec.fr/,49.470838,-1.633678 -Musée de la Guerre et de la Libération,"Fort du Roule - -Montée des Résistants",50100,CHERBOURG-OCTEVILLE,http://www.ville-cherbourg.fr,49.629987,-1.61425 -Musée Thomas Henry,"4, Rue Vastel",50100,CHERBOURG-OCTEVILLE,http://www.ville-cherbourg.fr,49.636993,-1.622034 -Musée Quesnel-Morinière,"2, Rue Quesnel-Morinière",50200,COUTANCES,http://www.ville-coutances.fr/musee.php,49.047263,-1.446668 -Musée du Vieux Granville,"2, Rue Lecarpentier",50400,GRANVILLE,http://www.ville-granville.fr (site de la ville),48.837248,-1.602814 -Musée Richard Anacréon,Place de l'Ithsme - La Haute Ville,50400,GRANVILLE,http://www.ville-granville.fr,48.835533,-1.584192 -Musée Christian Dior,"Villa les Rhumbs - -Route d'Estouteville",50400,GRANVILLE,http://www.museechristiandior.com,48.842113,-1.592429 -"Musée du Bocage Normand, ferme du bois Jugan","Ferme de Boisjugan - -Boulevard de la Commune",50000,SAINT-LO,http://www.saint-lo.fr,49.104029,-1.076496 -Musée du Granit,Le Bourg,50670,SAINT-MICHEL-DE-MONTJOIE,http://www.sitesetmusees.cg50.fr,48.761142,-1.029377 -Musée Barbey d'Aurevilly,"66, Rue Bottin Desylles",50390,SAINT-SAUVEUR-LE-VICOMTE,http://www.stlo.unicaen.fr/museebarbeydaurevilly,49.384496,-1.533002 -Ferme-Musée du Cotentin,Chemin de Beauvais,50480,SAINTE-MERE-EGLISE,,49.416129,-1.314998 -Musée Régional du Cidre et du Calvados,Rue du Petit Versailles,50700,VALOGNES,Site de la Mairie : www.mairie-valognes.fr,49.505988,-1.47042 -Musée de la Poeslerie,"Cour du Foyer - -25, Rue Général Huard",50800,VILLEDIEU-LES-POELES,http://www.museesvilledieu.sitew.com,48.840754,-1.221648 -Musée Municipal d'Archéologie,"Service Patrimoine - -44, Place Fulbert de Beina",61300,L'AIGLE,http://www.ville-laigle.fr,48.764698,0.629018 -Musée Municipal - Musée du Jouet,"32, Rue de la Victoire",61600,LA FERTE-MACE,site de la ville,48.590293,-0.359226 -Musée du Percheron,"3, Rue du Portail Saint-Denis",61400,MORTAGNE-AU-PERCHE,,48.52186,0.54697 -Musée Départemental des Arts et Traditions Populaires du Perche,Prieuré de Sainte-Gaubruge,61130,SAINT-CYR LA ROSIERE,http://www.ecomuseeduperche.fr,48.324467,0.65589 -Musée Départemental d'Art Sacré,Place du Général de Gaulle,61500,SEES,http://www.cg61.fr/musee-art-religieux-sees.html,48.605419,0.171871 -Musée Artisanal et Industriel d'Instruments à Vent,"2, Rue d'Ivry",27750,LA COUTURE-BOUSSEY,http://www.lacoutureboussey.com/default.asp?file=pg25-1_fr,48.897628,1.407809 -Musée Municipal Nicolas Poussin,Rue Sainte-Clotilde,27700,LES ANDELYS,http://www.museenicolaspoussin.fr,49.245779,1.420095 -Musée Municipal,"19, Rue Pierre Mendes France - -B.P. 621",27400,LOUVIERS Cedex,Site des musées de Haute-Normandie ou site de la ville,49.214044,1.169269 -Musée Alfred Canel,"84, Rue de la République",27500,PONT-AUDEMER,http://www.ville-pont-audemer.fr ou www.musee-haute-normandie.fr,49.357088,0.516022 -Musée Alphonse-Georges Poulain,"12, Rue du Pont",27200,VERNON,site de la ville www.ville-vernon27.fr/musee/,49.094855,1.484927 -Musée Municipal,"Hôtel de Ville - -Place de la Libération",76360,BARENTIN,Site de la ville : www.ville-barentin.fr,49.545721,0.952516 -Pavillon Flaubert,"18, Quai Gustave Flaubert - -Dieppedalle-Croisset",76380,CANTELEU,http://www.litterature-lieux.com/fiche-site-80.htm - Site des musées de la région,49.435017,1.031203 -Maison des Templiers,"1, Rue Thomas-Bazin",76490,CAUDEBEC-EN-CAUX,site des musées de la région,49.52525,0.725969 -Château-Musée de Dieppe,Rue de Chastes,76200,DIEPPE,http://www.mairie-dieppe.fr ou www.musees-haute-normandie.fr,49.923949,1.07162 -Musée d'Elbeuf,"La Fabrique des Savoirs - LA CREA - -7 cours Gambetta",76500,ELBEUF,http://www.la-crea.fr ou Site des musées de la région,49.28505,1.006272 -Musée Louis-Philippe du Château d'Eu,Place d'Orléans,76260,EU,http://www.louis-philippe.eu - www.ville-eu.fr ou site des musées de la région,50.048831,1.418363 -Musée des Arts & de l'Enfance,"21, Rue Alexandre Legros",76400,FECAMP,http://www.fecamp.com,49.756192,0.377521 -Musée du Prieuré,"Mairie d'Harfleur - -50, Rue de la République - -B.P. 97",76700,HARFLEUR,site de la région,49.919318,1.075715 -Muséum d'Histoire Naturelle,Place du Vieux Marché,76600,LE HAVRE,http://www.museum-lehavre.fr ou site de la ville,49.487581,0.108279 -Musée du Prieuré de Graville,"1, Rue Elisée Reclus",76600,LE HAVRE,Site de la ville - www.ville-lehavre.fr,49.502468,0.162885 -La Maison de l'Armateur,"3, Quai de l'Ile - -(Quartier Saint-François)",76600,LE HAVRE,Site de la ville - www.ville-lehavre.fr,49.486876,0.112619 -Musée d'Art Moderne André Malraux - MuMa,"2, boulevard Clemenceau",76600,LE HAVRE,http://www.muma-lehavre.fr,49.484796,0.102188 -Musée de l'Hôtel Dubocage de Bléville,"1, Rue Jérôme Bellarmarto",76600,LE HAVRE,Site de la ville : www.ville-lehavre.fr,49.488697,0.114414 -Musée Départemental Pierre Corneille,"502, Rue Pierre Corneille",76650,LE PETIT-COURONNE,http://www.museepierrecorneille.fr,49.389368,1.0216 -Musée des Traditions et Arts Normands,Château de Martainville,76116,MARTAINVILLE-EPREVILLE,http://www.chateaudemartainville.fr,49.460105,1.297348 -Musée des Sapeurs-Pompiers de France,Rue Baron Bigot,76710,MONTVILLE,http://www.musee-sapeurs-pompiers.org - www.mairie-montville.fr,49.54688,1.076136 -Musée Mathon-Durand,Grande rue St-Pierre,76270,NEUFCHATEL-EN-BRAY,site des musées de la région,49.733325,1.433587 -Musée Industriel de la Corderie Vallois,"185, Route de Dieppe",76960,NOTRE-DAME-DE-BONDEVILLE,http://www.corderievallois.fr,49.494507,1.048567 -Musée National de l'Education,"CANOPE - -6, Rue de Bihorel",76000,ROUEN,http://www.reseau-canope.fr/musee/,49.448524,1.102602 -Musée des Antiquités de la Seine-Maritime,"198, Rue Beauvoisine",76000,ROUEN,http://www.museedesantiquites.fr,49.447268,1.098932 -Musée Flaubert et d'Histoire de la Médecine,"Ancien Hôtel-Dieu - -51, rue de Lecat",76000,ROUEN,http://www.rouen.fr/medecine ou http://www3.chu-rouen.fr/Internet/connaitreCHU/culture/musee_flaubert,49.445232,1.081736 -Musée Le Secq des Tournelles,"2, rue Jacques Villon",76000,ROUEN,http://www.rouen-musees.com,49.444259,1.094815 -Muséum d'Histoire Naturelle,"198, Rue Beauvoisine",76000,ROUEN,Site de la ville,49.447268,1.098932 -Musée de la Céramique,"1, rue Faucon",76000,ROUEN,http://www.rouen-musees.com,49.445543,1.093846 -Musée des Beaux-Arts,"1, Place restout",76000,ROUEN,http://www.rouen-musees.com,49.445222,1.094076 -Musée-Maison Natale Pierre Corneille,"Bibliothèques de Rouen - -3, rue Jacques Villon",76043,ROUEN Cedex 1,Site des musées de la région ou www.rouen.fr/corneille ou www.rouen-histoire.com/Corneille/,49.445148,1.094521 -Musée Victor Hugo,Quai Victor Hugo,76490,VILLEQUIER,http://www.museevictorhugo.fr,49.510304,0.672863 -Musée Municipal des Ivoires,"Office de Tourisme d'Yvetot et sa Région - -8, Place Maréchal Joffre",76190,YVETOT,Site de l'OT - www.tourisme-yvetot.fr,49.617332,0.754906 -Musée des Marais Salant,"29 bis, Rue Pasteur",44740,BATZ-SUR-MER,http://www.cap-atlantique.fr/tourisme-loisirs/Musee/,47.278542,-2.478054 -"Musée de la Fève, Crèches et Traditions Populaires","2, Place Jean Guihard",44130,BLAIN,http://www.musee-de-blain.fr/accueil2.htm,47.475727,-1.764575 -Musée du Pays de Retz,"6, Rue des Moines - -B.P. 14",44580,BOURGNEUF-EN-RETZ,http://www.museepaysderetz.com,47.042318,-1.952164 -Musée du Pays de Guérande et de la Porte Saint Michel,"Porte Saint-Michel - -CS 85139",44351,GUERANDE Cedex,site de la ville - www.ville-guerande.fr,47.327509,-2.428149 -Musée du Vignoble Nantais,"82, Rue Pierre Abélard",44330,LE PALLET,http://www.musee-vignoble-nantais.eu,47.134414,-1.329883 -Musée des Beaux-Arts de Nantes,"10, Rue Georges Clémenceau",44000,NANTES,http://www.museedesbeauxarts.nantes.fr,47.219276,-1.547096 -Muséum d'Histoire Naturelle de Nantes,"12, Rue Voltaire",44000,NANTES,http://www.museum.nantes.fr,47.212387,-1.564651 -Musée du Château des Ducs de Bretagne,"4, Place Marc Elder",44000,NANTES,http://www.château-nantes.fr,47.216353,-1.549389 -Musée Départemental Dobrée,"18, Rue Voltaire - -B.P. 40 415",44004,NANTES Cedex 1,http://www.loire-atlantique.fr ou www.cg44.fr ou www.culture.cg44.fr,47.211788,-1.566123 -Musées du Parc Naturel Régional de Brière,"177, Île de Fedrun",44720,SAINT-JOACHIM,http://www.parc-naturel-briere.fr,47.376078,-2.211119 -Musée du Chaume/Kerhinet - PNR Brière,Village de Kerhinet,44410,SAINT-LYPHARD,http://www.parc-naturel-briere.fr,47.363725,-2.34928 -Maison de l'Eclusier /Parc Naturel Régional de Brière,"Rozé - -Place des Eclusiers",44550,SAINT-MALO DE GUERSAC,http://www.parc-naturel-briere.fr,47.357542,-2.184789 -Musée Jean Lurcat et de la Tapisserie Contemporaine,"Direction des musées d'Angers - -14, Rue du Musée",49100,ANGERS,Site de la ville,47.469132,-0.554372 -Galerie David d'Angers,"Direction des musées d'Angers - -14, Rue du Musée",49100,ANGERS,http://www.angers.fr/mba,47.469132,-0.554372 -Musée des Beaux-Arts,"Direction des musées d'Angers - -14, Rue du Musée",49100,ANGERS,http://www.angers.fr/mba,47.469132,-0.554372 -Musée Pincé,32 bis rue Lenepveu,49100,ANGERS,Site de la ville,47.471767,-0.551841 -Muséum des Sciences Naturelles,"43, Rue Jules Guitton",49100,ANGERS,http://www.ville-angers.fr/museum,47.473475,-0.546412 -Musée d'Art et d'Histoire,"Château du Roi René d'Anjou - -place de l'Europe",49150,BAUGE,http://www.damm49.fr,47.542018,-0.104489 -Musée Joseph Denais,"5, Place Notre-Dame",49250,BEAUFORT-EN-VALLEE,http://www.damm49.fr,47.44022,-0.217749 -Musée du Textile,Rue du Docteur Roux,49300,CHOLET,http://www.museedutextile.com ou www.ville-cholet.fr/musee-textile.php,47.069587,-0.897687 -Musée d'Art et d'Histoire,"27, Avenue de l'Abreuvoir",49300,CHOLET,Site de la ville : www.ville-cholet.fr/musee-art-histoire.php,47.058385,-0.882284 -Musée Jules-Desbois,"1, Place Jules-Desbois",49390,PARCAY-LES-PINS,http://www.musee-julesdesbois.fr ou www.damm49.fr,47.436645,0.159784 -Château-Musée,"Hôtel de Ville - -Rue Molière - CS 54006",49408,SAUMUR Cedex,http://www.chateau-saumur.com,47.260647,-0.076544 -Musée de l'Ardoise,"32, Chemin de la Maraîchère",49800,TRELAZE,http://www.lemuseedelardoise.fr,47.450792,-0.491518 -Musée des Tisserands Mayennais,Place Billard de Veaux,53300,AMBRIERES-LES-VALLEES,Site de la ville,48.403045,-0.630604 -Musée d'Art et d'Archéologie Hôtel Fouquet,"2, Rue Jean Bourré",53200,CHATEAU-GONTIER,http://patrimoine.chateaugontier.fr/,47.828879,-0.705135 -Musée Communal Robert Tatin,"""La Maison des Champs"" - -La Frénouse",53230,COSSE-LE-VIVIEN,http://www.musee-robert-tatin.fr,47.936091,-0.897018 -Musée Municipal,Place de l'Hôtel de Ville,53500,ERNEE,,48.296721,-0.93946 -Musée Archéologique Départemental de Jublains,"13, Rue de la Libération - -B.P. 1",53160,JUBLAINS,site du conseil général - www.cg53.fr ou lamayenne.fr,45.724344,3.340092 -Musée des Sciences - CCSTI,"21, Rue du Douanier Rousseau",53000,LAVAL,http://www.multimedia.com/ccstidelaval,48.065559,-0.77102 -Musée du Vieux Château,Place de la Trémoille,53000,LAVAL,Site de la mairie - http://musees.laval.fr,48.068827,-0.772298 -Musée du Château,Place Juhel,53100,MAYENNE,http://www.museeduchateaudemayenne.fr,48.302373,-0.620107 -Musée de l'Ardoise et de la Géologie,"""Longchamp""",53800,RENAZE,http://geocities.com/musardoise,47.79351,-1.041392 -Musée Heurteloup-Chevalier,,72500,CHATEAU-DU-LOIR,,47.693176,0.417263 -Musée Vert Véron-de-Forbonnais,"204, Avenue Jean-jaurès",72100,LE MANS,site de la ville,47.986868,0.208417 -Musée de la Reine Bérengère,Rue de la Reine Bérengère,72000,LE MANS,Site de la ville - www.ville-lemans.fr,48.008972,0.197657 -Musée de Tessé,"2, Avenue de Paderborn",72000,LE MANS,site de la ville - www.ville-lemans.fr,48.009934,0.204194 -Musée Espace Faïence de Malicorne,"24, Rue Victor Hugo - -B.P. 10",72270,MALICORNE-SUR-SARTHE,http://www.espacefaience.fr,50.723479,1.604654 -Bibliothèque-musée,Rue Charles-Garnier,72120,SAINT-CALAIS,http://www.saint-calais.fr,47.92073,0.742885 -Centre Minier de Faymoreau,"""La Cour""",85240,FAYMOREAU,http://www.centre-minier-vendee.com,46.555399,-0.630409 -Ecomusée du Marais Breton Vendéen,Le Daviaud,85550,LA BARRE-DE-MONTS,http://www.ecomusee-ledaviaud.com,46.875564,-2.101948 -Musée des Traditions,Place de l'Eglise,85680,LA GUERINIERE,http://www.ile-noirmoutier.com/html/activites,46.967659,-2.233874 -Musée Municipal,Rue Jean Jaurès,85000,LA ROCHE-SUR-YON,site de la ville,46.66947,-1.428914 -Musée ornithologique Charles-Payraudeau,"4, Rue des Noyers",85310,LA-CHAIZE-LE-VICOMTE,http://www.lachaizelevicomte.fr/fr/information/6403/musee-ornithologique-charles-payraudeau,46.671559,-1.297313 -Historial et Mémorial de la Vendée,,85170,LES LUCS-SUR-BOULOGNE,http://www.historial.vendee.fr,46.849748,-1.48315 -Musée de l'Abbaye de Sainte-Croix,Rue de Verdun,85100,LES SABLES D'OLONNE,http://www.lessablesdolonne.fr,46.497296,-1.777296 -Musée Clemenceau et de Lattre-de-Tassigny,"1, rue Plante-Choux",85390,MOUILLERON-EN-PAREDS,http://www.musee-deuxvictoires.fr,46.675283,-0.849535 -Musée de la Construction Navale Artisanale,"Rue de l'Ecluse - -Le Port",85330,NOIRMOUTIER EN L'ILE,Site Vendée-Tourisme,46.999023,-2.24565 -Musée du Château,Place d'Armes,85330,NOIRMOUTIER-EN-L'ILE,Site de la ville - www.ville-noirmoutier.fr,46.999717,-2.243097 -Musée La Bourrine du Bois-Juquaud,"4, Chemin du Bois Juquaud",85270,SAINT-HILAIRE-DE-RIEZ,Site de la ville : www.sainthilairederiez.fr,46.755703,-1.951773 -Musée Milcendeau Jean-Yole,Le Bois Durand,85300,SOULLANS,http://www.musee-milcendeau.fr,46.778672,-1.90656 -Musée Franco-Américain du Château de Blérancourt,Château,02300,BLERANCOURT,http://www.museefrancoamericain.fr,49.502836,3.12906 -Musée Jean de la Fontaine,"12, Rue Jean de la Fontaine",02400,CHATEAU-THIERRY,http://www.musee-jean-de-la-fontaine.fr ou www.la-fontaine-ch-thierry.net,49.047062,3.400374 -Musée municipal de Chauny,"28, Rue de la Paix",02300,CHAUNY,http://www.ville-chauny.fr/culture/musee_presentation.php,49.615394,3.216814 -Musée - Centre de Documentation Alfred Desmasures,Impasse du Château,02500,HIRSON,,49.925734,4.079378 -Musée Jeanne d'Aboville,"5, Rue du Général de Gaulle",02800,LA FERE,http://perso.wanadoo.fr/lafere/musee.htm,49.661963,3.367721 -Musée Archéologique Municipal,"32, Rue Georges Ermant",02000,LAON,http://www.ville-laon.fr,49.563206,3.626781 -Musée Monseigneur Pigneau de Behaine,Rue du Musée,02550,ORIGNY-EN-THIERACHE,,49.89599,4.022348 -Musée Entomologique,"Espace Saint Jacques - -14, Rue de la Sellerie",02100,SAINT-QUENTIN,Site de la ville,49.845971,3.288915 -Musée Antoine Lecuyer,"28, Rue Antoine Lecuyer",02100,SAINT-QUENTIN,http://www.museeantoinelecuyer.fr/ ou www.mquentindelatour.com/,49.849891,3.285398 -Musée Municipal de Soissons,"Conservation - -Abbaye Saint-Jean-des-Vignes - -2, rue de la Congrégation - -Logis de l'Abbé",02200,SOISSONS,http://www.musee-soissons.org,49.38437,3.327236 -Musée de la Résistance et de la Déportation,"Place Carnegie - -Fargniers",02700,TERGNIER,http://www.resistance-deportation-picardie.com,49.658461,3.314801 -Musée de La Thiérache,"3, 5 Rue du Traité de Paix - -(Place du général de Gaulle)",02140,VERVINS,http://museedelathierache.jimdo.com ou www.evasion-aisne.com,49.834482,3.906585 -Musée Alexandre Dumas,"24, Rue Demoustiers",02600,VILLERS COTTERETS,Site de la mairie - www.mairie-villerscotterets.fr,49.253009,3.089691 -Musée de la Céramique Architecturale,"432, Avenue du Maréchal Foch",60390,AUNEUIL,http://www.annuaire-mairie.fr/musee-de-la-ceramique-architecturale.html,49.371601,1.991542 -Musée Départemental de l'Oise,"1, Rue Cambry - -B.P. 941",60024,BEAUVAIS cedex,http://www.oise.fr/culture-et-vie-locale/le-musee-departemental/,49.438331,2.07676 -Musée Antoine Vivenel,"Hôtel de Songeons-Bicquilley - -2, Rue d'Austerlitz",60200,COMPIEGNE,http://www.musee-vivenel.fr,49.417414,2.821526 -Musée du Château de Compiègne,Place du Général de Gaulle,60200,COMPIEGNE,http://www.musee-chateau-compiegne.fr,49.418363,2.829197 -Musée de la Figurine Historique,"28, Place de l'Hôtel de Ville",60200,COMPIEGNE,http://www.musee-figurine.fr,49.417761,2.826358 -Musée Gallé-Juillet,Place François Miterrand,60100,CREIL,site de la mairie - www.mairie-creil.fr,49.250515,2.462918 -Musée de l'Archerie et de l'Art Sacré,Rue Gustave Chopinet,60800,CREPY-EN-VALOIS,http://www.musee-archerie-valois.fr,49.236775,2.883938 -Musée de la Nacre et de la Tabletterie,"51, rue Roger Salengro",60110,MERU,http://www.musee-nacre.com,49.238287,2.137907 -Musée Calvin,"6, Place Aristide Briand",60400,NOYON,http://www.ville-noyon.fr/Le-musee-Jean-Calvin.html,49.581666,2.998444 -Musée du Noyonnais,"7, Rue de l'Evêché",60400,NOYON,http://www.ville-noyon.fr/Le-musee-du-Noyonnais.html,49.581852,2.999742 -Musée d'Art et d'Archéologie,Place Notre Dame,60300,SENLIS,http://www.musees-senlis.fr,49.206455,2.585686 -Musée de la Vénerie,Place du Parvis Notre Dame,60300,SENLIS,Site de la ville,49.207061,2.585529 -Musée Archéologique de l'Oise,Les Marmousets,60120,VENDEUIL-CAPLY,http://www.m-a-o.org,49.627157,2.316919 -Musée Boucher-de-Perthes,"24, Rue Gontier-Patin",80100,ABBEVILLE,Site de la ville,50.10712,1.833193 -Muséum d'histoire naturelle,,80000,AMIENS,,49.886416,2.309886 -Musée de l'Hôtel de Berny,"36, Rue Victor Hugo",80000,AMIENS,http://www.amiens.com,49.892882,2.302463 -Musée de Picardie,"48, Rue de la République",80000,AMIENS,http://www.amiens.fr/musees,49.890625,2.296238 -Musée Lombart,"7, Rue du Musée",80600,DOULLENS,http://mairie-doullens.pagesperso-orange.fr/zone1/pageLibre00010098.html,50.154818,2.343242 -Musée Archéologique et Historique,"44, Rue de Montmoreau",16000,ANGOULEME,site de société archéologique - www.sahc-charente.org,45.647813,0.160838 -Atelier - Musée du Papier,"134, Rue de Bordeaux",16000,ANGOULEME,http://www.alienor.org,45.653356,0.149932 -Musée des Beaux-Arts,"1, rue de Friedland",16000,ANGOULEME,Site collectif du CMPC : www.Alienor.org,45.6493,0.152569 -Musée de la Bande Dessinée - Centre National de la Bande Dessinée et de l'Image,"121, Rue de Bordeaux - -B.P. 72308",16023,ANGOULEME Cedex,http://www.cnbdi.fr ou www.alienor.org,45.653155,0.150537 -Musée des Arts du Cognac,"Les Remparts - -Place de la Salle Verte",16100,COGNAC,http://www.musees-cognac.fr,45.695308,-0.332648 -Musée Municipal de Cognac,"48, Bd Denfert Rochereau",16100,COGNAC,http://www.musees-cognac.fr,45.695744,-0.326186 -Musée de la Mytiliculture,Parvis de l'Eglise,17137,ESNANDES,http://www.maison-baiemaraispoitevin.fr,46.249998,-1.112028 -Musée Napoléonien et Africain,Rue Napoléon,17123,ILE D'AIX,http://www.musees-nationaux-napoleoniens.org/index.htm,46.012163,-1.17493 -Muséum d'Histoire Naturelle,"28, Rue Albert 1er",17000,LA ROCHELLE,http://www.museum-larochelle.fr,46.164822,-1.151488 -Musée Protestant,"2, Rue Saint-Michel",17000,LA ROCHELLE,http://www.protestantisme-museelarochelle.fr,46.159482,-1.150024 -Musée d'Orbigny-Bernon,"2, Rue Saint-Côme",17000,LA ROCHELLE, www.alienor.org,46.160696,-1.155323 -Musée du Nouveau Monde,"10, Rue Fleuriau",17000,LA ROCHELLE,http://www.alienor.org,46.161435,-1.151157 -Musée des Beaux-Arts,"28, Rue Gargoulleau",17000,LA ROCHELLE,http://www.alienor.org,46.161969,-1.151552 -Musée Cappon,"62, Rue d'Aligre - -BP 16",17230,MARANS,http://www.alienor.org,46.309723,-0.991941 -Musée National de la Marine,"Hôtel de Cheusses - -1, Place de La Galissonnière",17300,ROCHEFORT,http://www.musee-marine.fr/rochefort,45.934947,-0.958113 -Hôtel Hèbre de Saint-Clément - Musée d'Art et d'Histoire,63-65 avenue de Gaulle,17300,ROCHEFORT,Site de la ville,45.935539,-0.961839 -Musée de la Maison de Pierre Loti,"141, Rue Pierre Loti",17300,ROCHEFORT,http://www.maisondepierreloti.fr/,45.934155,-0.962754 -Musée Municipal des Cordeliers,"9, Rue Régnaud",17400,SAINT-JEAN-D'ANGELY,http://www.angely.net,45.942596,-0.523419 -Musée Ernest Cognacq,"Hôtel de Clerjotte - -13, avenue Victor Bouthillier",17410,SAINT-MARTIN-DE-RE,http://www.musee-ernest-cognacq.fr,46.2055,-1.365288 -Musée du Présidial,"28, Rue Victor Hugo",17100,SAINTES,http://www.ville-saintes.fr ou www.alienor.org,45.746251,-0.632034 -Musée Archéologique,Esplanade André Malraux,17100,SAINTES,http://www.alienor.org,49.16182,-0.361097 -Musée de l'Echevinage,"29 ter, Rue Alsace-Lorraine",17100,SAINTES,http://www.ville-saintes.fr ou www.alienor.org,-20.901199,55.456407 -Musée Dupuy-Mestreau,4 rue Monconseil,17100,SAINTES,http://www.alienor.org,45.743294,-0.634076 -Musée de l'Ile d'Oléron,"9, Place Gambetta",17310,ST-PIERRE D'OLERON,http://www.oleron-nature-culture.com ou www.alienor.org,45.942781,-1.307498 -Musée Municipal,Place de l'Hôtel de Ville,79300,BRESSUIRE,http://www.alienor.org,46.841761,-0.493191 -Musée d'Histoire Naturelle,"28, avenue de Limoges",79000,NIORT,,46.321065,-0.454107 -Musée ethnographique et archéologique du Donjon,Rue Duguesclin,79000,NIORT,http://www.alienor.org,46.329114,-0.509612 -Musée Bernard d’Agesci,"28, avenue de Limoges",79000,NIORT,http://www.agglo-niort.fr/-Musee-Bernard-d-Agesci,46.321065,-0.454107 -Musée d'Art et d'Histoire Georges Turpin,"1, Rue de la Vau Saint-Jacques",79200,PARTHENAY,http://www.alienor.org ou www.cc-parthenay.fr/Patrimoine/Musee/presentation/present-2.htm,46.652064,-0.24942 -Musée Municipal de l'Hôtel de Sully,"14, Rue Sully",86100,CHATELLERAULT,http://www.alienor.org,46.815984,0.542083 -"Musée de la Moto, de l'Automobile et du Vélo","La Manu - -3, Rue Clément Krebs",86100,CHATELLERAULT,http://www.alienor.org,46.81393,0.536238 -Musée des Traditions Populaires et d'Archéologie,"3, Rue St-Pierre - -B.P. 64",86300,CHAUVIGNY,http://www.chauvigny-patrimoine.fr,46.570457,0.648858 -Musée Charbonneau-Lassay,Rue du Martray,86200,LOUDUN,http://www.alienor.org,47.010038,0.075488 -Musée de Préhistoire Raymond Touchard,"La Sabline - -21, Route de Montmorillon - - BP 23 - -B.P. 23",86320,LUSSAC-LES-CHATEAUX,http://www.lasabline.fr ou www.lussac-les-chateaux.fr,46.40347,0.725699 -Musée Municipal de la Maison-Dieu,"6, Rue des Augustins",86500,MONTMORILLON,http://www.montmorillon.fr,46.421876,0.863517 -Musée de l'hypogée des Dunes,101 Rue du Père-de-la-Croix,86000,POITIERS,http://www.musees-poitiers.org,46.575749,0.359509 -Musée Rupert de Chièvres,"9, Rue Victor Hugo",86000,POITIERS,http://www.musees.poitiers.org ou www.alienor.org,46.580602,0.337972 -Musée Sainte-Croix,"3 bis, Rue Jean Jaurès",86000,POITIERS,http://www.musees-poitiers.org ou www.alienor.org,46.581133,0.34448 -Collection d'Histoire Naturelle,"1, Place de la Cathédrale - -B.P. 80964",86038,POITIERS Cedex,http://www.maison-des-sciences.org/,46.581791,0.34942 -Musée de la vallée,"Villa ""La Sapinière"" - -10, Avenue de la Libération",04400,BARCELONNETTE,site de la ville - www.barcelonnette.com,44.387609,6.655077 -Musée Gassendi,"64, Bd Gassendi",04000,DIGNE-LES-BAINS,http://www.musee-gassendi.org,44.092825,6.235477 -Musée Municipal,"1, Place du Bourguet",04300,FORCALQUIER,http://www.ville-forcalquier.fr/service-culturel.html,43.959969,5.780363 -Musée de Salagon,Prieuré de Salagon,04300,MANE,http://www.musee-de-salagon.com,43.933685,5.760073 -Musée départemental de préhistoire,Route de Montmeyan,04500,QUINSON,http://www.museeprehistoire.com,43.709921,6.039802 -Musée Archéologique,Mairie de Riez,04500,RIEZ-LA-ROMAINE,,43.81739,6.093211 -Musée archéologique,Mairie de Vachères,04110,VACHERES,,43.955752,5.644228 -Musée d'Histoire et d'Archéologie,"Direction des musées - -4, rue des Cordiers",06600,ANTIBES,http://www.antibes-juanslespins.com/fr/culture/musees,43.582877,7.125853 -Musée Picasso,"Direction des musées - -4, rue des Cordiers",06600,ANTIBES,http://www.antibes-juanlespins.com,43.582877,7.125853 -Musée d'Histoire Locale,"9, Rue Saint-Sébastien",06410,BIOT,http://musee-de-biot.fr/,43.627369,7.098469 -Musée Fernand Léger,Chemin Val de Pome,06410,BIOT,http://www.musee-fernandleger.fr ou http://www.musees-nationaux-alpesmaritimes.fr,43.62084,7.112018 -Musée Renoir,Chemin des Collettes,06800,CAGNES-SUR-MER,http://www.cagnes-tourisme.com/renoir ou www.chez.com/renoir/cagnes.htm,43.67428,7.154955 -Musée-Château de Cagnes,"7, Place Grimaldi - -Haut de Cagnes",06800,CAGNES-SUR-MER,Site de la ville,43.666892,7.145213 -Musée de la Castre,Le Suquet,06400,CANNES,Site de la ville,43.551506,7.010014 -Musée de la Mer,Ile-Sainte-Marguerite,06400,CANNES,Site de la ville,43.5204,7.045113 -Villa-Musée Jean-Honoré Fragonard,"23, Boulevard Fragonard",06130,GRASSE,http://www.museesdegrasse.com,43.65674,6.921089 -Musée International de la Parfumerie,"2, Boulevard du Jeu de Ballon",06130,GRASSE,http://www.museesdegrasse.com,43.658385,6.922 -Musée d'Art et d'Histoire de Provence,"2, Rue Mirabeau",06130,GRASSE,http://www.museesdegrasse.com,43.657615,6.922508 -Musée Bonnard,16 bd Sadi Carnot,06110,LE CANNET,http://www.museebonnard.fr,43.576273,7.019853 -Musée des Beaux-Arts,"3, Avenue de la Madone",06500,MENTON,Site de la ville,43.766659,7.4884 -Musée Jean Cocteau Collection Séverin Wunderman,"2, Quai de Monléon",06500,MENTON,http://museecocteaumenton.fr,43.775097,7.506512 -Musée de Préhistoire Régionale,Rue Lorédan Larchey,06500,MENTON,Site de la ville,43.77648,7.503609 -Muséum d'Histoire Naturelle,60 Boulevard Risso,06300,NICE,http://www.mhnnice.org ou www.nice-coteazur.org,43.701924,7.279572 -Musée Matisse,"164, Avenue des Arènes de Cimiez",06000,NICE,http://www.musee-matisse-nice.org,43.720041,7.276769 -Musée National Message Biblique Marc Chagall,Avenue du Docteur Menard,06000,NICE,http://www.musees-nationaux-alpesmaritimes.fr,43.70931,7.268731 -Musée du Vieux-Logis de Nice,"59, Avenue Saint Barthélémy",06000,NICE,Site de la ville de Nice,43.720727,7.255101 -Musée d'Art et d'Histoire,"65, Rue de France",06000,NICE,http://www.nice.fr/mairie_nice_1489.html,43.695672,7.259251 -Musée des Beaux-Arts Jules Chéret,"33, Avenue des Baumettes",06000,NICE,http://www.musee-beaux-arts-nice.org,43.694886,7.248937 -Musée d'Art Naïf Anatole Jakovsky,"Château Ste Hélène - -Avenue de Fabron",06200,NICE,Site de la ville : www.nice.fr/Culture/Musees-et-expositions/Musee-d-Art-Naif,43.69582,7.215225 -Musée Archéologique - Site de Cimiez,"Site de Cimiez - -160, Avenue des Arènes",06000,NICE,http://www.musee-archeologique-nice.org/ ou www.nice-coteazur.org,43.719221,7.275115 -Musée du Palais Lascaris,"15, Rue Droite",06300,NICE,http://www.nice.fr/mairie_nice_131.html,43.697686,7.27733 -Musée Archéologique - Site de Terra Amata,"25, Boulevard Carnot",06364,NICE Cedex 04,http://www.musee-archeologique-nice.org,43.698046,7.288686 -Ecomusée du Pays de la Roudoule,Place des Tilleuls,06260,PUGET-ROSTANG,http://www.ecomusee-roudoule.fr,43.973912,6.918815 -Musée Départemental des Merveilles,Avenue du 16 Septembre 1947,06430,TENDE,http://www.museedesmerveilles.com,44.088882,7.593204 -"Musée Magnelli, Musée de la Céramique",Place de la Libération,06220,VALLAURIS,http://www.vallauris-golfe-juan.fr/musee.php,43.579881,7.053064 -Musée Picasso la guerre et la paix à Vallauris,Place de la Libération,06220,VALLAURIS,http://www.musees-nationaux-alpesmaritimes.fr ou www.musee-picasso-vallauris.fr,43.579881,7.053064 -Musées de la Citadelle,Citadelle de Villefranche sur Mer,06230,VILLEFRANCHE-SUR-MER,http://www.villefranche-sur-mer.org,43.701093,7.311425 -Musée du Parlement de Provence et du Vieil Aix,"Hôtel Estienne-de-Saint-Jean - -17, Rue Gaston de Saporta",13100,AIX-EN-PROVENCE,http://www.mairie-aixenprovence.fr/Musee-du-Vieil-Aix,43.530857,5.447432 -Musée Granet,Place Saint-Jean de Malte,13100,AIX-EN-PROVENCE,http://www.museegranet-aixenprovence.fr,43.49437,5.411084 -Musée Paul Arbaud,2 bis rue du Quatre-Septembre,13100,AIX-EN-PROVENCE,http://www.academiedaix.org,43.525307,5.450165 -Muséum d'Histoire Naturelle,"6, Rue Espariat",13100,AIX-EN-PROVENCE,http://www.museum-aix-en-provence.org,43.528026,5.448883 -Musée du Pavillon de Vendôme,"13, Rue de la Molle",13100,AIX-EN-PROVENCE,http://www.mairie-aixenprovence.fr/Pavillon-Vendome ou www.museum-aix-en-provence.org,43.531176,5.442093 -Musée- Atelier de Paul Cézanne,"9, Avenue Paul Cézanne",13090,AIX-EN-PROVENCE,http://www.atelier-cezanne.com,43.537816,5.446601 -Muséon Arlaten,"29, Rue de la République",13200,ARLES,http://www.museonarlaten.fr,43.676298,4.627512 -Musée Camarguais,Mas du Pont de Rousty,13200,ARLES,http://www.parc-camargue.fr,43.623694,4.529959 -Musée Réattu,"10, Rue du Grand Prieuré",13200,ARLES,http://www.museereattu.arles.fr,43.679186,4.627903 -Musée Municipal Méditerranéen de Cassis A.T.P.,Rue Xavier-D'Authier,13260,CASSIS,Site de la ville - www.cassis.fr,43.214464,5.540037 -Musée Alphonse Daudet,Avenue des Moulins,13390,FONTVIEILLE,Site de la ville - www.fontvieille.provence.com,43.728918,4.717172 -Musée Ciotaden,"1, Quai Ganteaume",13600,LA CIOTAT,http://www.museeciotaden.org,43.173755,5.60973 -Musée Folklorique du Vieux Lambesc,"2, rue du Jas",13410,LAMBESC,http://www.museelambesc.free.fr,43.651927,5.261964 -Fondation Louis Jou,"Grand-Rue Frédéric Mistral - -Le Village",13520,LES-BAUX-DE-PROVENCE,http://fondationlouisjou.pagesperso-orange.fr/LouisJou.html,43.744253,4.79527 -Musée Frédéric Mistral,"11, avenue Lamartine",13910,MAILLANE,http://www.maillane.fr/tourisme/musee.php,43.831079,4.782653 -Musée de la Mode,"11 , Rue La Canebière",13001,MARSEILLE,Site de la ville - www.mairie-marseille.fr - http://www.espacemodemediterranee.com/,43.295788,5.375807 -Musée Borély,"134, Avenue Clot-Bey - -Château Borély",13008,MARSEILLE,http://www.marseille.fr/siteculture/les-lieux-culturels/musees/musee-borely,43.255924,5.382309 -Musée des Docks Romains,"10, Place Vivaux",13002,MARSEILLE,Site de la ville,43.296541,5.367959 -Cabinet des Monnaies et Médailles de Marseille,"10, Rue Clovis Hugues - -Archives Municipales Palais des Beaux-Arts",13001,MARSEILLE,http://www.marseille.fr/siteculture/jsp/site/Portal.jsp?page_id=52,43.311254,5.384295 -Musée d'Archéologie Méditerranéenne,"Centre de la Charité - -2, rue de la Charité",13002,MARSEILLE,Site de la ville,43.300162,5.367615 -"Musée d'Arts Africains, Océaniens, Amérindiens","Centre de la Vieille Charité - -2, rue de la Charité",13002,MARSEILLE,http://www.mairie-marseille.fr/vivre/culture/musees.htm,43.300162,5.367615 -Musée de la Faïence,"Château Pastré - -157, Avenue de Montredon",13008,MARSEILLE,Site de la ville - musée Borély,43.242043,5.36918 -Musée d'Art Contemporain - Galeries Contemporaines,"69, avenue d'Haïfa",13000,MARSEILLE,http://www.mairie-marseille.fr/vivre/culture/musees.htm,43.250373,5.389337 -Musée du Vieux Marseille,"2, Rue de la Prison",13002,MARSEILLE,http://www.mairie-marseille.fr/vivre/culture/musees.htm,43.296896,5.369449 -Musée Grobet-Labadié,"140, Boulevard Longchamp",13001,MARSEILLE,http://www.marseille.fr,43.303412,5.392866 -Musée Cantini,"19, Rue Grignan",13006,MARSEILLE,http://www.mairie-marseille.fr/vivre/culture/musees/cantini.htm,43.292383,5.378137 -Musée Ziem,Boulevard du 14 Juillet,13500,MARTIGUES,www-martigues.fr,43.408557,5.054904 -Musée Suffren et du Vieux Saint-Cannat,"Espace Suffren - -3 Avenue Pasteur",13760,SAINT-CANNAT,http://www.saint-cannat.fr/institutions-culturelles/espace-suffren.html,43.621529,5.295215 -Musée Municipal Paul Lafran,Montée des Pénitents,13250,SAINT-CHAMAS,http://www.paullafran.free.fr (site des Amis du musée),43.548914,5.033602 -Musée Estrine,"Hôtel Estrine - -8, rue Estrine",13210,SAINT-REMY-DE-PROVENCE,http://www.ateliermuseal.net,43.788801,4.832266 -Musée des Alpilles,"1, Place Favier",13210,SAINT-REMY-DE-PROVENCE,http://www.musees.mediterannee.org,43.784998,4.831984 -Musée Baroncelli,Rue Victor-Hugo,13460,SAINTES-MARIES-DE-LA-MER,http://www.saintesmaries.com/fr/accueil/village/le-musee.html,43.451204,4.427786 -Musée de l'Empéri de Salon et de la Crau,Montée du Puech,13300,SALON-DE-PROVENCE,Site de la ville www.salon-de-provence.org - www.visitsalondeprovence.com/fr,43.639268,5.097878 -Musée de Salon et de la Crau,"Château de l'Empéri - -1, Montée du Puech",13300,SALON-DE-PROVENCE,http://www.visitsalondeprovence.com/fr/quatre_musees.php#guidemusees,43.639268,5.097878 -Musée du Vieux Queyras,,05470,AIGUILLES,http://museum.cg05.fr/3939-musee-du-vieux-queyras.htm,44.768646,6.875318 -Musée Départemental des Hautes-Alpes,"6, Avenue Maréchal Foch",05000,GAP,http://museum.cg05.fr ou site du conseil général,44.562517,6.085539 -Musée des tourneurs,Rue Haute,83630,AIGUINES,,43.775897,6.243615 -Musée des Arts et d’Histoire,"103, Rue Carnot",83230,BORMES-LES-MIMOSAS,,43.150789,6.340603 -Musée du Pays Brignolais,Place du Palais des Comtes de Provence,83170,BRIGNOLES,http://www.museebrignolais.com,43.407253,6.058464 -Musée de la Société d’Etudes Scientifiques et Achéologiques,"21, allées d'Azémar",83300,DRAGUIGNAN,,43.536558,6.461588 -Musée Municipal de Draguignan,"9, Rue de la République",83300,DRAGUIGNAN,Site de la ville - www.ville-draguignan.fr,43.537982,6.464818 -Musée des A.T.P. de Moyenne Provence,"Communauté d'Agglomération Dracenoise - -15, Rue Roumanille",83300,DRAGUIGNAN,http://www.culture-dracenie.com,43.538087,6.466246 -Musée Archéologique Municipal,"Salle du Vieux Fréjus - -Place Calvini",83600,FREJUS,http://www.frejus.fr/Musee_Archeologique__155.html,43.433518,6.736701 -Musée des Troupes de Marine,B.P. 94,83608,FREJUS Cedex,http://www.ville-frejus.fr/hermes/culture/musee.htm,49.486759,4.424344 -Musée et site d'Olbia,3204 Route de l'Almanarre,83400,HYERES Cedex,Site de la ville : http://www.ville-hyeres.fr/,43.080197,6.123127 -Musée Jean Aicard,"Villa les Lauriers Roses - -705, Avenue du 8 Mai 1945",83130,LE POUVEREL LA GARDE,Site de la ville de Toulon,43.114675,6.004393 -Musée Archéologique,Parvis de la Vieille Eglise,83700,SAINT-RAPHAËL,http://www.musee-saintraphael.com,43.425688,6.769154 -Musée du Vêtement Provençal (Jean Aicard),Rue Jules Ferry,83210,SOLLIES-VILLE,http://www.solliesville.fr,43.181669,6.039146 -Musée d'Art,"113, Boulevard du Maréchal Leclerc",83000,TOULON,Site de la ville,43.125971,5.927245 -Muséum d'Histoire Naturelle de Toulon et du Var,737 Chemin du Jonquet,83200,TOULON,http://www.museum-toulon.org,43.143304,5.91086 -Musée Municipal d'Archéologie,"14, Place du Postel",84400,APT,Site OT de la ville,43.875736,5.397687 -Musée du Petit Palais,Place du Palais des Papes,84000,AVIGNON,http://www.petit-palais.org,43.951051,4.806367 -Musée Calvet (et musée Lapidaire),"65, Rue Joseph Vernet",84000,AVIGNON,http://www.musee-calvet.org & www.musee-lapidaire.org,43.946985,4.803388 -Muséum d'Histoire Naturelle Esprit Requien,"67, Rue Joseph Vernet",84000,AVIGNON,http://www.museum-avignon.org,43.946695,4.803397 -Musée Comtadin-Duplessis,"Bibliothèque-musée Inguimbertine - -234, Boulevard Albin-Durand",84200,CARPENTRAS,Site de la ville,44.054305,5.044975 -Musée Sobirats,"112, Rue du Collège",84200,CARPENTRAS,Site de la ville,44.054477,5.046269 -Musée Lapidaire et Archéologie,Rue des Saintes Maries,84200,CARPENTRAS,,44.055921,5.046746 -Musée Jouve,"52, Place Castil-Blaze",84300,CAVAILLON,http://www.cavaillon.org,43.836961,5.037899 -Musée de l'Hôtel Dieu,Porte d'Avignon,84300,CAVAILLON,http://www.cavaillon.org,43.860186,5.008627 -Synagogue Musée Juif Comtadin,Rue Hébraïque,84300,CAVAILLON,http://www.cavaillon.org,43.83691,5.03856 -Musée Marc Deydier,Rue de l'Eglise,84160,CUCURON,http://www.cucuron-luberon.com/musee-marc-deydier/,43.773637,5.437547 -Musée-Bibliothèque François Pétrarque,Rive gauche de la Sorgue,84800,FONTAINE-DE-VAUCLUSE,http://www.vaucluse.fr/pages/page/num/519/lan/1,43.91753,5.056542 -Musée Jean Garcin 39-45 : L'appel de la Liberté,Chemin du Gouffre,84800,FONTAINE-DE-VAUCLUSE,http://www.vaucluse.fr,46.569305,0.629669 -Musée Philippe de Girard,Hötel de ville,84160,LOUMARIN - CADENET,,43.74147,5.365245 -Musée Municipal Camille Pautet,"27, Rue St-Nazaire",84380,MAZAN,Sie de la ville : http://www.mazan.fr,44.056725,5.127889 -Musée Municipal,Hôtel de Ville,84390,SAULT,Site de la ville - www.mairie-sault-84.fr,44.032815,5.45738 -Musée Archéologique Théo Desplans,"Hôtel de Ville - -Cours Taulignan",84110,VAISON-LA-ROMAINE,Site de la ville - www.vaison-la-romaine.com,44.241777,5.075377 -Musée du Cartonnage et de l'Imprimerie,"3, Avenue du Maréchal Foch",84600,VALREAS,http://www.vaucluse.fr/pages/page/num/1037/lan/1,44.382664,4.987351 -Muséum Agricole et Industriel Stella Matutina,"6,Allée des Flamboyants",97424,PITON-SAINT-LEU,http://www.museesreunion.re,-21.202292,55.295071 -Musée Départemental de la Réunion - Musée Léon Dierx,"28, rue de Paris",97400,SAINT-DENIS,http://www.cg974.fr,-20.883698,55.450251 -Muséum d'Histoire Naturelle,"1, rue Poivre",97400,SAINT-DENIS DE LA REUNION,http//www.cg974.fr/museum,-20.887712,55.451452 -Musée des Arts Décoratifs de l’Océan indien (MADOI),"17 A, Chemin Rouge",97450,SAINT-LOUIS,http://www.madoi.re ou www.museesreunion.re,-21.264365,55.412125 -Musée Départemental des Pays De l'Ain,"34, rue du Général Delestraint",01000,BOURG-EN-BRESSE,http://www.musees.ain.fr,46.196543,5.219217 -Musée du Brou,"Monastère Royal de Brou - -63, Boulevard de Brou",01000,BOURG-EN-BRESSE,http://brou.munuments-nationaux.fr,46.19658,5.236495 -Musée de la Société d'Histoire et d'Archéologie,Mairie,01470,BRIORD,,45.778307,5.486674 -Musée Archéologique,Place de l'Eglise,01580,IZERNORE,http://www.archeologie-izernore.com,46.219977,5.555636 -Musée d'Histoire de la Résistance et de la Déportation de L'Ain et du Haut Jura,"3, Montée de l'Abbaye",01130,NANTUA,http://www.musees.ain.fr,46.152378,5.608744 -Musée du Vieux Pérouges,Place du Tilleul,01800,PEROUGES,http://www.ostelleriedeperouges.com,45.903409,5.179532 -Musée Chintreuil,"Hôtel de Ville - -66, Rue Maréchal de Lattre de Tassigny",01190,PONT-DE-VAUX,http://www.musee-chintreuil.com,46.430076,4.938125 -Musée Louis Jourdan,"Mairie - -Place Louis Jourdan",01240,SAINT-PAUL-DE-VARAX,,46.09868,5.128995 -Musée du Bois,Place de la République,01420,SEYSSEL,,45.95902,5.831513 -Musée Départemental du Revermont,Cuisiat,01370,TREFFORT-CUISIAT,http://www.treffort-cuisiat.com/musee.htm,46.310948,5.349415 -Musée de la Dombe,Maison de la Dombes,01330,VILLARS DE LA DOMBE,,46.000411,5.027094 -MuséaAL - Musée Archéologique d'Alba,Quartier Saint-Pierre,07400,ALBA-LA-ROMAINE,http://www.ardeche.fr,44.563818,4.593506 -Musée Vivarois César Filhol,"15, Rue Jean-Baptiste Béchetoille",07100,ANNONAY,http://www.cc-bassin-annonay.fr/-Le-Musee-Cesar-Filhol-.html,45.240803,4.668129 -Musée Régional de Préhistoire,Orgnac Grand Site de France,07150,ORGNAC-L'AVEN,http://www.orgnac.com,44.307021,4.408291 -Musée de la terre ardéchoise,"2, Place des Récollets",07000,PRIVAS,,44.735836,4.594922 -Musée de la Batellerie du Rhône,"154, rue Auguste Vincent",07340,SERRIERES,http://www.serrieres-ardeche.fr,45.31795,4.763273 -Musée et Site Archéologiques,Place de la Déesse Soïo,07130,SOYONS,http://www.soyons.fr,44.887803,4.850777 -Musée du Rhône,"14, Place Auguste Faure",07300,TOURNON-SUR-RHÔNE,http://www.ville-tournon.com/chateau-musee,45.067885,4.831823 -Musée d'Histoire et d'Archéologie,"11, Rue Camille-Buffardel",26150,DIE,http://www.museediois.wix.com/musee,44.75504,5.369785 -Château de Grignan,Le Château,26230,GRIGNAN,http://chateaux.ladrome.fr,44.419252,4.909247 -Musée du Château des Adhémars,"Mairie - -Place Emile Loubet",26200,MONTELIMAR,http://www.cg26.fr/fr/tourisme/chateaux/montelimar.html,44.557315,4.749186 -Musée d'Art Sacré,Le Village,26540,MOURS SAINT-EUSEBE,http://www.musee-art-sacre.com,45.068744,5.056328 -Musée d'Archéologie Tricastine,B.P. 44,26131,SAINT-PAUL-TROIS-CHATEAUX Cedex,http://www.ville-saintpaultroischateaux.fr/-Musee-d-archeologie-tricastine-.html,47.394544,0.712424 -Musée du site Préhistorique,La Hâle,26420,VASSIEUX-EN-VERCORS,http://www.prehistoire-vercors.fr,44.913986,5.379117 -Musée-château d'Annecy,Place du château,74000,ANNECY,http://musees.agglo-annecy.fr,45.897256,6.124943 -Musée Alpin,"89, Avenue Michel Croz",74400,CHAMONIX,Site de la ville - www.chamonix.com,45.923429,6.871049 -Musée Léon Marès,"Château de Montrottier - -60, Allée du Château",74330,LOVAGNY,http://www.chateaudemontrottier.com ou www.academie-florimontane.fr,45.898181,6.040579 -Musée de Rumilly et de l'Albanais,"""Les Tabacs"" - -23, Avenue Gantin",74150,RUMILLY,,45.861568,5.947618 -Musée des Vallées de Thônes,"2, Rue Blanche",74230,THONES,,45.881618,6.324161 -Musée du Chablais,"Château de Sonnaz - -2, Rue Michaud",74200,THONON-LES-BAINS,Site de la ville,46.373501,6.478563 -Musée d'Huez et de l'Oisans,Route de la Poste,38750,ALPE D'HUEZ,http://www.musee.alpedhuez.com,45.09125,6.064274 -Musée de Bourgoin-Jallieu,"17, Rue Victor Hugo",38300,BOURGOIN-JALLIEU,http://www.bourgoinjallieu.fr,45.586588,5.279159 -Musée - Parc Archéologique du Lac de Paladru,"15, Place de l'Eglise",38850,CHARAVINES,http://www.museelacdepaladru.com,45.42854,5.515787 -Musée Géo-Charles,"1, Rue Géo-Charles",38130,ECHIROLLES,http://www.ville-echirolles.fr/sortir/geocharles/geocharles.html,45.149349,5.699616 -Musée Archéologique Grenoble Saint-Laurent,Place Saint-Laurent,38000,GRENOBLE,http://www.musee-archeologique-grenoble.com,45.197841,5.731863 -Musée de la Résistance de la Déportation de l'Isère,"14, rue Hébert",38000,GRENOBLE,http://www.resistance-en-isere.fr,45.190041,5.735245 -Musée Stendhal,"1, Rue Hector Berlioz",38000,GRENOBLE,Site du patrimoine de l'Isère - www.patrimoine-en-isere.fr,45.192869,5.726204 -Musée de Grenoble,5 Place de Lavalette,38000,GRENOBLE,http://www.museedegrenoble.fr,45.194026,5.732094 -Musée Dauphinois,"30, Rue Maurice Gignoux",38031,GRENOBLE Cedex 1,http://www.musee-dauphinois.fr,45.195383,5.727094 -Muséum d'Histoire Naturelle de Grenoble,"1, Rue Dolomieu - -B.P. 3022",38816,GRENOBLE Cedex 1,http://www.museum-grenoble.fr,45.188059,5.735163 -Maison du Patrimoine de Hières-sur-Amby,Montée de la Cure,38118,HIERES SUR AMBY,http://www.musee.larina-hieres.com ou Site du patrimoine en isère - www.patrimoine-en-isere.fr,45.797253,5.294061 -Musée Hébert,Chemin Hébert,38700,LA TRONCHE,http://www.musee-hebert.fr,45.205577,5.7513 -Musée Hector Berlioz,"69, Rue de la République - -B.P. 63",38261,LA-COTE-SAINT-ANDRE Cedex,http://www.musee-hector-berlioz.com,45.393386,5.261189 -Musée de la Grande Chartreuse,La Correrie,38380,SAINT-PIERRE-DE-CHARTREUSE,http://www.musee-grande-chartreuse.fr,45.350369,5.791339 -Musée des Beaux-Arts et d'Archéologie,Place de Miremont,38200,VIENNE,http://www.musees-vienne.fr - Site du patrimoine de l'Isère,43.369246,1.415628 -Musée-Cloître Saint-André-Le-Bas,Place du Jeau de Paume,38200,VIENNE,http://www.musees-vienne.fr Site du patrimoine de l'Isère,45.526917,4.873476 -Musée Lapidaire Saint-Pierre,Place Saint-Pierre,38200,VIENNE,http://www.musees-vienne.fr - Site du patrimoine de l'Isère,45.523327,4.870902 -Musée Lucien Mainssieux,"B.P. 268 - -7, Place Léon Chaloin",38507,VOIRON Cedex,http://www.ville-voiron.fr,45.356152,5.579655 -Musée Alice Taverne,Rue de la Grye,42820,AMBIERLE,http://alicetaverne-musee.pagesperso-orange.fr/accueil/accueil.html,46.105295,3.896343 -Musée Hospitalier,"9, Boulevard du Général Leclerc - -B.P. 14",42190,CHARLIEU,http://www.ville-charlieu.fr/avoir/musees/le-musee-hospitalier,46.160588,4.171147 -Musée de la Soierie,"9, Boulevard Général Leclerc - -B.P. 14",42190,CHARLIEU,http://www.ville-charlieu.fr/avoir/musees/musee-de-la-soierie,46.160588,4.171147 -Atelier - Musée du Chapeau,"31, Rue Martouret",42140,CHAZELLES-SUR-LYON,http://www.museeduchapeau.com,45.638304,4.394089 -Musée d'Histoire du 20ème Siècle - Résistance et Déportation,Rue du Couvent,42380,ESTIVAREILLES,,45.416139,4.00978 -Musée d'Archéologie,"3, Rue Victor de Laprade",42110,FEURS,Site de la ville : www.feurs.org,45.744232,4.219876 -Musée Historial,"Mairie - -Le Bourg",42260,GREZOLLES,,45.863803,3.951671 -Musée d'Allard,"13, Boulevard de la Préfecture",42600,MONTBRISON,Site de la ville,45.608241,4.06195 -Musée de la Diana,"7, Rue Florimont Robertet",42600,MONTBRISON,http://www.ladiana.com,45.605503,4.066793 -Musée de la Maille,RUE DE SAINT-ANDRE,42300,RIORGES,L'Ecomusée du Roannais a récupéré les collections du musée,46.034805,4.037461 -Ecomusée du Roannais,Passage Général Giraud,42300,ROANNE,,46.034216,4.056283 -Musée des Beaux-Arts et d'Archéologie J. Dechelette,"22, Rue Anatole France",42300,ROANNE,Site de la mairie,46.034953,4.069842 -Musée d'Art Moderne de St-Etienne Métropole,La Terrasse,42000,SAINT-ETIENNE,http://www.mam-st-etienne.fr/,45.465063,4.376969 -Musée de la Mine / Site Couriot,"3, Boulevard Franchet d'Esperey",42000,SAINT-ETIENNE,http://www.musee-mine.saint-etienne.fr OU site de la ville : www.saint-etienne.fr,45.438944,4.376487 -Musée Folklorique du Vieux Saint-Etienne,"Hôtel de Villeneuve - -13 bis, Rue Gambetta",42000,SAINT-ETIENNE,http://www.vieux-saint-etienne.com,45.434935,4.388595 -Musée d'Art et d'Industrie,"2, Place Louis Comte",42026,SAINT-ETIENNE Cedex 1,"www.musee-art-industrie.saint-etienne.fr ou www.saint-etienne.fr rubrique ""vivre la culture""",45.431538,4.387485 -Château de la Bastie d'Urfé,,42130,SAINT-ETIENNE-LE-MOLARD,Site du conseil général,45.749273,4.105263 -Musée Municipal,Place de l'hôtel de Ville,42260,SAINT-GERMAIN-LAVAL,,45.830255,4.010602 -Musée des Civilisations,Place Madeleine Rousseau,42170,SAINT-JUST-SAINT-RAMBERT,,45.499124,4.265111 -Musée Barthélemy-Thimonnier,Place de l'Hôtel de Ville,69550,AMPLEPUIS,"www.graha-museethimonnier.org - -site de la ville",45.972623,4.331458 -Musée Folklorique et Traditions Populaires Marius Audin,Place de l'Hôtel de Ville,69430,BEAUJEU,http://www.beaujolaisvignoble.com,46.154579,4.587283 -Musée d'Art Contemporain de Lyon,"Cité Internationale - -81, Quai Charles de Gaulle",69006,LYON,http://www.moca-lyon.org,45.784064,4.852585 -Musée des Sapeurs-Pompiers de Lyon,"CASC Musée - -17, Rue Rabelais",69003,LYON,http://www.museepompiers.com/,45.762684,4.844539 -Centre d'Histoire de la Résistance et de la Déportation,"14, Avenue Berthelot",69007,LYON,http://www.chrd.lyon.fr,45.747037,4.835797 -Musée des Confluences,"10, Rue Boileau",69006,LYON,http://www.museedesconfluences.fr,45.774092,4.847976 -Musée de la Civilisation Gallo-Romaine,"17, Rue Cléberg",69005,LYON,http://www.musees-gallo-romains.com,45.760529,4.82004 -Musée des Hospices Civils de Lyon,"Hôtel Dieu - -1, Place de l'Hôpital",69002,LYON,http://www.chu-lyon.fr/internet/chu/musee/presentation_musee.htm,45.759326,4.83587 -Muséum - Musée des Confluences,"10, Rue Boileau",69006,LYON,http://www.museedesconfluences.fr,45.774092,4.847976 -Musée Historique des Tissus,"34, Rue de la Charité",69002,LYON,http://www.musee-des-tissus.com,45.753117,4.831633 -Musée des Beaux-Arts,"20, Place des Terreaux",69001,LYON,http://www.mba-lyon.fr,45.767177,4.833576 -Musée des Arts Décoratifs,"34, Rue de la Charité",69002,LYON,http://www.musee-des-tissus.com,45.753117,4.831633 -Musées Gadagne,"1, Place du Petit Collège",69005,LYON,http://www.gadagne.musees.lyon.fr,45.763856,4.827749 -Musée de l'Imprimerie,"13, Rue de la Poulaillerie",69002,LYON,http://www.imprimerie.lyon.fr,45.764219,4.834752 -Musée Archéologique,"Route Départementale 502 - -2, Chemin de la Plaine",69560,SAINT-ROMAIN-EN-GAL,http://www.musees-gallo-romains.com ou www.rhone.fr,45.532101,4.867764 -Musée Paul Dini,"2, Place Faubert",69400,VILLEFRANCHE-SUR-SAONE,http://www.musee-paul-dini.com,45.990936,4.721527 -Musée Lapidaire,Place Maurice Mollard,73100,AIX-LES-BAINS,,45.688657,5.915597 -Musée du docteur Faure,"Villa ""Les Chimères"" - -10, Boulevard des Côtes",73100,AIX-LES-BAINS,Site de la ville - www.aixlesbains.fr,45.692157,5.915578 -Musée du Costume,Hauteville-Gondon,73700,BOURG-SAINT-MAURICE,,45.594598,6.759433 -Musée des Beaux-Arts,Place du Palais de Justice,73000,CHAMBERY,Site de la ville - www.chambery.fr,45.568097,5.919108 -Musée des Charmettes,"890, Chemin des Charmettes",73000,CHAMBERY,Site de la mairie,45.552732,5.93011 -Muséum d'histoire naturelle,"208, Avenue de Lyon - -B.P. 844",73007,CHAMBERY CEDEX,,45.563911,5.916266 -Musée de l'Académie de La Val d'Isère,"23, Place Saint-Pierre",73600,MOÛTIERS,Site de l'Académie - http://academie.sup.fr,45.483424,6.532778 diff --git a/python-samples/csv_to_woosmap/csv_to_woosmap.py b/python-samples/csv_to_woosmap/csv_to_woosmap.py deleted file mode 100644 index eb25c93..0000000 --- a/python-samples/csv_to_woosmap/csv_to_woosmap.py +++ /dev/null @@ -1,168 +0,0 @@ -import unicodecsv as csv -import json -import os -import time -import requests -from hashlib import sha1 - -YOUR_INPUT_CSV_FILE = 'foodmarkets.csv' -WOOSMAP_PRIVATE_API_KEY = '23713926-1af5-4321-ba54-032966f6e95d' -BATCH_SIZE = 5 - - -class MyCSVDialect(csv.Dialect): - delimiter = ',' - quotechar = '"' - doublequote = True - skipinitialspace = False - lineterminator = '\n' - quoting = csv.QUOTE_ALL - - -class Woosmap: - """A wrapper around the Woosmap Data API.""" - - WOOSMAP_API_HOSTNAME = 'api.woosmap.com' - - def __init__(self): - self.session = requests.Session() - - def delete(self): - self.session.delete('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}) - - def post(self, payload): - return self.session.post('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}, - json={'stores': payload}) - - def end(self): - self.session.close() - - -def get_name(asset): - name = asset.get('Name', '') - if name: - return name - else: - raise ValueError('Unable to get the Name') - - -def generate_id(asset): - asset_id = sha1(get_name(asset).encode('utf-8')).hexdigest() - return asset_id - - -def get_contact(asset): - return { - 'website': asset.get('Website', ''), - 'phone': asset.get('Contact Phone', ''), - 'email': asset.get('Contact Email', '') - } - - -def get_geometry(asset): - latitude = asset.get('Latitude', None) - longitude = asset.get('Longitude', None) - if latitude is not None and longitude is not None: - return { - 'lat': float(latitude), - 'lng': float(longitude) - } - else: - raise ValueError('Unable to get the location') - - -def get_address(asset): - return { - 'lines': [asset.get('Address Line', '')], - 'city': asset.get('City', ''), - 'zipcode': asset.get('Zipcode', '') - } - - -def convert_to_woosmap(asset): - converted_asset = {} - try: - converted_asset.update({ - 'storeId': generate_id(asset), - 'name': get_name(asset), - 'address': get_address(asset), - 'contact': get_contact(asset), - 'location': get_geometry(asset) - }) - except ValueError as ve: - print('ValueError Raised {0} for Asset {1}'.format(ve, json.dumps(asset, indent=2))) - - return converted_asset - - -def import_assets(assets_data, woosmap_api_helper): - try: - print('Batch import {count} Assets...'.format(count=len(assets_data))) - response = woosmap_api_helper.post(assets_data) - if response.status_code >= 400: - response.raise_for_status() - - except requests.exceptions.HTTPError as http_exception: - if http_exception.response.status_code >= 400: - print('Woosmap API Import Error: {0}'.format(http_exception.response.text)) - else: - print('Error requesting the API: {0}'.format(http_exception)) - return False - except Exception as exception: - print('Failed importing Assets! {0}'.format(exception)) - return False - - print('Successfully imported in {0} seconds'.format(response.elapsed.total_seconds())) - return True - - -def batch(assets_data, n=1): - l = len(assets_data) - for ndx in range(0, l, n): - yield assets_data[ndx:min(ndx + n, l)] - - -def main(): - start = time.time() - print('Start parsing and importing your data...') - with open(file_path, 'rb') as csv_file: - try: - reader = csv.DictReader(csv_file, dialect=MyCSVDialect()) - woosmap_assets = [] - for asset in reader: - converted_asset = convert_to_woosmap(asset) - if bool(converted_asset): - woosmap_assets.append(converted_asset) - - print('{0} Assets converted from source file'.format(len(woosmap_assets))) - - woosmap_api_helper = Woosmap() - # /!\ deleting existing assets before posting new ones /!\ - woosmap_api_helper.delete() - - count_imported_assets = 0 - for chunk in batch(woosmap_assets, BATCH_SIZE): - imported_success = import_assets(chunk, woosmap_api_helper) - if imported_success: - count_imported_assets += len(chunk) - - woosmap_api_helper.end() - print("{0} Assets successfully imported".format(count_imported_assets)) - - except csv.Error as csv_error: - print('Error in CSV file found: {0}'.format(csv_error)) - except Exception as exception: - print("Script Failed! {0}".format(exception)) - finally: - end = time.time() - print('...Script ended in {0} seconds'.format(end - start)) - - -if __name__ == '__main__': - file_path = os.path.join(os.getcwd(), YOUR_INPUT_CSV_FILE) - if os.path.exists(file_path): - main() - else: - print('File not found: {0} '.format(file_path)) diff --git a/python-samples/excel_to_woosmap/excel_to_woosmap.py b/python-samples/excel_to_woosmap/excel_to_woosmap.py deleted file mode 100644 index db80abe..0000000 --- a/python-samples/excel_to_woosmap/excel_to_woosmap.py +++ /dev/null @@ -1,173 +0,0 @@ -from openpyxl import load_workbook -import json -import os -import time -import requests -from hashlib import sha1 - -INPUT_EXCEL_FILE = 'foodmarkets.xlsx' -WORKSHEET_NAME = 'foodmarkets' -WOOSMAP_PRIVATE_API_KEY = '23713926-1af5-4321-ba54-032966f6e95d' -BATCH_SIZE = 5 - - -class Woosmap: - """A wrapper around the Woosmap Data API.""" - - WOOSMAP_API_HOSTNAME = 'api.woosmap.com' - - def __init__(self): - self.session = requests.Session() - - def delete(self): - self.session.delete('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}) - - def post(self, payload): - return self.session.post('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}, - json={'stores': payload}) - - def end(self): - self.session.close() - - -class ExcelFile: - """A simple wrapper around the needed openpyxl functions for this script""" - - def __init__(self, filename, worksheet_name=''): - self.filename = filename - self.workbook = load_workbook(self.filename) - self.worksheet_name = worksheet_name if worksheet_name else self.get_first_worksheet_name() - self.worksheet = self.workbook.get_sheet_by_name(self.worksheet_name) - - def get_first_worksheet_name(self): - return self.workbook.get_sheet_names()[0] - - def iter_rows(self): - for row in self.worksheet.iter_rows(): - yield [cell.value for cell in row] - - -def get_name(asset): - name = asset.get('Name', '') - if name: - return name - else: - raise ValueError('Unable to get the Name') - - -def generate_id(asset): - asset_id = sha1(get_name(asset).encode('utf-8')).hexdigest() - return asset_id - - -def get_contact(asset): - return { - 'website': asset.get('Website', ''), - 'phone': asset.get('Contact Phone', ''), - 'email': asset.get('Contact Email', '') - } - - -def get_geometry(asset): - latitude = asset.get('Latitude', None) - longitude = asset.get('Longitude', None) - if latitude is not None and longitude is not None: - return { - 'lat': float(latitude), - 'lng': float(longitude) - } - else: - raise ValueError('Unable to get the location') - - -def get_address(asset): - return { - 'lines': [asset.get('Address Line', '')], - 'city': asset.get('City', ''), - 'zipcode': asset.get('Zipcode', '') - } - - -def convert_to_woosmap(asset): - converted_asset = {} - try: - converted_asset.update({ - 'storeId': generate_id(asset), - 'name': get_name(asset), - 'address': get_address(asset), - 'contact': get_contact(asset), - 'location': get_geometry(asset) - }) - except ValueError as ve: - print('ValueError Raised {0} for Asset {1}'.format(ve, json.dumps(asset, indent=2))) - - return converted_asset - - -def import_assets(assets_data, woosmap_api_helper): - try: - print('Batch import {count} Assets...'.format(count=len(assets_data))) - response = woosmap_api_helper.post(assets_data) - if response.status_code >= 400: - response.raise_for_status() - - except requests.exceptions.HTTPError as http_exception: - if http_exception.response.status_code >= 400: - print('Woosmap API Import Error: {0}'.format(http_exception.response.text)) - else: - print('Error requesting the API: {0}'.format(http_exception)) - return False - except Exception as exception: - print('Failed importing Assets! {0}'.format(exception)) - return False - - print('Successfully imported in {0} seconds'.format(response.elapsed.total_seconds())) - return True - - -def batch(assets_data, n=1): - l = len(assets_data) - for ndx in range(0, l, n): - yield assets_data[ndx:min(ndx + n, l)] - - -def main(): - start = time.time() - print('Start parsing and importing your data...') - excel_file = ExcelFile(INPUT_EXCEL_FILE, WORKSHEET_NAME) - sheet_data = list(excel_file.iter_rows()) - header = sheet_data.pop(0) - assets_as_dict = [dict(zip(header, item)) for item in sheet_data] - - woosmap_assets = [] - for asset in assets_as_dict: - converted_asset = convert_to_woosmap(asset) - if bool(converted_asset): - woosmap_assets.append(converted_asset) - - print('{0} Assets converted from source file'.format(len(woosmap_assets))) - - woosmap_api_helper = Woosmap() - # /!\ deleting existing assets before posting new ones /!\ - woosmap_api_helper.delete() - - count_imported_assets = 0 - for chunk in batch(woosmap_assets): - imported_success = import_assets(chunk, woosmap_api_helper) - if imported_success: - count_imported_assets += len(chunk) - - woosmap_api_helper.end() - - end = time.time() - print('...Script ended in {0} seconds'.format(end - start)) - - -if __name__ == '__main__': - file_path = os.path.join(os.getcwd(), INPUT_EXCEL_FILE) - if os.path.exists(file_path): - main() - else: - print('File not found: {0} '.format(file_path)) diff --git a/python-samples/googlemybusiness_to_woosmap/googlemybusiness_to_woosmap.py b/python-samples/googlemybusiness_to_woosmap/googlemybusiness_to_woosmap.py deleted file mode 100644 index 502eff6..0000000 --- a/python-samples/googlemybusiness_to_woosmap/googlemybusiness_to_woosmap.py +++ /dev/null @@ -1,307 +0,0 @@ -import httplib2 -import json -import os -import requests - -from apiclient import discovery -from oauth2client.client import flow_from_clientsecrets -from oauth2client.file import Storage -from oauth2client import tools - -from timezonefinder import TimezoneFinder - -tf = TimezoneFinder() - -WOOSMAP_PRIVATE_API_KEY = 'eafb8805-3743-4cb9-abff-xxxxxxxxxxxx' - -GOOGLE_ACCOUNT_NAME = "accounts/123456789101112131415" # The Account Manager -GOOGLE_CREDENTIALS_PATH = "client_secret_xxxxxxx-xxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com.json" - - -class GoogleMyBusiness(object): - """A wrapper around the Google My Business API.""" - - API_NAME = 'mybusiness' - API_VERSION = 'v3' - DISCOVERY_URI = 'https://developers.google.com/my-business/samples/{api}_google_rest_{apiVersion}.json' - SCOPE = "https://www.googleapis.com/auth/plus.business.manage" - REDIRECT_URI = 'http://localshot:8080' - - def __init__(self, credentials_path, account_name=''): - self.credentials_path = credentials_path - self.account_name = account_name - self.credentials = self.get_credentials() - self.http = self.credentials.authorize(httplib2.Http()) - if self.credentials is not None and self.credentials.access_token_expired: - self.credentials.refresh(self.http) - - self.service = self.build_service() - if not self.account_name: - # If GOOGLE_ACCOUNT_NAME constant is not set, we pick the first in account listing - self.account_name = self.list_accounts()['accounts'][0]['name'] - - def build_service(self): - return discovery.build(self.API_NAME, self.API_VERSION, http=self.http, - discoveryServiceUrl=self.DISCOVERY_URI) - - def get_credentials(self): - storage_path = '.' + os.path.splitext(os.path.basename(__file__))[0] + '.credentials' - storage = Storage(storage_path) - - credentials = storage.get() if os.path.exists(storage_path) else None - - if credentials is None or credentials.invalid: - flow = flow_from_clientsecrets(self.credentials_path, - scope=self.SCOPE, - redirect_uri=self.REDIRECT_URI) - - flow.params['access_type'] = 'offline' - flow.params['approval_prompt'] = 'force' - credentials = tools.run_flow(flow, storage) - - return credentials - - def list_accounts(self): - return self.service.accounts().list().execute() - - def list_locations(self): - return self.service.accounts().locations().list(name=self.account_name).execute() - - -class Woosmap: - """A wrapper around the Woosmap Data API.""" - - WOOSMAP_API_HOSTNAME = 'api.woosmap.com' - - def __init__(self): - self.session = requests.Session() - - def delete(self): - self.session.delete('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}) - - def post(self, payload): - return self.session.post('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}, - json={'stores': payload}) - - def end(self): - self.session.close() - - -def get_name(asset): - return asset.get('locationName') - - -def get_id(asset): - return asset.get('name').rsplit('/', 1)[1] - - -def get_contact(asset): - return { - 'website': asset.get('websiteUrl', ''), - 'phone': asset.get('primaryPhone', '') - } - - -def get_tags(asset): - return asset.get('labels', []) - - -def get_primary_category(asset): - primary_type = [] - primary_category = asset.get('primaryCategory', {}) - if primary_category: - primary_type.append(primary_category.get('name', '')) - - return primary_type - - -def get_additional_categories(asset): - additional_types = [] - additional_categories = asset.get('additionalCategories', []) - if additional_categories: - for category in additional_categories: - additional_types.append(category.get('name', '')) - - return additional_types - - -def get_types(asset): - types = [] - types.extend(get_primary_category(asset)) - types.extend(get_additional_categories(asset)) - return types - - -def get_user_properties(asset): - # return all other useful attributes to store in "userProperties" property - return { - 'photos': asset.get('photos', {}), - 'metadata': asset.get('metadata', {}), - 'storeCode': asset.get('storeCode', ''), - 'languageCode': asset.get('languageCode', ''), - 'attributes': asset.get('attributes', []), - 'serviceArea': asset.get('serviceArea', {}), - 'locationKey': asset.get('locationKey', {}), - 'priceLists': asset.get('priceLists', []), - 'locationState': asset.get('locationState', {}), - 'additionalPhones': asset.get('locationState', []), - 'adWordsLocationExtensions': asset.get('adWordsLocationExtensions', {}), - } - - -def get_geometry(asset): - # latlng can be empty {} if you did'nt moved the pushpin location was created on Google - # Thus you need to geocode address location to get lat/lng - latlng = asset.get('latlng', {}) - if latlng: - return { - 'lat': latlng.get('latitude'), - 'lng': latlng.get('longitude') - } - else: - # TODO: geocode address - raise ValueError('Unable to get the latlng') - - -def get_address(asset): - address = asset.get('address', {}) - if address: - return { - 'lines': address.get('addressLines', []), - 'city': address.get('locality', ''), - 'zipcode': address.get('postalCode', ''), - 'countryCode': address.get('country', '') - } - else: - raise ValueError('Unable to get the Address') - - -def find_timezone(asset): - latlng = get_geometry(asset) - timezone_name = '' - try: - lat = float(latlng['lat']) - lng = float(latlng['lng']) - timezone_name = tf.timezone_at(lng=lng, lat=lat) - if timezone_name is None: - timezone_name = tf.closest_timezone_at(lng=lng, lat=lat) - return timezone_name - - except ValueError: - print('Unable to Get the timezone for {latlng}'.format(latlng=latlng)) - timezone_name = 'Europe/Paris' - - finally: - return {'timezone': timezone_name} - - -def get_regular_hours(asset): - # TODO: manage regular hours that take longer than 24hours (e.g. open monday 9am and close Tuesday 9pm) - regular_hours = asset.get('regularHours', {}) - periods = regular_hours.get('periods', []) - - usual = {} - week_days = [{'MONDAY': '1'}, {'TUESDAY': '2'}, {'WEDNESDAY': '3'}, {'THURSDAY': '4'}, {'FRIDAY': '5'}, - {'SATURDAY': '6'}, - {'SUNDAY': '7'}] - if periods: - for period in periods: - for day in week_days: - for key in day: - if period['openDay'] == key: - usual.setdefault(day[key], []).append({'start': period['openTime'], 'end': period['closeTime']}) - - return {'usual': usual} - - -def get_special_hours(asset): - # TODO: manage special hours that take longer than 24hours (e.g. open monday 9am and close Tuesday 9pm) - special_hours = asset.get('specialHours', {}) - periods = special_hours.get('specialHourPeriods', []) - - special = {} - if periods: - for period in periods: - start_date = period.get('startDate', '') - if start_date: - key = str(start_date.get('year')) + '-' + str(start_date.get('month')) + '-' + str( - start_date.get('day')) - if period.get('isClosed', False): - special.setdefault(key, []) - else: - special.setdefault(key, []).append({'start': period['openTime'], 'end': period['closeTime']}) - - return {'special': special} - - -def get_hours(asset): - return dict(find_timezone(asset).items() + get_regular_hours(asset).items() + get_special_hours(asset).items()) - - -def convert_mybusiness_to_woosmap(data): - converted_asset = {} - try: - converted_asset.update({ - 'storeId': get_id(data), - 'name': get_name(data), - 'address': get_address(data), - 'contact': get_contact(data), - 'location': get_geometry(data), - 'openingHours': get_hours(data), - 'tags': get_tags(data), - 'types': get_types(data), - 'userProperties': get_user_properties(data) - }) - except ValueError as ve: - print('ValueError Raised {0} for MyBusiness location {1}'.format(ve, json.dumps(data, indent=2))) - - return converted_asset - - -def import_assets(assets_data, woosmap_api): - try: - print('Batch import {count} Assets to Woosmap...'.format(count=len(assets_data))) - response = woosmap_api.post(assets_data) - if response.status_code >= 400: - response.raise_for_status() - - except requests.exceptions.HTTPError as http_exception: - if http_exception.response.status_code >= 400: - print('Woosmap API Import Error: {0}'.format(http_exception.response.text)) - else: - print('Error requesting the API: {0}'.format(http_exception)) - return False - except Exception as exception: - print('Failed importing Assets! {0}'.format(exception)) - return False - - print('Successfully imported in {0} seconds'.format(response.elapsed.total_seconds())) - return True - - -def batch(assets_data, n=1): - l = len(assets_data) - for ndx in range(0, l, n): - yield assets_data[ndx:min(ndx + n, l)] - - -def main(): - google_my_business = GoogleMyBusiness(GOOGLE_CREDENTIALS_PATH, GOOGLE_ACCOUNT_NAME) - extracted_my_business = google_my_business.list_locations() - woosmap_assets = [] - for location in extracted_my_business["locations"]: - converted_asset = convert_mybusiness_to_woosmap(location) - if bool(converted_asset): - woosmap_assets.append(converted_asset) - - woosmap_api = Woosmap() - woosmap_api.delete() - - for chunk in batch(woosmap_assets): - import_assets(chunk, woosmap_api) - - -if __name__ == '__main__': - main() diff --git a/python-samples/googleplaces_to_woosmap/googleplaces_to_woosmap.py b/python-samples/googleplaces_to_woosmap/googleplaces_to_woosmap.py deleted file mode 100644 index 6d38223..0000000 --- a/python-samples/googleplaces_to_woosmap/googleplaces_to_woosmap.py +++ /dev/null @@ -1,158 +0,0 @@ -import codecs -import time -from hashlib import sha1 - -import simplejson as json # useful to deal with Decimal(x.x) potential errors -from googleplaces import GooglePlaces, GooglePlacesAttributeError, GooglePlacesError -from timezonefinder import TimezoneFinder - -GOOGLE_API_KEY = 'AIzaSyDRcaVMH1F_H3pIbm1T-XXXXXXXXXXX' -WOOSMAP_OUTPUT_JSON = 'woosmap_output.json' -SEARCH_DATA_PATH = 'search_data.json' - -tf = TimezoneFinder() - - -def get_location(places_location): - return { - 'lat': float(places_location['geometry']['location']['lat']), - 'lng': float(places_location['geometry']['location']['lng']) - } - - -def get_id(places_location): - return sha1(places_location.get('place_id')).hexdigest() - - -def find_timezone(places_location): - latlng = get_location(places_location) - timezone_name = '' - try: - lat = latlng['lat'] - lng = latlng['lng'] - timezone_name = tf.timezone_at(lng=lng, lat=lat) - if timezone_name is None: - timezone_name = tf.closest_timezone_at(lng=lng, lat=lat) - return timezone_name - - except ValueError: - print('Unable to Get the timezone for {latlng}'.format(latlng=latlng)) - timezone_name = 'Europe/Paris' - - finally: - return {'timezone': timezone_name} - - -# TODO : Update for multi opening and closing in a day -def get_regular_hours(places_location): - weekdays = [1, 2, 3, 4, 5, 6, 0] - day_index = 1 - usual = {} - day_hours = places_location.get('opening_hours', {}) - - if bool(day_hours): - try: - for day in weekdays: - hours = [] - start_hour = '' - end_hour = '' - if len(day_hours['periods']) == 1: - hours.append({'all-day': True}) - else: - for period in day_hours['periods']: - if period['open']['day'] and period['open']['day'] == day: - start_hour = period['open']['time'][:2] + ':' + period['open']['time'][-2:] - if period['close']: - end_hour = period['close']['time'][:2] + ':' + period['close']['time'][-2:] - break - if start_hour and end_hour: - hours.append({'start': start_hour, 'end': end_hour}) - - usual[day_index] = hours - day_index += 1 - - except Exception as error: - raise ValueError('Unable to get the OpeningHours: {0}'.format(error)) - - return {'usual': usual} - - -def get_contact(places_location): - website = places_location.get('website', '') if places_location.get('website', '') else places_location.get('url') - return { - 'website': website, - 'phone': places_location.get('formatted_phone_number') - } - - -def get_address(places_location): - return { - 'lines': [places_location.get('formatted_address')] - } - - -def get_name(places_location): - return places_location.get('name') - - -def get_types(places_location): - return places_location.get('types', []) - - -def get_hours(places_location): - return dict(find_timezone(places_location).items() + get_regular_hours(places_location).items()) - - -def google_places_to_woosmap(places_location): - converted_asset = {} - try: - converted_asset.update({ - 'storeId': get_id(places_location), - 'name': get_name(places_location), - 'address': get_address(places_location), - 'contact': get_contact(places_location), - 'location': get_location(places_location), - 'openingHours': get_hours(places_location), - 'types': get_types(places_location) - }) - except ValueError as ve: - print('ValueError Raised {0} for Places Location {1}'.format(ve, json.dumps(places_location, indent=2))) - - return converted_asset - - -def export_to_woosmap_json(input_json): - data_places = {'stores': input_json} - with codecs.open(WOOSMAP_OUTPUT_JSON, 'w', encoding='utf8') as outfile: - json.dump(data_places, outfile, indent=2, ensure_ascii=False) - - -def main(): - woosmap_converted_asset = [] - with codecs.open(SEARCH_DATA_PATH, 'rb', encoding='utf8') as search_data_file: - search_data = json.loads(search_data_file.read()) - google_places = GooglePlaces(GOOGLE_API_KEY) - for place_id in search_data['places_ids']: - try: - place = google_places.get_place(place_id) - converted_asset = google_places_to_woosmap(place.details) - if bool(converted_asset): - print("... {place_name} ...converted to Wosmap OK".format(place_name=place.name.encode('utf-8'))) - woosmap_converted_asset.append(converted_asset) - - except (GooglePlacesError, GooglePlacesAttributeError) as error_detail: - print('Google Returned an Error : {0} for Place ID : {1}'.format(error_detail, place_id)) - pass - - except Exception as exception: - print('Exception Returned {0} for Place ID : {1}'.format(exception, place_id)) - time.sleep(1) - pass - - export_to_woosmap_json(woosmap_converted_asset) - print('{0} google places extracted for {1} places_ids found '.format(len(woosmap_converted_asset), - len(search_data['places_ids']))) - - -if __name__ == '__main__': - main() diff --git a/python-samples/googleplaces_to_woosmap/search_data.json b/python-samples/googleplaces_to_woosmap/search_data.json deleted file mode 100644 index d646efd..0000000 --- a/python-samples/googleplaces_to_woosmap/search_data.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "places_ids": [ - "ChIJC4Wu9QmvthIRDmojjnJB6rA", - "ChIJuRFUv33Ew0cRk0JQb2xQOfs", - "ChIJ9yGYrIwd9kcRlV-tE8ixHpw", - "ChIJJ9NzTOW83UcRh3KE1nh9bno", - "ChIJNx8atyPbEEgRXZQc7nPAKpQ", - "ChIJWRTTPgJl5kcROB_8Pwj9BeI" - ] -} \ No newline at end of file diff --git a/python-samples/googlesheet_to_woosmap/googlesheet_to_woosmap.py b/python-samples/googlesheet_to_woosmap/googlesheet_to_woosmap.py deleted file mode 100644 index f94ef81..0000000 --- a/python-samples/googlesheet_to_woosmap/googlesheet_to_woosmap.py +++ /dev/null @@ -1,198 +0,0 @@ -import httplib2 -import os -import json -import requests -from hashlib import sha1 - -from apiclient import discovery -from oauth2client.client import flow_from_clientsecrets -from oauth2client import tools -from oauth2client.file import Storage - -GOOGLE_CREDENTIALS_PATH = 'client_secret_xxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com.json' -GOOGLE_SPREADSHEET_ID = '1bRQubfDVmFg53ohzY_SLSJRahf04kDtV2O0Ql28hP7U' -GOOGLE_RANGE_NAME = 'foodmarkets' - -WOOSMAP_PRIVATE_API_KEY = 'eafb8805-3743-4cb9-abff-xxxxxxxxxxx' - - -class GoogleSheets(object): - """A wrapper around the Google Sheets API.""" - - API_NAME = 'sheets' - API_VERSION = 'v4' - DISCOVERY_URI = 'https://sheets.googleapis.com/$discovery/rest?version={apiVersion}' - SCOPES = 'https://www.googleapis.com/auth/spreadsheets.readonly' - APPLICATION_NAME = 'Google Sheets To Woosmap' - REDIRECT_URI = 'http://localhost:8080' - - def __init__(self, credentials_path, spreadsheet_id, range_name=''): - self.credentials_path = credentials_path - self.spreadsheet_id = spreadsheet_id - self.credentials = self.get_credentials() - self.http = self.credentials.authorize(httplib2.Http()) - if self.credentials is not None and self.credentials.access_token_expired: - self.credentials.refresh(self.http) - - self.service = self.build_service() - self.range_name = range_name if range_name else self.get_first_sheetname() - - def build_service(self): - return discovery.build(self.API_NAME, self.API_VERSION, http=self.http, - discoveryServiceUrl=self.DISCOVERY_URI) - - def get_credentials(self): - storage_path = '.' + os.path.splitext(os.path.basename(__file__))[0] + '.credentials' - storage = Storage(storage_path) - credentials = storage.get() if os.path.exists(storage_path) else None - if credentials is None or credentials.invalid: - flow = flow_from_clientsecrets(self.credentials_path, self.SCOPES, redirect_uri=self.REDIRECT_URI) - flow.user_agent = self.APPLICATION_NAME - credentials = tools.run_flow(flow, storage) - - return credentials - - def get_values(self): - return self.service.spreadsheets().values().get(spreadsheetId=self.spreadsheet_id, - range=self.range_name).execute() - - def get_first_sheetname(self): - sheet_metadata = self.service.spreadsheets().get(spreadsheetId=self.spreadsheet_id).execute() - return sheet_metadata.get('sheets', '')[0].get("properties", {}).get("title", "Sheet1") - - -class Woosmap: - """A wrapper around the Woosmap Data API.""" - - WOOSMAP_API_HOSTNAME = 'api.woosmap.com' - - def __init__(self): - self.session = requests.Session() - - def delete(self): - self.session.delete('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}) - - def post(self, payload): - return self.session.post('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}, - json={'stores': payload}) - - def end(self): - self.session.close() - - -def get_name(asset): - name = asset.get('Name', '') - if name: - return name - else: - raise ValueError('Unable to get the Name') - - -def generate_id(asset): - asset_id = sha1(get_name(asset).encode('utf-8')).hexdigest() - return asset_id - - -def get_contact(asset): - return { - 'website': asset.get('Website', ''), - 'phone': asset.get('Contact Phone', ''), - 'email': asset.get('Contact Email', '') - } - - -def get_geometry(asset): - latitude = asset.get('Latitude', None) - longitude = asset.get('Longitude', None) - if latitude is not None and longitude is not None: - return { - 'lat': float(latitude), - 'lng': float(longitude) - } - else: - raise ValueError('Unable to get the location') - - -def get_address(asset): - return { - 'lines': [asset.get('Address Line', '')], - 'city': asset.get('City', ''), - 'zipcode': asset.get('Zipcode', '') - } - - -def convert_to_woosmap(asset): - converted_asset = {} - try: - converted_asset.update({ - 'storeId': generate_id(asset), - 'name': get_name(asset), - 'address': get_address(asset), - 'contact': get_contact(asset), - 'location': get_geometry(asset) - }) - except ValueError as ve: - print('ValueError Raised {0} for Asset {1}'.format(ve, json.dumps(asset, indent=2))) - - return converted_asset - - -def import_assets(assets_data, woosmap_api_helper): - try: - print('Batch import {count} Assets...'.format(count=len(assets_data))) - response = woosmap_api_helper.post(assets_data) - if response.status_code >= 400: - response.raise_for_status() - - except requests.exceptions.HTTPError as http_exception: - if http_exception.response.status_code >= 400: - print('Woosmap API Import Error: {0}'.format(http_exception.response.text)) - else: - print('Error requesting the API: {0}'.format(http_exception)) - return False - except Exception as exception: - print('Failed importing Assets! {0}'.format(exception)) - return False - - print('Successfully imported in {0} seconds'.format(response.elapsed.total_seconds())) - return True - - -def batch(assets_data, n=1): - l = len(assets_data) - for ndx in range(0, l, n): - yield assets_data[ndx:min(ndx + n, l)] - - -def main(): - google_sheets = GoogleSheets(GOOGLE_CREDENTIALS_PATH, GOOGLE_SPREADSHEET_ID) - - sheet_data = google_sheets.get_values().get('values', []) - header = sheet_data.pop(0) - assets_as_dict = [dict(zip(header, item)) for item in sheet_data] - - woosmap_assets = [] - for asset in assets_as_dict: - converted_asset = convert_to_woosmap(asset) - if bool(converted_asset): - woosmap_assets.append(converted_asset) - - print('{0} Assets converted from source file'.format(len(woosmap_assets))) - - woosmap_api_helper = Woosmap() - # /!\ deleting existing assets before posting new ones /!\ - woosmap_api_helper.delete() - - count_imported_assets = 0 - for chunk in batch(woosmap_assets): - imported_success = import_assets(chunk, woosmap_api_helper) - if imported_success: - count_imported_assets += len(chunk) - - woosmap_api_helper.end() - - -if __name__ == '__main__': - main() diff --git a/python-samples/woosmap_jsonschema_validation/foodmarkets.json b/python-samples/woosmap_jsonschema_validation/foodmarkets.json deleted file mode 100644 index 50274ea..0000000 --- a/python-samples/woosmap_jsonschema_validation/foodmarkets.json +++ /dev/null @@ -1,366 +0,0 @@ -{ - "stores": [ - { - "address": { - "city": "Rotterdam", - "lines": [ - "Dominee Jan Scharpstraat 298" - ], - "zipcode": "3011 GZ" - }, - "storeId": "d3911140d1fbadb82b83e74486af99142623a0dc", - "location": { - "lat": 51.919948, - "lng": 4.486843 - }, - "name": "Markthal Rotterdam", - "contact": { - "website": "http://markthalrotterdam.nl/", - "phone": "+31 (0)30 234 64 64", - "email": "info@markthalrotterdam.nl" - } - }, - { - "address": { - "city": "Roma", - "lines": [ - "Via Galvani/Via Alessandro Volta" - ], - "zipcode": "00118" - }, - "storeId": "6f4c75c5e74336d99ab73bd715ce1ce0a3850970", - "location": { - "lat": 41.877657, - "lng": 12.473909 - }, - "name": "Testaccio Market", - "contact": { - "website": "http://www.mercatotestaccio.com/", - "phone": "+39 06 578 0638", - "email": "info@mercatotestaccio.ocm" - } - }, - { - "address": { - "city": "Vienna", - "lines": [ - "Via Galvani/Via Alessandro Volta" - ], - "zipcode": "1060" - }, - "storeId": "72adb1b1419c16f4d28f90d4ac4394de25f2f555", - "location": { - "lat": 48.199044, - "lng": 16.364234 - }, - "name": "Naschmarkt Vienna", - "contact": { - "website": "http://www.naschmarkt-vienna.com/", - "phone": "+43 1 240555", - "email": "contact@naschmarkt-vienna.com" - } - }, - { - "address": { - "city": "Madrid", - "lines": [ - "Plaza de San Miguel" - ], - "zipcode": "28005" - }, - "storeId": "8af852d45a9059090b5225a96cf424ae0ddcefc5", - "location": { - "lat": 40.415261, - "lng": -3.708944 - }, - "name": "Mercado de San Miguel", - "contact": { - "website": "http://www.mercadodesanmiguel.es/en", - "phone": "(+34) 915 42 49 36", - "email": "administracion@mercadodesanmiguel.es" - } - }, - { - "address": { - "city": "Barcelona", - "lines": [ - "La Rambla, 91" - ], - "zipcode": "08001" - }, - "storeId": "ba3599fcc23989456c146997e68b1a7816fcf5fc", - "location": { - "lat": 41.381635, - "lng": 2.171596 - }, - "name": "Mercado de La Boqueria", - "contact": { - "website": "http://www.boqueria.info/", - "phone": "+34 933 18 25 84", - "email": "administracion@mercadodesanmiguel.es" - } - }, - { - "address": { - "city": "Nice", - "lines": [ - "Place Charles F\u00e9lix" - ], - "zipcode": "06300" - }, - "storeId": "e49e1fcf75bd3b509bf5b5201903ca5270930977", - "location": { - "lat": 43.69553, - "lng": 7.275492 - }, - "name": "Cours Saleya", - "contact": { - "website": "http://leblogduvieuxnice.nicematin.com/.services/blog/6a0120a864ed46970b0162fd89fc17970d/search?filter.q=march\u00e9", - "phone": "", - "email": "" - } - }, - { - "address": { - "city": "Paris", - "lines": [ - "Place d\u2019Aligre" - ], - "zipcode": "75012" - }, - "storeId": "7fe6cfa7b39dbae2eec8fdd7faed315561619cff", - "location": { - "lat": 48.849021, - "lng": 2.3777 - }, - "name": "March\u00e9 d\u2019Aligre", - "contact": { - "website": "http://equipement.paris.fr/marche-couvert-beauvau-marche-d-aligre-5480", - "phone": "01 45 11 71 11", - "email": "" - } - }, - { - "address": { - "city": "Firenze", - "lines": [ - "Piazza del Mercato Centrale" - ], - "zipcode": "50123" - }, - "storeId": "6080ed9f535dcb2e925c334068abe0a4431bb08d", - "location": { - "lat": 43.77654, - "lng": 11.253133 - }, - "name": "Mercato Centrale di San Lorenzo", - "contact": { - "website": "http://www.mercatocentrale.it", - "phone": "+39 0552399798", - "email": "info@mercatocentrale.it" - } - }, - { - "address": { - "city": "Copenhagen", - "lines": [ - "Frederiksborggade 21" - ], - "zipcode": "1360" - }, - "storeId": "56eb5f571b8c083d384658a95a929ea997d69f9f", - "location": { - "lat": 55.684025, - "lng": 12.569469 - }, - "name": "Torvehallerne", - "contact": { - "website": "http://torvehallernekbh.dk", - "phone": "+39 0552399798", - "email": "info@torvehallernekbh.dk" - } - }, - { - "address": { - "city": "London", - "lines": [ - "8 Southwark St" - ], - "zipcode": "SE1 1TL" - }, - "storeId": "74cf59f30119a3672647ef0c4bd9f0587cfcfba4", - "location": { - "lat": 51.505046, - "lng": -0.090679 - }, - "name": "Borough Market", - "contact": { - "website": "http://boroughmarket.org.uk", - "phone": "020 7407 1002", - "email": "" - } - }, - { - "address": { - "city": "M\u00fcnchen", - "lines": [ - "Viktualienmarkt 3" - ], - "zipcode": "80881" - }, - "storeId": "71504c368f415a820795c254d852cadfd711651c", - "location": { - "lat": 48.135105, - "lng": 11.576246 - }, - "name": "Victuals Market", - "contact": { - "website": "http://www.muenchen.de/int/en/shopping/markets/viktualienmarkt.html", - "phone": "+49 89 89068205", - "email": "" - } - }, - { - "address": { - "city": "S\u00e8te", - "lines": [ - "Rue Gambetta" - ], - "zipcode": "34200" - }, - "storeId": "e5bac7b59e5f6a2d4260b412904e09052f6df12c", - "location": { - "lat": 43.40214, - "lng": 3.69545 - }, - "name": "Halles de S\u00e8te", - "contact": { - "website": "http://www.halles-sete.com/", - "phone": "04 99 04 70 00", - "email": "commerce-artisanat@ville-sete.fr" - } - }, - { - "address": { - "city": "Uz\u00e8s", - "lines": [ - "Place aux Herbes" - ], - "zipcode": "30700" - }, - "storeId": "e50bcf462a9fa23cedb6ef2fe57b81472f3477ba", - "location": { - "lat": 44.011821, - "lng": 4.418889 - }, - "name": "March\u00e9 d'Uz\u00e8s", - "contact": { - "website": "http://www.uzes.fr/Calendrier-des-marches-brocantes-et-foires_a126.html", - "phone": "+33 (0)4 66 22 68 88", - "email": "" - } - }, - { - "address": { - "city": "Apt", - "lines": [ - "Place de la Bouquerie" - ], - "zipcode": "84400" - }, - "storeId": "8b685237005769cf1b85a6eb07ccc156b820ec9b", - "location": { - "lat": 43.876503, - "lng": 5.393588 - }, - "name": "Le Grand March\u00e9 d'Apt", - "contact": { - "website": "http://www.luberon-apt.fr/index.php/fr/sortir/les-marches", - "phone": "+33 (0)4 90 74 03 18", - "email": "oti@paysapt-luberon.fr" - } - }, - { - "address": { - "city": "La Flotte", - "lines": [ - "Rue du March\u00e9" - ], - "zipcode": 17630 - }, - "storeId": "846c55afb649f6c5715fd0d04700fbac8c369611", - "location": { - "lat": 46.187465, - "lng": -1.327086 - }, - "name": "Le March\u00e9 de la Flotte", - "contact": { - "website": "http://laflotte.fr/index.php/Vie-quotidienne/les-marches.html", - "phone": "05.46.09.15.00", - "email": "annie@laflotte.fr" - } - }, - { - "address": { - "city": "Edimburgh", - "lines": [ - "Castle Terrace" - ], - "zipcode": "EH1 UK" - }, - "storeId": "01d822835de61e34be80dc747fe0e73773aef015", - "location": { - "lat": 55.947817, - "lng": -3.203562 - }, - "name": "Edimburgh Farmers' Market", - "contact": { - "website": "http://www.edinburghfarmersmarket.co.uk/", - "phone": "0131 220 8580", - "email": "" - } - }, - { - "address": { - "city": "Belfast", - "lines": [ - "12 - 20 East Bridge Street" - ], - "zipcode": "BT1 3NQ" - }, - "storeId": "7fc2088804d1700be543f85eb44392134a1e3b4b", - "location": { - "lat": 54.596073, - "lng": -5.921654 - }, - "name": "St George's Market", - "contact": { - "website": "http://www.belfastcity.gov.uk/tourism-venues/stgeorgesmarket/stgeorgesmarket-index.aspx", - "phone": "028 9043 5704", - "email": "markets@belfastcity.gov.uk" - } - }, - { - "address": { - "city": "Lille", - "lines": [ - "Place de la nouvelle aventure" - ], - "zipcode": "59000" - }, - "storeId": "b61c2fca4f713cb9b6136150a9459f5be59a5eed", - "location": { - "lat": 50.62671, - "lng": 3.049325 - }, - "name": "Halles de Wazemmes", - "contact": { - "website": "http://www.halles-wazemmes.com/", - "phone": "", - "email": "", - "ErrorKey":2 - } - - } - ] -} \ No newline at end of file diff --git a/python-samples/woosmap_jsonschema_validation/woosmap_jsonschema_validation.py b/python-samples/woosmap_jsonschema_validation/woosmap_jsonschema_validation.py deleted file mode 100644 index d18fe6f..0000000 --- a/python-samples/woosmap_jsonschema_validation/woosmap_jsonschema_validation.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -import json -from jsonschema import ValidationError, validate - -JSON_TO_VALIDATE = os.path.join(os.getcwd(), 'foodmarkets.json') - -WOOSMAP_SCHEMA = { - '$ref': 'https://raw.githubusercontent.com/woosmap/woosmap-json-schema/master/asset.json#' -} -WOOSMAP_COLLECTION_SCHEMA = { - '$ref': 'https://raw.githubusercontent.com/woosmap/woosmap-json-schema/master/assetCollection.json#' -} - - -def load_json(file_path): - with open(file_path, 'r') as f: - return json.load(f) - - -def validate_collection(assets): - """ Validate an array of Assets expected as {"stores":[{asset},{asset},...]} - Less time consuming but will raise ValidationError at first error """ - try: - print("Validating Collection of Assets...") - validate(assets, WOOSMAP_COLLECTION_SCHEMA) - except ValidationError as error: - print Exception("Asset not Matching: {0}".format(error.message)) - else: - print("...Validated Collection Successful!") - - -def validate_one_by_one(assets): - """ Validate individually each Asset that could be useful to identify - all Asset which could be in wrong schema. A little slower """ - print("Validating assets individually..") - for item in assets["stores"]: - try: - validate(item, WOOSMAP_SCHEMA) - except ValidationError as error: - print Exception( - "Asset not Matching: {0}".format(error.message)) - else: - print("...Validated Asset {id} Successful!".format(id=item["storeId"])) - - -def main(): - assets_to_validate = load_json(JSON_TO_VALIDATE) - validate_collection(assets_to_validate) - validate_one_by_one(assets_to_validate) - - -if __name__ == '__main__': - if os.path.exists(os.path.join(os.getcwd(), JSON_TO_VALIDATE)): - main() - else: - print('File not found: {0} '.format(JSON_TO_VALIDATE)) diff --git a/python-samples/woosmap_to_geojson/woosmap_to_geojson.py b/python-samples/woosmap_to_geojson/woosmap_to_geojson.py deleted file mode 100644 index 391a068..0000000 --- a/python-samples/woosmap_to_geojson/woosmap_to_geojson.py +++ /dev/null @@ -1,46 +0,0 @@ -import codecs -import json - -import requests - -origin_public_key = 'woos-3886aa76-xxxxxxxxxx' -output_file = origin_public_key + '.json' -allowed_referer = 'http://localhost/' -api_server_host = 'api.woosmap.com' -geojson_features = [] - - -def get_all_stores(page=1): - params = dict(key=origin_public_key, page=page) - ref_header = {'referer': allowed_referer} - - response = session.get(url='http://{api_server_host}/stores'.format( - api_server_host=api_server_host), - params=params, - headers=ref_header) - - temp_json = response.json() - for feature in temp_json['features']: - geojson_features.append(feature) - - if temp_json['pagination']['page'] != temp_json['pagination']['pageCount']: - get_all_stores(temp_json['pagination']['page'] + 1) - - return geojson_features - - -def export_input_geojson(inputjson): - with codecs.open(output_file, 'w', encoding='utf8') as outfile: - woosmap_geojson = {'type': 'FeatureCollection', 'features': inputjson} - json.dump(woosmap_geojson, outfile, indent=2, ensure_ascii=False) - - -if __name__ == '__main__': - session = requests.Session() - batch = [] - try: - stores_geojson = get_all_stores() - export_input_geojson(stores_geojson) - - except BaseException as error: # bad bad way! - print('An exception occurred: {}'.format(error)) diff --git a/python-samples/woosmap_to_woosmap/README.md b/python-samples/woosmap_to_woosmap/README.md deleted file mode 100644 index 5e319f3..0000000 --- a/python-samples/woosmap_to_woosmap/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Python Script to export data from a Woosmap Customer as a JSON file and eventually import them to another customer. - -You need to define parameter for **public_key** (origin data). -You could define parameter for **private key** (destination project) if you want to re-import them. - -sample usage: - - python woosmap_to_woosmap.py - -The exported data is saved in the file `data.json` \ No newline at end of file diff --git a/python-samples/woosmap_to_woosmap/woosmap_to_woosmap.py b/python-samples/woosmap_to_woosmap/woosmap_to_woosmap.py deleted file mode 100644 index 5c5e8ed..0000000 --- a/python-samples/woosmap_to_woosmap/woosmap_to_woosmap.py +++ /dev/null @@ -1,103 +0,0 @@ -import json -import requests -import codecs - -origin_public_key = 'woos-54e9fe79-5c35-3641-ace8-215e5610278d' -private_key = '' -output_file = 'woos-54e9fe79-5c35-3641-ace8-215e5610278d.json' -allowed_referer = 'http://localhost/' -api_server_host = 'api.woosmap.com' -geojson_features = [] -stores_batch_size = 500 - - -def get_geometry(store): - return { - 'lat': store['geometry']['coordinates'][1], - 'lng': store['geometry']['coordinates'][0] - } - - -def transform_geojson_woosmap(extracted_geojson): - stores = [] - for feature in extracted_geojson: - try: - prop = feature["properties"] - stores.append({"location": get_geometry(feature), - "storeId": prop.get("store_id"), - "openingHours": prop.get("opening_hours", {}), - "userProperties": prop.get("user_properties", {}), - "types": prop.get("types", []), - "address": prop.get("address", {}), - "name": prop.get("name", ""), - "tags": prop.get("tags", []), - "contact": prop.get("contact", {})}) - except Exception as err: - print('An exception occurred: {}'.format(err)) - - return stores - - -def get_all_stores(page=1): - params = dict(key=origin_public_key, page=page) - ref_header = {'referer': allowed_referer} - - response = session.get(url='http://{api_server_host}/stores'.format( - api_server_host=api_server_host), - params=params, - headers=ref_header) - - temp_json = response.json() - for feature in temp_json['features']: - geojson_features.append(feature) - - if temp_json['pagination']['page'] != temp_json['pagination']['pageCount']: - get_all_stores(temp_json['pagination']['page'] + 1) - - return geojson_features - - -def export_input_json(inputjson): - with codecs.open(output_file, 'w', encoding='utf8') as outfile: - woosmap_data = {'stores': inputjson} - json.dump(woosmap_data, outfile, indent=2, ensure_ascii=False) - - -def import_location(locations): - print('Importing locations (%d) ...' % len(locations)) - response = session.post( - 'http://{api_server_host}/stores'.format( - api_server_host=api_server_host), - params={'private_key': private_key}, - json={'stores': locations}) - - print('Import time:', response.elapsed.total_seconds()) - if response.status_code >= 400: - print('Import Failed') - print(response.text) - return False - - return True - - -if __name__ == '__main__': - session = requests.Session() - batch = [] - try: - stores_geojson = get_all_stores() - stores_woosmap = transform_geojson_woosmap(stores_geojson) - if output_file: - export_input_json(stores_woosmap) - if private_key: - for store in stores_woosmap: - batch.append(store) - if len(batch) == stores_batch_size: - batch_result = import_location(batch) - batch = [] - - if batch: - batch_result = import_location(batch) - batch = [] - - except Exception as err: - print('An exception occurred: {}'.format(err)) diff --git a/python-samples/woosmapjson_import/README.md b/python-samples/woosmapjson_import/README.md deleted file mode 100644 index f61b0d2..0000000 --- a/python-samples/woosmapjson_import/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Python Script to import a simple dataset to Woosmap Database. - -sample usage: - - python woosmapjson_import.py - -This python script may only be used for small data (less than 1000 locations). - -The sample JSON File (foodmarkets.json) represent a list of some of the best Food Markets in Europe. \ No newline at end of file diff --git a/python-samples/woosmapjson_import/woosmapjson_import.py b/python-samples/woosmapjson_import/woosmapjson_import.py deleted file mode 100644 index c537e6a..0000000 --- a/python-samples/woosmapjson_import/woosmapjson_import.py +++ /dev/null @@ -1,64 +0,0 @@ -import json -import requests - -WOOSMAP_JSON_FILE = 'foodmarkets.json' -WOOSMAP_PRIVATE_API_KEY = '23713926-1af5-4321-ba54-xxxxxxxxxxx' - - -class Woosmap: - """A wrapper around the Woosmap Data API.""" - - WOOSMAP_API_HOSTNAME = 'api.woosmap.com' - - def __init__(self): - self.session = requests.Session() - - def delete(self): - self.session.delete('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}) - - def post(self, payload): - return self.session.post('https://{hostname}/stores/'.format(hostname=self.WOOSMAP_API_HOSTNAME), - params={'private_key': WOOSMAP_PRIVATE_API_KEY}, - json={'stores': payload}) - - def end(self): - self.session.close() - - -def import_assets(assets_data, woosmap_api_helper): - try: - print('Batch import {count} Assets...'.format(count=len(assets_data))) - response = woosmap_api_helper.post(assets_data) - if response.status_code >= 400: - response.raise_for_status() - - except requests.exceptions.HTTPError as http_exception: - if http_exception.response.status_code >= 400: - print('Woosmap API Import Error: {0}'.format(http_exception.response.text)) - else: - print('Error requesting the API: {0}'.format(http_exception)) - return False - except Exception as exception: - print('Failed importing Assets! {0}'.format(exception)) - return False - - print('Successfully imported in {0} seconds'.format(response.elapsed.total_seconds())) - return True - - -def main(): - with open(WOOSMAP_JSON_FILE, 'rb') as f: - assets = json.loads(f.read()) - try: - woosmap_api_helper = Woosmap() - # /!\ deleting existing assets before posting new ones /!\ - woosmap_api_helper.delete() - import_assets(assets["stores"], woosmap_api_helper) - woosmap_api_helper.end() - except Exception as error: - print("Unable to import file {0} : {1}".format(WOOSMAP_JSON_FILE, error)) - - -if __name__ == '__main__': - main() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..48e68a1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest>=8 +responses>=0.25 +ruff>=0.5 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..1631d06 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,12 @@ +line-length = 100 +target-version = "py310" + +[lint] +select = ["E", "F", "I", "B", "UP", "ANN", "C90"] +ignore = ["ANN401"] + +[lint.mccabe] +max-complexity = 10 + +[lint.per-file-ignores] +"test_*.py" = ["ANN"] diff --git a/static-map/README.md b/static-map/README.md new file mode 100644 index 0000000..bddeeb3 --- /dev/null +++ b/static-map/README.md @@ -0,0 +1,19 @@ +# Static map for an e-mail or a PDF + +Order confirmations, appointment reminders and delivery notes want a small map of the store. Rendering it +server-side with the [Static Map API](https://developers.woosmap.com/products/map-static-api/get-started/) +and attaching the image keeps your API key out of the message. + +```sh +pip install -r python/requirements.txt +python python/static_map.py --lat 51.919948 --lng 4.486843 --zoom 15 \ + --marker 51.919948,4.486843 --retina --output store.webp +python python/static_map.py --lat 48.85 --lng 2.35 --zoom 12 --width 800 --height 300 \ + --marker "48.86,2.34,https://example.com/pin@2x.png" --marker 48.84,2.37 +``` + +The API returns WebP. Most webmail and mobile clients render it; classic Outlook does not, convert with +Pillow or ImageMagick if that audience matters. + +The script also prints the equivalent URL with a `key=YOUR_PUBLIC_KEY` placeholder, for pages where an +`` with a referer-restricted public key is the better fit. diff --git a/static-map/python/requirements.txt b/static-map/python/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/static-map/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/static-map/python/static_map.py b/static-map/python/static_map.py new file mode 100644 index 0000000..e104d13 --- /dev/null +++ b/static-map/python/static_map.py @@ -0,0 +1,99 @@ +"""Render a Static Map image server-side, ready to attach to an e-mail or a PDF.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com/maps/static" + + +def parse_marker(text: str) -> dict[str, Any]: + parts = [part.strip() for part in text.split(",", 2)] + if len(parts) < 2: + raise ValueError(f"marker {text!r} must be lat,lng[,icon-url]") + marker: dict[str, Any] = {"lat": float(parts[0]), "lng": float(parts[1])} + if len(parts) == 3 and parts[2]: + marker["url"] = parts[2] + return marker + + +def build_params(args: argparse.Namespace) -> list[tuple[str, str]]: + params: list[tuple[str, str]] = [ + ("lat", str(args.lat)), + ("lng", str(args.lng)), + ("zoom", str(args.zoom)), + ("width", str(args.width)), + ("height", str(args.height)), + ] + if args.retina: + params.append(("retina", "true")) + if args.language: + params.append(("language", args.language)) + params.extend( + ("markers", json.dumps(parse_marker(m), separators=(",", ":"))) for m in args.marker + ) + return params + + +def fetch_image( + session: requests.Session, private_key: str, params: list[tuple[str, str]] +) -> bytes: + response = session.get(API_URL, params=[*params, ("private_key", private_key)], timeout=60) + if response.status_code >= 400: + raise RuntimeError(f"static map failed ({response.status_code}): {response.text}") + if not response.headers.get("Content-Type", "").startswith("image/"): + raise RuntimeError(f"unexpected content type {response.headers.get('Content-Type')!r}") + return response.content + + +def public_url(params: list[tuple[str, str]]) -> str: + return ( + requests.Request("GET", API_URL, params=[*params, ("key", "YOUR_PUBLIC_KEY")]).prepare().url + or "" + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lat", type=float, required=True) + parser.add_argument("--lng", type=float, required=True) + parser.add_argument("--zoom", type=int, default=15) + parser.add_argument("--width", type=int, default=600) + parser.add_argument("--height", type=int, default=400) + parser.add_argument("--retina", action="store_true") + parser.add_argument("--language", help="labels language, e.g. fr") + parser.add_argument( + "--marker", action="append", default=[], metavar="LAT,LNG[,ICON_URL]", help="repeatable" + ) + parser.add_argument( + "--output", type=Path, default=Path("map.webp"), help="the API returns WebP" + ) + return parser + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + params = build_params(args) + png = fetch_image(requests.Session(), private_key_from_env(), params) + args.output.write_bytes(png) + print(f"wrote {len(png)} bytes to {args.output}", file=sys.stderr) + print(f"same map with a public key: {public_url(params)}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static-map/python/test_static_map.py b/static-map/python/test_static_map.py new file mode 100644 index 0000000..9bceef9 --- /dev/null +++ b/static-map/python/test_static_map.py @@ -0,0 +1,67 @@ +import pytest +import requests +import responses +import static_map as mod + + +def test_parse_marker_with_and_without_icon(): + assert mod.parse_marker("48.8,2.3") == {"lat": 48.8, "lng": 2.3} + assert mod.parse_marker("48.8, 2.3, https://x/y.png") == { + "lat": 48.8, + "lng": 2.3, + "url": "https://x/y.png", + } + + +def test_parse_marker_rejects_single_value(): + with pytest.raises(ValueError): + mod.parse_marker("48.8") + + +def test_build_params_repeats_markers_as_compact_json(): + args = mod.build_parser().parse_args( + ["--lat", "1", "--lng", "2", "--marker", "1,2", "--marker", "3,4,https://i.png", "--retina"] + ) + params = mod.build_params(args) + assert ("retina", "true") in params + assert [v for k, v in params if k == "markers"] == [ + '{"lat":1.0,"lng":2.0}', + '{"lat":3.0,"lng":4.0,"url":"https://i.png"}', + ] + + +@responses.activate +def test_fetch_image_returns_image_bytes(): + responses.get(mod.API_URL, body=b"\x89PNG", content_type="image/png") + png = mod.fetch_image(requests.Session(), "k", [("lat", "1")]) + assert png == b"\x89PNG" + assert responses.calls[0].request.params["private_key"] == "k" + + +@responses.activate +def test_fetch_image_rejects_non_image_response(): + responses.get(mod.API_URL, json={"detail": "oops"}) + with pytest.raises(RuntimeError, match="unexpected content type"): + mod.fetch_image(requests.Session(), "k", []) + + +@responses.activate +def test_fetch_image_raises_on_http_error(): + responses.get(mod.API_URL, status=401, body='{"detail":"bad key"}') + with pytest.raises(RuntimeError, match="bad key"): + mod.fetch_image(requests.Session(), "k", []) + + +def test_public_url_never_contains_the_private_key(): + url = mod.public_url([("lat", "1"), ("lng", "2")]) + assert url.startswith(mod.API_URL) + assert "key=YOUR_PUBLIC_KEY" in url + + +@responses.activate +def test_main_writes_the_file(tmp_path, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + responses.get(mod.API_URL, body=b"img", content_type="image/png") + output = tmp_path / "m.webp" + assert mod.main(["--lat", "1", "--lng", "2", "--output", str(output)]) == 0 + assert output.read_bytes() == b"img" diff --git a/stores-export/README.md b/stores-export/README.md new file mode 100644 index 0000000..5d11410 --- /dev/null +++ b/stores-export/README.md @@ -0,0 +1,16 @@ +# Export a project + +Dump every store as Woosmap JSON, which [stores-sync](../stores-sync/) and the Stores API accept as input, +or as GeoJSON for QGIS, a BI tool or a backup. + +```sh +pip install -r python/requirements.txt +python python/export_stores.py --output stores.json +python python/export_stores.py --format geojson --output stores.geojson +python python/export_stores.py --query 'type:"grocery"' --output grocery.json +``` + +The export walks `GET /stores/search` page by page. The `--query` filter uses the +[Stores API query syntax](https://developers.woosmap.com/products/stores-api/concepts/query-syntax/). +Response-only fields such as `open`, `weekly_opening` and `last_updated` are dropped from the Woosmap +JSON output so the file can be re-imported as is. diff --git a/stores-export/python/export_stores.py b/stores-export/python/export_stores.py new file mode 100644 index 0000000..aa6ca68 --- /dev/null +++ b/stores-export/python/export_stores.py @@ -0,0 +1,119 @@ +"""Export every store of a Woosmap project as re-importable Woosmap JSON or as GeoJSON.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com" +PAGE_SIZE = 300 # stores_by_page maximum + +Asset = dict[str, Any] + + +def fetch_page( + session: requests.Session, private_key: str, page: int, query: str | None +) -> dict[str, Any]: + params: dict[str, Any] = {"private_key": private_key, "stores_by_page": PAGE_SIZE, "page": page} + if query: + params["query"] = query + for attempt in range(3): + response = session.get(f"{API_URL}/stores/search", params=params, timeout=60) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + if response.status_code >= 400: + raise RuntimeError(f"search failed ({response.status_code}): {response.text}") + return response.json() + + +def fetch_all( + session: requests.Session, private_key: str, query: str | None +) -> list[dict[str, Any]]: + features: list[dict[str, Any]] = [] + page = 1 + while True: + body = fetch_page(session, private_key, page, query) + features.extend(body.get("features", [])) + if page >= body.get("pagination", {}).get("pageCount", 1): + return features + page += 1 + + +def strip_empty(value: Any) -> Any: + if isinstance(value, dict): + cleaned = {k: strip_empty(v) for k, v in value.items()} + return {k: v for k, v in cleaned.items() if v not in (None, {}, [], "")} + return value + + +def feature_to_asset(feature: dict[str, Any]) -> Asset: + props = feature["properties"] + lng, lat = feature["geometry"]["coordinates"] + address = props.get("address") or {} + return strip_empty( + { + "storeId": props["store_id"], + "name": props.get("name"), + "location": {"lat": lat, "lng": lng}, + "address": { + "lines": address.get("lines"), + "city": address.get("city"), + "zipcode": address.get("zipcode"), + "countryCode": address.get("country_code"), + }, + "contact": props.get("contact"), + "types": props.get("types"), + "tags": props.get("tags"), + "userProperties": props.get("user_properties"), + "openingHours": props.get("opening_hours"), + } + ) + + +def to_geojson(features: list[dict[str, Any]]) -> dict[str, Any]: + return {"type": "FeatureCollection", "features": features} + + +def to_woosmap_json(features: list[dict[str, Any]]) -> dict[str, Any]: + return {"stores": [feature_to_asset(feature) for feature in features]} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--format", choices=("woosmap", "geojson"), default="woosmap") + parser.add_argument("--query", help='optional Stores API query, e.g. type:"grocery"') + parser.add_argument("--output", type=Path, help="output file (default: stdout)") + return parser + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + features = fetch_all(requests.Session(), private_key_from_env(), args.query) + document = to_geojson(features) if args.format == "geojson" else to_woosmap_json(features) + text = json.dumps(document, indent=2, ensure_ascii=False) + if args.output: + args.output.write_text(text + "\n", encoding="utf-8") + print(f"wrote {len(features)} stores to {args.output}", file=sys.stderr) + else: + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stores-export/python/requirements.txt b/stores-export/python/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/stores-export/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/stores-export/python/test_export_stores.py b/stores-export/python/test_export_stores.py new file mode 100644 index 0000000..1fa766d --- /dev/null +++ b/stores-export/python/test_export_stores.py @@ -0,0 +1,65 @@ +import json + +import export_stores as mod +import pytest +import requests +import responses + + +def feature(store_id, **props): + return { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [2.0, 48.5]}, + "properties": {"store_id": store_id, "name": "Shop", **props}, + } + + +def test_feature_to_asset_drops_empty_fields_and_renames_keys(): + asset = mod.feature_to_asset( + feature("a", address={"country_code": "FR", "lines": []}, contact=None, tags=[]) + ) + assert asset == { + "storeId": "a", + "name": "Shop", + "location": {"lat": 48.5, "lng": 2.0}, + "address": {"countryCode": "FR"}, + } + + +@responses.activate +def test_fetch_all_walks_every_page_and_passes_query(): + url = f"{mod.API_URL}/stores/search" + responses.get(url, json={"features": [feature("a")], "pagination": {"pageCount": 2}}) + responses.get(url, json={"features": [feature("b")], "pagination": {"pageCount": 2}}) + features = mod.fetch_all(requests.Session(), "k", 'type:"grocery"') + assert [f["properties"]["store_id"] for f in features] == ["a", "b"] + assert responses.calls[0].request.params["query"] == 'type:"grocery"' + + +@responses.activate +def test_server_errors_fail_immediately_with_body(): + responses.get(f"{mod.API_URL}/stores/search", status=503, body="down") + with pytest.raises(RuntimeError, match="down"): + mod.fetch_all(requests.Session(), "k", None) + assert len(responses.calls) == 1 + + +@responses.activate +def test_rate_limit_is_retried_with_retry_after(monkeypatch): + waits = [] + monkeypatch.setattr(mod.time, "sleep", waits.append) + responses.get(f"{mod.API_URL}/stores/search", status=429, headers={"Retry-After": "7"}) + responses.get(f"{mod.API_URL}/stores/search", json={"features": []}) + assert mod.fetch_all(requests.Session(), "k", None) == [] + assert waits == [7.0] + + +@responses.activate +def test_main_writes_geojson_file(tmp_path, monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + responses.get(f"{mod.API_URL}/stores/search", json={"features": [feature("a")]}) + output = tmp_path / "stores.geojson" + assert mod.main(["--format", "geojson", "--output", str(output)]) == 0 + document = json.loads(output.read_text()) + assert document["type"] == "FeatureCollection" + assert document["features"][0]["properties"]["store_id"] == "a" diff --git a/stores-import/README.md b/stores-import/README.md new file mode 100644 index 0000000..d6f0049 --- /dev/null +++ b/stores-import/README.md @@ -0,0 +1,62 @@ +# Import stores from a spreadsheet + +Turn a CSV, an Excel workbook or a Google Sheet into Woosmap assets and load them with one atomic +`POST /stores/replace`. The previous dataset stays online until the new one is accepted, and a rejected +batch leaves the project untouched. + +Ask yourself first whether you need [stores-sync](../stores-sync/) instead: replace is right for a first +load or a small dataset; a nightly refresh of thousands of stores should only send what changed. + +## Input + +One row per store. Default column headers, override any of them with `--column FIELD=HEADER`: + +| Field | Header | Notes | +| --- | --- | --- | +| `name` | Name | required | +| `lat`, `lng` | Latitude, Longitude | required, decimal comma accepted | +| `storeId` | Store ID | optional, derived from the name when absent, kept to `[A-Za-z0-9]` | +| `addressLine`, `city`, `zipcode`, `countryCode` | Address Line, City, Zipcode, Country Code | | +| `website`, `phone`, `email` | Website, Contact Phone, Contact Email | | +| `types`, `tags` | Type, Tags | `|`-separated lists | + +Give every store a stable `Store ID` before anything beyond a one-off load. An id derived from the +name changes when the name does, and [stores-sync](../stores-sync/) then sees a deletion and a creation. + +Google Sheets: share the sheet with "anyone with the link" and pass the browser URL. The script downloads +the CSV export, no OAuth involved. Excel files are read with openpyxl (Python only). + +## Layout + +Reading the file is one module, `spreadsheet.py` (`spreadsheet.mjs` in Node): CSV sniffing, XLSX, and the +Google Sheets export URL. Everything Woosmap is in `import_stores.py` (`import-stores.mjs`): rows to assets, +assets to the API. Swap the reader for your own source and the rest still applies. + +## Python + +```sh +pip install -r python/requirements.txt +python python/import_stores.py ../data/foodmarkets.csv --dry-run --output stores.json +python python/import_stores.py ../data/foodmarkets.xlsx --sheet foodmarkets +python python/import_stores.py "https://docs.google.com/spreadsheets/d//edit#gid=0" --column name="Shop name" +python python/import_stores.py new_stores.csv --mode create --batch-size 300 +``` + +`--dry-run` validates and prints what would be sent. `--output` writes the converted Woosmap JSON, which +is the input format of [stores-sync](../stores-sync/). `--strict` fails on the first bad row instead of +skipping it. `--mode create` and `--mode update` batch `POST` and `PUT /stores` for incremental loads. + +## Node + +```sh +node node/import-stores.mjs ../data/foodmarkets.csv --dry-run --output stores.json +``` + +Same flags. Reads CSV and Google Sheets, not XLSX. + +## Behaviour worth knowing + +- Duplicate `storeId` values in the source are reported and only the first row is kept. +- Requests above the 15MB body limit are refused locally before reaching the API. +- 429 responses are retried after `Retry-After`; any other error stops with the API message. +- Bad input stops with one line, not a traceback. diff --git a/stores-import/node/import-stores.mjs b/stores-import/node/import-stores.mjs new file mode 100644 index 0000000..e1c59ae --- /dev/null +++ b/stores-import/node/import-stores.mjs @@ -0,0 +1,187 @@ +// Import stores from a spreadsheet into a Woosmap project. Reading the file is in spreadsheet.mjs. +import { writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +import { readSource } from "./spreadsheet.mjs"; + +export const API_URL = "https://api.woosmap.com"; +const MAX_BODY_BYTES = 15 * 1024 * 1024; // Stores API request body limit + +export const DEFAULT_COLUMNS = { + storeId: "Store ID", + name: "Name", + lat: "Latitude", + lng: "Longitude", + addressLine: "Address Line", + city: "City", + zipcode: "Zipcode", + countryCode: "Country Code", + website: "Website", + phone: "Contact Phone", + email: "Contact Email", + types: "Type", + tags: "Tags", +}; + +// storeId must match [A-Za-z0-9]+ +const slugify = (value) => + value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9]+/g, ""); +const parseList = (value) => value.split("|").map((v) => v.trim()).filter(Boolean); + +function parseCoordinate(value, name) { + const number = Number.parseFloat(String(value).replace(",", ".")); + if (Number.isNaN(number)) throw new Error(`invalid ${name} '${value}'`); + return number; +} + +function compact(object) { + const entries = Object.entries(object).filter(([, v]) => v !== "" && v !== undefined && v !== null); + return entries.length ? Object.fromEntries(entries) : undefined; +} + +export function rowToAsset(row, columns = DEFAULT_COLUMNS) { + const col = (key) => row[columns[key]] ?? ""; + if (!col("name")) throw new Error("missing name"); + const storeId = slugify(col("storeId") || col("name")); + if (!storeId) throw new Error("no storeId and no name to derive one from"); + const asset = { + storeId, + name: col("name"), + location: { lat: parseCoordinate(col("lat"), "latitude"), lng: parseCoordinate(col("lng"), "longitude") }, + }; + const address = compact({ + lines: col("addressLine") ? [col("addressLine")] : undefined, + city: col("city"), + zipcode: col("zipcode"), + countryCode: col("countryCode").toUpperCase(), + }); + const contact = compact({ website: col("website"), phone: col("phone"), email: col("email") }); + if (address) asset.address = address; + if (contact) asset.contact = contact; + if (col("types")) asset.types = parseList(col("types")); + if (col("tags")) asset.tags = parseList(col("tags")); + return asset; +} + +export function convertRows(rows, columns = DEFAULT_COLUMNS) { + const assets = []; + const errors = []; + const seen = new Map(); + let derivedIds = 0; + rows.forEach((row, index) => { + const line = index + 2; + try { + const asset = rowToAsset(row, columns); + if (seen.has(asset.storeId)) { + errors.push(`row ${line}: duplicate storeId '${asset.storeId}' (first seen row ${seen.get(asset.storeId)})`); + return; + } + seen.set(asset.storeId, line); + assets.push(asset); + if (!row[columns.storeId]) derivedIds += 1; + } catch (error) { + errors.push(`row ${line}: ${error.message}`); + } + }); + return { assets, errors, derivedIds }; +} + +const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + +export class WoosmapStores { + constructor(privateKey, fetchImpl = fetch, sleepImpl = sleep) { + this.privateKey = privateKey; + this.fetch = fetchImpl; + this.sleep = sleepImpl; + } + + async send(method, path, stores) { + const body = JSON.stringify({ stores }); + if (Buffer.byteLength(body) > MAX_BODY_BYTES) throw new Error("request body is above the 15MB limit"); + let response; + for (let attempt = 0; attempt < 3; attempt += 1) { + response = await this.fetch(`${API_URL}${path}?private_key=${encodeURIComponent(this.privateKey)}`, { + method, + headers: { "Content-Type": "application/json" }, + body, + }); + if (response.status !== 429 || attempt === 2) break; + // 429 is the only status the API asks to retry, and Retry-After says when + await this.sleep(Number(response.headers.get("Retry-After") ?? 2 ** attempt)); + } + if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${await response.text()}`); + return response.json(); + } + + replaceAll = (stores) => this.send("POST", "/stores/replace", stores); + // POST rejects the whole batch if one storeId already exists, PUT if one is missing + create = (stores) => this.send("POST", "/stores", stores); + update = (stores) => this.send("PUT", "/stores", stores); +} + +export function chunked(items, size) { + const chunks = []; + for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); + return chunks; +} + +export async function upload(api, assets, mode, batchSize) { + if (mode === "replace") { + await api.replaceAll(assets); + console.log(`replaced the project with ${assets.length} stores`); + return; + } + const action = mode === "create" ? api.create : api.update; + for (const batch of chunked(assets, batchSize)) { + await action(batch); + console.log(`${mode}d ${batch.length} stores`); + } +} + +export function parseColumnOverrides(values, base = DEFAULT_COLUMNS) { + const columns = { ...base }; + for (const value of values) { + const [key, ...rest] = value.split("="); + const header = rest.join("="); + if (!(key in columns) || !header) throw new Error(`--column expects FIELD=HEADER with FIELD in ${Object.keys(columns).join(", ")}`); + columns[key] = header; + } + return columns; +} + +export async function main(argv) { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + column: { type: "string", multiple: true, default: [] }, + mode: { type: "string", default: "replace" }, + "batch-size": { type: "string", default: "500" }, + output: { type: "string" }, + "dry-run": { type: "boolean", default: false }, + strict: { type: "boolean", default: false }, + }, + }); + const [source] = positionals; + if (!source) throw new Error("usage: node import-stores.mjs [options]"); + const rows = await readSource(source); + const { assets, errors, derivedIds } = convertRows(rows, parseColumnOverrides(values.column)); + errors.forEach((error) => console.error(error)); + console.log(`${assets.length} stores ready, ${errors.length} rows skipped`); + if (derivedIds) console.error(`storeId derived from the name for ${derivedIds} stores; add a Store ID column before relying on stores-sync`); + if (values.strict && errors.length) return 1; + if (values.output) await writeFile(values.output, JSON.stringify({ stores: assets }, null, 2)); + if (values["dry-run"] || !assets.length) return 0; + const privateKey = process.env.WOOSMAP_PRIVATE_KEY; + if (!privateKey) throw new Error("set WOOSMAP_PRIVATE_KEY in the environment"); + await upload(new WoosmapStores(privateKey), assets, values.mode, Number(values["batch-size"])); + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then((code) => process.exit(code), (error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/stores-import/node/import-stores.test.mjs b/stores-import/node/import-stores.test.mjs new file mode 100644 index 0000000..f72cdd5 --- /dev/null +++ b/stores-import/node/import-stores.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + API_URL, + DEFAULT_COLUMNS, + WoosmapStores, + convertRows, + parseColumnOverrides, + rowToAsset, + upload, +} from "./import-stores.mjs"; + +function fakeFetch(responses) { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url, init }); + const next = responses.shift() ?? { status: 200, body: "{}" }; + return new Response(next.body ?? "{}", { status: next.status ?? 200, headers: next.headers }); + }; + return { fetchImpl, calls }; +} + +test("row to asset maps default columns", () => { + const asset = rowToAsset({ + Name: "Markthal", + Latitude: "51.9", + Longitude: "4,48", + City: "Rotterdam", + "Country Code": "nl", + Type: "covered|indoor", + }); + assert.deepEqual(asset, { + storeId: "Markthal", + name: "Markthal", + location: { lat: 51.9, lng: 4.48 }, + address: { city: "Rotterdam", countryCode: "NL" }, + types: ["covered", "indoor"], + }); +}); + +test("accents are transliterated in derived ids", () => { + assert.equal(rowToAsset({ Name: "Le Grand Marché d'Apt", Latitude: "1", Longitude: "2" }).storeId, "LeGrandMarchedApt"); +}); + +test("bad coordinates and duplicate ids are reported per row", () => { + const { assets, errors } = convertRows([ + { Name: "A", Latitude: "", Longitude: "2" }, + { Name: "B", Latitude: "1", Longitude: "2" }, + { Name: "B", Latitude: "1", Longitude: "2" }, + ]); + assert.equal(assets.length, 1); + assert.deepEqual(errors, ["row 2: invalid latitude ''", "row 4: duplicate storeId 'B' (first seen row 3)"]); +}); + +test("column overrides validate the field name", () => { + assert.equal(parseColumnOverrides(["name=Shop name"]).name, "Shop name"); + assert.equal(parseColumnOverrides([]).lat, DEFAULT_COLUMNS.lat); + assert.throws(() => parseColumnOverrides(["colour=Blue"])); +}); + +test("replace posts every store once with the private key", async () => { + const { fetchImpl, calls } = fakeFetch([{ status: 200, body: '{"status":"OK"}' }]); + await upload(new WoosmapStores("secret", fetchImpl), [{ storeId: "a" }, { storeId: "b" }], "replace", 1); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, `${API_URL}/stores/replace?private_key=secret`); + assert.equal(JSON.parse(calls[0].init.body).stores.length, 2); +}); + +test("create mode batches and retries on 429", async () => { + const { fetchImpl, calls } = fakeFetch([ + { status: 429, headers: { "Retry-After": "0" } }, + { status: 200 }, + { status: 200 }, + ]); + const api = new WoosmapStores("k", fetchImpl, async () => {}); + await upload(api, [{ storeId: "1" }, { storeId: "2" }, { storeId: "3" }], "create", 2); + assert.equal(calls.length, 3); + assert.equal(calls[0].init.method, "POST"); +}); + +test("api errors carry the response body", async () => { + const { fetchImpl } = fakeFetch([{ status: 400, body: '{"detail":"bad storeId"}' }]); + await assert.rejects(new WoosmapStores("k", fetchImpl).create([{ storeId: "a b" }]), /bad storeId/); +}); diff --git a/stores-import/node/package.json b/stores-import/node/package.json new file mode 100644 index 0000000..60e586f --- /dev/null +++ b/stores-import/node/package.json @@ -0,0 +1,9 @@ +{ + "name": "woosmap-stores-import", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "test": "node --test" + } +} diff --git a/stores-import/node/spreadsheet.mjs b/stores-import/node/spreadsheet.mjs new file mode 100644 index 0000000..a10db2a --- /dev/null +++ b/stores-import/node/spreadsheet.mjs @@ -0,0 +1,74 @@ +// Read rows from a CSV file or a published Google Sheet. +import { readFile } from "node:fs/promises"; + +export async function readSource(source, fetchImpl = fetch) { + if (/^https?:\/\//.test(source)) { + const response = await fetchImpl(googleSheetExportUrl(source)); + if (!response.ok) throw new Error(`Google Sheets export failed (${response.status})`); + return parseCsv(await response.text()); + } + try { + return parseCsv(await readFile(source, "utf8")); + } catch (error) { + throw new Error(error.code === "ENOENT" ? `no such file: ${source}` : `${source}: ${error.message}`); + } +} + +export function googleSheetExportUrl(url) { + const match = url.match(/\/spreadsheets\/d\/([\w-]+)/); + if (!match) throw new Error(`Not a Google Sheets URL: ${url}`); + const gid = url.match(/[#&?]gid=(\d+)/); + return `https://docs.google.com/spreadsheets/d/${match[1]}/export?format=csv${gid ? `&gid=${gid[1]}` : ""}`; +} + +export function parseCsv(text, delimiter = detectDelimiter(text)) { + const rows = []; + let row = []; + let cell = ""; + let quoted = false; + const source = text.replace(/^/, ""); + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + if (quoted) { + if (char === '"' && source[i + 1] === '"') { + cell += '"'; + i += 1; + } else if (char === '"') { + quoted = false; + } else { + cell += char; + } + } else if (char === '"') { + quoted = true; + } else if (char === delimiter) { + row.push(cell); + cell = ""; + } else if (char === "\n" || char === "\r") { + if (char === "\r" && source[i + 1] === "\n") i += 1; + row.push(cell); + rows.push(row); + row = []; + cell = ""; + } else { + cell += char; + } + } + if (cell !== "" || row.length) { + row.push(cell); + rows.push(row); + } + return toRecords(rows.filter((r) => r.some((c) => c.trim() !== ""))); +} + +function detectDelimiter(text) { + const firstLine = text.split(/\r?\n/, 1)[0] ?? ""; + const counts = [",", ";", "\t"].map((d) => [d, firstLine.split(d).length]); + return counts.sort((a, b) => b[1] - a[1])[0][0]; +} + +function toRecords([header = [], ...lines]) { + const keys = header.map((h) => h.trim()); + return lines.map((line) => + Object.fromEntries(keys.map((key, index) => [key, (line[index] ?? "").trim()]).filter(([k]) => k)), + ); +} diff --git a/stores-import/node/spreadsheet.test.mjs b/stores-import/node/spreadsheet.test.mjs new file mode 100644 index 0000000..dd3f682 --- /dev/null +++ b/stores-import/node/spreadsheet.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { googleSheetExportUrl, parseCsv, readSource } from "./spreadsheet.mjs"; + +const DATA = fileURLToPath(new URL("../../data/", import.meta.url)); + +test("parses the food markets fixture", async () => { + const rows = await readSource(`${DATA}foodmarkets.csv`); + assert.equal(rows.length, 18); + assert.equal(rows[0].Name, "Markthal Rotterdam"); +}); + +test("handles quotes, embedded delimiters, CRLF and semicolons", () => { + assert.deepEqual(parseCsv('Name;City\r\n"Shop; ""Le"" Coin";Paris\r\n'), [ + { Name: 'Shop; "Le" Coin', City: "Paris" }, + ]); +}); + +test("keeps a newline inside a quoted field", () => { + assert.deepEqual(parseCsv('Name,Address\n"A","line 1\nline 2"\n'), [ + { Name: "A", Address: "line 1\nline 2" }, + ]); +}); + +test("google sheet url becomes a csv export url", () => { + assert.equal( + googleSheetExportUrl("https://docs.google.com/spreadsheets/d/1abc_-9/edit#gid=7"), + "https://docs.google.com/spreadsheets/d/1abc_-9/export?format=csv&gid=7", + ); + assert.throws(() => googleSheetExportUrl("https://example.com/x.csv")); +}); + +test("a missing file reports its path, not a stack", async () => { + await assert.rejects(readSource(`${DATA}nope.csv`), /no such file/); +}); + +test("downloads a google sheet through the export url", async () => { + const calls = []; + const rows = await readSource("https://docs.google.com/spreadsheets/d/1abc/edit", async (url) => { + calls.push(url); + return new Response("Name,Latitude\nShop,48.5\n"); + }); + assert.match(calls[0], /export\?format=csv$/); + assert.deepEqual(rows, [{ Name: "Shop", Latitude: "48.5" }]); +}); diff --git a/stores-import/python/import_stores.py b/stores-import/python/import_stores.py new file mode 100644 index 0000000..a3256c8 --- /dev/null +++ b/stores-import/python/import_stores.py @@ -0,0 +1,267 @@ +"""Import stores from a spreadsheet into a Woosmap project. The reader lives in spreadsheet.py.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +import unicodedata +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import requests +from spreadsheet import Row, read_source + +API_URL = "https://api.woosmap.com" +MAX_BODY_BYTES = 15 * 1024 * 1024 # Stores API request body limit + +DEFAULT_COLUMNS = { + "storeId": "Store ID", + "name": "Name", + "lat": "Latitude", + "lng": "Longitude", + "addressLine": "Address Line", + "city": "City", + "zipcode": "Zipcode", + "countryCode": "Country Code", + "website": "Website", + "phone": "Contact Phone", + "email": "Contact Email", + "types": "Type", + "tags": "Tags", +} + +Asset = dict[str, Any] + + +@dataclass +class Conversion: + assets: list[Asset] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + derived_ids: int = 0 + + +def slugify(value: str) -> str: + # storeId must match [A-Za-z0-9]+ + ascii_text = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode() + return re.sub(r"[^A-Za-z0-9]+", "", ascii_text) + + +def parse_list(value: str) -> list[str]: + return [item.strip() for item in value.split("|") if item.strip()] + + +def parse_coordinate(value: str, name: str) -> float: + try: + return float(value.replace(",", ".")) + except ValueError as error: + raise ValueError(f"invalid {name} {value!r}") from error + + +def store_id_for(row: Row, columns: dict[str, str]) -> str: + explicit = row.get(columns["storeId"], "") + store_id = slugify(explicit or row.get(columns["name"], "")) + if not store_id: + raise ValueError("no storeId and no name to derive one from") + return store_id + + +def optional_object(pairs: dict[str, Any]) -> dict[str, Any] | None: + cleaned = {key: value for key, value in pairs.items() if value} + return cleaned or None + + +def row_to_asset(row: Row, columns: dict[str, str]) -> Asset: + def col(key: str) -> str: + return row.get(columns[key], "") + + name = col("name") + if not name: + raise ValueError("missing name") + asset: Asset = { + "storeId": store_id_for(row, columns), + "name": name, + "location": { + "lat": parse_coordinate(col("lat"), "latitude"), + "lng": parse_coordinate(col("lng"), "longitude"), + }, + } + address = optional_object( + { + "lines": [col("addressLine")] if col("addressLine") else None, + "city": col("city"), + "zipcode": col("zipcode"), + "countryCode": col("countryCode").upper(), + } + ) + contact = optional_object( + {"website": col("website"), "phone": col("phone"), "email": col("email")} + ) + for key, value in (("address", address), ("contact", contact)): + if value: + asset[key] = value + for key in ("types", "tags"): + if col(key): + asset[key] = parse_list(col(key)) + return asset + + +def convert_rows(rows: list[Row], columns: dict[str, str]) -> Conversion: + result = Conversion() + seen: dict[str, int] = {} + for index, row in enumerate(rows, start=2): + try: + asset = row_to_asset(row, columns) + except ValueError as error: + result.errors.append(f"row {index}: {error}") + continue + if asset["storeId"] in seen: + result.errors.append( + f"row {index}: duplicate storeId {asset['storeId']!r} " + f"(first seen row {seen[asset['storeId']]})" + ) + continue + seen[asset["storeId"]] = index + result.assets.append(asset) + if not row.get(columns["storeId"]): + result.derived_ids += 1 + return result + + +class WoosmapStores: + def __init__(self, private_key: str, session: requests.Session | None = None) -> None: + self.private_key = private_key + self.session = session or requests.Session() + + def send(self, method: str, path: str, stores: list[Asset]) -> dict[str, Any]: + body = json.dumps({"stores": stores}).encode() + if len(body) > MAX_BODY_BYTES: + raise ValueError(f"request body is {len(body)} bytes, above the 15MB limit") + for attempt in range(3): + response = self.session.request( + method, + f"{API_URL}{path}", + params={"private_key": self.private_key}, + data=body, + headers={"Content-Type": "application/json"}, + timeout=120, + ) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + if response.status_code >= 400: + raise RuntimeError(f"{method} {path} failed ({response.status_code}): {response.text}") + return response.json() + + def replace_all(self, stores: list[Asset]) -> None: + self.send("POST", "/stores/replace", stores) + + # POST rejects the whole batch if one storeId already exists, PUT if one is missing + def create(self, stores: list[Asset]) -> None: + self.send("POST", "/stores", stores) + + def update(self, stores: list[Asset]) -> None: + self.send("PUT", "/stores", stores) + + +def chunked(items: list[Asset], size: int) -> list[list[Asset]]: + return [items[i : i + size] for i in range(0, len(items), size)] + + +def upload(api: WoosmapStores, assets: list[Asset], mode: str, batch_size: int) -> None: + if mode == "replace": + api.replace_all(assets) + print(f"replaced the project with {len(assets)} stores") + return + action = api.create if mode == "create" else api.update + for batch in chunked(assets, batch_size): + action(batch) + print(f"{mode}d {len(batch)} stores") + + +def parse_column_overrides(values: list[str]) -> dict[str, str]: + columns = dict(DEFAULT_COLUMNS) + for value in values: + key, _, column = value.partition("=") + if key not in columns or not column: + raise SystemExit(f"--column expects one of {sorted(columns)}=
, got {value!r}") + columns[key] = column + return columns + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "source", + help="CSV or XLSX path, or a Google Sheets URL shared with 'anyone with the link'", + ) + parser.add_argument("--sheet", help="worksheet name for XLSX sources (default: first sheet)") + parser.add_argument( + "--column", + action="append", + default=[], + metavar="FIELD=HEADER", + help="override a column mapping", + ) + parser.add_argument("--mode", choices=("replace", "create", "update"), default="replace") + parser.add_argument( + "--batch-size", type=int, default=500, help="stores per request in create/update mode" + ) + parser.add_argument( + "--output", type=Path, help="also write the converted stores as Woosmap JSON" + ) + parser.add_argument( + "--dry-run", action="store_true", help="convert and validate without calling the API" + ) + parser.add_argument("--strict", action="store_true", help="stop if any row fails to convert") + return parser + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + session = requests.Session() + try: + rows = read_source(args.source, args.sheet, session) + except FileNotFoundError: + raise SystemExit(f"no such file: {args.source}") from None + conversion = convert_rows(rows, parse_column_overrides(args.column)) + for error in conversion.errors: + print(error, file=sys.stderr) + print(f"{len(conversion.assets)} stores ready, {len(conversion.errors)} rows skipped") + if conversion.derived_ids: + print( + f"storeId derived from the name for {conversion.derived_ids} stores; " + "add a Store ID column before relying on stores-sync", + file=sys.stderr, + ) + if args.strict and conversion.errors: + return 1 + if args.output: + args.output.write_text( + json.dumps({"stores": conversion.assets}, indent=2, ensure_ascii=False) + ) + if args.dry_run or not conversion.assets: + return 0 + upload( + WoosmapStores(private_key_from_env(), session), + conversion.assets, + args.mode, + args.batch_size, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stores-import/python/requirements.txt b/stores-import/python/requirements.txt new file mode 100644 index 0000000..5f1721d --- /dev/null +++ b/stores-import/python/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31 +openpyxl>=3.1 diff --git a/stores-import/python/spreadsheet.py b/stores-import/python/spreadsheet.py new file mode 100644 index 0000000..a17de6a --- /dev/null +++ b/stores-import/python/spreadsheet.py @@ -0,0 +1,72 @@ +"""Read rows from a CSV file, an Excel workbook or a published Google Sheet.""" + +from __future__ import annotations + +import csv +import io +import re +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import requests +from openpyxl import load_workbook + +Row = dict[str, str] + + +def read_source(source: str, sheet: str | None, session: requests.Session) -> list[Row]: + if urlparse(source).scheme in ("http", "https"): + return read_google_sheet(source, session) + path = Path(source) + if path.suffix.lower() in (".xlsx", ".xlsm"): + return read_xlsx_file(path, sheet) + return read_csv_file(path) + + +def read_csv_text(text: str) -> list[Row]: + try: + dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t") + except csv.Error: + dialect = csv.excel + reader = csv.DictReader(io.StringIO(text), dialect=dialect) + return [{k.strip(): (v or "").strip() for k, v in row.items() if k} for row in reader] + + +def read_csv_file(path: Path) -> list[Row]: + return read_csv_text(path.read_text(encoding="utf-8-sig")) + + +def read_xlsx_file(path: Path, sheet: str | None) -> list[Row]: + workbook = load_workbook(path, read_only=True, data_only=True) + worksheet = workbook[sheet] if sheet else workbook[workbook.sheetnames[0]] + rows = worksheet.iter_rows(values_only=True) + header = [str(cell).strip() if cell is not None else "" for cell in next(rows)] + return [ + {key: cell_text(value) for key, value in zip(header, row, strict=False) if key} + for row in rows + if any(value is not None for value in row) + ] + + +def cell_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value).strip() + + +def google_sheet_export_url(url: str) -> str: + match = re.search(r"/spreadsheets/d/([\w-]+)", url) + if not match: + raise ValueError(f"Not a Google Sheets URL: {url}") + gid_match = re.search(r"[#&?]gid=(\d+)", url) + gid = f"&gid={gid_match.group(1)}" if gid_match else "" + return f"https://docs.google.com/spreadsheets/d/{match.group(1)}/export?format=csv{gid}" + + +def read_google_sheet(url: str, session: requests.Session) -> list[Row]: + response = session.get(google_sheet_export_url(url), timeout=30) + response.raise_for_status() + return read_csv_text(response.content.decode("utf-8-sig")) diff --git a/stores-import/python/test_import_stores.py b/stores-import/python/test_import_stores.py new file mode 100644 index 0000000..0cc5543 --- /dev/null +++ b/stores-import/python/test_import_stores.py @@ -0,0 +1,128 @@ +import json +from pathlib import Path + +import import_stores as mod +import pytest +import responses + +DATA = Path(__file__).resolve().parents[2] / "data" + + +def test_row_to_asset_maps_default_columns(): + row = { + "Name": "Markthal", + "Latitude": "51.9", + "Longitude": "4,48", + "Address Line": "Dominee 298", + "City": "Rotterdam", + "Country Code": "nl", + "Type": "covered|indoor", + } + asset = mod.row_to_asset(row, mod.DEFAULT_COLUMNS) + assert asset == { + "storeId": "Markthal", + "name": "Markthal", + "location": {"lat": 51.9, "lng": 4.48}, + "address": {"lines": ["Dominee 298"], "city": "Rotterdam", "countryCode": "NL"}, + "types": ["covered", "indoor"], + } + + +def test_accents_are_transliterated_in_derived_ids(): + row = {"Name": "Le Grand Marché d'Apt", "Latitude": "1", "Longitude": "2"} + assert mod.row_to_asset(row, mod.DEFAULT_COLUMNS)["storeId"] == "LeGrandMarchedApt" + + +def test_explicit_store_id_is_slugified(): + row = {"Store ID": "shop-12 a", "Name": "x", "Latitude": "1", "Longitude": "2"} + assert mod.row_to_asset(row, mod.DEFAULT_COLUMNS)["storeId"] == "shop12a" + + +def test_missing_coordinates_are_reported_per_row(): + rows = [{"Name": "A", "Latitude": "", "Longitude": "2"}] + result = mod.convert_rows(rows, mod.DEFAULT_COLUMNS) + assert result.assets == [] + assert result.errors == ["row 2: invalid latitude ''"] + + +def test_duplicate_store_ids_are_rejected(): + rows = [ + {"Name": "Same", "Latitude": "1", "Longitude": "2"}, + {"Name": "Same", "Latitude": "3", "Longitude": "4"}, + ] + result = mod.convert_rows(rows, mod.DEFAULT_COLUMNS) + assert len(result.assets) == 1 + assert "duplicate storeId 'Same'" in result.errors[0] + + +def test_column_override_parsing(): + columns = mod.parse_column_overrides(["name=Shop name", "lat=Y"]) + assert columns["name"] == "Shop name" + assert columns["lat"] == "Y" + + +def test_unknown_column_override_exits(): + with pytest.raises(SystemExit): + mod.parse_column_overrides(["colour=Blue"]) + + +@responses.activate +def test_replace_posts_all_stores_once(): + responses.post(f"{mod.API_URL}/stores/replace", json={"status": "OK"}) + api = mod.WoosmapStores("secret") + mod.upload(api, [{"storeId": "a"}, {"storeId": "b"}], "replace", 1) + assert len(responses.calls) == 1 + assert responses.calls[0].request.params["private_key"] == "secret" + assert json.loads(responses.calls[0].request.body)["stores"][1]["storeId"] == "b" + + +@responses.activate +def test_create_mode_batches_requests(): + responses.post(f"{mod.API_URL}/stores", json={"status": "OK"}) + mod.upload(mod.WoosmapStores("k"), [{"storeId": str(i)} for i in range(5)], "create", 2) + assert len(responses.calls) == 3 + + +@responses.activate +def test_retries_on_429_then_succeeds(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + responses.post(f"{mod.API_URL}/stores", status=429, headers={"Retry-After": "0"}) + responses.post(f"{mod.API_URL}/stores", json={"status": "OK"}) + mod.WoosmapStores("k").create([{"storeId": "a"}]) + assert len(responses.calls) == 2 + + +@responses.activate +def test_server_errors_are_not_retried(): + responses.post(f"{mod.API_URL}/stores", status=503, body="down") + with pytest.raises(RuntimeError, match="down"): + mod.WoosmapStores("k").create([{"storeId": "a"}]) + assert len(responses.calls) == 1 + + +@responses.activate +def test_api_error_is_raised_with_body(): + responses.post(f"{mod.API_URL}/stores", status=400, body='{"detail":"bad storeId"}') + with pytest.raises(RuntimeError, match="bad storeId"): + mod.WoosmapStores("k").create([{"storeId": "a b"}]) + + +def test_body_above_15mb_is_refused(): + api = mod.WoosmapStores("k") + with pytest.raises(ValueError, match="15MB"): + api.send("POST", "/stores", [{"storeId": "x" * (16 * 1024 * 1024)}]) + + +@responses.activate +def test_main_dry_run_writes_json_and_skips_api(tmp_path): + output = tmp_path / "stores.json" + code = mod.main([str(DATA / "foodmarkets.csv"), "--dry-run", "--output", str(output)]) + assert code == 0 + assert len(json.loads(output.read_text())["stores"]) == 18 + assert len(responses.calls) == 0 + + +def test_main_strict_fails_on_bad_rows(tmp_path): + source = tmp_path / "bad.csv" + source.write_text("Name,Latitude,Longitude\nA,,2\n") + assert mod.main([str(source), "--dry-run", "--strict"]) == 1 diff --git a/stores-import/python/test_spreadsheet.py b/stores-import/python/test_spreadsheet.py new file mode 100644 index 0000000..cd53ce5 --- /dev/null +++ b/stores-import/python/test_spreadsheet.py @@ -0,0 +1,80 @@ +from pathlib import Path + +import pytest +import requests +import responses +import spreadsheet as mod +from openpyxl import Workbook + +DATA = Path(__file__).resolve().parents[2] / "data" + + +def test_reads_the_food_markets_csv_fixture(): + rows = mod.read_csv_file(DATA / "foodmarkets.csv") + assert len(rows) == 18 + assert rows[0]["Name"] == "Markthal Rotterdam" + + +def test_sniffs_semicolon_delimited_csv(): + assert mod.read_csv_text('Name;Latitude\n"A";1.5\n') == [{"Name": "A", "Latitude": "1.5"}] + + +def test_keeps_a_newline_inside_a_quoted_field(): + rows = mod.read_csv_text('Name,Address\n"A","line 1\nline 2"\n') + assert rows == [{"Name": "A", "Address": "line 1\nline 2"}] + + +def test_single_column_csv_falls_back_to_the_default_dialect(): + assert mod.read_csv_text("Name\nA\n") == [{"Name": "A"}] + + +def test_reads_xlsx_first_sheet(tmp_path): + workbook = Workbook() + sheet = workbook.active + sheet.append(["Name", "Latitude", "Longitude"]) + sheet.append(["Shop", 48.5, 2.0]) + path = tmp_path / "shops.xlsx" + workbook.save(path) + assert mod.read_xlsx_file(path, None) == [ + {"Name": "Shop", "Latitude": "48.5", "Longitude": "2"} + ] + + +def test_reads_the_named_xlsx_sheet(): + rows = mod.read_xlsx_file(DATA / "foodmarkets.xlsx", "foodmarkets") + assert len(rows) == 18 + assert rows[0]["Name"] == "Markthal Rotterdam" + + +def test_google_sheet_url_becomes_a_csv_export_url(): + url = "https://docs.google.com/spreadsheets/d/1abcDEF_-9/edit#gid=42" + assert mod.google_sheet_export_url(url) == ( + "https://docs.google.com/spreadsheets/d/1abcDEF_-9/export?format=csv&gid=42" + ) + + +def test_google_sheet_url_without_a_gid(): + url = "https://docs.google.com/spreadsheets/d/1abcDEF_-9/edit" + assert mod.google_sheet_export_url(url).endswith("export?format=csv") + + +def test_non_google_url_is_rejected(): + with pytest.raises(ValueError): + mod.google_sheet_export_url("https://example.com/file.csv") + + +@responses.activate +def test_read_source_downloads_a_google_sheet(): + responses.get( + "https://docs.google.com/spreadsheets/d/1abc/export", + body=b"Name,Latitude\nShop,48.5\n", + ) + rows = mod.read_source( + "https://docs.google.com/spreadsheets/d/1abc/edit", None, requests.Session() + ) + assert rows == [{"Name": "Shop", "Latitude": "48.5"}] + + +def test_read_source_picks_the_reader_from_the_extension(): + assert mod.read_source(str(DATA / "foodmarkets.xlsx"), None, requests.Session())[0]["City"] + assert mod.read_source(str(DATA / "foodmarkets.csv"), None, requests.Session())[0]["City"] diff --git a/stores-sync/README.md b/stores-sync/README.md new file mode 100644 index 0000000..bbe04e9 --- /dev/null +++ b/stores-sync/README.md @@ -0,0 +1,38 @@ +# Keep a project in sync with a source of truth + +Compare a Woosmap JSON file with the stores currently in the project, then create, update and delete only +the differences. Meant for a scheduled job fed by your ERP, PIM or master data export. + +Produce the input with [stores-import](../stores-import/) (`--dry-run --output stores.json`) or +[stores-export](../stores-export/). The file is `{"stores": [...]}` using the request-body field names +(`storeId`, `countryCode`, `userProperties`, `openingHours`). + +## Python + +```sh +pip install -r python/requirements.txt +python python/sync_stores.py stores.json --dry-run +python python/sync_stores.py stores.json +python python/sync_stores.py stores.json --no-delete +``` + +## Node + +```sh +node node/sync-stores.mjs stores.json --dry-run +``` + +## How the diff works + +1. Every remote store is fetched through `GET /stores/search`, paginated by 300. +2. Response fields are mapped back to request fields (`store_id` to `storeId`, `country_code` to `countryCode`). +3. Both sides are normalised before comparison: empty values dropped, coordinates rounded to six decimals, + string lists sorted. +4. Local wins. A store present on both sides but different is sent with `PUT`, whole. +5. Stores absent from the file are deleted with `DELETE /stores?query=idstore:="a" OR idstore:="b"`, + fifty per request. `--no-delete` turns that off. + +Temporary closures that have already ended are dropped by the API, so the comparison ignores them too. + +Running the sync twice in a row must report nothing to do. If it does not, a field is not echoed back by +the API the way it was sent; open an issue with the store id. diff --git a/stores-sync/node/package.json b/stores-sync/node/package.json new file mode 100644 index 0000000..c5c074c --- /dev/null +++ b/stores-sync/node/package.json @@ -0,0 +1,9 @@ +{ + "name": "woosmap-stores-sync", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "test": "node --test" + } +} diff --git a/stores-sync/node/sync-stores.mjs b/stores-sync/node/sync-stores.mjs new file mode 100644 index 0000000..55ebd4b --- /dev/null +++ b/stores-sync/node/sync-stores.mjs @@ -0,0 +1,187 @@ +// Synchronise a Woosmap project with a Woosmap JSON file, changing only what differs. +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +export const API_URL = "https://api.woosmap.com"; +const PAGE_SIZE = 300; // stores_by_page maximum + +const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + +export class WoosmapStores { + constructor(privateKey, fetchImpl = fetch, sleepImpl = sleep) { + this.privateKey = privateKey; + this.fetch = fetchImpl; + this.sleep = sleepImpl; + } + + async request(method, path, { params = {}, body } = {}) { + const query = new URLSearchParams({ private_key: this.privateKey, ...params }); + let response; + for (let attempt = 0; attempt < 3; attempt += 1) { + response = await this.fetch(`${API_URL}${path}?${query}`, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + if (response.status !== 429 || attempt === 2) break; + // 429 is the only status the API asks to retry, and Retry-After says when + await this.sleep(Number(response.headers.get("Retry-After") ?? 2 ** attempt)); + } + if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${await response.text()}`); + return response.json(); + } + + async fetchAll() { + const features = []; + for (let page = 1; ; page += 1) { + const body = await this.request("GET", "/stores/search", { params: { stores_by_page: PAGE_SIZE, page } }); + features.push(...(body.features ?? [])); + if (page >= (body.pagination?.pageCount ?? 1)) return features; + } + } + + create = (stores) => this.request("POST", "/stores", { body: { stores } }); + update = (stores) => this.request("PUT", "/stores", { body: { stores } }); + delete = (storeIds) => this.request("DELETE", "/stores", { params: { query: deleteQuery(storeIds) } }); +} + +// idstore is the query-language name of storeId +export const deleteQuery = (storeIds) => storeIds.map((id) => `idstore:="${id}"`).join(" OR "); + +export function featureToAsset(feature) { + const props = feature.properties; + const [lng, lat] = feature.geometry.coordinates; + const address = props.address ?? {}; + return { + storeId: props.store_id, + name: props.name, + location: { lat, lng }, + address: { + lines: address.lines, + city: address.city, + zipcode: address.zipcode, + countryCode: address.country_code, + }, + contact: props.contact, + types: props.types, + tags: props.tags, + userProperties: props.user_properties, + openingHours: props.opening_hours, + }; +} + +const isEmpty = (v) => + v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0) || (isPlainObject(v) && Object.keys(v).length === 0); +const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v); + +export function normalise(value) { + if (Array.isArray(value)) { + const items = value.map(normalise); + return items.every((i) => typeof i === "string") ? [...items].sort() : items; + } + if (isPlainObject(value)) { + const entries = Object.entries(value) + .map(([k, v]) => [k, normalise(v)]) + .filter(([, v]) => !isEmpty(v)) + .sort(([a], [b]) => a.localeCompare(b)); + return Object.fromEntries(entries); + } + if (typeof value === "number" && !Number.isInteger(value)) return Number(value.toFixed(6)); + return value; +} + +// the Stores API drops temporary closures once they have ended +export function withoutExpiredClosures(asset, today = new Date()) { + const closures = asset.openingHours?.temporary_closure; + if (!closures?.length) return asset; + const limit = today.toISOString().slice(0, 10); + const kept = closures.filter((closure) => (closure.end ?? "") >= limit); + return { ...asset, openingHours: { ...asset.openingHours, temporary_closure: kept } }; +} + +export const sameAsset = (a, b) => + JSON.stringify(normalise(withoutExpiredClosures(a))) === JSON.stringify(normalise(withoutExpiredClosures(b))); + +export function buildPlan(localAssets, remoteFeatures) { + const local = new Map(localAssets.map((a) => [a.storeId, a])); + const remote = new Map(remoteFeatures.map(featureToAsset).map((a) => [a.storeId, a])); + const plan = { create: [], update: [], delete: [] }; + for (const [storeId, asset] of local) { + if (!remote.has(storeId)) plan.create.push(asset); + else if (!sameAsset(asset, remote.get(storeId))) plan.update.push(asset); + } + plan.delete = [...remote.keys()].filter((id) => !local.has(id)).sort(); + return plan; +} + +export function chunked(items, size) { + const chunks = []; + for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); + return chunks; +} + +export async function applyPlan(api, plan, batchSize, allowDelete) { + for (const batch of chunked(plan.create, batchSize)) { + await api.create(batch); + console.log(`created ${batch.length}`); + } + for (const batch of chunked(plan.update, batchSize)) { + await api.update(batch); + console.log(`updated ${batch.length}`); + } + if (!allowDelete) return; + for (const batch of chunked(plan.delete, 50)) { + await api.delete(batch); + console.log(`deleted ${batch.length}`); + } +} + +export const describe = (plan) => + `${plan.create.length} to create, ${plan.update.length} to update, ${plan.delete.length} to delete`; + +async function loadLocalAssets(path) { + let document; + try { + document = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + throw new Error(error.code === "ENOENT" ? `no such file: ${path}` : `${path}: ${error.message}`); + } + const stores = Array.isArray(document?.stores) ? document.stores : null; + if (!stores) throw new Error(`${path} must be a JSON object with a "stores" array`); + const withoutId = stores.findIndex((asset) => !asset.storeId); + if (withoutId >= 0) throw new Error(`store without a storeId at position ${withoutId + 1}`); + return stores; +} + +export async function main(argv) { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + "batch-size": { type: "string", default: "500" }, + "no-delete": { type: "boolean", default: false }, + "dry-run": { type: "boolean", default: false }, + }, + }); + const [source] = positionals; + if (!source) throw new Error("usage: node sync-stores.mjs [--dry-run] [--no-delete]"); + const privateKey = process.env.WOOSMAP_PRIVATE_KEY; + if (!privateKey) throw new Error("set WOOSMAP_PRIVATE_KEY in the environment"); + const localAssets = await loadLocalAssets(source); + const api = new WoosmapStores(privateKey); + const plan = buildPlan(localAssets, await api.fetchAll()); + console.log(describe(plan)); + plan.delete.forEach((id) => console.log(` delete ${id}`)); + const empty = !plan.create.length && !plan.update.length && !plan.delete.length; + if (values["dry-run"] || empty) return 0; + await applyPlan(api, plan, Number(values["batch-size"]), !values["no-delete"]); + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then((code) => process.exit(code), (error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/stores-sync/node/sync-stores.test.mjs b/stores-sync/node/sync-stores.test.mjs new file mode 100644 index 0000000..32767f2 --- /dev/null +++ b/stores-sync/node/sync-stores.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { API_URL, WoosmapStores, applyPlan, buildPlan, deleteQuery, featureToAsset, sameAsset, withoutExpiredClosures } from "./sync-stores.mjs"; + +const feature = (store_id, props = {}, [lat, lng] = [48.5, 2.0]) => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [lng, lat] }, + properties: { store_id, name: "Shop", ...props }, +}); +const asset = (storeId, extra = {}) => ({ storeId, name: "Shop", location: { lat: 48.5, lng: 2.0 }, ...extra }); + +function fakeFetch(responses) { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url: new URL(url), init }); + const next = responses.shift() ?? {}; + return new Response(JSON.stringify(next.body ?? {}), { status: next.status ?? 200, headers: next.headers }); + }; + return { fetchImpl, calls }; +} + +test("feature to asset renames snake_case fields", () => { + const converted = featureToAsset(feature("a", { address: { country_code: "FR" }, user_properties: { k: 1 } })); + assert.equal(converted.address.countryCode, "FR"); + assert.deepEqual(converted.userProperties, { k: 1 }); + assert.deepEqual(converted.location, { lat: 48.5, lng: 2.0 }); +}); + +test("empty fields, list order and coordinate noise do not count as changes", () => { + const local = asset("a", { types: ["b", "a"], address: { city: "Paris", lines: [] }, location: { lat: 48.5000000001, lng: 2 } }); + const remote = featureToAsset(feature("a", { types: ["a", "b"], address: { city: "Paris" } })); + assert.ok(sameAsset(local, remote)); +}); + +test("expired temporary closures are ignored, future ones are kept", () => { + const local = asset("a", { openingHours: { timezone: "Europe/Paris", temporary_closure: [{ start: "2020-01-01", end: "2020-01-05" }] } }); + const remote = featureToAsset(feature("a", { opening_hours: { timezone: "Europe/Paris", temporary_closure: [] } })); + assert.ok(sameAsset(local, remote)); + const future = withoutExpiredClosures( + asset("a", { openingHours: { temporary_closure: [{ start: "2020-01-01", end: "2999-01-01" }] } }), + new Date("2026-01-01"), + ); + assert.deepEqual(future.openingHours.temporary_closure, [{ start: "2020-01-01", end: "2999-01-01" }]); +}); + +test("plan splits create, update and delete", () => { + const plan = buildPlan( + [asset("keep"), asset("changed", { name: "New" }), asset("new")], + [feature("keep"), feature("changed"), feature("gone")], + ); + assert.deepEqual(plan.create.map((a) => a.storeId), ["new"]); + assert.deepEqual(plan.update.map((a) => a.storeId), ["changed"]); + assert.deepEqual(plan.delete, ["gone"]); +}); + +test("delete query uses OR clauses", () => { + assert.equal(deleteQuery(["a", "b"]), 'idstore:="a" OR idstore:="b"'); +}); + +test("fetchAll follows pagination", async () => { + const { fetchImpl, calls } = fakeFetch([ + { body: { features: [feature("a")], pagination: { page: 1, pageCount: 2 } } }, + { body: { features: [feature("b")], pagination: { page: 2, pageCount: 2 } } }, + ]); + const features = await new WoosmapStores("k", fetchImpl).fetchAll(); + assert.deepEqual(features.map((f) => f.properties.store_id), ["a", "b"]); + assert.equal(calls[1].url.searchParams.get("page"), "2"); + assert.equal(calls[1].url.searchParams.get("stores_by_page"), "300"); +}); + +test("applyPlan issues POST, PUT then DELETE with the private key", async () => { + const { fetchImpl, calls } = fakeFetch([{}, {}, {}]); + const plan = { create: [asset("n")], update: [asset("u")], delete: ["d1", "d2"] }; + await applyPlan(new WoosmapStores("k", fetchImpl), plan, 500, true); + assert.deepEqual(calls.map((c) => c.init.method), ["POST", "PUT", "DELETE"]); + assert.equal(calls[0].url.origin + calls[0].url.pathname, `${API_URL}/stores`); + assert.equal(calls[0].url.searchParams.get("private_key"), "k"); + assert.equal(calls[2].url.searchParams.get("query"), 'idstore:="d1" OR idstore:="d2"'); +}); + +test("--no-delete skips DELETE", async () => { + const { fetchImpl, calls } = fakeFetch([{}]); + await applyPlan(new WoosmapStores("k", fetchImpl), { create: [asset("n")], update: [], delete: ["d"] }, 500, false); + assert.deepEqual(calls.map((c) => c.init.method), ["POST"]); +}); + +test("errors include the API body and 5xx are not retried", async () => { + const { fetchImpl, calls } = fakeFetch([{ status: 503, body: { detail: "down" } }]); + const api = new WoosmapStores("k", fetchImpl, async () => {}); + await assert.rejects(api.create([asset("x")]), /down/); + assert.equal(calls.length, 1); +}); + +test("429 is retried after Retry-After", async () => { + const waits = []; + const { fetchImpl, calls } = fakeFetch([{ status: 429, headers: { "Retry-After": "3" } }, { body: {} }]); + await new WoosmapStores("k", fetchImpl, async (s) => waits.push(s)).create([asset("x")]); + assert.equal(calls.length, 2); + assert.deepEqual(waits, [3]); +}); diff --git a/stores-sync/python/requirements.txt b/stores-sync/python/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/stores-sync/python/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/stores-sync/python/sync_stores.py b/stores-sync/python/sync_stores.py new file mode 100644 index 0000000..cf29e61 --- /dev/null +++ b/stores-sync/python/sync_stores.py @@ -0,0 +1,215 @@ +"""Synchronise a Woosmap project with a Woosmap JSON file, changing only what differs.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com" +PAGE_SIZE = 300 # stores_by_page maximum + +Asset = dict[str, Any] + + +@dataclass +class Plan: + create: list[Asset] = field(default_factory=list) + update: list[Asset] = field(default_factory=list) + delete: list[str] = field(default_factory=list) + + def is_empty(self) -> bool: + return not (self.create or self.update or self.delete) + + +class WoosmapStores: + def __init__(self, private_key: str, session: requests.Session | None = None) -> None: + self.private_key = private_key + self.session = session or requests.Session() + + def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + params = {"private_key": self.private_key, **kwargs.pop("params", {})} + for attempt in range(3): + response = self.session.request( + method, f"{API_URL}{path}", params=params, timeout=120, **kwargs + ) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + if response.status_code >= 400: + raise RuntimeError(f"{method} {path} failed ({response.status_code}): {response.text}") + return response.json() + + def fetch_all(self) -> list[dict[str, Any]]: + features: list[dict[str, Any]] = [] + page = 1 + while True: + body = self.request( + "GET", "/stores/search", params={"stores_by_page": PAGE_SIZE, "page": page} + ) + features.extend(body.get("features", [])) + pagination = body.get("pagination", {}) + if page >= pagination.get("pageCount", 1): + return features + page += 1 + + def create(self, stores: list[Asset]) -> None: + self.request("POST", "/stores", json={"stores": stores}) + + def update(self, stores: list[Asset]) -> None: + self.request("PUT", "/stores", json={"stores": stores}) + + def delete(self, store_ids: list[str]) -> None: + self.request("DELETE", "/stores", params={"query": delete_query(store_ids)}) + + +def delete_query(store_ids: list[str]) -> str: + # idstore is the query-language name of storeId + return " OR ".join(f'idstore:="{store_id}"' for store_id in store_ids) + + +def feature_to_asset(feature: dict[str, Any]) -> Asset: + props = feature["properties"] + lng, lat = feature["geometry"]["coordinates"] + address = props.get("address") or {} + return { + "storeId": props["store_id"], + "name": props.get("name"), + "location": {"lat": lat, "lng": lng}, + "address": { + "lines": address.get("lines"), + "city": address.get("city"), + "zipcode": address.get("zipcode"), + "countryCode": address.get("country_code"), + }, + "contact": props.get("contact"), + "types": props.get("types"), + "tags": props.get("tags"), + "userProperties": props.get("user_properties"), + "openingHours": props.get("opening_hours"), + } + + +def normalise(value: Any) -> Any: + if isinstance(value, dict): + cleaned = {k: normalise(v) for k, v in value.items()} + return {k: v for k, v in cleaned.items() if v not in (None, {}, [], "")} + if isinstance(value, list): + items = [normalise(v) for v in value] + return sorted(items, key=json.dumps) if all(isinstance(i, str) for i in items) else items + if isinstance(value, float): + return round(value, 6) + return value + + +def without_expired_closures(asset: Asset, today: date | None = None) -> Asset: + # the Stores API drops temporary closures once they have ended + hours = asset.get("openingHours") or {} + closures = hours.get("temporary_closure") + if not closures: + return asset + limit = (today or date.today()).isoformat() + kept = [closure for closure in closures if closure.get("end", "") >= limit] + return {**asset, "openingHours": {**hours, "temporary_closure": kept}} + + +def same_asset(local: Asset, remote: Asset) -> bool: + left = json.dumps(normalise(without_expired_closures(local)), sort_keys=True) + right = json.dumps(normalise(without_expired_closures(remote)), sort_keys=True) + return left == right + + +def load_local_assets(path: Path) -> list[Asset]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise SystemExit(f"no such file: {path}") from None + except json.JSONDecodeError as error: + raise SystemExit(f"{path} is not valid JSON: {error}") from None + stores = document.get("stores") if isinstance(document, dict) else None + if stores is None: + raise SystemExit(f'{path} must be a JSON object with a "stores" array') + without_id = [index for index, asset in enumerate(stores, start=1) if not asset.get("storeId")] + if without_id: + raise SystemExit(f"stores without a storeId at position {without_id[:5]}") + return stores + + +def build_plan(local_assets: list[Asset], remote_features: list[dict[str, Any]]) -> Plan: + local = {asset["storeId"]: asset for asset in local_assets} + remote = {asset["storeId"]: asset for asset in map(feature_to_asset, remote_features)} + plan = Plan() + for store_id, asset in local.items(): + if store_id not in remote: + plan.create.append(asset) + elif not same_asset(asset, remote[store_id]): + plan.update.append(asset) + plan.delete = sorted(set(remote) - set(local)) + return plan + + +def chunked(items: list[Any], size: int) -> list[list[Any]]: + return [items[i : i + size] for i in range(0, len(items), size)] + + +def apply_plan(api: WoosmapStores, plan: Plan, batch_size: int, allow_delete: bool) -> None: + for batch in chunked(plan.create, batch_size): + api.create(batch) + print(f"created {len(batch)}") + for batch in chunked(plan.update, batch_size): + api.update(batch) + print(f"updated {len(batch)}") + if not allow_delete: + return + for batch in chunked(plan.delete, 50): + api.delete(batch) + print(f"deleted {len(batch)}") + + +def describe(plan: Plan) -> str: + return ( + f"{len(plan.create)} to create, {len(plan.update)} to update, {len(plan.delete)} to delete" + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path, help='Woosmap JSON file: {"stores": [...]}') + parser.add_argument("--batch-size", type=int, default=500) + parser.add_argument("--no-delete", action="store_true", help="never delete remote stores") + parser.add_argument("--dry-run", action="store_true", help="print the plan and stop") + return parser + + +def private_key_from_env() -> str: + key = os.environ.get("WOOSMAP_PRIVATE_KEY") + if not key: + raise SystemExit("set WOOSMAP_PRIVATE_KEY in the environment") + return key + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + local_assets = load_local_assets(args.source) + api = WoosmapStores(private_key_from_env()) + plan = build_plan(local_assets, api.fetch_all()) + print(describe(plan)) + for store_id in plan.delete: + print(f" delete {store_id}") + if args.dry_run or plan.is_empty(): + return 0 + apply_plan(api, plan, args.batch_size, allow_delete=not args.no_delete) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stores-sync/python/test_sync_stores.py b/stores-sync/python/test_sync_stores.py new file mode 100644 index 0000000..7eefea7 --- /dev/null +++ b/stores-sync/python/test_sync_stores.py @@ -0,0 +1,142 @@ +import json +from datetime import date +from pathlib import Path + +import pytest +import responses +import sync_stores as mod + +DATA = Path(__file__).resolve().parents[2] / "data" + + +def feature(store_id, name="Shop", lat=48.5, lng=2.0, **props): + return { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [lng, lat]}, + "properties": {"store_id": store_id, "name": name, **props}, + } + + +def asset(store_id, name="Shop", lat=48.5, lng=2.0, **extra): + return {"storeId": store_id, "name": name, "location": {"lat": lat, "lng": lng}, **extra} + + +def test_feature_to_asset_maps_snake_case_to_request_fields(): + converted = mod.feature_to_asset( + feature("a", address={"country_code": "FR", "city": "Paris"}, user_properties={"k": 1}) + ) + assert converted["address"]["countryCode"] == "FR" + assert converted["userProperties"] == {"k": 1} + assert converted["location"] == {"lat": 48.5, "lng": 2.0} + + +def test_identical_assets_compare_equal_despite_empty_fields_and_order(): + local = asset("a", types=["b", "a"], address={"city": "Paris", "lines": []}) + remote = mod.feature_to_asset(feature("a", types=["a", "b"], address={"city": "Paris"})) + assert mod.same_asset(local, remote) + + +def test_coordinate_noise_beyond_six_decimals_is_ignored(): + assert mod.same_asset(asset("a", lat=48.5000000001), mod.feature_to_asset(feature("a"))) + + +def test_expired_temporary_closures_are_ignored_in_the_comparison(): + hours = { + "timezone": "Europe/Paris", + "temporary_closure": [{"start": "2020-01-01", "end": "2020-01-05"}], + } + local = asset("a", openingHours=hours) + remote = mod.feature_to_asset( + feature("a", opening_hours={"timezone": "Europe/Paris", "temporary_closure": []}) + ) + assert mod.same_asset(local, remote) + + +def test_future_temporary_closures_still_count(): + pruned = mod.without_expired_closures( + asset( + "a", openingHours={"temporary_closure": [{"start": "2020-01-01", "end": "2999-01-01"}]} + ), + today=date(2026, 1, 1), + ) + assert pruned["openingHours"]["temporary_closure"] == [ + {"start": "2020-01-01", "end": "2999-01-01"} + ] + + +def test_loading_reports_bad_input_without_a_traceback(tmp_path): + missing = tmp_path / "nope.json" + with pytest.raises(SystemExit, match="no such file"): + mod.load_local_assets(missing) + bare = tmp_path / "bare.json" + bare.write_text('[{"storeId": "a"}]') + with pytest.raises(SystemExit, match='"stores" array'): + mod.load_local_assets(bare) + no_id = tmp_path / "noid.json" + no_id.write_text('{"stores": [{"name": "No id"}]}') + with pytest.raises(SystemExit, match="without a storeId"): + mod.load_local_assets(no_id) + + +def test_loading_accepts_the_food_markets_fixture(): + assert len(mod.load_local_assets(DATA / "foodmarkets.json")) == 18 + + +def test_plan_splits_create_update_delete(): + local = [asset("keep"), asset("changed", name="New name"), asset("new")] + remote = [feature("keep"), feature("changed"), feature("gone")] + plan = mod.build_plan(local, remote) + assert [a["storeId"] for a in plan.create] == ["new"] + assert [a["storeId"] for a in plan.update] == ["changed"] + assert plan.delete == ["gone"] + + +def test_delete_query_uses_or_clauses(): + assert mod.delete_query(["a", "b"]) == 'idstore:="a" OR idstore:="b"' + + +@responses.activate +def test_fetch_all_follows_pagination(): + url = f"{mod.API_URL}/stores/search" + responses.get(url, json={"features": [feature("a")], "pagination": {"page": 1, "pageCount": 2}}) + responses.get(url, json={"features": [feature("b")], "pagination": {"page": 2, "pageCount": 2}}) + features = mod.WoosmapStores("k").fetch_all() + assert [f["properties"]["store_id"] for f in features] == ["a", "b"] + assert responses.calls[1].request.params["page"] == "2" + assert responses.calls[1].request.params["stores_by_page"] == "300" + + +@responses.activate +def test_apply_plan_issues_expected_requests(): + responses.post(f"{mod.API_URL}/stores", json={}) + responses.put(f"{mod.API_URL}/stores", json={}) + responses.delete(f"{mod.API_URL}/stores", json={}) + plan = mod.Plan(create=[asset("n")], update=[asset("u")], delete=["d1", "d2"]) + mod.apply_plan(mod.WoosmapStores("k"), plan, batch_size=500, allow_delete=True) + methods = [c.request.method for c in responses.calls] + assert methods == ["POST", "PUT", "DELETE"] + assert json.loads(responses.calls[1].request.body)["stores"][0]["storeId"] == "u" + assert responses.calls[2].request.params["query"] == 'idstore:="d1" OR idstore:="d2"' + + +@responses.activate +def test_no_delete_skips_delete_requests(): + responses.post(f"{mod.API_URL}/stores", json={}) + plan = mod.Plan(create=[asset("n")], delete=["d"]) + mod.apply_plan(mod.WoosmapStores("k"), plan, batch_size=500, allow_delete=False) + assert [c.request.method for c in responses.calls] == ["POST"] + + +@responses.activate +def test_api_errors_surface_with_body(): + responses.post(f"{mod.API_URL}/stores", status=400, body='{"detail":"nope"}') + with pytest.raises(RuntimeError, match="nope"): + mod.WoosmapStores("k").create([asset("x")]) + + +@responses.activate +def test_main_dry_run_only_reads(monkeypatch): + monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") + responses.get(f"{mod.API_URL}/stores/search", json={"features": [], "pagination": {}}) + assert mod.main([str(DATA / "foodmarkets.json"), "--dry-run"]) == 0 + assert [c.request.method for c in responses.calls] == ["GET"] From e4151ca747f38222e49deb4cf3d8529ca36c9c46 Mon Sep 17 00:00:00 2001 From: galela Date: Mon, 14 Sep 2026 15:35:54 +0200 Subject: [PATCH 3/6] Retry 429s in datasets, isochrone-stores, geolocation-stores and static-map These four samples called the API without the retry policy the README promises for every script. They now retry once on 429 after Retry-After, same as the rest. --- datasets/python/manage_dataset.py | 23 ++++++++++++----- datasets/python/test_manage_dataset.py | 25 +++++++++++++++++++ .../python/nearest_stores_by_ip.py | 21 +++++++++------- .../python/test_nearest_stores_by_ip.py | 16 ++++++++++++ .../python/stores_within_isochrone.py | 11 +++++--- .../python/test_stores_within_isochrone.py | 16 ++++++++++++ static-map/python/static_map.py | 8 +++++- static-map/python/test_static_map.py | 16 ++++++++++++ 8 files changed, 117 insertions(+), 19 deletions(-) diff --git a/datasets/python/manage_dataset.py b/datasets/python/manage_dataset.py index c36327c..1a18126 100644 --- a/datasets/python/manage_dataset.py +++ b/datasets/python/manage_dataset.py @@ -25,9 +25,14 @@ def __init__(self, private_key: str, session: requests.Session | None = None) -> def call(self, method: str, path: str = "", **kwargs: Any) -> dict[str, Any]: params = {"private_key": self.private_key, **kwargs.pop("params", {})} - response = self.session.request( - method, f"{API_URL}{path}", params=params, timeout=60, **kwargs - ) + for attempt in range(3): + response = self.session.request( + method, f"{API_URL}{path}", params=params, timeout=60, **kwargs + ) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) if response.status_code >= 400: raise RuntimeError( f"{method} {path or '/'} failed ({response.status_code}): {response.text}" @@ -49,9 +54,15 @@ def trigger_import(self, dataset_id: str) -> None: def status(self, dataset_id: str) -> dict[str, Any]: # 404 "No dataset status available" for a few seconds after the import is triggered - response = self.session.get( - f"{API_URL}{dataset_id}/status", params={"private_key": self.private_key}, timeout=60 - ) + for attempt in range(3): + response = self.session.get( + f"{API_URL}{dataset_id}/status", + params={"private_key": self.private_key}, + timeout=60, + ) + if response.status_code != 429 or attempt == 2: + break + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) if response.status_code == 404: return {"status": "pending", "steps": []} if response.status_code >= 400: diff --git a/datasets/python/test_manage_dataset.py b/datasets/python/test_manage_dataset.py index 53bf3c0..c80b9c3 100644 --- a/datasets/python/test_manage_dataset.py +++ b/datasets/python/test_manage_dataset.py @@ -96,6 +96,31 @@ def test_errors_include_the_api_body(): mod.Datasets("k").list() +@responses.activate +def test_call_retries_on_429_then_succeeds(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + responses.get(mod.API_URL, status=429, headers={"Retry-After": "0"}) + responses.get(mod.API_URL, json={"datasets": [{"id": DATASET}]}) + assert mod.Datasets("k").list() == [{"id": DATASET}] + + +@responses.activate +def test_call_does_not_retry_server_errors(): + responses.get(mod.API_URL, status=503, body="down") + with pytest.raises(RuntimeError, match="down"): + mod.Datasets("k").list() + assert len(responses.calls) == 1 + + +@responses.activate +def test_status_retries_on_429_then_succeeds(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + status_url = f"{mod.API_URL}{DATASET}/status" + responses.get(status_url, status=429, headers={"Retry-After": "0"}) + responses.get(status_url, json={"status": "success", "steps": []}) + assert mod.Datasets("k").status(DATASET)["status"] == "success" + + @responses.activate def test_main_import_wait_returns_1_on_failure(monkeypatch): monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") diff --git a/geolocation-stores/python/nearest_stores_by_ip.py b/geolocation-stores/python/nearest_stores_by_ip.py index db34f2e..37e71d9 100644 --- a/geolocation-stores/python/nearest_stores_by_ip.py +++ b/geolocation-stores/python/nearest_stores_by_ip.py @@ -6,6 +6,7 @@ import json import os import sys +import time from typing import Any import requests @@ -14,15 +15,17 @@ def locate(session: requests.Session, private_key: str, ip: str, **params: Any) -> dict[str, Any]: - response = session.get( - API_URL, - params={ - "private_key": private_key, - "ip_address": ip, - **{k: v for k, v in params.items() if v}, - }, - timeout=30, - ) + request_params = { + "private_key": private_key, + "ip_address": ip, + **{k: v for k, v in params.items() if v}, + } + for attempt in range(3): + response = session.get(API_URL, params=request_params, timeout=30) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) if response.status_code >= 400: raise RuntimeError(f"geolocation failed ({response.status_code}): {response.text}") return response.json() diff --git a/geolocation-stores/python/test_nearest_stores_by_ip.py b/geolocation-stores/python/test_nearest_stores_by_ip.py index 407e2de..3c004ba 100644 --- a/geolocation-stores/python/test_nearest_stores_by_ip.py +++ b/geolocation-stores/python/test_nearest_stores_by_ip.py @@ -35,6 +35,22 @@ def test_locate_raises_on_error(): mod.locate(requests.Session(), "k", "1.2.3.4") +@responses.activate +def test_locate_retries_on_429_then_succeeds(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + responses.get(mod.API_URL, status=429, headers={"Retry-After": "0"}) + responses.get(mod.API_URL, json=body()) + assert mod.locate(requests.Session(), "k", "1.2.3.4")["city"] == "Paris" + + +@responses.activate +def test_locate_does_not_retry_server_errors(): + responses.get(mod.API_URL, status=503, body="down") + with pytest.raises(RuntimeError, match="down"): + mod.locate(requests.Session(), "k", "1.2.3.4") + assert len(responses.calls) == 1 + + def test_describe_location_includes_city_country_and_accuracy(): assert mod.describe_location(body()) == "Paris, France (accuracy 5 km)" diff --git a/isochrone-stores/python/stores_within_isochrone.py b/isochrone-stores/python/stores_within_isochrone.py index 7235270..79695c8 100644 --- a/isochrone-stores/python/stores_within_isochrone.py +++ b/isochrone-stores/python/stores_within_isochrone.py @@ -7,6 +7,7 @@ import math import os import sys +import time from typing import Any import requests @@ -73,9 +74,13 @@ def __init__(self, private_key: str, session: requests.Session | None = None) -> self.session = session or requests.Session() def get(self, path: str, **params: Any) -> dict[str, Any]: - response = self.session.get( - f"{API_URL}{path}", params={"private_key": self.private_key, **params}, timeout=60 - ) + request_params = {"private_key": self.private_key, **params} + for attempt in range(3): + response = self.session.get(f"{API_URL}{path}", params=request_params, timeout=60) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) if response.status_code >= 400: raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}") body = response.json() diff --git a/isochrone-stores/python/test_stores_within_isochrone.py b/isochrone-stores/python/test_stores_within_isochrone.py index 2f19029..fbaa2c1 100644 --- a/isochrone-stores/python/test_stores_within_isochrone.py +++ b/isochrone-stores/python/test_stores_within_isochrone.py @@ -91,6 +91,22 @@ def test_stores_search_has_no_status_field_and_still_works(): assert mod.Woosmap("k").stores_around((48.8, 2.3), 1000, None) == [] +@responses.activate +def test_get_retries_on_429_then_succeeds(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + responses.get(f"{mod.API_URL}/stores/search", status=429, headers={"Retry-After": "0"}) + responses.get(f"{mod.API_URL}/stores/search", json={"features": [], "pagination": {}}) + assert mod.Woosmap("k").stores_around((48.8, 2.3), 1000, None) == [] + + +@responses.activate +def test_get_does_not_retry_server_errors(): + responses.get(f"{mod.API_URL}/stores/search", status=503, body="down") + with pytest.raises(RuntimeError, match="down"): + mod.Woosmap("k").stores_around((48.8, 2.3), 1000, None) + assert len(responses.calls) == 1 + + @responses.activate def test_isochrone_decodes_the_isoline_geometry(): responses.get( diff --git a/static-map/python/static_map.py b/static-map/python/static_map.py index e104d13..5057584 100644 --- a/static-map/python/static_map.py +++ b/static-map/python/static_map.py @@ -6,6 +6,7 @@ import json import os import sys +import time from pathlib import Path from typing import Any @@ -45,7 +46,12 @@ def build_params(args: argparse.Namespace) -> list[tuple[str, str]]: def fetch_image( session: requests.Session, private_key: str, params: list[tuple[str, str]] ) -> bytes: - response = session.get(API_URL, params=[*params, ("private_key", private_key)], timeout=60) + for attempt in range(3): + response = session.get(API_URL, params=[*params, ("private_key", private_key)], timeout=60) + if response.status_code != 429 or attempt == 2: + break + # 429 is the only status the API asks to retry, and Retry-After says when + time.sleep(float(response.headers.get("Retry-After", 2**attempt))) if response.status_code >= 400: raise RuntimeError(f"static map failed ({response.status_code}): {response.text}") if not response.headers.get("Content-Type", "").startswith("image/"): diff --git a/static-map/python/test_static_map.py b/static-map/python/test_static_map.py index 9bceef9..57b2986 100644 --- a/static-map/python/test_static_map.py +++ b/static-map/python/test_static_map.py @@ -52,6 +52,22 @@ def test_fetch_image_raises_on_http_error(): mod.fetch_image(requests.Session(), "k", []) +@responses.activate +def test_fetch_image_retries_on_429_then_succeeds(monkeypatch): + monkeypatch.setattr(mod.time, "sleep", lambda _: None) + responses.get(mod.API_URL, status=429, headers={"Retry-After": "0"}) + responses.get(mod.API_URL, body=b"img", content_type="image/png") + assert mod.fetch_image(requests.Session(), "k", []) == b"img" + + +@responses.activate +def test_fetch_image_does_not_retry_server_errors(): + responses.get(mod.API_URL, status=503, body="down") + with pytest.raises(RuntimeError, match="down"): + mod.fetch_image(requests.Session(), "k", []) + assert len(responses.calls) == 1 + + def test_public_url_never_contains_the_private_key(): url = mod.public_url([("lat", "1"), ("lng", "2")]) assert url.startswith(mod.API_URL) From 6e7b5f63a6911590be6f1b490145b18562325579 Mon Sep 17 00:00:00 2001 From: galela Date: Mon, 14 Sep 2026 16:31:07 +0200 Subject: [PATCH 4/6] Wait for ratelimit-reset, and refuse bad CLI values The API never sends Retry-After; it advertises its limits with the RateLimit headers, so the retry now reads ratelimit-reset and keeps Retry-After as a proxy fallback that no longer crashes on an HTTP date. The Node importer refused no longer silently updates stores when --mode is misspelled, and a batch size below one is rejected instead of hanging Node or skipping Python batches while deletes still run. --- README.md | 3 +- batch-geocoding/python/geocode_csv.py | 13 +++++-- datasets/python/manage_dataset.py | 15 ++++++-- distance-matrix/node/async-matrix.mjs | 13 +++++-- distance-matrix/python/async_matrix.py | 13 +++++-- .../python/nearest_stores_by_ip.py | 13 +++++-- .../python/stores_within_isochrone.py | 13 +++++-- static-map/python/static_map.py | 13 +++++-- stores-export/python/export_stores.py | 13 +++++-- stores-import/README.md | 2 +- stores-import/node/import-stores.mjs | 29 +++++++++++++-- stores-import/node/import-stores.test.mjs | 36 +++++++++++++++++++ stores-import/python/import_stores.py | 27 ++++++++++++-- stores-import/python/test_import_stores.py | 30 ++++++++++++++++ stores-sync/node/sync-stores.mjs | 25 +++++++++++-- stores-sync/node/sync-stores.test.mjs | 18 +++++++++- stores-sync/python/sync_stores.py | 24 +++++++++++-- stores-sync/python/test_sync_stores.py | 10 ++++++ 18 files changed, 278 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 6f9981b..9bc06a1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ Test data lives in [data/](data/). The food markets set is small enough to impor - Python 3.10+, type hints everywhere, `requests` as the only HTTP dependency. - Node 20+, ES modules, the built-in `fetch`, no dependency. -- One retry policy: 429 is retried after `Retry-After`, anything else fails with the response body. +- One retry policy: 429 waits for `ratelimit-reset`, the header the API actually sends, then retries. + Anything else fails with the response body. - Write operations use `/stores/replace` or explicit create, update and delete, never delete-then-post. - Each sample ships its tests. `pytest` and `node --test` run offline against mocked responses. - Where a sample needs plumbing that is not about Woosmap, it sits in its own module next to the main one. diff --git a/batch-geocoding/python/geocode_csv.py b/batch-geocoding/python/geocode_csv.py index 8159e26..cfd684b 100644 --- a/batch-geocoding/python/geocode_csv.py +++ b/batch-geocoding/python/geocode_csv.py @@ -27,6 +27,16 @@ Row = dict[str, str] +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + @dataclass(frozen=True) class Options: address_columns: list[str] @@ -74,8 +84,7 @@ def call(self, params: dict[str, str]) -> dict[str, Any]: ) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"HTTP {response.status_code}: {response.text[:200]}") return response.json() diff --git a/datasets/python/manage_dataset.py b/datasets/python/manage_dataset.py index 1a18126..dba4713 100644 --- a/datasets/python/manage_dataset.py +++ b/datasets/python/manage_dataset.py @@ -18,6 +18,16 @@ OPERATORS = ("within", "intersects", "contains", "nearby") +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + class Datasets: def __init__(self, private_key: str, session: requests.Session | None = None) -> None: self.private_key = private_key @@ -31,8 +41,7 @@ def call(self, method: str, path: str = "", **kwargs: Any) -> dict[str, Any]: ) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError( f"{method} {path or '/'} failed ({response.status_code}): {response.text}" @@ -62,7 +71,7 @@ def status(self, dataset_id: str) -> dict[str, Any]: ) if response.status_code != 429 or attempt == 2: break - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code == 404: return {"status": "pending", "steps": []} if response.status_code >= 400: diff --git a/distance-matrix/node/async-matrix.mjs b/distance-matrix/node/async-matrix.mjs index 11ce0e1..95d70eb 100644 --- a/distance-matrix/node/async-matrix.mjs +++ b/distance-matrix/node/async-matrix.mjs @@ -19,6 +19,16 @@ export function parsePoints(text) { return points; } +// Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies +function retryDelay(response, attempt) { + for (const header of ["ratelimit-reset", "retry-after"]) { + const raw = response.headers.get(header); + const value = raw?.trim() ? Number(raw) : Number.NaN; + if (Number.isFinite(value) && value >= 0) return value; + } + return 2 ** attempt; +} + export class AsyncMatrix { constructor(privateKey, fetchImpl = fetch, sleepImpl = sleep, now = () => Date.now() / 1000) { this.privateKey = privateKey; @@ -38,8 +48,7 @@ export class AsyncMatrix { redirect: "follow", }); if (response.status !== 429 || attempt === 2) break; - // 429 is the only status the API asks to retry, and Retry-After says when - await this.sleep(Number(response.headers.get("Retry-After") ?? 2 ** attempt)); + await this.sleep(retryDelay(response, attempt)); } if (!response.ok) throw new Error(`${method} ${url} failed (${response.status}): ${await response.text()}`); return response.json(); diff --git a/distance-matrix/python/async_matrix.py b/distance-matrix/python/async_matrix.py index 65980e3..d786505 100644 --- a/distance-matrix/python/async_matrix.py +++ b/distance-matrix/python/async_matrix.py @@ -17,6 +17,16 @@ OUTPUT_COLUMNS = ["origin_index", "destination_index", "status", "distance_m", "duration_s"] +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + def read_points(path: Path) -> list[str]: points: list[str] = [] for line in path.read_text(encoding="utf-8-sig").splitlines(): @@ -45,8 +55,7 @@ def _call(self, method: str, url: str, **kwargs: Any) -> requests.Response: ) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"{method} {url} failed ({response.status_code}): {response.text}") return response diff --git a/geolocation-stores/python/nearest_stores_by_ip.py b/geolocation-stores/python/nearest_stores_by_ip.py index 37e71d9..f1a9281 100644 --- a/geolocation-stores/python/nearest_stores_by_ip.py +++ b/geolocation-stores/python/nearest_stores_by_ip.py @@ -14,6 +14,16 @@ API_URL = "https://api.woosmap.com/geolocation/stores" +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + def locate(session: requests.Session, private_key: str, ip: str, **params: Any) -> dict[str, Any]: request_params = { "private_key": private_key, @@ -24,8 +34,7 @@ def locate(session: requests.Session, private_key: str, ip: str, **params: Any) response = session.get(API_URL, params=request_params, timeout=30) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"geolocation failed ({response.status_code}): {response.text}") return response.json() diff --git a/isochrone-stores/python/stores_within_isochrone.py b/isochrone-stores/python/stores_within_isochrone.py index 79695c8..d508ced 100644 --- a/isochrone-stores/python/stores_within_isochrone.py +++ b/isochrone-stores/python/stores_within_isochrone.py @@ -19,6 +19,16 @@ LatLng = tuple[float, float] +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + def decode_polyline(encoded: str, precision: int = 5) -> list[LatLng]: factor = 10**precision points: list[LatLng] = [] @@ -79,8 +89,7 @@ def get(self, path: str, **params: Any) -> dict[str, Any]: response = self.session.get(f"{API_URL}{path}", params=request_params, timeout=60) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}") body = response.json() diff --git a/static-map/python/static_map.py b/static-map/python/static_map.py index 5057584..3dea27c 100644 --- a/static-map/python/static_map.py +++ b/static-map/python/static_map.py @@ -15,6 +15,16 @@ API_URL = "https://api.woosmap.com/maps/static" +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + def parse_marker(text: str) -> dict[str, Any]: parts = [part.strip() for part in text.split(",", 2)] if len(parts) < 2: @@ -50,8 +60,7 @@ def fetch_image( response = session.get(API_URL, params=[*params, ("private_key", private_key)], timeout=60) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"static map failed ({response.status_code}): {response.text}") if not response.headers.get("Content-Type", "").startswith("image/"): diff --git a/stores-export/python/export_stores.py b/stores-export/python/export_stores.py index aa6ca68..30de5ed 100644 --- a/stores-export/python/export_stores.py +++ b/stores-export/python/export_stores.py @@ -18,6 +18,16 @@ Asset = dict[str, Any] +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + def fetch_page( session: requests.Session, private_key: str, page: int, query: str | None ) -> dict[str, Any]: @@ -28,8 +38,7 @@ def fetch_page( response = session.get(f"{API_URL}/stores/search", params=params, timeout=60) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"search failed ({response.status_code}): {response.text}") return response.json() diff --git a/stores-import/README.md b/stores-import/README.md index d6f0049..3e9ef9f 100644 --- a/stores-import/README.md +++ b/stores-import/README.md @@ -58,5 +58,5 @@ Same flags. Reads CSV and Google Sheets, not XLSX. - Duplicate `storeId` values in the source are reported and only the first row is kept. - Requests above the 15MB body limit are refused locally before reaching the API. -- 429 responses are retried after `Retry-After`; any other error stops with the API message. +- 429 responses wait for `ratelimit-reset` seconds and retry; any other error stops with the API message. - Bad input stops with one line, not a traceback. diff --git a/stores-import/node/import-stores.mjs b/stores-import/node/import-stores.mjs index e1c59ae..3691c2e 100644 --- a/stores-import/node/import-stores.mjs +++ b/stores-import/node/import-stores.mjs @@ -89,6 +89,16 @@ export function convertRows(rows, columns = DEFAULT_COLUMNS) { const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +// Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies +function retryDelay(response, attempt) { + for (const header of ["ratelimit-reset", "retry-after"]) { + const raw = response.headers.get(header); + const value = raw?.trim() ? Number(raw) : Number.NaN; + if (Number.isFinite(value) && value >= 0) return value; + } + return 2 ** attempt; +} + export class WoosmapStores { constructor(privateKey, fetchImpl = fetch, sleepImpl = sleep) { this.privateKey = privateKey; @@ -107,8 +117,7 @@ export class WoosmapStores { body, }); if (response.status !== 429 || attempt === 2) break; - // 429 is the only status the API asks to retry, and Retry-After says when - await this.sleep(Number(response.headers.get("Retry-After") ?? 2 ** attempt)); + await this.sleep(retryDelay(response, attempt)); } if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${await response.text()}`); return response.json(); @@ -120,13 +129,25 @@ export class WoosmapStores { update = (stores) => this.send("PUT", "/stores", stores); } +export const MODES = ["replace", "create", "update"]; + +export function positiveInt(value, name) { + const number = Number(value); + if (!Number.isInteger(number) || number < 1) { + throw new Error(`${name} must be a whole number of 1 or more, got '${value}'`); + } + return number; +} + export function chunked(items, size) { + if (!Number.isInteger(size) || size < 1) throw new Error(`batch size must be 1 or more, got ${size}`); const chunks = []; for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); return chunks; } export async function upload(api, assets, mode, batchSize) { + if (!MODES.includes(mode)) throw new Error(`--mode must be one of ${MODES.join(", ")}, got '${mode}'`); if (mode === "replace") { await api.replaceAll(assets); console.log(`replaced the project with ${assets.length} stores`); @@ -165,6 +186,8 @@ export async function main(argv) { }); const [source] = positionals; if (!source) throw new Error("usage: node import-stores.mjs [options]"); + if (!MODES.includes(values.mode)) throw new Error(`--mode must be one of ${MODES.join(", ")}, got '${values.mode}'`); + const batchSize = positiveInt(values["batch-size"], "--batch-size"); const rows = await readSource(source); const { assets, errors, derivedIds } = convertRows(rows, parseColumnOverrides(values.column)); errors.forEach((error) => console.error(error)); @@ -175,7 +198,7 @@ export async function main(argv) { if (values["dry-run"] || !assets.length) return 0; const privateKey = process.env.WOOSMAP_PRIVATE_KEY; if (!privateKey) throw new Error("set WOOSMAP_PRIVATE_KEY in the environment"); - await upload(new WoosmapStores(privateKey), assets, values.mode, Number(values["batch-size"])); + await upload(new WoosmapStores(privateKey), assets, values.mode, batchSize); return 0; } diff --git a/stores-import/node/import-stores.test.mjs b/stores-import/node/import-stores.test.mjs index f72cdd5..34afcec 100644 --- a/stores-import/node/import-stores.test.mjs +++ b/stores-import/node/import-stores.test.mjs @@ -5,8 +5,10 @@ import { API_URL, DEFAULT_COLUMNS, WoosmapStores, + chunked, convertRows, parseColumnOverrides, + positiveInt, rowToAsset, upload, } from "./import-stores.mjs"; @@ -59,6 +61,40 @@ test("column overrides validate the field name", () => { assert.throws(() => parseColumnOverrides(["colour=Blue"])); }); +test("an unknown mode is refused instead of falling through to update", async () => { + const { fetchImpl, calls } = fakeFetch([{}]); + await assert.rejects( + upload(new WoosmapStores("k", fetchImpl), [{ storeId: "a" }], "replcae", 500), + /--mode must be one of/, + ); + assert.equal(calls.length, 0); +}); + +test("chunked refuses a batch size below one instead of hanging", () => { + for (const size of [0, -1, Number.NaN]) { + assert.throws(() => chunked([1, 2, 3], size), /1 or more/); + } + assert.deepEqual(chunked([1, 2, 3], 2), [[1, 2], [3]]); +}); + +test("positiveInt rejects text, zero and fractions", () => { + for (const value of ["abc", "0", "-2", "1.5", ""]) { + assert.throws(() => positiveInt(value, "--batch-size"), /1 or more/); + } + assert.equal(positiveInt("300", "--batch-size"), 300); +}); + +test("a date in Retry-After falls back to backoff instead of retrying at once", async () => { + const waits = []; + const { fetchImpl } = fakeFetch([ + { status: 429, headers: { "Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT" } }, + { status: 429, headers: { "ratelimit-reset": "5" } }, + { status: 200 }, + ]); + await new WoosmapStores("k", fetchImpl, async (s) => waits.push(s)).create([{ storeId: "a" }]); + assert.deepEqual(waits, [1, 5]); +}); + test("replace posts every store once with the private key", async () => { const { fetchImpl, calls } = fakeFetch([{ status: 200, body: '{"status":"OK"}' }]); await upload(new WoosmapStores("secret", fetchImpl), [{ storeId: "a" }, { storeId: "b" }], "replace", 1); diff --git a/stores-import/python/import_stores.py b/stores-import/python/import_stores.py index a3256c8..669cf65 100644 --- a/stores-import/python/import_stores.py +++ b/stores-import/python/import_stores.py @@ -38,6 +38,16 @@ Asset = dict[str, Any] +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + @dataclass class Conversion: assets: list[Asset] = field(default_factory=list) @@ -152,8 +162,7 @@ def send(self, method: str, path: str, stores: list[Asset]) -> dict[str, Any]: ) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"{method} {path} failed ({response.status_code}): {response.text}") return response.json() @@ -169,7 +178,16 @@ def update(self, stores: list[Asset]) -> None: self.send("PUT", "/stores", stores) +def positive_int(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be a whole number of 1 or more") + return number + + def chunked(items: list[Asset], size: int) -> list[list[Asset]]: + if size < 1: + raise ValueError(f"batch size must be 1 or more, got {size}") return [items[i : i + size] for i in range(0, len(items), size)] @@ -210,7 +228,10 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--mode", choices=("replace", "create", "update"), default="replace") parser.add_argument( - "--batch-size", type=int, default=500, help="stores per request in create/update mode" + "--batch-size", + type=positive_int, + default=500, + help="stores per request in create/update mode", ) parser.add_argument( "--output", type=Path, help="also write the converted stores as Woosmap JSON" diff --git a/stores-import/python/test_import_stores.py b/stores-import/python/test_import_stores.py index 0cc5543..f942464 100644 --- a/stores-import/python/test_import_stores.py +++ b/stores-import/python/test_import_stores.py @@ -3,6 +3,7 @@ import import_stores as mod import pytest +import requests import responses DATA = Path(__file__).resolve().parents[2] / "data" @@ -66,6 +67,35 @@ def test_unknown_column_override_exits(): mod.parse_column_overrides(["colour=Blue"]) +def test_rate_limit_delay_prefers_the_ratelimit_reset_header(): + response = requests.Response() + response.headers["ratelimit-reset"] = "7" + response.headers["Retry-After"] = "99" + assert mod.retry_delay(response, 0) == 7.0 + + +def test_rate_limit_delay_falls_back_to_retry_after_then_to_backoff(): + response = requests.Response() + response.headers["Retry-After"] = "4" + assert mod.retry_delay(response, 0) == 4.0 + response.headers["Retry-After"] = "Wed, 21 Oct 2026 07:28:00 GMT" + assert mod.retry_delay(response, 2) == 4.0 + del response.headers["Retry-After"] + assert mod.retry_delay(response, 3) == 8.0 + + +def test_chunked_rejects_a_batch_size_below_one(): + for size in (0, -1): + with pytest.raises(ValueError, match="1 or more"): + mod.chunked([{"storeId": "a"}], size) + + +def test_batch_size_option_rejects_zero_and_text(): + for value in ("0", "-3", "abc"): + with pytest.raises(SystemExit): + mod.build_parser().parse_args(["x.csv", "--batch-size", value]) + + @responses.activate def test_replace_posts_all_stores_once(): responses.post(f"{mod.API_URL}/stores/replace", json={"status": "OK"}) diff --git a/stores-sync/node/sync-stores.mjs b/stores-sync/node/sync-stores.mjs index 55ebd4b..0e2e369 100644 --- a/stores-sync/node/sync-stores.mjs +++ b/stores-sync/node/sync-stores.mjs @@ -8,6 +8,16 @@ const PAGE_SIZE = 300; // stores_by_page maximum const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +// Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies +function retryDelay(response, attempt) { + for (const header of ["ratelimit-reset", "retry-after"]) { + const raw = response.headers.get(header); + const value = raw?.trim() ? Number(raw) : Number.NaN; + if (Number.isFinite(value) && value >= 0) return value; + } + return 2 ** attempt; +} + export class WoosmapStores { constructor(privateKey, fetchImpl = fetch, sleepImpl = sleep) { this.privateKey = privateKey; @@ -25,8 +35,7 @@ export class WoosmapStores { body: body ? JSON.stringify(body) : undefined, }); if (response.status !== 429 || attempt === 2) break; - // 429 is the only status the API asks to retry, and Retry-After says when - await this.sleep(Number(response.headers.get("Retry-After") ?? 2 ** attempt)); + await this.sleep(retryDelay(response, attempt)); } if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${await response.text()}`); return response.json(); @@ -115,7 +124,16 @@ export function buildPlan(localAssets, remoteFeatures) { return plan; } +export function positiveInt(value, name) { + const number = Number(value); + if (!Number.isInteger(number) || number < 1) { + throw new Error(`${name} must be a whole number of 1 or more, got '${value}'`); + } + return number; +} + export function chunked(items, size) { + if (!Number.isInteger(size) || size < 1) throw new Error(`batch size must be 1 or more, got ${size}`); const chunks = []; for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); return chunks; @@ -168,6 +186,7 @@ export async function main(argv) { if (!source) throw new Error("usage: node sync-stores.mjs [--dry-run] [--no-delete]"); const privateKey = process.env.WOOSMAP_PRIVATE_KEY; if (!privateKey) throw new Error("set WOOSMAP_PRIVATE_KEY in the environment"); + const batchSize = positiveInt(values["batch-size"], "--batch-size"); const localAssets = await loadLocalAssets(source); const api = new WoosmapStores(privateKey); const plan = buildPlan(localAssets, await api.fetchAll()); @@ -175,7 +194,7 @@ export async function main(argv) { plan.delete.forEach((id) => console.log(` delete ${id}`)); const empty = !plan.create.length && !plan.update.length && !plan.delete.length; if (values["dry-run"] || empty) return 0; - await applyPlan(api, plan, Number(values["batch-size"]), !values["no-delete"]); + await applyPlan(api, plan, batchSize, !values["no-delete"]); return 0; } diff --git a/stores-sync/node/sync-stores.test.mjs b/stores-sync/node/sync-stores.test.mjs index 32767f2..b47efef 100644 --- a/stores-sync/node/sync-stores.test.mjs +++ b/stores-sync/node/sync-stores.test.mjs @@ -1,7 +1,17 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { API_URL, WoosmapStores, applyPlan, buildPlan, deleteQuery, featureToAsset, sameAsset, withoutExpiredClosures } from "./sync-stores.mjs"; +import { + API_URL, + WoosmapStores, + applyPlan, + buildPlan, + chunked, + deleteQuery, + featureToAsset, + sameAsset, + withoutExpiredClosures, +} from "./sync-stores.mjs"; const feature = (store_id, props = {}, [lat, lng] = [48.5, 2.0]) => ({ type: "Feature", @@ -54,6 +64,12 @@ test("plan splits create, update and delete", () => { assert.deepEqual(plan.delete, ["gone"]); }); +test("chunked refuses a batch size below one instead of hanging", () => { + for (const size of [0, -1, Number.NaN]) { + assert.throws(() => chunked([1, 2, 3], size), /1 or more/); + } +}); + test("delete query uses OR clauses", () => { assert.equal(deleteQuery(["a", "b"]), 'idstore:="a" OR idstore:="b"'); }); diff --git a/stores-sync/python/sync_stores.py b/stores-sync/python/sync_stores.py index cf29e61..9f55824 100644 --- a/stores-sync/python/sync_stores.py +++ b/stores-sync/python/sync_stores.py @@ -20,6 +20,16 @@ Asset = dict[str, Any] +def retry_delay(response: requests.Response, attempt: int) -> float: + # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + for header in ("ratelimit-reset", "Retry-After"): + try: + return max(0.0, float(response.headers[header])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + @dataclass class Plan: create: list[Asset] = field(default_factory=list) @@ -43,8 +53,7 @@ def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: ) if response.status_code != 429 or attempt == 2: break - # 429 is the only status the API asks to retry, and Retry-After says when - time.sleep(float(response.headers.get("Retry-After", 2**attempt))) + time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"{method} {path} failed ({response.status_code}): {response.text}") return response.json() @@ -157,7 +166,16 @@ def build_plan(local_assets: list[Asset], remote_features: list[dict[str, Any]]) return plan +def positive_int(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be a whole number of 1 or more") + return number + + def chunked(items: list[Any], size: int) -> list[list[Any]]: + if size < 1: + raise ValueError(f"batch size must be 1 or more, got {size}") return [items[i : i + size] for i in range(0, len(items), size)] @@ -184,7 +202,7 @@ def describe(plan: Plan) -> str: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("source", type=Path, help='Woosmap JSON file: {"stores": [...]}') - parser.add_argument("--batch-size", type=int, default=500) + parser.add_argument("--batch-size", type=positive_int, default=500) parser.add_argument("--no-delete", action="store_true", help="never delete remote stores") parser.add_argument("--dry-run", action="store_true", help="print the plan and stop") return parser diff --git a/stores-sync/python/test_sync_stores.py b/stores-sync/python/test_sync_stores.py index 7eefea7..c8cd2f3 100644 --- a/stores-sync/python/test_sync_stores.py +++ b/stores-sync/python/test_sync_stores.py @@ -91,6 +91,16 @@ def test_plan_splits_create_update_delete(): assert plan.delete == ["gone"] +def test_chunked_rejects_a_batch_size_below_one(): + with pytest.raises(ValueError, match="1 or more"): + mod.chunked([asset("a")], 0) + + +def test_batch_size_option_rejects_zero(): + with pytest.raises(SystemExit): + mod.build_parser().parse_args(["stores.json", "--batch-size", "0"]) + + def test_delete_query_uses_or_clauses(): assert mod.delete_query(["a", "b"]) == 'idstore:="a" OR idstore:="b"' From c977ac70bd7d2380d2a8be027b5660c31943ce52 Mon Sep 17 00:00:00 2001 From: galela Date: Tue, 15 Sep 2026 16:57:12 +0200 Subject: [PATCH 5/6] Read the current RateLimit header, and pace store batches proactively ratelimit-reset is a compat header pending removal; the API's current RateLimit header carries the same reset time in its t= parameter, so every sample now reads that first and only falls back to the legacy header. stores-import and stores-sync also read RateLimit's remaining count and pause on their own once it hits zero, instead of burning all three retries on a 429 mid-batch. --- README.md | 4 +-- batch-geocoding/python/geocode_csv.py | 13 ++++++- batch-geocoding/python/test_geocode_csv.py | 8 +++++ datasets/python/manage_dataset.py | 13 ++++++- datasets/python/test_manage_dataset.py | 8 +++++ distance-matrix/node/async-matrix.mjs | 12 ++++++- distance-matrix/node/async-matrix.test.mjs | 10 ++++++ distance-matrix/python/async_matrix.py | 13 ++++++- distance-matrix/python/test_async_matrix.py | 8 +++++ .../python/nearest_stores_by_ip.py | 13 ++++++- .../python/test_nearest_stores_by_ip.py | 7 ++++ .../python/stores_within_isochrone.py | 13 ++++++- .../python/test_stores_within_isochrone.py | 8 +++++ static-map/python/static_map.py | 13 ++++++- static-map/python/test_static_map.py | 7 ++++ stores-export/python/export_stores.py | 13 ++++++- stores-export/python/test_export_stores.py | 7 ++++ stores-import/README.md | 4 ++- stores-import/node/import-stores.mjs | 21 +++++++++++- stores-import/node/import-stores.test.mjs | 23 +++++++++++++ stores-import/python/import_stores.py | 25 +++++++++++++- stores-import/python/test_import_stores.py | 34 +++++++++++++++++++ stores-sync/node/sync-stores.mjs | 21 +++++++++++- stores-sync/node/sync-stores.test.mjs | 23 +++++++++++++ stores-sync/python/sync_stores.py | 26 +++++++++++++- stores-sync/python/test_sync_stores.py | 29 ++++++++++++++++ 26 files changed, 361 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9bc06a1..14c0c6e 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ Test data lives in [data/](data/). The food markets set is small enough to impor - Python 3.10+, type hints everywhere, `requests` as the only HTTP dependency. - Node 20+, ES modules, the built-in `fetch`, no dependency. -- One retry policy: 429 waits for `ratelimit-reset`, the header the API actually sends, then retries. - Anything else fails with the response body. +- One retry policy: 429 waits for the reset time in the `RateLimit` header (falling back to the + legacy `ratelimit-reset`), then retries. Anything else fails with the response body. - Write operations use `/stores/replace` or explicit create, update and delete, never delete-then-post. - Each sample ships its tests. `pytest` and `node --test` run offline against mocked responses. - Where a sample needs plumbing that is not about Woosmap, it sits in its own module next to the main one. diff --git a/batch-geocoding/python/geocode_csv.py b/batch-geocoding/python/geocode_csv.py index cfd684b..cdcd553 100644 --- a/batch-geocoding/python/geocode_csv.py +++ b/batch-geocoding/python/geocode_csv.py @@ -6,6 +6,7 @@ import csv import io import os +import re import sys import time from dataclasses import dataclass @@ -27,8 +28,18 @@ Row = dict[str, str] +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/batch-geocoding/python/test_geocode_csv.py b/batch-geocoding/python/test_geocode_csv.py index 6249d12..3bc512f 100644 --- a/batch-geocoding/python/test_geocode_csv.py +++ b/batch-geocoding/python/test_geocode_csv.py @@ -2,6 +2,7 @@ from pathlib import Path import geocode_csv as mod +import requests import responses DATA = Path(__file__).resolve().parents[2] / "data" @@ -128,3 +129,10 @@ def test_main_writes_input_columns_plus_geocode_columns(tmp_path, monkeypatch): assert rows[0]["id"] == "markthalrotterdam" assert rows[0]["geocode_lat"] == "48.8" assert responses.calls[0].request.params["latlng"] == "51.919948,4.486843" + + +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + assert mod.retry_delay(response, 0) == 9.0 diff --git a/datasets/python/manage_dataset.py b/datasets/python/manage_dataset.py index dba4713..f8beea2 100644 --- a/datasets/python/manage_dataset.py +++ b/datasets/python/manage_dataset.py @@ -5,6 +5,7 @@ import argparse import json import os +import re import sys import time from pathlib import Path @@ -18,8 +19,18 @@ OPERATORS = ("within", "intersects", "contains", "nearby") +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/datasets/python/test_manage_dataset.py b/datasets/python/test_manage_dataset.py index c80b9c3..f878f32 100644 --- a/datasets/python/test_manage_dataset.py +++ b/datasets/python/test_manage_dataset.py @@ -2,6 +2,7 @@ import manage_dataset as mod import pytest +import requests import responses DATASET = "11111111-2222-3333-4444-555555555555" @@ -136,3 +137,10 @@ def test_main_list_prints_json(capsys, monkeypatch): responses.get(mod.API_URL, json={"datasets": [{"id": DATASET}], "pagination": {"page": 1}}) assert mod.main(["list"]) == 0 assert DATASET in capsys.readouterr().out + + +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + assert mod.retry_delay(response, 0) == 9.0 diff --git a/distance-matrix/node/async-matrix.mjs b/distance-matrix/node/async-matrix.mjs index 95d70eb..d981740 100644 --- a/distance-matrix/node/async-matrix.mjs +++ b/distance-matrix/node/async-matrix.mjs @@ -19,8 +19,18 @@ export function parsePoints(text) { return points; } -// Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies +// IETF RateLimit header: "policy";r=;t=; first policy only +function parseRateLimit(value) { + const result = {}; + for (const match of value.split(",", 1)[0].matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); + return result; +} + +// RateLimit's t= is current; ratelimit-reset is a compat header pending removal; +// Retry-After only ever comes from a proxy function retryDelay(response, attempt) { + const reset = parseRateLimit(response.headers.get("RateLimit") ?? "").t; + if (reset !== undefined) return reset; for (const header of ["ratelimit-reset", "retry-after"]) { const raw = response.headers.get(header); const value = raw?.trim() ? Number(raw) : Number.NaN; diff --git a/distance-matrix/node/async-matrix.test.mjs b/distance-matrix/node/async-matrix.test.mjs index eae6887..63b6279 100644 --- a/distance-matrix/node/async-matrix.test.mjs +++ b/distance-matrix/node/async-matrix.test.mjs @@ -58,3 +58,13 @@ test("errors fail immediately and carry the body", async () => { await assert.rejects(new AsyncMatrix("k", fetchImpl, async () => {}).status("m1"), /gateway/); assert.equal(calls.length, 1); }); + +test("RateLimit's t= wins over the legacy reset header", async () => { + const waits = []; + const { fetchImpl } = fakeFetch([ + { status: 429, headers: { RateLimit: '"default";r=0;t=9', "ratelimit-reset": "2" } }, + { body: { status: "accepted" } }, + ]); + await new AsyncMatrix("k", fetchImpl, async (s) => waits.push(s)).status("m1"); + assert.deepEqual(waits, [9]); +}); diff --git a/distance-matrix/python/async_matrix.py b/distance-matrix/python/async_matrix.py index d786505..b0b3cc7 100644 --- a/distance-matrix/python/async_matrix.py +++ b/distance-matrix/python/async_matrix.py @@ -5,6 +5,7 @@ import argparse import csv import os +import re import sys import time from pathlib import Path @@ -17,8 +18,18 @@ OUTPUT_COLUMNS = ["origin_index", "destination_index", "status", "distance_m", "duration_s"] +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/distance-matrix/python/test_async_matrix.py b/distance-matrix/python/test_async_matrix.py index 7ba866f..b6af966 100644 --- a/distance-matrix/python/test_async_matrix.py +++ b/distance-matrix/python/test_async_matrix.py @@ -2,6 +2,7 @@ import async_matrix as mod import pytest +import requests import responses @@ -153,3 +154,10 @@ def test_main_requires_inputs_or_matrix_id(tmp_path, monkeypatch): monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") with pytest.raises(SystemExit): mod.main([str(tmp_path / "out.csv")]) + + +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + assert mod.retry_delay(response, 0) == 9.0 diff --git a/geolocation-stores/python/nearest_stores_by_ip.py b/geolocation-stores/python/nearest_stores_by_ip.py index f1a9281..8f9c741 100644 --- a/geolocation-stores/python/nearest_stores_by_ip.py +++ b/geolocation-stores/python/nearest_stores_by_ip.py @@ -5,6 +5,7 @@ import argparse import json import os +import re import sys import time from typing import Any @@ -14,8 +15,18 @@ API_URL = "https://api.woosmap.com/geolocation/stores" +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/geolocation-stores/python/test_nearest_stores_by_ip.py b/geolocation-stores/python/test_nearest_stores_by_ip.py index 3c004ba..41b359c 100644 --- a/geolocation-stores/python/test_nearest_stores_by_ip.py +++ b/geolocation-stores/python/test_nearest_stores_by_ip.py @@ -71,3 +71,10 @@ def test_main_prints_location_and_stores(capsys, monkeypatch): out = capsys.readouterr().out assert "Paris, France" in out assert "Shop A" in out + + +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + assert mod.retry_delay(response, 0) == 9.0 diff --git a/isochrone-stores/python/stores_within_isochrone.py b/isochrone-stores/python/stores_within_isochrone.py index d508ced..369c40e 100644 --- a/isochrone-stores/python/stores_within_isochrone.py +++ b/isochrone-stores/python/stores_within_isochrone.py @@ -6,6 +6,7 @@ import json import math import os +import re import sys import time from typing import Any @@ -19,8 +20,18 @@ LatLng = tuple[float, float] +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/isochrone-stores/python/test_stores_within_isochrone.py b/isochrone-stores/python/test_stores_within_isochrone.py index fbaa2c1..bf9532c 100644 --- a/isochrone-stores/python/test_stores_within_isochrone.py +++ b/isochrone-stores/python/test_stores_within_isochrone.py @@ -1,4 +1,5 @@ import pytest +import requests import responses import stores_within_isochrone as mod @@ -136,3 +137,10 @@ def test_main_prints_matching_stores(capsys, monkeypatch): out = capsys.readouterr().out assert out.startswith("near\t") assert "far" not in out + + +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + assert mod.retry_delay(response, 0) == 9.0 diff --git a/static-map/python/static_map.py b/static-map/python/static_map.py index 3dea27c..c9f70cd 100644 --- a/static-map/python/static_map.py +++ b/static-map/python/static_map.py @@ -5,6 +5,7 @@ import argparse import json import os +import re import sys import time from pathlib import Path @@ -15,8 +16,18 @@ API_URL = "https://api.woosmap.com/maps/static" +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/static-map/python/test_static_map.py b/static-map/python/test_static_map.py index 57b2986..2fde076 100644 --- a/static-map/python/test_static_map.py +++ b/static-map/python/test_static_map.py @@ -60,6 +60,13 @@ def test_fetch_image_retries_on_429_then_succeeds(monkeypatch): assert mod.fetch_image(requests.Session(), "k", []) == b"img" +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + assert mod.retry_delay(response, 0) == 9.0 + + @responses.activate def test_fetch_image_does_not_retry_server_errors(): responses.get(mod.API_URL, status=503, body="down") diff --git a/stores-export/python/export_stores.py b/stores-export/python/export_stores.py index 30de5ed..42a3504 100644 --- a/stores-export/python/export_stores.py +++ b/stores-export/python/export_stores.py @@ -5,6 +5,7 @@ import argparse import json import os +import re import sys import time from pathlib import Path @@ -18,8 +19,18 @@ Asset = dict[str, Any] +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/stores-export/python/test_export_stores.py b/stores-export/python/test_export_stores.py index 1fa766d..e99cfb6 100644 --- a/stores-export/python/test_export_stores.py +++ b/stores-export/python/test_export_stores.py @@ -63,3 +63,10 @@ def test_main_writes_geojson_file(tmp_path, monkeypatch): document = json.loads(output.read_text()) assert document["type"] == "FeatureCollection" assert document["features"][0]["properties"]["store_id"] == "a" + + +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + assert mod.retry_delay(response, 0) == 9.0 diff --git a/stores-import/README.md b/stores-import/README.md index 3e9ef9f..f7e76aa 100644 --- a/stores-import/README.md +++ b/stores-import/README.md @@ -58,5 +58,7 @@ Same flags. Reads CSV and Google Sheets, not XLSX. - Duplicate `storeId` values in the source are reported and only the first row is kept. - Requests above the 15MB body limit are refused locally before reaching the API. -- 429 responses wait for `ratelimit-reset` seconds and retry; any other error stops with the API message. +- 429 responses wait for the reset time in `RateLimit` (or the legacy `ratelimit-reset`) and retry; + any other error stops with the API message. A batch also pauses on its own once `RateLimit` + reports no requests left, instead of waiting to be rate-limited. - Bad input stops with one line, not a traceback. diff --git a/stores-import/node/import-stores.mjs b/stores-import/node/import-stores.mjs index 3691c2e..e65568a 100644 --- a/stores-import/node/import-stores.mjs +++ b/stores-import/node/import-stores.mjs @@ -89,8 +89,18 @@ export function convertRows(rows, columns = DEFAULT_COLUMNS) { const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); -// Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies +// IETF RateLimit header: "policy";r=;t=; first policy only +function parseRateLimit(value) { + const result = {}; + for (const match of value.split(",", 1)[0].matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); + return result; +} + +// RateLimit's t= is current; ratelimit-reset is a compat header pending removal; +// Retry-After only ever comes from a proxy function retryDelay(response, attempt) { + const reset = parseRateLimit(response.headers.get("RateLimit") ?? "").t; + if (reset !== undefined) return reset; for (const header of ["ratelimit-reset", "retry-after"]) { const raw = response.headers.get(header); const value = raw?.trim() ? Number(raw) : Number.NaN; @@ -99,6 +109,13 @@ function retryDelay(response, attempt) { return 2 ** attempt; } +function rateLimitRemaining(response) { + const remaining = parseRateLimit(response.headers.get("RateLimit") ?? "").r; + if (remaining !== undefined) return remaining; + const legacy = response.headers.get("RateLimit-Remaining"); + return legacy !== null && Number.isFinite(Number(legacy)) ? Number(legacy) : undefined; +} + export class WoosmapStores { constructor(privateKey, fetchImpl = fetch, sleepImpl = sleep) { this.privateKey = privateKey; @@ -120,6 +137,8 @@ export class WoosmapStores { await this.sleep(retryDelay(response, attempt)); } if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${await response.text()}`); + // the quota is gone for this window; wait it out now instead of 429ing the next batch + if (rateLimitRemaining(response) === 0) await this.sleep(retryDelay(response, 0)); return response.json(); } diff --git a/stores-import/node/import-stores.test.mjs b/stores-import/node/import-stores.test.mjs index 34afcec..c679ee9 100644 --- a/stores-import/node/import-stores.test.mjs +++ b/stores-import/node/import-stores.test.mjs @@ -95,6 +95,29 @@ test("a date in Retry-After falls back to backoff instead of retrying at once", assert.deepEqual(waits, [1, 5]); }); +test("RateLimit's t= wins over the legacy reset header", async () => { + const waits = []; + const { fetchImpl } = fakeFetch([ + { status: 429, headers: { RateLimit: '"default";r=0;t=9', "ratelimit-reset": "2" } }, + { status: 200 }, + ]); + await new WoosmapStores("k", fetchImpl, async (s) => waits.push(s)).create([{ storeId: "a" }]); + assert.deepEqual(waits, [9]); +}); + +test("a batch pauses on its own once RateLimit reports no requests left", async () => { + const waits = []; + const { fetchImpl, calls } = fakeFetch([ + { status: 200, headers: { RateLimit: '"default";r=0;t=4' } }, + { status: 200 }, + ]); + const api = new WoosmapStores("k", fetchImpl, async (s) => waits.push(s)); + await api.create([{ storeId: "a" }]); + await api.create([{ storeId: "b" }]); + assert.equal(calls.length, 2); + assert.deepEqual(waits, [4]); +}); + test("replace posts every store once with the private key", async () => { const { fetchImpl, calls } = fakeFetch([{ status: 200, body: '{"status":"OK"}' }]); await upload(new WoosmapStores("secret", fetchImpl), [{ storeId: "a" }, { storeId: "b" }], "replace", 1); diff --git a/stores-import/python/import_stores.py b/stores-import/python/import_stores.py index 669cf65..18a6d0d 100644 --- a/stores-import/python/import_stores.py +++ b/stores-import/python/import_stores.py @@ -38,8 +38,18 @@ Asset = dict[str, Any] +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) @@ -48,6 +58,16 @@ def retry_delay(response: requests.Response, attempt: int) -> float: return float(2**attempt) +def rate_limit_remaining(response: requests.Response) -> int | None: + remaining = parse_ratelimit(response.headers.get("RateLimit", "")).get("r") + if remaining is not None: + return remaining + try: + return int(response.headers["RateLimit-Remaining"]) + except (KeyError, ValueError): + return None + + @dataclass class Conversion: assets: list[Asset] = field(default_factory=list) @@ -165,6 +185,9 @@ def send(self, method: str, path: str, stores: list[Asset]) -> dict[str, Any]: time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"{method} {path} failed ({response.status_code}): {response.text}") + if rate_limit_remaining(response) == 0: + # the quota is gone for this window; wait it out now instead of 429ing the next batch + time.sleep(retry_delay(response, 0)) return response.json() def replace_all(self, stores: list[Asset]) -> None: diff --git a/stores-import/python/test_import_stores.py b/stores-import/python/test_import_stores.py index f942464..eb5aecb 100644 --- a/stores-import/python/test_import_stores.py +++ b/stores-import/python/test_import_stores.py @@ -84,6 +84,40 @@ def test_rate_limit_delay_falls_back_to_retry_after_then_to_backoff(): assert mod.retry_delay(response, 3) == 8.0 +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + response.headers["Retry-After"] = "1" + assert mod.retry_delay(response, 0) == 9.0 + + +def test_rate_limit_remaining_reads_ratelimit_then_the_legacy_header(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + assert mod.rate_limit_remaining(response) == 0 + del response.headers["RateLimit"] + response.headers["RateLimit-Remaining"] = "3" + assert mod.rate_limit_remaining(response) == 3 + del response.headers["RateLimit-Remaining"] + assert mod.rate_limit_remaining(response) is None + + +@responses.activate +def test_a_batch_pauses_on_its_own_once_the_quota_is_gone(monkeypatch): + waits = [] + monkeypatch.setattr(mod.time, "sleep", waits.append) + responses.post( + f"{mod.API_URL}/stores", json={"status": "OK"}, headers={"RateLimit": '"default";r=0;t=4'} + ) + responses.post(f"{mod.API_URL}/stores", json={"status": "OK"}) + api = mod.WoosmapStores("k") + api.create([{"storeId": "a"}]) + api.create([{"storeId": "b"}]) + assert waits == [4.0] + assert len(responses.calls) == 2 + + def test_chunked_rejects_a_batch_size_below_one(): for size in (0, -1): with pytest.raises(ValueError, match="1 or more"): diff --git a/stores-sync/node/sync-stores.mjs b/stores-sync/node/sync-stores.mjs index 0e2e369..9dd03d1 100644 --- a/stores-sync/node/sync-stores.mjs +++ b/stores-sync/node/sync-stores.mjs @@ -8,8 +8,18 @@ const PAGE_SIZE = 300; // stores_by_page maximum const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); -// Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies +// IETF RateLimit header: "policy";r=;t=; first policy only +function parseRateLimit(value) { + const result = {}; + for (const match of value.split(",", 1)[0].matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); + return result; +} + +// RateLimit's t= is current; ratelimit-reset is a compat header pending removal; +// Retry-After only ever comes from a proxy function retryDelay(response, attempt) { + const reset = parseRateLimit(response.headers.get("RateLimit") ?? "").t; + if (reset !== undefined) return reset; for (const header of ["ratelimit-reset", "retry-after"]) { const raw = response.headers.get(header); const value = raw?.trim() ? Number(raw) : Number.NaN; @@ -18,6 +28,13 @@ function retryDelay(response, attempt) { return 2 ** attempt; } +function rateLimitRemaining(response) { + const remaining = parseRateLimit(response.headers.get("RateLimit") ?? "").r; + if (remaining !== undefined) return remaining; + const legacy = response.headers.get("RateLimit-Remaining"); + return legacy !== null && Number.isFinite(Number(legacy)) ? Number(legacy) : undefined; +} + export class WoosmapStores { constructor(privateKey, fetchImpl = fetch, sleepImpl = sleep) { this.privateKey = privateKey; @@ -38,6 +55,8 @@ export class WoosmapStores { await this.sleep(retryDelay(response, attempt)); } if (!response.ok) throw new Error(`${method} ${path} failed (${response.status}): ${await response.text()}`); + // the quota is gone for this window; wait it out now instead of 429ing the next batch + if (rateLimitRemaining(response) === 0) await this.sleep(retryDelay(response, 0)); return response.json(); } diff --git a/stores-sync/node/sync-stores.test.mjs b/stores-sync/node/sync-stores.test.mjs index b47efef..77d7308 100644 --- a/stores-sync/node/sync-stores.test.mjs +++ b/stores-sync/node/sync-stores.test.mjs @@ -115,3 +115,26 @@ test("429 is retried after Retry-After", async () => { assert.equal(calls.length, 2); assert.deepEqual(waits, [3]); }); + +test("RateLimit's t= wins over the legacy reset header", async () => { + const waits = []; + const { fetchImpl } = fakeFetch([ + { status: 429, headers: { RateLimit: '"default";r=0;t=9', "ratelimit-reset": "2" } }, + { body: {} }, + ]); + await new WoosmapStores("k", fetchImpl, async (s) => waits.push(s)).create([asset("x")]); + assert.deepEqual(waits, [9]); +}); + +test("a batch pauses on its own once RateLimit reports no requests left", async () => { + const waits = []; + const { fetchImpl, calls } = fakeFetch([ + { body: {}, headers: { RateLimit: '"default";r=0;t=4' } }, + { body: {} }, + ]); + const api = new WoosmapStores("k", fetchImpl, async (s) => waits.push(s)); + await api.create([asset("a")]); + await api.create([asset("b")]); + assert.equal(calls.length, 2); + assert.deepEqual(waits, [4]); +}); diff --git a/stores-sync/python/sync_stores.py b/stores-sync/python/sync_stores.py index 9f55824..aaa318e 100644 --- a/stores-sync/python/sync_stores.py +++ b/stores-sync/python/sync_stores.py @@ -5,6 +5,7 @@ import argparse import json import os +import re import sys import time from dataclasses import dataclass, field @@ -20,8 +21,18 @@ Asset = dict[str, Any] +def parse_ratelimit(header: str) -> dict[str, int]: + # IETF RateLimit header: "policy";r=;t=; first policy only + first_policy = header.split(",", 1)[0] + return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} + + def retry_delay(response: requests.Response, attempt: int) -> float: - # Woosmap sends ratelimit-reset, in seconds; Retry-After only comes from proxies + # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; + # Retry-After only ever comes from a proxy + reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") + if reset is not None: + return float(reset) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) @@ -30,6 +41,16 @@ def retry_delay(response: requests.Response, attempt: int) -> float: return float(2**attempt) +def rate_limit_remaining(response: requests.Response) -> int | None: + remaining = parse_ratelimit(response.headers.get("RateLimit", "")).get("r") + if remaining is not None: + return remaining + try: + return int(response.headers["RateLimit-Remaining"]) + except (KeyError, ValueError): + return None + + @dataclass class Plan: create: list[Asset] = field(default_factory=list) @@ -56,6 +77,9 @@ def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: time.sleep(retry_delay(response, attempt)) if response.status_code >= 400: raise RuntimeError(f"{method} {path} failed ({response.status_code}): {response.text}") + if rate_limit_remaining(response) == 0: + # the quota is gone for this window; wait it out now instead of 429ing the next batch + time.sleep(retry_delay(response, 0)) return response.json() def fetch_all(self) -> list[dict[str, Any]]: diff --git a/stores-sync/python/test_sync_stores.py b/stores-sync/python/test_sync_stores.py index c8cd2f3..1fa5de4 100644 --- a/stores-sync/python/test_sync_stores.py +++ b/stores-sync/python/test_sync_stores.py @@ -3,6 +3,7 @@ from pathlib import Path import pytest +import requests import responses import sync_stores as mod @@ -144,6 +145,34 @@ def test_api_errors_surface_with_body(): mod.WoosmapStores("k").create([asset("x")]) +def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + response.headers["ratelimit-reset"] = "2" + assert mod.retry_delay(response, 0) == 9.0 + + +def test_rate_limit_remaining_reads_ratelimit_then_the_legacy_header(): + response = requests.Response() + response.headers["RateLimit"] = '"default";r=0;t=9' + assert mod.rate_limit_remaining(response) == 0 + del response.headers["RateLimit"] + assert mod.rate_limit_remaining(response) is None + + +@responses.activate +def test_a_batch_pauses_on_its_own_once_the_quota_is_gone(monkeypatch): + waits = [] + monkeypatch.setattr(mod.time, "sleep", waits.append) + responses.post(f"{mod.API_URL}/stores", json={}, headers={"RateLimit": '"default";r=0;t=4'}) + responses.post(f"{mod.API_URL}/stores", json={}) + api = mod.WoosmapStores("k") + api.create([asset("a")]) + api.create([asset("b")]) + assert waits == [4.0] + assert len(responses.calls) == 2 + + @responses.activate def test_main_dry_run_only_reads(monkeypatch): monkeypatch.setenv("WOOSMAP_PRIVATE_KEY", "k") From 15e9ec0b8521e09c8bd100c7c7111862706482d8 Mon Sep 17 00:00:00 2001 From: galela Date: Tue, 15 Sep 2026 20:45:20 +0200 Subject: [PATCH 6/6] Bind rate-limit waits to the exhausted policy, not the first one in the header Endpoints with more than one limiter (distance matrix has requests and elements) list every policy in RateLimit, comma-separated, and the one that hit zero on a 429 isn't necessarily first. parse_ratelimit now reads every policy and both retry_delay and rate_limit_remaining pick the one that is actually exhausted, live-tested against the sandbox project. --- batch-geocoding/python/geocode_csv.py | 22 ++++++++------ batch-geocoding/python/test_geocode_csv.py | 6 ++++ datasets/python/manage_dataset.py | 22 ++++++++------ datasets/python/test_manage_dataset.py | 6 ++++ distance-matrix/node/async-matrix.mjs | 22 +++++++++----- distance-matrix/node/async-matrix.test.mjs | 10 +++++++ distance-matrix/python/async_matrix.py | 22 ++++++++------ distance-matrix/python/test_async_matrix.py | 6 ++++ .../python/nearest_stores_by_ip.py | 22 ++++++++------ .../python/test_nearest_stores_by_ip.py | 6 ++++ .../python/stores_within_isochrone.py | 22 ++++++++------ .../python/test_stores_within_isochrone.py | 6 ++++ static-map/python/static_map.py | 22 ++++++++------ static-map/python/test_static_map.py | 6 ++++ stores-export/python/export_stores.py | 22 ++++++++------ stores-export/python/test_export_stores.py | 6 ++++ stores-import/node/import-stores.mjs | 28 ++++++++++------- stores-import/node/import-stores.test.mjs | 10 +++++++ stores-import/python/import_stores.py | 30 +++++++++++-------- stores-import/python/test_import_stores.py | 12 ++++++++ stores-sync/node/sync-stores.mjs | 28 ++++++++++------- stores-sync/node/sync-stores.test.mjs | 10 +++++++ stores-sync/python/sync_stores.py | 30 +++++++++++-------- stores-sync/python/test_sync_stores.py | 6 ++++ 24 files changed, 267 insertions(+), 115 deletions(-) diff --git a/batch-geocoding/python/geocode_csv.py b/batch-geocoding/python/geocode_csv.py index cdcd553..0d86bab 100644 --- a/batch-geocoding/python/geocode_csv.py +++ b/batch-geocoding/python/geocode_csv.py @@ -28,18 +28,22 @@ Row = dict[str, str] -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/batch-geocoding/python/test_geocode_csv.py b/batch-geocoding/python/test_geocode_csv.py index 3bc512f..a40d3b0 100644 --- a/batch-geocoding/python/test_geocode_csv.py +++ b/batch-geocoding/python/test_geocode_csv.py @@ -136,3 +136,9 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): response.headers["RateLimit"] = '"default";r=0;t=9' response.headers["ratelimit-reset"] = "2" assert mod.retry_delay(response, 0) == 9.0 + + +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 diff --git a/datasets/python/manage_dataset.py b/datasets/python/manage_dataset.py index f8beea2..2743984 100644 --- a/datasets/python/manage_dataset.py +++ b/datasets/python/manage_dataset.py @@ -19,18 +19,22 @@ OPERATORS = ("within", "intersects", "contains", "nearby") -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/datasets/python/test_manage_dataset.py b/datasets/python/test_manage_dataset.py index f878f32..c11f948 100644 --- a/datasets/python/test_manage_dataset.py +++ b/datasets/python/test_manage_dataset.py @@ -144,3 +144,9 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): response.headers["RateLimit"] = '"default";r=0;t=9' response.headers["ratelimit-reset"] = "2" assert mod.retry_delay(response, 0) == 9.0 + + +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 diff --git a/distance-matrix/node/async-matrix.mjs b/distance-matrix/node/async-matrix.mjs index d981740..89cde1f 100644 --- a/distance-matrix/node/async-matrix.mjs +++ b/distance-matrix/node/async-matrix.mjs @@ -19,18 +19,24 @@ export function parsePoints(text) { return points; } -// IETF RateLimit header: "policy";r=;t=; first policy only +// IETF RateLimit header: comma-separated "policy";r=;t= entries function parseRateLimit(value) { - const result = {}; - for (const match of value.split(",", 1)[0].matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); - return result; + return value + .split(",") + .filter((policy) => policy.trim()) + .map((policy) => { + const result = {}; + for (const match of policy.matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); + return result; + }); } -// RateLimit's t= is current; ratelimit-reset is a compat header pending removal; -// Retry-After only ever comes from a proxy +// a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; +// ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy function retryDelay(response, attempt) { - const reset = parseRateLimit(response.headers.get("RateLimit") ?? "").t; - if (reset !== undefined) return reset; + const policies = parseRateLimit(response.headers.get("RateLimit") ?? ""); + const exhausted = policies.filter((p) => p.r === 0 && p.t !== undefined).map((p) => p.t); + if (exhausted.length) return Math.max(...exhausted); for (const header of ["ratelimit-reset", "retry-after"]) { const raw = response.headers.get(header); const value = raw?.trim() ? Number(raw) : Number.NaN; diff --git a/distance-matrix/node/async-matrix.test.mjs b/distance-matrix/node/async-matrix.test.mjs index 63b6279..2b6f66f 100644 --- a/distance-matrix/node/async-matrix.test.mjs +++ b/distance-matrix/node/async-matrix.test.mjs @@ -68,3 +68,13 @@ test("RateLimit's t= wins over the legacy reset header", async () => { await new AsyncMatrix("k", fetchImpl, async (s) => waits.push(s)).status("m1"); assert.deepEqual(waits, [9]); }); + +test("the exhausted policy governs even when it is not first in the header", async () => { + const waits = []; + const { fetchImpl } = fakeFetch([ + { status: 429, headers: { RateLimit: '"requests";r=5;t=1, "elements";r=0;t=30' } }, + { body: { status: "accepted" } }, + ]); + await new AsyncMatrix("k", fetchImpl, async (s) => waits.push(s)).status("m1"); + assert.deepEqual(waits, [30]); +}); diff --git a/distance-matrix/python/async_matrix.py b/distance-matrix/python/async_matrix.py index b0b3cc7..7c36dcf 100644 --- a/distance-matrix/python/async_matrix.py +++ b/distance-matrix/python/async_matrix.py @@ -18,18 +18,22 @@ OUTPUT_COLUMNS = ["origin_index", "destination_index", "status", "distance_m", "duration_s"] -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/distance-matrix/python/test_async_matrix.py b/distance-matrix/python/test_async_matrix.py index b6af966..afd000c 100644 --- a/distance-matrix/python/test_async_matrix.py +++ b/distance-matrix/python/test_async_matrix.py @@ -161,3 +161,9 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): response.headers["RateLimit"] = '"default";r=0;t=9' response.headers["ratelimit-reset"] = "2" assert mod.retry_delay(response, 0) == 9.0 + + +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 diff --git a/geolocation-stores/python/nearest_stores_by_ip.py b/geolocation-stores/python/nearest_stores_by_ip.py index 8f9c741..c6a4d23 100644 --- a/geolocation-stores/python/nearest_stores_by_ip.py +++ b/geolocation-stores/python/nearest_stores_by_ip.py @@ -15,18 +15,22 @@ API_URL = "https://api.woosmap.com/geolocation/stores" -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/geolocation-stores/python/test_nearest_stores_by_ip.py b/geolocation-stores/python/test_nearest_stores_by_ip.py index 41b359c..93233f9 100644 --- a/geolocation-stores/python/test_nearest_stores_by_ip.py +++ b/geolocation-stores/python/test_nearest_stores_by_ip.py @@ -78,3 +78,9 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): response.headers["RateLimit"] = '"default";r=0;t=9' response.headers["ratelimit-reset"] = "2" assert mod.retry_delay(response, 0) == 9.0 + + +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 diff --git a/isochrone-stores/python/stores_within_isochrone.py b/isochrone-stores/python/stores_within_isochrone.py index 369c40e..95c0c6e 100644 --- a/isochrone-stores/python/stores_within_isochrone.py +++ b/isochrone-stores/python/stores_within_isochrone.py @@ -20,18 +20,22 @@ LatLng = tuple[float, float] -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/isochrone-stores/python/test_stores_within_isochrone.py b/isochrone-stores/python/test_stores_within_isochrone.py index bf9532c..be0398e 100644 --- a/isochrone-stores/python/test_stores_within_isochrone.py +++ b/isochrone-stores/python/test_stores_within_isochrone.py @@ -144,3 +144,9 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): response.headers["RateLimit"] = '"default";r=0;t=9' response.headers["ratelimit-reset"] = "2" assert mod.retry_delay(response, 0) == 9.0 + + +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 diff --git a/static-map/python/static_map.py b/static-map/python/static_map.py index c9f70cd..ece1196 100644 --- a/static-map/python/static_map.py +++ b/static-map/python/static_map.py @@ -16,18 +16,22 @@ API_URL = "https://api.woosmap.com/maps/static" -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/static-map/python/test_static_map.py b/static-map/python/test_static_map.py index 2fde076..5ebfcef 100644 --- a/static-map/python/test_static_map.py +++ b/static-map/python/test_static_map.py @@ -67,6 +67,12 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): assert mod.retry_delay(response, 0) == 9.0 +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 + + @responses.activate def test_fetch_image_does_not_retry_server_errors(): responses.get(mod.API_URL, status=503, body="down") diff --git a/stores-export/python/export_stores.py b/stores-export/python/export_stores.py index 42a3504..98c0ae2 100644 --- a/stores-export/python/export_stores.py +++ b/stores-export/python/export_stores.py @@ -19,18 +19,22 @@ Asset = dict[str, Any] -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) diff --git a/stores-export/python/test_export_stores.py b/stores-export/python/test_export_stores.py index e99cfb6..4e65656 100644 --- a/stores-export/python/test_export_stores.py +++ b/stores-export/python/test_export_stores.py @@ -70,3 +70,9 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): response.headers["RateLimit"] = '"default";r=0;t=9' response.headers["ratelimit-reset"] = "2" assert mod.retry_delay(response, 0) == 9.0 + + +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 diff --git a/stores-import/node/import-stores.mjs b/stores-import/node/import-stores.mjs index e65568a..b22bd4b 100644 --- a/stores-import/node/import-stores.mjs +++ b/stores-import/node/import-stores.mjs @@ -89,18 +89,24 @@ export function convertRows(rows, columns = DEFAULT_COLUMNS) { const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); -// IETF RateLimit header: "policy";r=;t=; first policy only +// IETF RateLimit header: comma-separated "policy";r=;t= entries function parseRateLimit(value) { - const result = {}; - for (const match of value.split(",", 1)[0].matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); - return result; + return value + .split(",") + .filter((policy) => policy.trim()) + .map((policy) => { + const result = {}; + for (const match of policy.matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); + return result; + }); } -// RateLimit's t= is current; ratelimit-reset is a compat header pending removal; -// Retry-After only ever comes from a proxy +// a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; +// ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy function retryDelay(response, attempt) { - const reset = parseRateLimit(response.headers.get("RateLimit") ?? "").t; - if (reset !== undefined) return reset; + const policies = parseRateLimit(response.headers.get("RateLimit") ?? ""); + const exhausted = policies.filter((p) => p.r === 0 && p.t !== undefined).map((p) => p.t); + if (exhausted.length) return Math.max(...exhausted); for (const header of ["ratelimit-reset", "retry-after"]) { const raw = response.headers.get(header); const value = raw?.trim() ? Number(raw) : Number.NaN; @@ -110,8 +116,10 @@ function retryDelay(response, attempt) { } function rateLimitRemaining(response) { - const remaining = parseRateLimit(response.headers.get("RateLimit") ?? "").r; - if (remaining !== undefined) return remaining; + // the tightest policy governs: if any one is at zero, so is the batch's real budget + const policies = parseRateLimit(response.headers.get("RateLimit") ?? ""); + const remaining = policies.filter((p) => p.r !== undefined).map((p) => p.r); + if (remaining.length) return Math.min(...remaining); const legacy = response.headers.get("RateLimit-Remaining"); return legacy !== null && Number.isFinite(Number(legacy)) ? Number(legacy) : undefined; } diff --git a/stores-import/node/import-stores.test.mjs b/stores-import/node/import-stores.test.mjs index c679ee9..5eec93f 100644 --- a/stores-import/node/import-stores.test.mjs +++ b/stores-import/node/import-stores.test.mjs @@ -105,6 +105,16 @@ test("RateLimit's t= wins over the legacy reset header", async () => { assert.deepEqual(waits, [9]); }); +test("the exhausted policy governs even when it is not first in the header", async () => { + const waits = []; + const { fetchImpl } = fakeFetch([ + { status: 429, headers: { RateLimit: '"requests";r=5;t=1, "elements";r=0;t=30' } }, + { status: 200 }, + ]); + await new WoosmapStores("k", fetchImpl, async (s) => waits.push(s)).create([{ storeId: "a" }]); + assert.deepEqual(waits, [30]); +}); + test("a batch pauses on its own once RateLimit reports no requests left", async () => { const waits = []; const { fetchImpl, calls } = fakeFetch([ diff --git a/stores-import/python/import_stores.py b/stores-import/python/import_stores.py index 18a6d0d..4e19028 100644 --- a/stores-import/python/import_stores.py +++ b/stores-import/python/import_stores.py @@ -38,18 +38,22 @@ Asset = dict[str, Any] -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) @@ -59,9 +63,11 @@ def retry_delay(response: requests.Response, attempt: int) -> float: def rate_limit_remaining(response: requests.Response) -> int | None: - remaining = parse_ratelimit(response.headers.get("RateLimit", "")).get("r") - if remaining is not None: - return remaining + # the tightest policy governs: if any one is at zero, so is the batch's real budget + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + remaining = [policy["r"] for policy in policies if "r" in policy] + if remaining: + return min(remaining) try: return int(response.headers["RateLimit-Remaining"]) except (KeyError, ValueError): diff --git a/stores-import/python/test_import_stores.py b/stores-import/python/test_import_stores.py index eb5aecb..9c38c1e 100644 --- a/stores-import/python/test_import_stores.py +++ b/stores-import/python/test_import_stores.py @@ -92,6 +92,18 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): assert mod.retry_delay(response, 0) == 9.0 +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 + + +def test_rate_limit_remaining_is_the_tightest_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.rate_limit_remaining(response) == 0 + + def test_rate_limit_remaining_reads_ratelimit_then_the_legacy_header(): response = requests.Response() response.headers["RateLimit"] = '"default";r=0;t=9' diff --git a/stores-sync/node/sync-stores.mjs b/stores-sync/node/sync-stores.mjs index 9dd03d1..7300e97 100644 --- a/stores-sync/node/sync-stores.mjs +++ b/stores-sync/node/sync-stores.mjs @@ -8,18 +8,24 @@ const PAGE_SIZE = 300; // stores_by_page maximum const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); -// IETF RateLimit header: "policy";r=;t=; first policy only +// IETF RateLimit header: comma-separated "policy";r=;t= entries function parseRateLimit(value) { - const result = {}; - for (const match of value.split(",", 1)[0].matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); - return result; + return value + .split(",") + .filter((policy) => policy.trim()) + .map((policy) => { + const result = {}; + for (const match of policy.matchAll(/\b([rt])=(\d+)/g)) result[match[1]] = Number(match[2]); + return result; + }); } -// RateLimit's t= is current; ratelimit-reset is a compat header pending removal; -// Retry-After only ever comes from a proxy +// a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; +// ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy function retryDelay(response, attempt) { - const reset = parseRateLimit(response.headers.get("RateLimit") ?? "").t; - if (reset !== undefined) return reset; + const policies = parseRateLimit(response.headers.get("RateLimit") ?? ""); + const exhausted = policies.filter((p) => p.r === 0 && p.t !== undefined).map((p) => p.t); + if (exhausted.length) return Math.max(...exhausted); for (const header of ["ratelimit-reset", "retry-after"]) { const raw = response.headers.get(header); const value = raw?.trim() ? Number(raw) : Number.NaN; @@ -29,8 +35,10 @@ function retryDelay(response, attempt) { } function rateLimitRemaining(response) { - const remaining = parseRateLimit(response.headers.get("RateLimit") ?? "").r; - if (remaining !== undefined) return remaining; + // the tightest policy governs: if any one is at zero, so is the batch's real budget + const policies = parseRateLimit(response.headers.get("RateLimit") ?? ""); + const remaining = policies.filter((p) => p.r !== undefined).map((p) => p.r); + if (remaining.length) return Math.min(...remaining); const legacy = response.headers.get("RateLimit-Remaining"); return legacy !== null && Number.isFinite(Number(legacy)) ? Number(legacy) : undefined; } diff --git a/stores-sync/node/sync-stores.test.mjs b/stores-sync/node/sync-stores.test.mjs index 77d7308..8db5e10 100644 --- a/stores-sync/node/sync-stores.test.mjs +++ b/stores-sync/node/sync-stores.test.mjs @@ -126,6 +126,16 @@ test("RateLimit's t= wins over the legacy reset header", async () => { assert.deepEqual(waits, [9]); }); +test("the exhausted policy governs even when it is not first in the header", async () => { + const waits = []; + const { fetchImpl } = fakeFetch([ + { status: 429, headers: { RateLimit: '"requests";r=5;t=1, "elements";r=0;t=30' } }, + { body: {} }, + ]); + await new WoosmapStores("k", fetchImpl, async (s) => waits.push(s)).create([asset("x")]); + assert.deepEqual(waits, [30]); +}); + test("a batch pauses on its own once RateLimit reports no requests left", async () => { const waits = []; const { fetchImpl, calls } = fakeFetch([ diff --git a/stores-sync/python/sync_stores.py b/stores-sync/python/sync_stores.py index aaa318e..021663d 100644 --- a/stores-sync/python/sync_stores.py +++ b/stores-sync/python/sync_stores.py @@ -21,18 +21,22 @@ Asset = dict[str, Any] -def parse_ratelimit(header: str) -> dict[str, int]: - # IETF RateLimit header: "policy";r=;t=; first policy only - first_policy = header.split(",", 1)[0] - return {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", first_policy)} +def parse_ratelimit(header: str) -> list[dict[str, int]]: + # IETF RateLimit header: comma-separated "policy";r=;t= entries + return [ + {key: int(value) for key, value in re.findall(r"\b([rt])=(\d+)", policy)} + for policy in header.split(",") + if policy.strip() + ] def retry_delay(response: requests.Response, attempt: int) -> float: - # RateLimit's t= is current; ratelimit-reset is a compat header pending removal; - # Retry-After only ever comes from a proxy - reset = parse_ratelimit(response.headers.get("RateLimit", "")).get("t") - if reset is not None: - return float(reset) + # a 429 is bound by whichever policy hit zero, not necessarily the first one in the header; + # ratelimit-reset is a compat header pending removal, Retry-After only ever comes from a proxy + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + exhausted = [policy["t"] for policy in policies if policy.get("r") == 0 and "t" in policy] + if exhausted: + return float(max(exhausted)) for header in ("ratelimit-reset", "Retry-After"): try: return max(0.0, float(response.headers[header])) @@ -42,9 +46,11 @@ def retry_delay(response: requests.Response, attempt: int) -> float: def rate_limit_remaining(response: requests.Response) -> int | None: - remaining = parse_ratelimit(response.headers.get("RateLimit", "")).get("r") - if remaining is not None: - return remaining + # the tightest policy governs: if any one is at zero, so is the batch's real budget + policies = parse_ratelimit(response.headers.get("RateLimit", "")) + remaining = [policy["r"] for policy in policies if "r" in policy] + if remaining: + return min(remaining) try: return int(response.headers["RateLimit-Remaining"]) except (KeyError, ValueError): diff --git a/stores-sync/python/test_sync_stores.py b/stores-sync/python/test_sync_stores.py index 1fa5de4..426e9fc 100644 --- a/stores-sync/python/test_sync_stores.py +++ b/stores-sync/python/test_sync_stores.py @@ -152,6 +152,12 @@ def test_rate_limit_delay_prefers_the_ratelimit_header_over_legacy_ones(): assert mod.retry_delay(response, 0) == 9.0 +def test_rate_limit_delay_uses_the_exhausted_policy_even_when_not_first(): + response = requests.Response() + response.headers["RateLimit"] = '"requests";r=5;t=1, "elements";r=0;t=30' + assert mod.retry_delay(response, 0) == 30.0 + + def test_rate_limit_remaining_reads_ratelimit_then_the_legacy_header(): response = requests.Response() response.headers["RateLimit"] = '"default";r=0;t=9'