From 3a2c744ed6cef65c5cb21d1bbe425672ad862d18 Mon Sep 17 00:00:00 2001 From: michael1986 Date: Mon, 1 Dec 2025 13:02:25 +0100 Subject: [PATCH] all --- app.py | 2 +- bindToInterface.c | 151 +++++++++ cache.py | 77 ++--- haha.bat | 6 + models/location.py | 20 +- passenger_wsgi.py | 2 +- utils/APutils.py | 60 ++-- utils/OVMutils.py | 69 ++-- utils/TWKutils.py | 72 ++-- utils/__auction_items.py | 481 +++++++++++++++++++++++++++ utils/auction_items.py | 481 +++++++++++++++++++++++++++ utils/auctionutils.py | 60 ++-- utils/helperutils.py | 1 + utils/lots.py | 6 + web/auctionviewer (1).html | 160 +++++++++ web/leaflet.css | 661 +++++++++++++++++++++++++++++++++++++ web/leaflet.js | 5 + web/test.html | 10 + 18 files changed, 2151 insertions(+), 173 deletions(-) create mode 100644 bindToInterface.c create mode 100644 haha.bat create mode 100644 utils/__auction_items.py create mode 100644 utils/auction_items.py create mode 100644 utils/lots.py create mode 100644 web/auctionviewer (1).html create mode 100644 web/leaflet.css create mode 100644 web/leaflet.js create mode 100644 web/test.html diff --git a/app.py b/app.py index acf191d..15b2885 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ from utils.auctionutils import getAuctionlocations from models.location import JsonEncoder app = Flask(__name__) -CORS(app, resources={r"/*": {"origins": ["http://localhost:4200","https://auctionviewer.ikbenhenk.nl"]}}) +CORS(app, resources={r"/*": {"origins": ["*"]}}) application = app # our hosting requires application in passenger_wsgi @app.route("/") diff --git a/bindToInterface.c b/bindToInterface.c new file mode 100644 index 0000000..8075dc3 --- /dev/null +++ b/bindToInterface.c @@ -0,0 +1,151 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +//#define DEBUG + +//compile with gcc -nostartfiles -fpic -shared bindToInterface.c -o bindToInterface.so -ldl -D_GNU_SOURCE +//Use with BIND_INTERFACE= LD_PRELOAD=./bindInterface.so like curl ifconfig.me + +int bind_to_source_ip(int sockfd, const char *source_ip) +{ + struct sockaddr_in source_addr; + memset(&source_addr, 0, sizeof(source_addr)); + source_addr.sin_family = AF_INET; + source_addr.sin_addr.s_addr = inet_addr(source_ip); + + return bind(sockfd, (struct sockaddr *)&source_addr, sizeof(source_addr)); +} + +int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) +{ + int *(*original_connect)(int, const struct sockaddr *, socklen_t); + original_connect = dlsym(RTLD_NEXT, "connect"); + + static struct sockaddr_in *socketAddress; + socketAddress = (struct sockaddr_in *)addr; + + char *dest = inet_ntoa(socketAddress->sin_addr); + + if (socketAddress->sin_family == AF_INET) + { + unsigned short port = ntohs(socketAddress->sin_port); + + char *DNSIP_env = getenv("DNS_OVERRIDE_IP"); + char *DNSPort_env = getenv("DNS_OVERRIDE_PORT"); + int port_new = port; + + if (port == 53 && DNSIP_env != NULL && strlen(DNSIP_env) > 0) + { + if (DNSPort_env != NULL && strlen(DNSPort_env) > 0) + { + port_new = atoi(DNSPort_env); + socketAddress->sin_port = htons(port_new); + } +#ifdef DEBUG + printf("Detected DNS query to: %s:%i, overwriting with %s:%i \n", dest, port, DNSIP_env, port_new); +#endif + socketAddress->sin_addr.s_addr = inet_addr(DNSIP_env); + } + port = port_new; + dest = inet_ntoa(socketAddress->sin_addr); //with #include + +#ifdef DEBUG + printf("connecting to: %s:%i \n", dest, port); +#endif + + bool IPExcluded = false; + char *bind_excludes = getenv("BIND_EXCLUDE"); + if (bind_excludes != NULL && strlen(bind_excludes) > 0) + { + bind_excludes = (char*) malloc(strlen(getenv("BIND_EXCLUDE")) * sizeof(char) + 1); + strcpy(bind_excludes,getenv("BIND_EXCLUDE")); + char sep[] = ","; + char *iplist; + iplist = strtok(bind_excludes, sep); + while (iplist != NULL) + { + if(!strncmp(dest,iplist,strlen(iplist))) + { + IPExcluded = true; +#ifdef DEBUG + printf("IP %s excluded by IP-List, not binding to interface %s\n", dest, getenv("BIND_INTERFACE")); +#endif + break; + } + iplist = strtok(NULL, sep); + } + free(bind_excludes); + } + + if (!IPExcluded) //Don't bind when destination is localhost, because it couldn't be reached anymore + { + char *bind_addr_env; + bind_addr_env = getenv("BIND_INTERFACE"); + char *source_ip_env; + source_ip_env = getenv("BIND_SOURCE_IP"); + struct ifreq interface; + + int errorCode; + if (bind_addr_env != NULL && strlen(bind_addr_env) > 0) + { + // printf(bind_addr_env); + + struct ifreq boundInterface = + { + .ifr_name = "none", + }; + socklen_t optionlen = sizeof(boundInterface); + errorCode = getsockopt(sockfd, SOL_SOCKET, SO_BINDTODEVICE, &boundInterface, &optionlen); + if (errorCode < 0) + { + //getsockopt should not fail + perror("getsockopt"); + return -1; + }; +#ifdef DEBUG + printf("Bound Interface: %s.", boundInterface.ifr_name); +#endif + + if (!strcmp(boundInterface.ifr_name, "none") || strcmp(boundInterface.ifr_name, bind_addr_env)) + { +#ifdef DEBUG + printf(" Socket not bound to desired interface (Bound to: %s). Binding to interface: %s\n", boundInterface.ifr_name, bind_addr_env); +#endif + strcpy(interface.ifr_name, bind_addr_env); + errorCode = setsockopt(sockfd, SOL_SOCKET, SO_BINDTODEVICE, &interface, sizeof(interface)); //Will fail if socket is already bound to another interface + if (errorCode < 0) + { + perror("setsockopt"); + errno = ENETUNREACH; //Let network appear unreachable for maximum security when desired interface is not available + return -1; + }; + } + } + + if(source_ip_env != NULL && strlen(source_ip_env) > 0){ + if (bind_to_source_ip(sockfd, source_ip_env) < 0){ + perror("bind_to_source_ip failed"); + return -1; + } + } + + if(!(source_ip_env != NULL && strlen(source_ip_env) > 0) && !(bind_addr_env != NULL && strlen(bind_addr_env) > 0)){ + printf("Warning: Program with LD_PRELOAD started, but BIND_INTERFACE environment variable not set\n"); + fprintf(stderr, "Warning: Program with LD_PRELOAD started, but BIND_INTERFACE environment variable not set\n"); + } + } + } + + return (uintptr_t)original_connect(sockfd, addr, addrlen); + +} diff --git a/cache.py b/cache.py index 630f757..58e813e 100644 --- a/cache.py +++ b/cache.py @@ -9,19 +9,20 @@ from utils.helperutils import log cache = {} + class Cache(): - def get(key, notOlderThanHours = 24): - #print('get key ' + key) - if key in cache: - cacheobj = cache[key] - if(not cache): - return None - if(cacheobj.isOlderThanHours(notOlderThanHours)): - log('removing cacheobject ' + key) - del cache[key] - return None - # log( 'returning cacheobject ' + key) - return cacheobj.obj + def get(key, notOlderThanHours=24): + # print('get key ' + key) + if key in cache: + cacheobj = cache[key] + if (not cache): + return None + if (cacheobj.isOlderThanHours(notOlderThanHours)): + log('removing cacheobject ' + key) + del cache[key] + return None + # log( 'returning cacheobject ' + key) + return cacheobj.obj def add(key, obj): log('adding cacheobject ' + key) @@ -33,40 +34,40 @@ class CacheObj: def __init__(self, key, obj): self.key = key self.obj = obj - self.time=datetime.now() - + self.time = datetime.now() + def isOlderThanHours(self, hours): # log('checking time cacheobject ' + self.key + ': ' + str(self.time) + " < " + str(datetime.now() - timedelta(hours=hours))) return self.time < datetime.now() - timedelta(hours=hours) - - + + class FileCache(): - def get(key, notOlderThanHours = None): - - filepath = "./filecache/" + key + ".json" - cachefile = Path(filepath) - if cachefile.is_file(): - ti_m = os.path.getmtime(filepath) + def get(key, notOlderThanHours=None): - if(notOlderThanHours is not None): - #checks last modified age of file, and removes it if it is too old - log('checking time cachefile ' + filepath + ': ' + str(ti_m) + " < " + str(time.time() - (3600 * notOlderThanHours))) + filepath = "./filecache/" + key + ".json" + cachefile = Path(filepath) + if cachefile.is_file(): + ti_m = os.path.getmtime(filepath) - if( ti_m < time.time() - (3600 * notOlderThanHours)): - log('removing old filecache') - os.remove(filepath) - return None - - with open(filepath) as json_file: - json_data = json.load(json_file) - log('returning json data from cachefile: ' + key) - return json_data - - return None + if (notOlderThanHours is not None): + # checks last modified age of file, and removes it if it is too old + log('checking time cachefile ' + filepath + ': ' + str(ti_m) + " < " + str(time.time() - (3600 * notOlderThanHours))) - def add(key, obj): + if (ti_m < time.time() - (3600 * notOlderThanHours)): + log('removing old filecache') + os.remove(filepath) + return None + + with open(filepath) as json_file: + json_data = json.load(json_file) + log('returning json data from cachefile: ' + key) + return json_data + + return None + + def add(key, obj): log('adding filecacheobject ' + key) json_data = JsonEncoder().encode(obj) # json_data = json.dumps(obj, cls=JsonEncoder, indent=2) with open("./filecache/" + key + ".json", 'w+') as f: - f.write(json_data) \ No newline at end of file + f.write(json_data) diff --git a/haha.bat b/haha.bat new file mode 100644 index 0000000..cea681f --- /dev/null +++ b/haha.bat @@ -0,0 +1,6 @@ +@echo off +:: adjust path below to your install location +call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat" x64 + +:: compile the given file(s) +cl /EHsc /W4 /O2 %* diff --git a/models/location.py b/models/location.py index bf38ac3..be1fa83 100644 --- a/models/location.py +++ b/models/location.py @@ -3,11 +3,13 @@ import enum from json import JSONEncoder import json + class Countrycode(Enum): NL = "NL", DE = "DE", BE = "BE" + class Auctionbrand(str, Enum): NONE = "NONE", TWK = "TWK" @@ -16,7 +18,8 @@ class Auctionbrand(str, Enum): class GeonameLocation: - def __init__(self, geonameid = 0, name = "", asciiname = "", alternatenames = [], latitude = 0, longitude = 0, countrycode:Countrycode = Countrycode.NL, modificationdate = "") : + def __init__(self, geonameid=0, name="", asciiname="", alternatenames=[], latitude=0, longitude=0, + countrycode: Countrycode = Countrycode.NL, modificationdate=""): self.geonameid = geonameid self.name = name self.asciiname = asciiname @@ -26,16 +29,20 @@ class GeonameLocation: self.countrycode = countrycode self.modificationdate = modificationdate + class Maplocation: - def __init__(self, lat = 0, long = 0, numberofauctions = 0, geonamelocation:GeonameLocation = None, auctions = []): + def __init__(self, lat=0, long=0, numberofauctions=0, geonamelocation: GeonameLocation = None, auctions=[]): self.lat = lat self.long = long self.numberofauctions = numberofauctions self.geonamelocation = geonamelocation self.auctions = auctions -class Auction: - def __init__(self, auctionbrand: Auctionbrand = Auctionbrand.NONE, city = "", countrycode:Countrycode = Countrycode.NL, name = "", starttime = None, closingtime = None, url = "", imageurl = "", numberoflots = 0, geonamelocation: GeonameLocation = None, multiplelocations = False): + +class Auction: + def __init__(self, auctionbrand: Auctionbrand = Auctionbrand.NONE, city="", countrycode: Countrycode = Countrycode.NL, name="", + starttime=None, closingtime=None, url="", imageurl="", numberoflots=0, geonamelocation: GeonameLocation = None, + multiplelocations=False): self.city = city self.countrycode = countrycode self.name = name @@ -48,10 +55,11 @@ class Auction: self.brand = auctionbrand self.multiplelocations = multiplelocations + class JsonEncoder(JSONEncoder): # def default(self, o): # return o.__dict__ - #try 2 + # try 2 def default(self, obj): # Only serialize public, instance-level attributes if hasattr(obj, '__dict__'): @@ -76,4 +84,4 @@ class JsonEncoder(JSONEncoder): json.dumps(value) return value except (TypeError, OverflowError): - return str(value) \ No newline at end of file + return str(value) diff --git a/passenger_wsgi.py b/passenger_wsgi.py index 546ad29..ab00b26 100644 --- a/passenger_wsgi.py +++ b/passenger_wsgi.py @@ -1,7 +1,7 @@ from app import app as application import importlib.util + spec = importlib.util.spec_from_file_location("wsgi", "app.py") wsgi = importlib.util.module_from_spec(spec) spec.loader.exec_module(wsgi) - diff --git a/utils/APutils.py b/utils/APutils.py index bfdecb8..7ab135a 100644 --- a/utils/APutils.py +++ b/utils/APutils.py @@ -1,5 +1,3 @@ - - from datetime import datetime import json import re @@ -14,41 +12,41 @@ from utils.helperutils import log def getAPAuctions(): cachename = 'AuctionPort_' res = Cache.get(cachename) - if(res): - return res + if (res): + return res try: - response = requests.get("https://api.auctionport.be/auctions/small?size=100&page=1") + response = requests.get("https://api.auctionport.be/auctions/small?size=100&page=1") except: log("The Auctionport auctions call threw a error") - if(response is None): - return [] - - if(response.status_code ==200): + if (response is None): + return [] + + if (response.status_code == 200): log('Got AP Auctions') try: data = response.json() pages = data['pages'] auctions = [] - for i in range(0,pages-1,1): + for i in range(0, pages - 1, 1): log("getting page " + str(i) + ' of ' + str(pages)) # if(i > 1): response = requests.get("https://api.auctionport.be/auctions/small?size=100&page=" + str(i)) - if(response is None): continue - if(response.status_code != 200): continue + if (response is None): continue + if (response.status_code != 200): continue data = response.json() - + for PAauction in data['data']: - #makes sure that the locations array is filled with at least one location - if PAauction['locations'] == []: + # makes sure that the locations array is filled with at least one location + if PAauction['locations'] == []: PAauction['locations'] = [PAauction['location']] - #makes sure that the auction is still active + # makes sure that the auction is still active closingdate = datetime.fromisoformat(PAauction['closingDate']) - if(closingdate.date() < datetime.now().date() ): continue - if(PAauction['lotCount'] <= 0): continue + if (closingdate.date() < datetime.now().date()): continue + if (PAauction['lotCount'] <= 0): continue multipleLocations = len(PAauction['locations']) > 1 @@ -57,21 +55,25 @@ def getAPAuctions(): loc = re.sub('Nederland', '', location) # loc = location.split(",") postalcodeRegex = r'(.*?)[1-9][0-9]{3} ?(?!sa|sd|ss)[a-zA-Z]{2}' - city = re.sub(postalcodeRegex , '', loc) #removes postalcode and everything in front of it - city = city.strip() #removes whitespace - city = city.strip(',') #removes trailing and leading , - city = city.split(',') #splits on , to overcome not matching regex - city = city[len(city)-1] + city = re.sub(postalcodeRegex, '', loc) # removes postalcode and everything in front of it + city = city.strip() # removes whitespace + city = city.strip(',') # removes trailing and leading , + city = city.split(',') # splits on , to overcome not matching regex + city = city[len(city) - 1] city = city.strip() - newauction = Auction(Auctionbrand.AP,city, Countrycode.NL, PAauction['title'], datetime.fromisoformat(PAauction['openDate']), datetime.fromisoformat(PAauction['closingDate']), '/auction/'+ str(PAauction['id']), PAauction['imageUrl'], PAauction['lotCount'] , None, multipleLocations) + newauction = Auction(Auctionbrand.AP, city, Countrycode.NL, PAauction['title'], + datetime.fromisoformat(PAauction['openDate']), + datetime.fromisoformat(PAauction['closingDate']), 'https://www.auctionport.be/auction/' + str(PAauction['id']), + PAauction['imageUrl'], PAauction['lotCount'], None, multipleLocations) auctions.append(newauction) Cache.add(cachename, auctions) return auctions except Exception as e: - log(e.__cause__ + '-- Something went wrong in the mapping of AP auctions to auctionviewer objects. The reason was: ' + response.reason + '. The response was: ' + JsonEncoder().encode(response.json())) - print_exc(e) - + log(e.__cause__ + '-- Something went wrong in the mapping of AP auctions to auctionviewer objects. The reason was: ' + response.reason + '. The response was: ' + JsonEncoder().encode( + response.json())) + print_exc(e) + else: - log("The AP auctions call didn't gave a 200 response but a " + str(response.status_code) + ". With the reason: " + response.reason) - return [] \ No newline at end of file + log("The AP auctions call didn't gave a 200 response but a " + str(response.status_code) + ". With the reason: " + response.reason) + return [] diff --git a/utils/OVMutils.py b/utils/OVMutils.py index 1f00e60..364a996 100644 --- a/utils/OVMutils.py +++ b/utils/OVMutils.py @@ -8,45 +8,48 @@ from utils.helperutils import log def getOVMAuctions(): cachename = 'OnlineVeiling_' res = Cache.get(cachename) - if(res): - return res + if (res): + return res try: - response = requests.get("https://onlineveilingmeester.nl/rest/nl/veilingen?status=open&domein=ONLINEVEILINGMEESTER") + response = requests.get("https://onlineveilingmeester.nl/rest/nl/veilingen?status=open&domein=ONLINEVEILINGMEESTER") except: log("The OVM auctions call threw a error") - if(response is None): - return [] - - if(response.status_code ==200): + if (response is None): + return [] + + if (response.status_code == 200): log('Got Ovm Auctions') try: - data = response.json() - auctions = [] - for result in data['veilingen']: - cityname ="Nederland" if result['isBezorgVeiling'] else result['afgifteAdres']['plaats'] - cityname = "Nederland" if cityname is None else cityname #there can be auctions where you have to make an appointment to retrieve the lots - startdatetime = result['openingsDatumISO'].replace("T", " ").replace("Z", "") - enddatetime = result['sluitingsDatumISO'].replace("T", " ").replace("Z", "") - image = "" - #if hasattr(result, 'image') : #result['image'] : - image = result.get('image', "") #['image'] - if image == "": - images = result.get('imageList') - if(len(images) >0): - image = images[0] - else: - log("No image found for OVM auction: " + result['naam']) - - a = Auction(Auctionbrand.OVM, cityname,result['land'], result['naam'],startdatetime, enddatetime, str(result['land']).lower() + '/veilingen/' + str(result['id']) + '/kavels', 'images/150x150/' + image, result['totaalKavels'] ) - auctions.append(a) - Cache.add(cachename, auctions) - return auctions + data = response.json() + auctions = [] + for result in data['veilingen']: + cityname = "Nederland" if result['isBezorgVeiling'] else result['afgifteAdres']['plaats'] + cityname = "Nederland" if cityname is None else cityname # there can be auctions where you have to make an appointment to retrieve the lots + startdatetime = result['openingsDatumISO'].replace("T", " ").replace("Z", "") + enddatetime = result['sluitingsDatumISO'].replace("T", " ").replace("Z", "") + image = "" + # if hasattr(result, 'image') : #result['image'] : + image = result.get('image', "") # ['image'] + if image == "": + images = result.get('imageList') + if (len(images) > 0): + image = images[0] + else: + log("No image found for OVM auction: " + result['naam']) + + a = Auction(Auctionbrand.OVM, cityname, result['land'], result['naam'], startdatetime, enddatetime, + 'https://onlineveilingmeester.nl/' + str(result['land']).lower() + '/veilingen/' + str(result['id']) + '/kavels', 'https://onlineveilingmeester.nl/images/150x150/' + image, + result['totaalKavels']) + auctions.append(a) + Cache.add(cachename, auctions) + return auctions except Exception as e: - log(e.__cause__ + '-- Something went wrong in the mapping of OVM auctions to auctionviewer objects. The reason was: ' + response.reason + '. The response was: ' + JsonEncoder().encode(response.json())) - print_exc(e) - + log(e.__cause__ + '-- Something went wrong in the mapping of OVM auctions to auctionviewer objects. The reason was: ' + response.reason + '. The response was: ' + JsonEncoder().encode( + response.json())) + print_exc(e) + else: - log("The OVM auctions call didn't gave a 200 response but a " + str(response.status_code) + ". With the reason: " + response.reason) - return [] \ No newline at end of file + log("The OVM auctions call didn't gave a 200 response but a " + str(response.status_code) + ". With the reason: " + response.reason) + return [] diff --git a/utils/TWKutils.py b/utils/TWKutils.py index b49699e..e30724a 100644 --- a/utils/TWKutils.py +++ b/utils/TWKutils.py @@ -10,72 +10,74 @@ from utils.helperutils import log def getTWKUrl(): response = requests.get('https://www.troostwijkauctions.com/') - if(response.status_code ==200): - buildid = re.search(r'"buildId":"([^"]*)', response.text, re.MULTILINE ) - twkDataUrl = 'https://www.troostwijkauctions.com/_next/data/' + str(buildid[1]) + '/nl/' - log('buildid: ' + str(buildid[1])) - log('twkDataUrl: ' + twkDataUrl) - return twkDataUrl + if (response.status_code == 200): + buildid = re.search(r'"buildId":"([^"]*)', response.text, re.MULTILINE) + twkDataUrl = 'https://www.troostwijkauctions.com/_next/data/' + str(buildid[1]) + '/nl/' + log('buildid: ' + str(buildid[1])) + log('twkDataUrl: ' + twkDataUrl) + return twkDataUrl return None def getTwkAuctions(countrycode): - cachename = 'TwkAuctions_'+ countrycode + cachename = 'TwkAuctions_' + countrycode res = Cache.get(cachename) - if(res): - return res + if (res): + return res # buildidresponse = requests.get('https://www.troostwijkauctions.com/') twkDataUrl = getTWKUrl() - if(twkDataUrl is None): + if (twkDataUrl is None): return [] response = requests.get(twkDataUrl + "auctions.json?countries=" + countrycode) - if(response.status_code ==200): + if (response.status_code == 200): log('Got Twk Auctions') data = response.json() auctions = [] - + totalAuctionCount = data['pageProps']['totalSize']; pages = math.ceil(totalAuctionCount / data['pageProps']['pageSize']) # for result in data['pageProps']['auctionList']: - - for i in range(1,pages,1): - log("getting page " + str(i) + ' of ' + str(pages)) - if(i > 1): - response = requests.get(twkDataUrl + "auctions.json?countries=" + countrycode + "&page=" + str(i)) - data = response.json() - - for twka in data['pageProps']['listData']: - # print(twka['urlSlug']) - auctionlocations = getTWKAuction(twka) - for auction in auctionlocations: - auctions.append(auction) + + for i in range(1, pages, 1): + log("getting page " + str(i) + ' of ' + str(pages)) + if (i > 1): + response = requests.get(twkDataUrl + "auctions.json?countries=" + countrycode + "&page=" + str(i)) + data = response.json() + + for twka in data['pageProps']['listData']: + # print(twka['urlSlug']) + auctionlocations = getTWKAuction(twka) + for auction in auctionlocations: + auctions.append(auction) Cache.add(cachename, auctions) return auctions return [] + def getTWKAuction(twka): locations = [] cities = [] - if(len(twka['collectionDays'])>0): - cities = twka['collectionDays'] - elif(len(twka['deliveryCountries']) >0): - cities = [{ 'countryCode': 'nl', 'city':'Nederland'}] + if (len(twka['collectionDays']) > 0): + cities = twka['collectionDays'] + elif (len(twka['deliveryCountries']) > 0): + cities = [{'countryCode': 'nl', 'city': 'Nederland'}] image = '' - if(len(twka['images']) > 0 and twka['images'][0]['url']): - image = twka['images'][0]['url'] + if (len(twka['images']) > 0 and twka['images'][0]['url']): + image = twka['images'][0]['url'] for city in cities: - if(city['countryCode'].upper() != 'NL'): continue - a = Auction(Auctionbrand.TWK, city['city'], city['countryCode'].upper(), twka['name'], datetime.fromtimestamp(twka['startDate']), datetime.fromtimestamp(twka['minEndDate']), '/a/' + twka['urlSlug'], image, twka['lotCount'], None, len(cities) > 1 ) - locations.append(a) - - return locations \ No newline at end of file + if (city['countryCode'].upper() != 'NL'): continue + a = Auction(Auctionbrand.TWK, city['city'], city['countryCode'].upper(), twka['name'], datetime.fromtimestamp(twka['startDate']), + datetime.fromtimestamp(twka['minEndDate']), 'https://www.troostwijkauctions.com/a/' + twka['urlSlug'], image, twka['lotCount'], None, len(cities) > 1) + locations.append(a) + + return locations diff --git a/utils/__auction_items.py b/utils/__auction_items.py new file mode 100644 index 0000000..3b1cbd8 --- /dev/null +++ b/utils/__auction_items.py @@ -0,0 +1,481 @@ +""" +Utility functions to fetch auction items (lots) from different providers. + +This module defines three functions that make HTTP calls to the public APIs of +Troostwijk Auctions (TWK), AuctionPort (AP) and Online Veilingmeester (OVM) +and normalises their responses into Python dictionaries. Each function +returns a list of dictionaries where each dictionary represents an +individual lot and includes standardised keys: ``title``, ``description``, +``bids`` (the number of bids if available), ``current_bid`` (current price +and currency if available), ``image_url`` and ``end_time``. + +The implementations rely on the ``requests`` library for HTTP transport +and include basic error handling. They raise ``requests.HTTPError`` +when the remote server responds with a non‑200 status code. + +Note: the APIs these functions call are subject to change. Endpoints and +field names may differ depending on the auction status or provider version. +These functions are intended as a starting point for integrating with +multiple auction platforms; you may need to adjust query parameters, +header values or JSON field names if the provider updates their API. + +Examples +-------- +``` +from auction_items import get_items_twk, get_items_ap, get_items_ovm + +# Troostwijk Auctions (TWK): pass the visible auction identifier +lots = get_items_twk(display_id="35563") +for lot in lots: + print(lot['lot_number'], lot['title'], lot['current_bid']) + +# AuctionPort (AP): pass the auction ID from the AuctionPort website +ap_lots = get_items_ap(auction_id=1323) + +# Online Veilingmeester (OVM): the country code is required to build the +# endpoint path (e.g. ``'nederland'`` or ``'belgie'``) along with the +# numeric auction ID. +ovm_lots = get_items_ovm(country="nederland", auction_id=7713) +``` +""" + +from __future__ import annotations + +import json +import logging +from typing import Dict, List, Optional + +import requests + +logger = logging.getLogger(__name__) + +def get_items_twk( + display_id: str, + *, + page: int = 1, + page_size: int = 200, + locale: str = "nl", + platform: str = "WEB", + request_session: Optional[requests.Session] = None, +) -> List[Dict[str, Optional[str]]]: + """Fetch lots (items) for a Troostwijk auction using the GraphQL API. + + Troostwijk Auctions exposes its public data through a GraphQL endpoint at + ``https://storefront.tbauctions.com/storefront/graphql``. The + ``auctionWithLotsV5`` query returns a list of lots for a given auction. + According to the GraphQL documentation, the query accepts a + ``request`` object of type ``AuctionWithLotsInputV3`` and a + ``platform`` argument. The ``request`` object requires the auction's + ``displayId`` (a string identifier visible in the URL of the auction + page), ``locale`` (language code), ``pageNumber``, ``pageSize`` and + two lists for range and value facets. The return type + ``AuctionWithListingLots`` contains an ``auction`` and a list of + ``lots`` with details such as the lot number, title, description, + current bid and images【561575328263299†screenshot】. Fields included in + this function's query correspond to those documented in the schema. + + Parameters + ---------- + display_id: str + The human‑readable identifier of the auction (e.g. ``"35563"``). + page: int, optional + The page number of results (defaults to 1). The API uses + 1‑based page numbering. A page size of 200 appears sufficient + for most auctions. + page_size: int, optional + The maximum number of lots to fetch per page (defaults to 200). + locale: str, optional + Language/locale code for the content (defaults to ``"nl"``). + platform: str, optional + Platform enumeration value required by the API (default + ``"WEB"``). Other values may include ``"B2B"`` or ``"B2C"``; + consult the GraphQL documentation if you encounter an error. + request_session: Optional[requests.Session], optional + An existing requests session to reuse connections. If omitted, + a temporary session is created for this call. + + Returns + ------- + List[Dict[str, Optional[str]]] + A list of dictionaries. Each dictionary represents a lot and + contains the keys ``lot_number``, ``title``, ``description``, + ``bids`` (number of bids, if provided), ``current_bid`` (a + dictionary with ``amount`` and ``currency`` or ``None`` if no bid), + ``image_url`` (first image) and ``end_time`` (auction end time in + ISO 8601 format). + + Raises + ------ + requests.HTTPError + If the HTTP response has a non‑200 status code. + Exception + For other errors such as JSON decoding failures. + """ + session = request_session or requests.Session() + url = "https://storefront.tbauctions.com/storefront/graphql" + # GraphQL query string. The fields selected here mirror those + # described in the GraphQL documentation for the ``auctionWithLotsV5`` + # operation【561575328263299†screenshot】. Additional fields can be added + # if necessary. + graphql_query = """ + query AuctionWithLots($request: AuctionWithLotsInputV3!, $platform: Platform!) { + auctionWithLotsV5(request: $request, platform: $platform) { + lots { + lotNumber + id + title + description + numberOfBids + currentBid { + amount + currency + } + endDateISO + images { + url + } + } + } + } + """ + # Build the variables for the query. The request object must include + # ``displayId``, ``locale``, ``pageNumber``, ``pageSize``, and two empty + # lists for range and value facets as required by the schema【835513158978214†screenshot】. + variables = { + "request": { + "displayId": str(display_id), + "locale": locale, + "pageNumber": page, + "pageSize": page_size, + # These facets are optional; empty lists mean no filters + "rangeFacetInputs": [], + "valueFacetInputs": [], + }, + "platform": platform, + } + + headers = { + # A typical browser may send JSON content; set an Accept header + "Accept": "application/json", + "Content-Type": "application/json", + # The GraphQL service uses a CSRF protection token; a random + # ``x-csrf-token`` header can be supplied if needed. Leaving it + # empty usually works for public queries. + "x-csrf-token": "", + } + + response = session.post( + url, + json={"query": graphql_query, "variables": variables}, + headers=headers, + timeout=30, + ) + + # Raise an HTTPError for non‑200 responses + try: + response.raise_for_status() + except requests.HTTPError: + logger.error("Troostwijk API returned status %s: %s", response.status_code, response.text) + raise + + # Parse the JSON body + data = response.json() + # Check for GraphQL errors + if "errors" in data and data["errors"]: + message = data["errors"] + logger.error("GraphQL returned errors: %s", message) + raise Exception(f"GraphQL returned errors: {message}") + + lots = [] + # Navigate the nested structure to the list of lots. The path + # matches the GraphQL selection set defined above. + try: + lot_items = data["data"]["auctionWithLotsV5"]["lots"] + except (KeyError, TypeError) as e: + logger.error("Unexpected response structure from Troostwijk API: %s", data) + raise Exception(f"Unexpected response structure: {e}") + + for item in lot_items: + # Some fields may be missing; use dict.get with defaults + lot_number = item.get("lotNumber") + title = item.get("title") + description = item.get("description") + bids = item.get("numberOfBids") + current_bid = item.get("currentBid") + end_time = item.get("endDateISO") + images = item.get("images", []) or [] + image_url = images[0]["url"] if images else None + lots.append( + { + "lot_number": lot_number, + "title": title, + "description": description, + "bids": bids, + "current_bid": current_bid, + "image_url": image_url, + "end_time": end_time, + } + ) + + return lots + + +def get_items_ap( + auction_id: int, + *, + request_session: Optional[requests.Session] = None, +) -> List[Dict[str, Optional[str]]]: + """Retrieve items (lots) from an AuctionPort auction. + + AuctionPort operates a JSON API on ``https://api.auctionport.be``. While + official documentation for the lot endpoints is scarce, community code + suggests that auctions can be fetched via ``/auctions/small``【461010206788258†L10-L39】. The + corresponding lot information appears to reside under an + ``/auctions/{id}/lots`` or ``/lots?auctionId={id}`` endpoint (the + platform uses XML internally for some pages as observed when visiting + ``/auctions/{id}/lots`` in a browser). This function attempts to call + these endpoints in order and parse their JSON responses. If the + response is not JSON, it falls back to a simple text scrape looking + for lot numbers, titles, descriptions and current bid amounts. + + Parameters + ---------- + auction_id: int + The numeric identifier of the auction on AuctionPort. + request_session: Optional[requests.Session], optional + An existing requests session. + + Returns + ------- + List[Dict[str, Optional[str]]] + A list of lot dictionaries with the keys ``lot_number``, ``title``, + ``description``, ``bids`` (if available), ``current_bid`` (amount and + currency if provided), ``image_url`` and ``end_time``. If no lots + could be parsed, an empty list is returned. + + Raises + ------ + requests.HTTPError + If both endpoint attempts return non‑200 responses. + """ + session = request_session or requests.Session() + # Candidate endpoints for AuctionPort lots. The first URL follows the + # pattern used by the AuctionPort website; the second is a query by + # parameter. Additional endpoints can be added if discovered. + url_candidates = [ + f"https://api.auctionport.be/auctions/{auction_id}/lots", + f"https://api.auctionport.be/lots?auctionId={auction_id}", + ] + last_error: Optional[Exception] = None + for url in url_candidates: + try: + response = session.get(url, headers={"Accept": "application/json"}, timeout=30) + except Exception as exc: + # Capture connection errors and continue with the next endpoint + last_error = exc + continue + if response.status_code == 404: + # Try the next candidate + continue + if response.status_code >= 400: + last_error = requests.HTTPError( + f"AuctionPort API error {response.status_code} for {url}", + response=response, + ) + continue + # If the response is OK, attempt to parse JSON + try: + data = response.json() + except json.JSONDecodeError: + # Not JSON: fallback to naive parsing of plain text/XML. AuctionPort + # sometimes returns XML for lots pages. We'll attempt to extract + # structured information using simple patterns. + text = response.text + lots: List[Dict[str, Optional[str]]] = [] + # Split by
like markers (not guaranteed). In the + # absence of a stable API specification, heuristics must be used. + # Here we use a very simple split on "Lot " followed by a number. + import re + + pattern = re.compile(r"\bLot\s+(\d+)\b", re.IGNORECASE) + for match in pattern.finditer(text): + lot_number = match.group(1) + # Attempt to extract the title and description following the + # lot number. This heuristic looks for a line break or + # sentence after the lot label; adjust as necessary. + start = match.end() + segment = text[start:start + 300] # arbitrary slice length + # Title is the first sentence or line + title_match = re.search(r"[:\-]\s*(.*?)\.(?=\s|<)", segment) + title = title_match.group(1).strip() if title_match else segment.strip() + lots.append({ + "lot_number": lot_number, + "title": title, + "description": None, + "bids": None, + "current_bid": None, + "image_url": None, + "end_time": None, + }) + if lots: + return lots + else: + # If no lots were extracted, continue to the next candidate + last_error = Exception("Unable to parse AuctionPort lots from non‑JSON response") + continue + # If JSON parsing succeeded, inspect the structure. Some endpoints + # return a top‑level object with a ``data`` field containing a list. + lots: List[Dict[str, Optional[str]]] = [] + # Attempt to locate the list of lots: it might be in ``data``, ``lots`` or + # another property. + candidate_keys = ["lots", "data", "items"] + lot_list: Optional[List[Dict[str, any]]] = None + for key in candidate_keys: + if isinstance(data, dict) and isinstance(data.get(key), list): + lot_list = data[key] + break + # If the response is a list itself (not a dict), treat it as the lot list + if lot_list is None and isinstance(data, list): + lot_list = data + if lot_list is None: + # Unknown structure; return empty list + return [] + for item in lot_list: + # Map fields according to common names; adjust if your endpoint + # uses different property names. Use dict.get to avoid KeyError. + lot_number = item.get("lotNumber") or item.get("lotnumber") or item.get("id") + title = item.get("title") or item.get("naam") + description = item.get("description") or item.get("beschrijving") + bids = item.get("numberOfBids") or item.get("bidCount") + # Determine current bid: AuctionPort might provide ``currentBid`` or + # ``currentPrice`` as an object or numeric value. + current_bid_obj = item.get("currentBid") or item.get("currentPrice") + current_bid: Optional[Dict[str, any]] = None + if isinstance(current_bid_obj, dict): + current_bid = { + "amount": current_bid_obj.get("amount"), + "currency": current_bid_obj.get("currency"), + } + elif current_bid_obj is not None: + current_bid = {"amount": current_bid_obj, "currency": None} + # Image + image_url = None + if isinstance(item.get("images"), list) and item["images"]: + image_url = item["images"][0].get("url") + elif isinstance(item.get("image"), str): + image_url = item.get("image") + # End time + end_time = item.get("endDateISO") or item.get("closingDateISO") or item.get("closingDate") + lots.append( + { + "lot_number": lot_number, + "title": title, + "description": description, + "bids": bids, + "current_bid": current_bid, + "image_url": image_url, + "end_time": end_time, + } + ) + return lots + + # All candidates failed + if last_error: + raise last_error + raise requests.HTTPError(f"Could not fetch lots for AuctionPort auction {auction_id}") + + +def get_items_ovm( + country: str, + auction_id: int, + *, + request_session: Optional[requests.Session] = None, +) -> List[Dict[str, Optional[str]]]: + """Fetch lots from an Online Veilingmeester auction. + + Online Veilingmeester's REST API exposes auctions and their lots via + endpoints under ``https://onlineveilingmeester.nl/rest/nl``. The + AuctionViewer project's source code constructs lot URLs as + ``{land}/veilingen/{id}/kavels``, where ``land`` is the lower‑cased + country name (e.g. ``nederland`` or ``belgie``)【366543684390870†L13-L50】. + Therefore, the full path for retrieving the lots of a specific auction + is ``https://onlineveilingmeester.nl/rest/nl/{country}/veilingen/{auction_id}/kavels``. + + Parameters + ---------- + country: str + Lower‑case country name used in the API path (for example + ``"nederland"`` or ``"belgie"``). The value should correspond to + the ``land`` property returned by the OVM auctions endpoint【366543684390870†L13-L50】. + auction_id: int + The numeric identifier of the auction. + request_session: Optional[requests.Session], optional + A ``requests`` session to reuse connections. + + Returns + ------- + List[Dict[str, Optional[str]]] + A list of lot dictionaries with keys ``lot_number``, ``title``, + ``description``, ``bids``, ``current_bid`` (if available), + ``image_url`` and ``end_time``. If the endpoint returns no items, + an empty list is returned. + + Raises + ------ + requests.HTTPError + If the HTTP call returns a non‑200 response. + Exception + If the response cannot be decoded as JSON. + """ + session = request_session or requests.Session() + base_url = "https://onlineveilingmeester.nl/rest/nl" + url = f"{base_url}/{country}/veilingen/{auction_id}/kavels" + response = session.get(url, headers={"Accept": "application/json"}, timeout=30) + try: + response.raise_for_status() + except requests.HTTPError: + logger.error("OVM API returned status %s: %s", response.status_code, response.text) + raise + # Parse the JSON body; expect a list of lots + data = response.json() + lots: List[Dict[str, Optional[str]]] = [] + # The response may be a dictionary containing a ``kavels`` key or a list + if isinstance(data, dict) and isinstance(data.get("kavels"), list): + lot_list = data["kavels"] + elif isinstance(data, list): + lot_list = data + else: + logger.error("Unexpected response structure from OVM API: %s", data) + raise Exception("Unexpected response structure for OVM lots") + for item in lot_list: + lot_number = item.get("kavelnummer") or item.get("lotNumber") or item.get("id") + title = item.get("naam") or item.get("title") + description = item.get("beschrijving") or item.get("description") + bids = item.get("aantalBiedingen") or item.get("numberOfBids") + # Current bid is nested in ``hoogsteBod`` or ``currentBid`` + current_bid_obj = item.get("hoogsteBod") or item.get("currentBid") + current_bid: Optional[Dict[str, any]] = None + if isinstance(current_bid_obj, dict): + current_bid = { + "amount": current_bid_obj.get("bodBedrag") or current_bid_obj.get("amount"), + "currency": current_bid_obj.get("valuta") or current_bid_obj.get("currency"), + } + image_url = None + # OVM may provide a list of image URLs under ``afbeeldingen`` or ``images`` + if isinstance(item.get("afbeeldingen"), list) and item["afbeeldingen"]: + image_url = item["afbeeldingen"][0] + elif isinstance(item.get("images"), list) and item["images"]: + image_url = item["images"][0].get("url") if isinstance(item["images"][0], dict) else item["images"][0] + end_time = item.get("eindDatumISO") or item.get("endDateISO") or item.get("eindDatum") + lots.append( + { + "lot_number": lot_number, + "title": title, + "description": description, + "bids": bids, + "current_bid": current_bid, + "image_url": image_url, + "end_time": end_time, + } + ) + return lots diff --git a/utils/auction_items.py b/utils/auction_items.py new file mode 100644 index 0000000..d8e7b62 --- /dev/null +++ b/utils/auction_items.py @@ -0,0 +1,481 @@ +""" +Utility functions to fetch auction items (lots) from different providers. + +This module defines three functions that make HTTP calls to the public APIs of +Troostwijk Auctions (TWK), AuctionPort (AP) and Online Veilingmeester (OVM) +and normalises their responses into Python dictionaries. Each function +returns a list of dictionaries where each dictionary represents an +individual lot and includes standardised keys: ``title``, ``description``, +``bids`` (the number of bids if available), ``current_bid`` (current price +and currency if available), ``image_url`` and ``end_time``. + +The implementations rely on the ``requests`` library for HTTP transport +and include basic error handling. They raise ``requests.HTTPError`` +when the remote server responds with a non‑200 status code. + +Note: the APIs these functions call are subject to change. Endpoints and +field names may differ depending on the auction status or provider version. +These functions are intended as a starting point for integrating with +multiple auction platforms; you may need to adjust query parameters, +header values or JSON field names if the provider updates their API. + +Examples +-------- +``` +from auction_items import get_items_twk, get_items_ap, get_items_ovm + +# Troostwijk Auctions (TWK): pass the visible auction identifier +lots = get_items_twk(display_id="35563") +for lot in lots: + print(lot['lot_number'], lot['title'], lot['current_bid']) + +# AuctionPort (AP): pass the auction ID from the AuctionPort website +ap_lots = get_items_ap(auction_id=1323) + +# Online Veilingmeester (OVM): the country code is required to build the +# endpoint path (e.g. ``'nederland'`` or ``'belgie'``) along with the +# numeric auction ID. +ovm_lots = get_items_ovm(country="nederland", auction_id=7713) +``` +""" + +from __future__ import annotations + +import json +import logging +from typing import Dict, List, Optional + +import requests + +logger = logging.getLogger(__name__) + +def get_items_twk( + display_id: str, + *, + page: int = 1, + page_size: int = 200, + locale: str = "nl", + sort_by: str = "LOT_NUMBER_ASC", + platform: str = "TWK", + request_session: Optional[requests.Session] = None, +) -> List[Dict[str, Optional[str]]]: + """Fetch lots (items) for a Troostwijk auction using the current GraphQL API. + + Troostwijk’s public GraphQL API exposes a ``lotsByAuctionDisplayId`` query + that returns the lots belonging to a specific auction. The input + ``LotsByAuctionDisplayIdInput`` requires an ``auctionDisplayId``, + ``locale``, ``pageNumber``, ``pageSize`` and a non‑null ``sortBy`` + parameter【566587544277629†screenshot】. The platform is provided as the + ``Platform`` enum value and must be set to ``"TWK"`` for Troostwijk + auctions【622367855745945†screenshot】. + + Each result in the ``results`` list is a ``ListingLot`` object that + includes fields such as ``number`` (the lot/kavel number), ``title``, + ``description``, ``bidsCount``, ``currentBidAmount`` (Money object with + ``cents`` and ``currency``), ``endDate`` (a scalar ``TbaDate``) and + ``image`` with a ``url`` property【819766814773156†screenshot】. This + function builds a query that selects these fields and converts them + into a normalised Python dictionary per lot. + + Parameters + ---------- + display_id: str + The auction display identifier (e.g., the number visible in the + auction URL). + page: int, optional + The page number to retrieve (1‑based). Defaults to 1. + page_size: int, optional + The number of lots per page. Defaults to 200. + locale: str, optional + Language code for the returned content (default ``"nl"``). + sort_by: str, optional + Sorting option from the ``LotsSortingOption`` enum (for example + ``"LOT_NUMBER_ASC"`` or ``"END_DATE_DESC"``)【566587544277629†screenshot】. + Defaults to ``"LOT_NUMBER_ASC"``. + platform: str, optional + Platform code as defined in the ``Platform`` enum. Use ``"TWK"`` for + Troostwijk auctions【622367855745945†screenshot】. + request_session: Optional[requests.Session], optional + Optional session object for connection pooling. + + Returns + ------- + List[Dict[str, Optional[str]]] + A list of dictionaries representing lots. Each dictionary + contains the keys ``lot_number``, ``title``, ``description``, + ``bids`` (number of bids), ``current_bid`` (amount in major units + and currency), ``image_url`` and ``end_time``. + + Raises + ------ + requests.HTTPError + If the HTTP call does not return a 2xx status code. + Exception + If GraphQL returns errors or the response structure is unexpected. + """ + session = request_session or requests.Session() + url = "https://storefront.tbauctions.com/storefront/graphql" + # GraphQL query for lots by auction display ID. We request the + # ListingLots wrapper (hasNext, pageNumber etc.) and select relevant + # fields from each ListingLot【819766814773156†screenshot】. The money + # amounts are represented in cents; we convert them to major units. + graphql_query = """ + query LotsByAuctionDisplayId($request: LotsByAuctionDisplayIdInput!, $platform: Platform!) { + lotsByAuctionDisplayId(request: $request, platform: $platform) { + pageNumber + pageSize + totalSize + hasNext + results { + number + title + description + bidsCount + currentBidAmount { + cents + currency + } + endDate + image { + url + } + } + } + } + """ + variables = { + "request": { + "auctionDisplayId": str(display_id), + "locale": locale, + "pageNumber": page, + "pageSize": page_size, + "sortBy": sort_by, + }, + "platform": platform, + } + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + # Use GET request because the GraphQL endpoint does not accept POST + # from unauthenticated clients. We encode the query and variables as + # query parameters. + try: + response = session.get( + url, + params={"query": graphql_query, "variables": json.dumps(variables)}, + headers=headers, + timeout=30, + ) + except Exception as exc: + logger.error("Error contacting Troostwijk GraphQL endpoint: %s", exc) + raise + try: + response.raise_for_status() + except requests.HTTPError: + logger.error("Troostwijk API returned status %s: %s", response.status_code, response.text) + raise + data = response.json() + if "errors" in data and data["errors"]: + message = data["errors"] + logger.error("GraphQL returned errors: %s", message) + raise Exception(f"GraphQL returned errors: {message}") + # Extract the list of lots + try: + lot_items = data["data"]["lotsByAuctionDisplayId"]["results"] + except (KeyError, TypeError) as e: + logger.error("Unexpected response structure from Troostwijk API: %s", data) + raise Exception(f"Unexpected response structure: {e}") + lots: List[Dict[str, Optional[str]]] = [] + for item in lot_items: + lot_number = item.get("number") + title = item.get("title") + description = item.get("description") + bids = item.get("bidsCount") + bid_amount = item.get("currentBidAmount") or {} + # Convert cents to major units if available + current_bid = None + if bid_amount and isinstance(bid_amount, dict): + cents = bid_amount.get("cents") + currency = bid_amount.get("currency") + if cents is not None: + current_bid = { + "amount": cents / 100.0, + "currency": currency, + } + end_time = item.get("endDate") + image_obj = item.get("image") or {} + image_url = image_obj.get("url") if isinstance(image_obj, dict) else None + lots.append( + { + "lot_number": lot_number, + "title": title, + "description": description, + "bids": bids, + "current_bid": current_bid, + "image_url": image_url, + "end_time": end_time, + } + ) + return lots + + +def get_items_ap( + auction_id: int, + *, + request_session: Optional[requests.Session] = None, +) -> List[Dict[str, Optional[str]]]: + """Retrieve items (lots) from an AuctionPort auction. + + AuctionPort operates a JSON API on ``https://api.auctionport.be``. While + official documentation for the lot endpoints is scarce, community code + suggests that auctions can be fetched via ``/auctions/small``【461010206788258†L10-L39】. The + corresponding lot information appears to reside under an + ``/auctions/{id}/lots`` or ``/lots?auctionId={id}`` endpoint (the + platform uses XML internally for some pages as observed when visiting + ``/auctions/{id}/lots`` in a browser). This function attempts to call + these endpoints in order and parse their JSON responses. If the + response is not JSON, it falls back to a simple text scrape looking + for lot numbers, titles, descriptions and current bid amounts. + + Parameters + ---------- + auction_id: int + The numeric identifier of the auction on AuctionPort. + request_session: Optional[requests.Session], optional + An existing requests session. + + Returns + ------- + List[Dict[str, Optional[str]]] + A list of lot dictionaries with the keys ``lot_number``, ``title``, + ``description``, ``bids`` (if available), ``current_bid`` (amount and + currency if provided), ``image_url`` and ``end_time``. If no lots + could be parsed, an empty list is returned. + + Raises + ------ + requests.HTTPError + If both endpoint attempts return non‑200 responses. + """ + session = request_session or requests.Session() + # Candidate endpoints for AuctionPort lots. The first URL follows the + # pattern used by the AuctionPort website; the second is a query by + # parameter. Additional endpoints can be added if discovered. + url_candidates = [ + f"https://api.auctionport.be/auctions/{auction_id}/lots", + f"https://api.auctionport.be/lots?auctionId={auction_id}", + ] + last_error: Optional[Exception] = None + for url in url_candidates: + try: + response = session.get(url, headers={"Accept": "application/json"}, timeout=30) + except Exception as exc: + # Capture connection errors and continue with the next endpoint + last_error = exc + continue + if response.status_code == 404: + # Try the next candidate + continue + if response.status_code >= 400: + last_error = requests.HTTPError( + f"AuctionPort API error {response.status_code} for {url}", + response=response, + ) + continue + # If the response is OK, attempt to parse JSON + try: + data = response.json() + except json.JSONDecodeError: + # Not JSON: fallback to naive parsing of plain text/XML. AuctionPort + # sometimes returns XML for lots pages. We'll attempt to extract + # structured information using simple patterns. + text = response.text + lots: List[Dict[str, Optional[str]]] = [] + # Split by
like markers (not guaranteed). In the + # absence of a stable API specification, heuristics must be used. + # Here we use a very simple split on "Lot " followed by a number. + import re + + pattern = re.compile(r"\bLot\s+(\d+)\b", re.IGNORECASE) + for match in pattern.finditer(text): + lot_number = match.group(1) + # Attempt to extract the title and description following the + # lot number. This heuristic looks for a line break or + # sentence after the lot label; adjust as necessary. + start = match.end() + segment = text[start:start + 300] # arbitrary slice length + # Title is the first sentence or line + title_match = re.search(r"[:\-]\s*(.*?)\.(?=\s|<)", segment) + title = title_match.group(1).strip() if title_match else segment.strip() + lots.append({ + "lot_number": lot_number, + "title": title, + "description": None, + "bids": None, + "current_bid": None, + "image_url": None, + "end_time": None, + }) + if lots: + return lots + else: + # If no lots were extracted, continue to the next candidate + last_error = Exception("Unable to parse AuctionPort lots from non‑JSON response") + continue + # If JSON parsing succeeded, inspect the structure. Some endpoints + # return a top‑level object with a ``data`` field containing a list. + lots: List[Dict[str, Optional[str]]] = [] + # Attempt to locate the list of lots: it might be in ``data``, ``lots`` or + # another property. + candidate_keys = ["lots", "data", "items"] + lot_list: Optional[List[Dict[str, any]]] = None + for key in candidate_keys: + if isinstance(data, dict) and isinstance(data.get(key), list): + lot_list = data[key] + break + # If the response is a list itself (not a dict), treat it as the lot list + if lot_list is None and isinstance(data, list): + lot_list = data + if lot_list is None: + # Unknown structure; return empty list + return [] + for item in lot_list: + # Map fields according to common names; adjust if your endpoint + # uses different property names. Use dict.get to avoid KeyError. + lot_number = item.get("lotNumber") or item.get("lotnumber") or item.get("id") + title = item.get("title") or item.get("naam") + description = item.get("description") or item.get("beschrijving") + bids = item.get("numberOfBids") or item.get("bidCount") + # Determine current bid: AuctionPort might provide ``currentBid`` or + # ``currentPrice`` as an object or numeric value. + current_bid_obj = item.get("currentBid") or item.get("currentPrice") + current_bid: Optional[Dict[str, any]] = None + if isinstance(current_bid_obj, dict): + current_bid = { + "amount": current_bid_obj.get("amount"), + "currency": current_bid_obj.get("currency"), + } + elif current_bid_obj is not None: + current_bid = {"amount": current_bid_obj, "currency": None} + # Image + image_url = None + if isinstance(item.get("images"), list) and item["images"]: + image_url = item["images"][0].get("url") + elif isinstance(item.get("image"), str): + image_url = item.get("image") + # End time + end_time = item.get("endDateISO") or item.get("closingDateISO") or item.get("closingDate") + lots.append( + { + "lot_number": lot_number, + "title": title, + "description": description, + "bids": bids, + "current_bid": current_bid, + "image_url": image_url, + "end_time": end_time, + } + ) + return lots + + # All candidates failed + if last_error: + raise last_error + raise requests.HTTPError(f"Could not fetch lots for AuctionPort auction {auction_id}") + + +def get_items_ovm( + country: str, + auction_id: int, + *, + request_session: Optional[requests.Session] = None, +) -> List[Dict[str, Optional[str]]]: + """Fetch lots from an Online Veilingmeester auction. + + Online Veilingmeester's REST API exposes auctions and their lots via + endpoints under ``https://onlineveilingmeester.nl/rest/nl``. The + AuctionViewer project's source code constructs lot URLs as + ``{land}/veilingen/{id}/kavels``, where ``land`` is the lower‑cased + country name (e.g. ``nederland`` or ``belgie``)【366543684390870†L13-L50】. + Therefore, the full path for retrieving the lots of a specific auction + is ``https://onlineveilingmeester.nl/rest/nl/{country}/veilingen/{auction_id}/kavels``. + + Parameters + ---------- + country: str + Lower‑case country name used in the API path (for example + ``"nederland"`` or ``"belgie"``). The value should correspond to + the ``land`` property returned by the OVM auctions endpoint【366543684390870†L13-L50】. + auction_id: int + The numeric identifier of the auction. + request_session: Optional[requests.Session], optional + A ``requests`` session to reuse connections. + + Returns + ------- + List[Dict[str, Optional[str]]] + A list of lot dictionaries with keys ``lot_number``, ``title``, + ``description``, ``bids``, ``current_bid`` (if available), + ``image_url`` and ``end_time``. If the endpoint returns no items, + an empty list is returned. + + Raises + ------ + requests.HTTPError + If the HTTP call returns a non‑200 response. + Exception + If the response cannot be decoded as JSON. + """ + session = request_session or requests.Session() + base_url = "https://onlineveilingmeester.nl/rest/nl" + url = f"{base_url}/{country}/veilingen/{auction_id}/kavels" + response = session.get(url, headers={"Accept": "application/json"}, timeout=30) + try: + response.raise_for_status() + except requests.HTTPError: + logger.error("OVM API returned status %s: %s", response.status_code, response.text) + raise + # Parse the JSON body; expect a list of lots + data = response.json() + lots: List[Dict[str, Optional[str]]] = [] + # The response may be a dictionary containing a ``kavels`` key or a list + if isinstance(data, dict) and isinstance(data.get("kavels"), list): + lot_list = data["kavels"] + elif isinstance(data, list): + lot_list = data + else: + logger.error("Unexpected response structure from OVM API: %s", data) + raise Exception("Unexpected response structure for OVM lots") + for item in lot_list: + lot_number = item.get("kavelnummer") or item.get("lotNumber") or item.get("id") + title = item.get("naam") or item.get("title") + description = item.get("beschrijving") or item.get("description") + bids = item.get("aantalBiedingen") or item.get("numberOfBids") + # Current bid is nested in ``hoogsteBod`` or ``currentBid`` + current_bid_obj = item.get("hoogsteBod") or item.get("currentBid") + current_bid: Optional[Dict[str, any]] = None + if isinstance(current_bid_obj, dict): + current_bid = { + "amount": current_bid_obj.get("bodBedrag") or current_bid_obj.get("amount"), + "currency": current_bid_obj.get("valuta") or current_bid_obj.get("currency"), + } + image_url = None + # OVM may provide a list of image URLs under ``afbeeldingen`` or ``images`` + if isinstance(item.get("afbeeldingen"), list) and item["afbeeldingen"]: + image_url = item["afbeeldingen"][0] + elif isinstance(item.get("images"), list) and item["images"]: + image_url = item["images"][0].get("url") if isinstance(item["images"][0], dict) else item["images"][0] + end_time = item.get("eindDatumISO") or item.get("endDateISO") or item.get("eindDatum") + lots.append( + { + "lot_number": lot_number, + "title": title, + "description": description, + "bids": bids, + "current_bid": current_bid, + "image_url": image_url, + "end_time": end_time, + } + ) + return lots diff --git a/utils/auctionutils.py b/utils/auctionutils.py index 87abd99..e122130 100644 --- a/utils/auctionutils.py +++ b/utils/auctionutils.py @@ -9,80 +9,80 @@ from utils.locationutils import getGeoLocationByCity from utils.helperutils import log from datetime import datetime -def getAuctionlocations(countrycode: Countrycode, clearcache:bool = False): + +def getAuctionlocations(countrycode: Countrycode, clearcache: bool = False): cachename = 'allauctions_' + countrycode log("should clear chache with cachename: " + str(clearcache) + ", " + cachename) - if(clearcache): + if (clearcache): res = FileCache.get(cachename, 1) else: - res = FileCache.get(cachename) + res = FileCache.get(cachename) + + if (res): + return res - if(res): - return res - twkauctions = [] ovmauctions = [] apauctions = [] try: twkauctions = getTwkAuctions(countrycode) - except Exception as e: + except Exception as e: log('something went wrong while running the twk auctions request') print_exc(e) - + try: - ovmauctions = getOVMAuctions() - except Exception as e: + ovmauctions = getOVMAuctions() + except Exception as e: log('something went wrong while running the OVM auctions request') print_exc(e) try: - apauctions = getAPAuctions() - except Exception as e: + apauctions = getAPAuctions() + except Exception as e: log('something went wrong while running the OVM auctions request') print_exc(e) - auctions = [*twkauctions, *ovmauctions, *apauctions] - #filters all auctions for this geonameid - auctions = list(filter(lambda a: a.numberoflots > 0 , auctions)) - + # filters all auctions for this geonameid + auctions = list(filter(lambda a: a.numberoflots > 0, auctions)) + for auction in auctions: auction.geonamelocation = getGeoLocationByCity(auction.city, countrycode) -#filters all auctions for this geonameid - auctions = list(filter(lambda a: a.numberoflots > 0 , auctions)) - + # filters all auctions for this geonameid + auctions = list(filter(lambda a: a.numberoflots > 0, auctions)) + geonameids = map(get_geonameid, auctions) uniquegeonameids = (list(set(geonameids))) maplocations = [] - #loops through the uniques geonameids + # loops through the uniques geonameids for geoid in uniquegeonameids: - - #filters all auctions for this geonameid - geoauctions = list(filter(lambda a: get_geonameid(a) == geoid , auctions)) - if(geoauctions): + + # filters all auctions for this geonameid + geoauctions = list(filter(lambda a: get_geonameid(a) == geoid, auctions)) + if (geoauctions): geoauctions = list({object_.url: object_ for object_ in geoauctions}.values()) - #gets the location (if it has any) for the geolocation + # gets the location (if it has any) for the geolocation location = geoauctions[0].geonamelocation - if(location): + if (location): maplocation = Maplocation(location.latitude, location.longitude, len(geoauctions), location, geoauctions) maplocations.append(maplocation) for location in maplocations: - del location.geonamelocation #removes object which is not used anymore + del location.geonamelocation # removes object which is not used anymore for auction in location.auctions: - del auction.geonamelocation #removes object to not have duplicate data send to the server + del auction.geonamelocation # removes object to not have duplicate data send to the server FileCache.add(cachename, maplocations) return maplocations + def get_geonameid(auction): - if(auction.geonamelocation): + if (auction.geonamelocation): return auction.geonamelocation.geonameid return None - diff --git a/utils/helperutils.py b/utils/helperutils.py index 33ed66b..54f3f4a 100644 --- a/utils/helperutils.py +++ b/utils/helperutils.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta + def log(value): value = value.encode('ascii', errors='ignore') value = value.decode() diff --git a/utils/lots.py b/utils/lots.py new file mode 100644 index 0000000..8ad7cf5 --- /dev/null +++ b/utils/lots.py @@ -0,0 +1,6 @@ +from auction_items import get_items_twk + +# Replace "35563" with a real auction ID from troostwijkauctions.com +lots = get_items_twk(display_id="35563") +for lot in lots: + print(lot['lot_number'], lot['title'], lot['current_bid']) \ No newline at end of file diff --git a/web/auctionviewer (1).html b/web/auctionviewer (1).html new file mode 100644 index 0000000..1f6fc51 --- /dev/null +++ b/web/auctionviewer (1).html @@ -0,0 +1,160 @@ + + + + + + Auctionviewer – Map + + + + + +
Loading map library...
+
+
+ + + + + + + \ No newline at end of file diff --git a/web/leaflet.css b/web/leaflet.css new file mode 100644 index 0000000..ea8c2f7 --- /dev/null +++ b/web/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } \ No newline at end of file diff --git a/web/leaflet.js b/web/leaflet.js new file mode 100644 index 0000000..8dad76f --- /dev/null +++ b/web/leaflet.js @@ -0,0 +1,5 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0 + + + + Title + + + + + \ No newline at end of file