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/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. diff --git a/README.md b/README.md index 607c6b9..14c0c6e 100644 --- a/README.md +++ b/README.md @@ -1 +1,68 @@ -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 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. + +## 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..0d86bab --- /dev/null +++ b/batch-geocoding/python/geocode_csv.py @@ -0,0 +1,214 @@ +"""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 re +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] + + +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: + # 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])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + +@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 + time.sleep(retry_delay(response, 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..a40d3b0 --- /dev/null +++ b/batch-geocoding/python/test_geocode_csv.py @@ -0,0 +1,144 @@ +import csv +from pathlib import Path + +import geocode_csv as mod +import requests +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" + + +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_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/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..2743984 --- /dev/null +++ b/datasets/python/manage_dataset.py @@ -0,0 +1,221 @@ +"""Declare, import and query a Woosmap dataset built from a hosted zipped Shapefile.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +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") + + +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: + # 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])) + 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 + 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", {})} + 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 + time.sleep(retry_delay(response, attempt)) + 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 + 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(retry_delay(response, attempt)) + 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..c11f948 --- /dev/null +++ b/datasets/python/test_manage_dataset.py @@ -0,0 +1,152 @@ +import json + +import manage_dataset as mod +import pytest +import requests +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_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") + 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 + + +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_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/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..89cde1f --- /dev/null +++ b/distance-matrix/node/async-matrix.mjs @@ -0,0 +1,163 @@ +// 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; +} + +// IETF RateLimit header: comma-separated "policy";r=;t= entries +function parseRateLimit(value) { + 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; + }); +} + +// 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 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; + 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; + 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; + await this.sleep(retryDelay(response, 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..2b6f66f --- /dev/null +++ b/distance-matrix/node/async-matrix.test.mjs @@ -0,0 +1,80 @@ +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); +}); + +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]); +}); + +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/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..7c36dcf --- /dev/null +++ b/distance-matrix/python/async_matrix.py @@ -0,0 +1,183 @@ +"""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 re +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 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: + # 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])) + 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(): + 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 + time.sleep(retry_delay(response, 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..afd000c --- /dev/null +++ b/distance-matrix/python/test_async_matrix.py @@ -0,0 +1,169 @@ +import csv + +import async_matrix as mod +import pytest +import requests +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")]) + + +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_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/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..c6a4d23 --- /dev/null +++ b/geolocation-stores/python/nearest_stores_by_ip.py @@ -0,0 +1,109 @@ +"""Find the stores closest to a visitor from their IP address, server-side.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com/geolocation/stores" + + +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: + # 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])) + 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, + "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 + time.sleep(retry_delay(response, attempt)) + 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..93233f9 --- /dev/null +++ b/geolocation-stores/python/test_nearest_stores_by_ip.py @@ -0,0 +1,86 @@ +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") + + +@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)" + + +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 + + +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_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/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..95c0c6e --- /dev/null +++ b/isochrone-stores/python/stores_within_isochrone.py @@ -0,0 +1,230 @@ +"""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 re +import sys +import time +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 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: + # 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])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + +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]: + 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 + 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() + # 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..be0398e --- /dev/null +++ b/isochrone-stores/python/test_stores_within_isochrone.py @@ -0,0 +1,152 @@ +import pytest +import requests +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_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( + 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 + + +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_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/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..ece1196 --- /dev/null +++ b/static-map/python/static_map.py @@ -0,0 +1,129 @@ +"""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 re +import sys +import time +from pathlib import Path +from typing import Any + +import requests + +API_URL = "https://api.woosmap.com/maps/static" + + +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: + # 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])) + 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: + 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: + 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 + 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/"): + 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..5ebfcef --- /dev/null +++ b/static-map/python/test_static_map.py @@ -0,0 +1,96 @@ +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", []) + + +@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" + + +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_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") + 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) + 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..98c0ae2 --- /dev/null +++ b/stores-export/python/export_stores.py @@ -0,0 +1,143 @@ +"""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 re +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 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: + # 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])) + 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]: + 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 + time.sleep(retry_delay(response, 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..4e65656 --- /dev/null +++ b/stores-export/python/test_export_stores.py @@ -0,0 +1,78 @@ +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" + + +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_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/README.md b/stores-import/README.md new file mode 100644 index 0000000..f7e76aa --- /dev/null +++ b/stores-import/README.md @@ -0,0 +1,64 @@ +# 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 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 new file mode 100644 index 0000000..b22bd4b --- /dev/null +++ b/stores-import/node/import-stores.mjs @@ -0,0 +1,237 @@ +// 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)); + +// IETF RateLimit header: comma-separated "policy";r=;t= entries +function parseRateLimit(value) { + 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; + }); +} + +// 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 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; + if (Number.isFinite(value) && value >= 0) return value; + } + return 2 ** attempt; +} + +function rateLimitRemaining(response) { + // 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; +} + +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; + 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(); + } + + 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 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`); + 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]"); + 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)); + 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, batchSize); + 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..5eec93f --- /dev/null +++ b/stores-import/node/import-stores.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + API_URL, + DEFAULT_COLUMNS, + WoosmapStores, + chunked, + convertRows, + parseColumnOverrides, + positiveInt, + 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("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("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("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([ + { 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); + 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..4e19028 --- /dev/null +++ b/stores-import/python/import_stores.py @@ -0,0 +1,317 @@ +"""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] + + +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: + # 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])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + +def rate_limit_remaining(response: requests.Response) -> int | None: + # 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): + return None + + +@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 + 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: + 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 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)] + + +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=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" + ) + 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..9c38c1e --- /dev/null +++ b/stores-import/python/test_import_stores.py @@ -0,0 +1,204 @@ +import json +from pathlib import Path + +import import_stores as mod +import pytest +import requests +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"]) + + +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_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_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' + 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"): + 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"}) + 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..7300e97 --- /dev/null +++ b/stores-sync/node/sync-stores.mjs @@ -0,0 +1,233 @@ +// 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)); + +// IETF RateLimit header: comma-separated "policy";r=;t= entries +function parseRateLimit(value) { + 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; + }); +} + +// 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 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; + if (Number.isFinite(value) && value >= 0) return value; + } + return 2 ** attempt; +} + +function rateLimitRemaining(response) { + // 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; +} + +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; + 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(); + } + + 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 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 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 batchSize = positiveInt(values["batch-size"], "--batch-size"); + 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, batchSize, !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..8db5e10 --- /dev/null +++ b/stores-sync/node/sync-stores.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +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", + 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("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"'); +}); + +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]); +}); + +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("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([ + { 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/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..021663d --- /dev/null +++ b/stores-sync/python/sync_stores.py @@ -0,0 +1,263 @@ +"""Synchronise a Woosmap project with a Woosmap JSON file, changing only what differs.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +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] + + +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: + # 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])) + except (KeyError, ValueError): + continue + return float(2**attempt) + + +def rate_limit_remaining(response: requests.Response) -> int | None: + # 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): + return None + + +@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 + 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]]: + 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 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)] + + +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=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 + + +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..426e9fc --- /dev/null +++ b/stores-sync/python/test_sync_stores.py @@ -0,0 +1,187 @@ +import json +from datetime import date +from pathlib import Path + +import pytest +import requests +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_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"' + + +@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")]) + + +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_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' + 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") + 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"]