ebird-api-data 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ import datetime as dt
2
+ import json
3
+ import logging
4
+ import random
5
+ import re
6
+ import socket
7
+ import string
8
+ import time
9
+
10
+ from decimal import Decimal
11
+ from typing import List, Optional
12
+ from urllib.error import HTTPError, URLError
13
+
14
+ from django.db import transaction
15
+ from django.utils.timezone import get_default_timezone
16
+
17
+ import requests
18
+
19
+ from bs4 import BeautifulSoup
20
+ from ebird.api.requests import get_checklist, get_regions, get_taxonomy, get_visits
21
+ from ebird.api.requests.constants import API_MAX_RESULTS
22
+
23
+ from .models import (
24
+ Checklist,
25
+ Country,
26
+ County,
27
+ Filter,
28
+ Location,
29
+ Observation,
30
+ Observer,
31
+ Species,
32
+ State,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Set timeout, in seconds, for SSL socket connections
38
+ socket.setdefaulttimeout(30)
39
+ # Total number number of retries to attempt
40
+ RETRY_LIMIT: int = 10
41
+ # Time, in seconds, to wait after an API call fails
42
+ RETRY_WAIT: int = 2
43
+ # Multiplier to apply to wait time after each failed attempt
44
+ RETRY_MULTIPLIER: float = 2.0
45
+
46
+
47
+ def str2datetime(value: str) -> dt.datetime:
48
+ return dt.datetime.fromisoformat(value).replace(tzinfo=get_default_timezone())
49
+
50
+
51
+ def random_word(length):
52
+ letters = string.ascii_lowercase
53
+ return "".join(random.choice(letters) for i in range(length))
54
+
55
+
56
+ class APILoader:
57
+ """
58
+ The APILoader downloads checklists from the eBird API and saves
59
+ them to the database.
60
+
61
+ Arguments:
62
+
63
+ api_key: Your key to access the eBird API.
64
+ Your can request a key at https://ebird.org/data/download.
65
+ You will need an eBird account to do so.
66
+
67
+ locales: A map of Django language codes to eBird locales so the species
68
+ common name, family name, etc. is displayed in the language
69
+ selected by the user.
70
+
71
+ limit: The total number of retries that can be attempted when calling
72
+ the API or scraping a web page. Defaults to RETRY_LIMIT if not
73
+ set.
74
+
75
+ wait: The initial number of seconds to wait. Defaults to RETRY_WAIT
76
+ if not set.
77
+
78
+ multiplier: The multiplier to apply to the wait time after each retry.
79
+ Defaults to RETRY_MULTIPLIER if not set.
80
+
81
+ The eBird API limits the number of records returned to 200. When downloading
82
+ the visits for a given region if 200 hundred records are returned then it is
83
+ assumed there are more and the loader will fetch the sub-regions and download
84
+ the visits for each, repeating the process if necessary. To give an extreme
85
+ example if you download the visits for the United States, "US", then the API
86
+ will always return 200 results and the loader then download the visits to
87
+ each of the 50 states and then each of the 3143 counties. DON'T TRY THIS
88
+ AT HOME. Even if you don't get banned, if you melt the eBird servers, then
89
+ karma will ensure bad things happen to you.
90
+
91
+ The loader uses a budget for retry attempts when calling the eBird API or
92
+ fetching a web page. After each failure, a wait is applied, which increases
93
+ after each attempt. Once the total number of retries is reached, no
94
+ further attempts are made. You can set the number of retries, the initial
95
+ wait, and the multiplier when creating an APILoader object, otherwise
96
+ sensible defaults are used.
97
+
98
+ The default limit for retries is 10, with an initial wait of 2 seconds and
99
+ a multiplier also of 2. This means the loader will wait, 2, 4, 8, 16, etc.
100
+ seconds after each attempt. Since the loader will be run periodically,
101
+ using a scheduler such as cron, the retry limit is probably too high. Most
102
+ of the errors seen to date are timeout error when creating an SSL socket
103
+ connection. The are relatively rare - only one or two a week - with a
104
+ loader that is scheduled to run every hour. You could easily reduce the
105
+ limit to 3, as then, successive errors will usually mean something serious
106
+ is wrong with the network, or the eBird servers are overloaded, and you
107
+ should stop from making the situation any worse.
108
+
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ api_key: str,
114
+ locales: dict,
115
+ limit: int = None,
116
+ wait: int = None,
117
+ multiplier: float = None,
118
+ ):
119
+ self.api_key: str = api_key
120
+ self.locales: dict = locales
121
+ self.retries: int = 0
122
+ self.retry_limit: int = limit if limit else RETRY_LIMIT
123
+ self.retry_wait: int = wait if wait else RETRY_WAIT
124
+ self.retry_multiplier: float = multiplier if multiplier else RETRY_MULTIPLIER
125
+
126
+ def call(self, func, *args, **kwargs):
127
+ wait: float = float(self.retry_wait)
128
+ while True:
129
+ try:
130
+ return func(*args, **kwargs)
131
+ except (URLError, HTTPError) as err:
132
+ logger.exception("Failed call #%d", self.retries)
133
+ self.retries += 1
134
+ if self.retries > self.retry_limit:
135
+ logger.exception("Retry limit reached")
136
+ raise err
137
+ time.sleep(wait)
138
+ wait *= self.retry_multiplier
139
+
140
+ def call_api(self, func, *args, **kwargs) -> dict | list:
141
+ return self.call(func, self.api_key, *args, **kwargs)
142
+
143
+ @staticmethod
144
+ def get_country(data: dict) -> Country:
145
+ code: str = data["countryCode"]
146
+ values: dict = {
147
+ "name": data["countryName"],
148
+ "place": data["countryName"],
149
+ }
150
+ country, created = Country.objects.get_or_create(code=code, defaults=values)
151
+ if created:
152
+ logger.info("Added country: %s, %s", code, values["name"])
153
+ return country
154
+
155
+ @staticmethod
156
+ def get_state(data: dict) -> State:
157
+ code: str = data["subnational1Code"]
158
+ values: dict = {
159
+ "name": data["subnational1Name"],
160
+ "place": "%s, %s" % (data["subnational1Name"], data["countryName"]),
161
+ }
162
+ state, created = State.objects.get_or_create(code=code, defaults=values)
163
+ if created:
164
+ logger.info("Added state: %s, %s", code, values["name"])
165
+ return state
166
+
167
+ @staticmethod
168
+ def get_county(data) -> County:
169
+ code: str = data["subnational2Code"]
170
+ values: dict = {
171
+ "name": data["subnational2Name"],
172
+ "place": "%s, %s, %s"
173
+ % (data["subnational2Name"], data["subnational1Name"], data["countryName"]),
174
+ }
175
+ county, created = County.objects.get_or_create(code=code, defaults=values)
176
+ if created:
177
+ logger.info("Added county: %s, %s", code, values["name"])
178
+ return county
179
+
180
+ def add_location(self, data: dict) -> Location:
181
+ identifier: str = data["locId"]
182
+ location: Location
183
+
184
+ values: dict = {
185
+ "identifier": identifier,
186
+ "name": data["name"],
187
+ "original": data["name"],
188
+ "country": self.get_country(data),
189
+ "state": self.get_state(data),
190
+ "county": None,
191
+ "hotspot": data["isHotspot"],
192
+ "latitude": Decimal(data["latitude"]),
193
+ "longitude": Decimal(data["longitude"]),
194
+ "url": "https://ebird.org/region/%s" % identifier,
195
+ "data": {"api": data},
196
+ }
197
+
198
+ if "subnational2Code" in data:
199
+ values["county"] = self.get_county(data)
200
+
201
+ location = Location.objects.create(**values)
202
+ logger.info("Added location: %s, %s", identifier, location.name)
203
+ return location
204
+
205
+ def add_species(self, code: str) -> Species:
206
+ """
207
+ Add the species with the eBird code.
208
+
209
+ Arguments:
210
+ code: the eBird code for the species, e.g. 'horlar' (Horned Lark).
211
+
212
+ """
213
+ values: dict = {
214
+ "common_name": {},
215
+ "family_common_name": {},
216
+ "data": {"api": {}},
217
+ }
218
+
219
+ for language, locale in self.locales.items():
220
+ data = self.call_api(get_taxonomy, locale=locale, species=code)[0]
221
+ values["taxon_order"] = int(data["taxonOrder"])
222
+ values["order"] = data.get("order", "")
223
+ values["category"] = data["category"]
224
+ values["family_code"] = data["familyCode"]
225
+ values["common_name"][language] = data["comName"]
226
+ values["scientific_name"] = data["sciName"]
227
+ values["family_common_name"][language] = data["familyComName"]
228
+ values["family_scientific_name"] = data["familySciName"]
229
+ values["data"]["api"][locale] = data
230
+
231
+ values["common_name"] = json.dumps(values["common_name"])
232
+ values["family_common_name"] = json.dumps(values["family_common_name"])
233
+
234
+ species = Species.objects.create(species_code=code, **values)
235
+ logger.info("Added species: %s, %s", code, species.get_common_name())
236
+
237
+ return species
238
+
239
+ def get_species(self, data: dict) -> Species:
240
+ code: str = data["speciesCode"]
241
+ species = Species.objects.filter(species_code=code).first()
242
+ if species is None:
243
+ species = self.add_species(code)
244
+ return species
245
+
246
+ @staticmethod
247
+ def get_urn(project_id, row: dict) -> str:
248
+ return f"URN:CornellLabOfOrnithology:{project_id}:{row['obsId']}"
249
+
250
+ def add_observation(self, data: dict, checklist: Checklist) -> Observation:
251
+ identifier: str = data["obsId"]
252
+ observation: Observation
253
+ species: Species = self.get_species(data)
254
+
255
+ values: dict = {
256
+ "edited": checklist.edited,
257
+ "published": False,
258
+ "checklist": checklist,
259
+ "country": checklist.country,
260
+ "state": checklist.state,
261
+ "county": checklist.county,
262
+ "location": checklist.location,
263
+ "observer": checklist.observer,
264
+ "species": species,
265
+ "date": checklist.date,
266
+ "time": checklist.time,
267
+ "started": checklist.started,
268
+ "count": 0,
269
+ "audio": False,
270
+ "photo": False,
271
+ "video": False,
272
+ "comments": "",
273
+ "urn": self.get_urn(checklist.project_code, data),
274
+ "data": {"api": data},
275
+ }
276
+
277
+ if re.match(r"\d+", data["howManyStr"]):
278
+ values["count"] = int(data["howManyStr"])
279
+
280
+ if "mediaCounts" in data:
281
+ values["audio"] = "A" in data["mediaCounts"]
282
+ values["photo"] = "P" in data["mediaCounts"]
283
+ values["video"] = "V" in data["mediaCounts"]
284
+
285
+ if "comments" in data:
286
+ values["comments"] = data["comments"]
287
+
288
+ observation = Observation.objects.create(identifier=identifier, **values)
289
+
290
+ return observation
291
+
292
+ def get_observer_identifier(self, data) -> str:
293
+ identifier: str = data["subId"]
294
+ logger.info("Scraping checklist: %s", identifier)
295
+ response = self.call(
296
+ requests.get, "https://ebird.org/checklist/%s" % identifier
297
+ )
298
+ content = response.content
299
+ soup = BeautifulSoup(content, "lxml")
300
+ attribute = "data-participant-userid"
301
+ node = soup.find("span", attrs={attribute: True})
302
+ return node[attribute] if node else ""
303
+
304
+ def get_observer(self, data: dict) -> Observer:
305
+ name: str = data.get("userDisplayName", "Anonymous eBirder")
306
+ observer, created = Observer.objects.get_or_create(
307
+ original=name, defaults={"name": name}
308
+ )
309
+ if observer.multiple:
310
+ observer, created = Observer.objects.get_or_create(
311
+ identifier=self.get_observer_identifier(data),
312
+ defaults={"original": random_word(8), "name": name},
313
+ )
314
+ elif observer.identifier == "":
315
+ observer.identifier = self.get_observer_identifier(data)
316
+ observer.save()
317
+ if created:
318
+ logger.info("Added observer: %s", observer.name)
319
+ return observer
320
+
321
+ def add_checklist(self, identifier: str) -> Checklist | None:
322
+ """
323
+ Add the checklist with the given identifier.
324
+
325
+ Arguments:
326
+ identifier: the eBird identifier for the checklist, e.g. "S318722167"
327
+
328
+ """
329
+ logger.info("Adding checklist: %s", identifier)
330
+
331
+ # Make sure loading a checklist is an all or nothing proposition.
332
+ # All the data is available from the eBird API call but there can
333
+ # still be further calls to scrape the checklist web page to get
334
+ # identifier of the observer, or the eBird API when a new species
335
+ # is added.
336
+
337
+ with transaction.atomic():
338
+ data: dict = self.call_api(get_checklist, identifier)
339
+ identifier: str = data["subId"]
340
+ created: dt.datetime = str2datetime(data["creationDt"])
341
+ edited: dt.datetime = str2datetime(data["lastEditedDt"])
342
+ started: dt.datetime = str2datetime(data["obsDt"])
343
+ location: Location = Location.objects.get(identifier=data["locId"])
344
+ checklist: Checklist
345
+ observer: Observer = self.get_observer(data)
346
+ observations: list = data.pop("obs", [])
347
+
348
+ if not observer.enabled:
349
+ return None
350
+
351
+ values: dict = {
352
+ "created": created,
353
+ "edited": edited,
354
+ "published": False,
355
+ "country": location.country,
356
+ "state": location.state,
357
+ "county": location.county,
358
+ "location": location,
359
+ "observer": observer,
360
+ "observer_count": None,
361
+ "species_count": data["numSpecies"],
362
+ "date": started.date(),
363
+ "time": None,
364
+ "started": started,
365
+ "protocol_code": data["protocolId"],
366
+ "project_code": data["projId"],
367
+ "duration": None,
368
+ "complete": data["allObsReported"],
369
+ "comments": "",
370
+ "url": "https://ebird.org/checklist/%s" % identifier,
371
+ "data": {"api": data},
372
+ }
373
+
374
+ if data["obsTimeValid"]:
375
+ values["time"] = started.time()
376
+
377
+ if "numObservers" in data:
378
+ values["observer_count"] = int(data["numObservers"])
379
+
380
+ if duration := data.get("durationHrs"):
381
+ values["duration"] = int(duration * 60.0)
382
+
383
+ if data["protocolId"] == "P22":
384
+ # The distance travelled might not be reported.
385
+ if "effortDistanceKm" in data:
386
+ dist: str = data["effortDistanceKm"]
387
+ values["distance"] = round(Decimal(dist), 3)
388
+ elif data["protocolId"] == "P23":
389
+ area: str = data["effortAreaHa"]
390
+ values["area"] = round(Decimal(area), 3)
391
+
392
+ if "comments" in data:
393
+ values["comments"] = data["comments"]
394
+
395
+ checklist = Checklist.objects.create(identifier=identifier, **values)
396
+
397
+ for observation_data in observations:
398
+ self.add_observation(observation_data, checklist)
399
+
400
+ return checklist
401
+
402
+ def fetch_subregions(self, region: str) -> List[str]:
403
+ region_types: list = ["subnational1", "subnational2", None]
404
+ levels: int = len(region.split("-", 2))
405
+ region_type: Optional[str] = region_types[levels - 1]
406
+
407
+ if region_type:
408
+ items: list = self.call_api(get_regions, region_type, region)
409
+ sub_regions = [item["code"] for item in items]
410
+ else:
411
+ sub_regions = []
412
+
413
+ return sub_regions
414
+
415
+ def fetch_visits(self, region: str, date: dt.date):
416
+ visits = []
417
+
418
+ results: list = get_visits(
419
+ self.api_key, region, date=date, max_results=API_MAX_RESULTS
420
+ )
421
+
422
+ if len(results) == API_MAX_RESULTS:
423
+ logger.info("API result limit reached - fetching visits for subregions")
424
+ if sub_regions := self.fetch_subregions(region):
425
+ for sub_region in sub_regions:
426
+ logger.info("Fetching visits for sub-region: %s", sub_region)
427
+ visits.extend(self.fetch_visits(sub_region, date))
428
+ else:
429
+ # No more sub-regions, issue a warning and return the results
430
+ visits.extend(results)
431
+ logger.warning(
432
+ "Fetching visits - API limit reached: %s, %s", region, date
433
+ )
434
+ else:
435
+ visits.extend(results)
436
+
437
+ return visits
438
+
439
+ def add_checklists(self, region: str, date: dt.date) -> None:
440
+ """
441
+ Add all the checklists submitted for a region for a given date.
442
+
443
+ Arguments:
444
+ region: The code for a national, subnational1, subnational2
445
+ area or hotspot identifier. For example, US, US-NY,
446
+ US-NY-109, or L1379126, respectively.
447
+
448
+ date: The date the observations were made.
449
+
450
+ """
451
+
452
+ logger.info("Adding checklists: %s, %s", region, date)
453
+
454
+ visits: list[dict] = self.fetch_visits(region, date)
455
+
456
+ logger.info("Visits made: %d ", len(visits))
457
+
458
+ for visit in visits:
459
+ data = visit["loc"]
460
+ identifier = data["locId"]
461
+ if not Location.objects.filter(identifier=identifier).exists():
462
+ self.add_location(data)
463
+
464
+ added: int = 0
465
+
466
+ for visit in visits:
467
+ identifier = visit["subId"]
468
+ if not Checklist.objects.filter(identifier=identifier).exists():
469
+ self.add_checklist(identifier)
470
+ added += 1
471
+
472
+ logger.info("Checklists added: %d ", added)
473
+ logger.info("Adding checklists completed")
474
+
475
+ @staticmethod
476
+ def run_filters():
477
+ for filter in Filter.objects.filter(enabled=True):
478
+ logger.info("Applying filter: %s", filter.name)
479
+ count = filter.apply()
480
+ logger.info("Observations updated: %d", count)
481
+
482
+ @staticmethod
483
+ def publish():
484
+ Checklist.objects.filter(published=False).update(published=True)
485
+ Observation.objects.filter(published=False).update(published=True)
File without changes
File without changes
@@ -0,0 +1,124 @@
1
+ """
2
+ load_api.py
3
+
4
+ A Django management command for loading observations from the eBird API.
5
+
6
+ Usage:
7
+ python manage.py add_checklists --date <date> <region>+
8
+ python manage.py add_checklists --days <n> <region>+
9
+ python manage.py add_checklists --date <date> --days <n> <region>+
10
+
11
+ Arguments:
12
+ --date <date> The date of the checklists to add to the database. If
13
+ no date is given then it defaults to today.
14
+
15
+ --days <n> The number of days to include. If not given then the number
16
+ of days defaults to 1. That means only checklists for the date will
17
+ be added.
18
+
19
+ <region> Required. One or more national, subnational1, subnational2, or hotspot
20
+ codes used by eBird. For example, US, US-NY, US-NY-109, L1379126
21
+
22
+ Examples:
23
+ # Load checklists added in the past week for New York state
24
+ python manage.py add_checklists --days 7 US-NY
25
+
26
+ # Load checklists all checklists added on the last Big Day
27
+ python manage.py add_checklists --date 2024-10-05 US-NY
28
+
29
+ Notes:
30
+ 1. The eBird API returns a maximum of 200 results. The APILoader works
31
+ around this by fetching checklists from sub-regions if necessary.
32
+ Downloading checklists once a day should be sufficient for all hotspots
33
+ or subnational2 areas. For large countries or places with lots of birders
34
+ downloads will have to be more frequent. For really large area, i.e. the
35
+ USA you shouldn't be using the API at all. Instead use the data from the
36
+ eBird Basic Dataset.
37
+
38
+ 2. Why does the command only add checklists, not update existing ones?
39
+ The number of checklists that are updated is relatively small, typically
40
+ less than 1%. The problem with the eBird API is that you can only find
41
+ out whether a checklist has changed by downloading it. This app basically
42
+ mirrors the eBird database for a given region so there's a strong temptation
43
+ to repeatedly download everything to keep the checklists in sync. That
44
+ means repeatedly downloading all the checklists submitted in the past week
45
+ or month, or longer to pick up a few changes. That's a heavy load on the
46
+ eBird servers and a lot of bandwidth for relatively little gain.
47
+
48
+ The API is really a news service. For accuracy and completeness you should
49
+ really use the eBird Basic Dataset, which is published on the 15th of each
50
+ month.
51
+
52
+ 3. The API has limitations. Observers are only identified by name. So if there
53
+ are two Juan Garcias birding in a region, then all the observations will
54
+ appear to belong to one person. To work around this, when an observer is
55
+ added the web page for the checklist is scraped to extract their eBird
56
+ user id.
57
+
58
+ Also the observations will not have been reviewed by moderators, so there
59
+ are likely to be records where the identification is incorrect.
60
+
61
+ 4. You automate running the command using a scheduler such as cron. If you use
62
+ the absolute paths to python and the command, then you don't need to deal
63
+ with activating the virtual environment, for example:
64
+
65
+ # Just before midnight, add all checklists submitted for the past week
66
+ 55 23 * * * /home/me/my-project/.venv/bin/python /home/me/my-project/manage.py add_checklists --days 7 US-NY
67
+
68
+ """
69
+
70
+ import datetime as dt
71
+
72
+ from django.conf import settings
73
+ from django.core.management.base import BaseCommand
74
+
75
+ from ebird.api.data.loaders import APILoader
76
+
77
+
78
+ class Command(BaseCommand):
79
+ help = "Add checklists from the eBird API"
80
+
81
+ def add_arguments(self, parser):
82
+ parser.add_argument(
83
+ "--date",
84
+ type=lambda s: dt.datetime.strptime(s, "%Y-%m-%d"),
85
+ help="The number of previous days to add",
86
+ )
87
+
88
+ parser.add_argument(
89
+ "--days",
90
+ type=int,
91
+ help="The number of previous days to add",
92
+ )
93
+
94
+ parser.add_argument(
95
+ "regions",
96
+ nargs="+",
97
+ type=str,
98
+ help="Codes for the eBird regions, e.g US-NY",
99
+ )
100
+
101
+ @staticmethod
102
+ def get_loader() -> APILoader:
103
+ key: str = getattr(settings, "EBIRD_API_KEY")
104
+ locales: dict = getattr(settings, "EBIRD_LOCALES")
105
+ return APILoader(key, locales)
106
+
107
+ @staticmethod
108
+ def get_dates(days) -> list[dt.date]:
109
+ today: dt.date = dt.date.today()
110
+ return [today - dt.timedelta(days=n) for n in range(days)]
111
+
112
+ def handle(self, *args, **options):
113
+ date = options["date"] or dt.date.today()
114
+ days = options["days"] or 1
115
+
116
+ dates = [date - dt.timedelta(days=n) for n in range(days)]
117
+ loader = self.get_loader()
118
+
119
+ for region in options["regions"]:
120
+ for date in dates:
121
+ loader.add_checklists(region, date)
122
+
123
+ loader.run_filters()
124
+ loader.publish()