avianki 0.1.1__tar.gz
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.
- avianki-0.1.1/PKG-INFO +139 -0
- avianki-0.1.1/README.md +124 -0
- avianki-0.1.1/pyproject.toml +39 -0
- avianki-0.1.1/src/avianki/__init__.py +0 -0
- avianki-0.1.1/src/avianki/allaboutbirds.py +143 -0
- avianki-0.1.1/src/avianki/anki_model.py +105 -0
- avianki-0.1.1/src/avianki/cli.py +295 -0
- avianki-0.1.1/src/avianki/ebird.py +52 -0
- avianki-0.1.1/src/avianki/media.py +63 -0
avianki-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: avianki
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Build Anki flashcard decks for bird identification from allaboutbirds.org
|
|
5
|
+
Keywords: anki,birds,flashcards,ornithology
|
|
6
|
+
Author: Ian Costa
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: genanki>=0.13.1
|
|
9
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
10
|
+
Requires-Dist: requests>=2.28.0
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Project-URL: Repository, https://github.com/Ian-Costa18/avianki
|
|
13
|
+
Project-URL: Issues, https://github.com/Ian-Costa18/avianki/issues
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# avianki
|
|
17
|
+
|
|
18
|
+
Builds Anki flashcard decks for learning to identify birds by sight and sound. Cards are sourced from [allaboutbirds.org](https://www.allaboutbirds.org) and include photos, call/song audio, and species descriptions.
|
|
19
|
+
|
|
20
|
+
Each species generates two card types:
|
|
21
|
+
|
|
22
|
+
- **Photo → Name** — given two photos and audio, identify the bird
|
|
23
|
+
- **Description → Name** — given audio and a written description, identify the bird
|
|
24
|
+
|
|
25
|
+
## Prerequisites
|
|
26
|
+
|
|
27
|
+
- **Python 3.10+**
|
|
28
|
+
- **[uv](https://docs.astral.sh/uv/)** — for dependency management
|
|
29
|
+
- **[ffmpeg](https://ffmpeg.org/)** — for trimming audio clips
|
|
30
|
+
|
|
31
|
+
Install ffmpeg:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Windows
|
|
35
|
+
winget install ffmpeg
|
|
36
|
+
|
|
37
|
+
# macOS
|
|
38
|
+
brew install ffmpeg
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/Ian-Costa18/avianki.git
|
|
45
|
+
cd avianki
|
|
46
|
+
uv sync
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Copy `.env.example` to `.env` and fill in your key:
|
|
52
|
+
|
|
53
|
+
```env
|
|
54
|
+
EBIRD_API_KEY=your_key_here
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
An eBird API key is only required if using an eBird region code as the location. Get one free at [ebird.org/api/keygen](https://ebird.org/api/keygen).
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv run avianki.py LOCATION [OPTIONS]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Location formats
|
|
66
|
+
|
|
67
|
+
**allaboutbirds.org browse URL** (recommended — species sorted by local frequency):
|
|
68
|
+
|
|
69
|
+
1. Go to [allaboutbirds.org/guide/browse](https://www.allaboutbirds.org/guide/browse)
|
|
70
|
+
2. Under **Birds Near Me**, enter your city, ZIP code, or state/province
|
|
71
|
+
3. Set the time of year — **Year-round** is recommended for a complete deck
|
|
72
|
+
4. Click **Browse**, then copy the URL from your browser's address bar
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv run avianki.py "https://www.allaboutbirds.org/guide/browse/filter/loc/ChIJGzE9DS1l44kRoOhiASS_fHg/date/all/behavior/all/size/all/colors/all/sort/score/view/list-view"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Google Place ID** (shorthand for the above):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run avianki.py ChIJGzE9DS1l44kRoOhiASS_fHg
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Find a Place ID at [developers.google.com/maps/documentation/javascript/examples/places-placeid-finder](https://developers.google.com/maps/documentation/javascript/examples/places-placeid-finder).
|
|
85
|
+
|
|
86
|
+
**eBird region code** (species in taxonomic order, requires API key):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv run avianki.py US-MA
|
|
90
|
+
uv run avianki.py US-MA-017 # county level
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Options
|
|
94
|
+
|
|
95
|
+
| Flag | Description |
|
|
96
|
+
| --------------------- | -------------------------------------------------------------- |
|
|
97
|
+
| `--limit N` | Cap the number of species (useful for testing) |
|
|
98
|
+
| `--output FILE` | Output `.apkg` path (default: auto-generated from location) |
|
|
99
|
+
| `--deck-name NAME` | Override the deck name shown in Anki |
|
|
100
|
+
| `--no-audio` | Skip downloading call and song audio |
|
|
101
|
+
| `--no-images` | Skip downloading photos |
|
|
102
|
+
| `--delay SECONDS` | Wait between requests in seconds (default: `0.5`) |
|
|
103
|
+
| `--clear-cache` | Delete previously downloaded media before running |
|
|
104
|
+
| `--log-file FILE` | Log file path (default: `avianki.log`) |
|
|
105
|
+
| `--verbose` | Show debug-level output in the console |
|
|
106
|
+
| `--quiet` | Only show warnings and errors in the console |
|
|
107
|
+
|
|
108
|
+
### Examples
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Build a deck for your area (recommended approach)
|
|
112
|
+
uv run avianki.py "https://www.allaboutbirds.org/guide/browse/..." --limit 50
|
|
113
|
+
|
|
114
|
+
# Re-download all media from scratch
|
|
115
|
+
uv run avianki.py US-MA --clear-cache
|
|
116
|
+
|
|
117
|
+
# Quick test with 5 species
|
|
118
|
+
uv run avianki.py US-MA --limit 5
|
|
119
|
+
|
|
120
|
+
# Custom output path and deck name
|
|
121
|
+
uv run avianki.py US-MA --output ~/Desktop/MyBirds.apkg --deck-name "My Birds"
|
|
122
|
+
|
|
123
|
+
# Images only, no audio
|
|
124
|
+
uv run avianki.py US-MA --no-audio
|
|
125
|
+
|
|
126
|
+
# Be polite to the server
|
|
127
|
+
uv run avianki.py US-MA --delay 1.5
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Output
|
|
131
|
+
|
|
132
|
+
An `.apkg` file is written to the current directory (e.g. `Birds_US_MA.apkg`). Import it into Anki via **File → Import**.
|
|
133
|
+
|
|
134
|
+
Downloaded images and audio are cached in `media/` so re-runs skip already-fetched files.
|
|
135
|
+
|
|
136
|
+
## Notes
|
|
137
|
+
|
|
138
|
+
- Audio clips are trimmed to 10 seconds via ffmpeg to keep file sizes small.
|
|
139
|
+
- allaboutbirds.org browse URLs sort species by likelihood score for your location, which gives much better study order than eBird's taxonomic ordering.
|
avianki-0.1.1/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# avianki
|
|
2
|
+
|
|
3
|
+
Builds Anki flashcard decks for learning to identify birds by sight and sound. Cards are sourced from [allaboutbirds.org](https://www.allaboutbirds.org) and include photos, call/song audio, and species descriptions.
|
|
4
|
+
|
|
5
|
+
Each species generates two card types:
|
|
6
|
+
|
|
7
|
+
- **Photo → Name** — given two photos and audio, identify the bird
|
|
8
|
+
- **Description → Name** — given audio and a written description, identify the bird
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
- **Python 3.10+**
|
|
13
|
+
- **[uv](https://docs.astral.sh/uv/)** — for dependency management
|
|
14
|
+
- **[ffmpeg](https://ffmpeg.org/)** — for trimming audio clips
|
|
15
|
+
|
|
16
|
+
Install ffmpeg:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# Windows
|
|
20
|
+
winget install ffmpeg
|
|
21
|
+
|
|
22
|
+
# macOS
|
|
23
|
+
brew install ffmpeg
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/Ian-Costa18/avianki.git
|
|
30
|
+
cd avianki
|
|
31
|
+
uv sync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
Copy `.env.example` to `.env` and fill in your key:
|
|
37
|
+
|
|
38
|
+
```env
|
|
39
|
+
EBIRD_API_KEY=your_key_here
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
An eBird API key is only required if using an eBird region code as the location. Get one free at [ebird.org/api/keygen](https://ebird.org/api/keygen).
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv run avianki.py LOCATION [OPTIONS]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Location formats
|
|
51
|
+
|
|
52
|
+
**allaboutbirds.org browse URL** (recommended — species sorted by local frequency):
|
|
53
|
+
|
|
54
|
+
1. Go to [allaboutbirds.org/guide/browse](https://www.allaboutbirds.org/guide/browse)
|
|
55
|
+
2. Under **Birds Near Me**, enter your city, ZIP code, or state/province
|
|
56
|
+
3. Set the time of year — **Year-round** is recommended for a complete deck
|
|
57
|
+
4. Click **Browse**, then copy the URL from your browser's address bar
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv run avianki.py "https://www.allaboutbirds.org/guide/browse/filter/loc/ChIJGzE9DS1l44kRoOhiASS_fHg/date/all/behavior/all/size/all/colors/all/sort/score/view/list-view"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Google Place ID** (shorthand for the above):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv run avianki.py ChIJGzE9DS1l44kRoOhiASS_fHg
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Find a Place ID at [developers.google.com/maps/documentation/javascript/examples/places-placeid-finder](https://developers.google.com/maps/documentation/javascript/examples/places-placeid-finder).
|
|
70
|
+
|
|
71
|
+
**eBird region code** (species in taxonomic order, requires API key):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv run avianki.py US-MA
|
|
75
|
+
uv run avianki.py US-MA-017 # county level
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Options
|
|
79
|
+
|
|
80
|
+
| Flag | Description |
|
|
81
|
+
| --------------------- | -------------------------------------------------------------- |
|
|
82
|
+
| `--limit N` | Cap the number of species (useful for testing) |
|
|
83
|
+
| `--output FILE` | Output `.apkg` path (default: auto-generated from location) |
|
|
84
|
+
| `--deck-name NAME` | Override the deck name shown in Anki |
|
|
85
|
+
| `--no-audio` | Skip downloading call and song audio |
|
|
86
|
+
| `--no-images` | Skip downloading photos |
|
|
87
|
+
| `--delay SECONDS` | Wait between requests in seconds (default: `0.5`) |
|
|
88
|
+
| `--clear-cache` | Delete previously downloaded media before running |
|
|
89
|
+
| `--log-file FILE` | Log file path (default: `avianki.log`) |
|
|
90
|
+
| `--verbose` | Show debug-level output in the console |
|
|
91
|
+
| `--quiet` | Only show warnings and errors in the console |
|
|
92
|
+
|
|
93
|
+
### Examples
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Build a deck for your area (recommended approach)
|
|
97
|
+
uv run avianki.py "https://www.allaboutbirds.org/guide/browse/..." --limit 50
|
|
98
|
+
|
|
99
|
+
# Re-download all media from scratch
|
|
100
|
+
uv run avianki.py US-MA --clear-cache
|
|
101
|
+
|
|
102
|
+
# Quick test with 5 species
|
|
103
|
+
uv run avianki.py US-MA --limit 5
|
|
104
|
+
|
|
105
|
+
# Custom output path and deck name
|
|
106
|
+
uv run avianki.py US-MA --output ~/Desktop/MyBirds.apkg --deck-name "My Birds"
|
|
107
|
+
|
|
108
|
+
# Images only, no audio
|
|
109
|
+
uv run avianki.py US-MA --no-audio
|
|
110
|
+
|
|
111
|
+
# Be polite to the server
|
|
112
|
+
uv run avianki.py US-MA --delay 1.5
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Output
|
|
116
|
+
|
|
117
|
+
An `.apkg` file is written to the current directory (e.g. `Birds_US_MA.apkg`). Import it into Anki via **File → Import**.
|
|
118
|
+
|
|
119
|
+
Downloaded images and audio are cached in `media/` so re-runs skip already-fetched files.
|
|
120
|
+
|
|
121
|
+
## Notes
|
|
122
|
+
|
|
123
|
+
- Audio clips are trimmed to 10 seconds via ffmpeg to keep file sizes small.
|
|
124
|
+
- allaboutbirds.org browse URLs sort species by likelihood score for your location, which gives much better study order than eBird's taxonomic ordering.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "avianki"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Build Anki flashcard decks for bird identification from allaboutbirds.org"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Ian Costa" },
|
|
9
|
+
]
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
keywords = ["anki", "birds", "flashcards", "ornithology"]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"genanki>=0.13.1",
|
|
14
|
+
"python-dotenv>=1.0.0",
|
|
15
|
+
"requests>=2.28.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
avianki = "avianki.cli:main"
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Repository = "https://github.com/Ian-Costa18/avianki"
|
|
23
|
+
Issues = "https://github.com/Ian-Costa18/avianki/issues"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.11.7,<0.12"]
|
|
27
|
+
build-backend = "uv_build"
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
pythonpath = ["src"]
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=9.0.3",
|
|
36
|
+
"pytest-cov>=7.1.0",
|
|
37
|
+
"ruff>=0.15.11",
|
|
38
|
+
"ty>=0.0.31",
|
|
39
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Scrape species data from allaboutbirds.org.
|
|
2
|
+
|
|
3
|
+
fetch_browse_species() accepts either a full browse URL or a Google Place ID:
|
|
4
|
+
https://www.allaboutbirds.org/guide/browse/filter/loc/{placeId}/
|
|
5
|
+
date/all/behavior/all/size/all/colors/all/sort/score/view/list-view
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("bird_deck")
|
|
15
|
+
|
|
16
|
+
AAB_BASE = "https://www.allaboutbirds.org/guide"
|
|
17
|
+
HEADERS = {
|
|
18
|
+
"User-Agent": (
|
|
19
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
20
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
21
|
+
),
|
|
22
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch_browse_species(url_or_place_id: str, limit: int | None = None) -> list[str]:
|
|
27
|
+
"""
|
|
28
|
+
Scrape an allaboutbirds.org browse page and return species slugs in
|
|
29
|
+
likelihood-score order (most commonly seen first).
|
|
30
|
+
|
|
31
|
+
Accepts either the full browse URL or just the Google Place ID.
|
|
32
|
+
"""
|
|
33
|
+
if url_or_place_id.startswith("http"):
|
|
34
|
+
url = url_or_place_id
|
|
35
|
+
else:
|
|
36
|
+
place_id = url_or_place_id
|
|
37
|
+
url = (
|
|
38
|
+
f"{AAB_BASE}/browse/filter/loc/{place_id}"
|
|
39
|
+
"/date/all/behavior/all/size/all/colors/all/sort/score/view/list-view"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
log.info("Fetching species list from allaboutbirds.org…")
|
|
43
|
+
log.debug("Browse URL: %s", url)
|
|
44
|
+
try:
|
|
45
|
+
resp = requests.get(url, headers=HEADERS, timeout=20)
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
slugs = list(dict.fromkeys(
|
|
48
|
+
re.findall(r"/guide/([A-Za-z][A-Za-z_\-]+)/overview", resp.text)
|
|
49
|
+
))
|
|
50
|
+
if limit:
|
|
51
|
+
slugs = slugs[:limit]
|
|
52
|
+
log.info(" %d species found (sorted by likelihood)", len(slugs))
|
|
53
|
+
return slugs
|
|
54
|
+
except Exception as e:
|
|
55
|
+
log.error("Browse fetch failed: %s", e)
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def slug_to_names(slug: str) -> dict:
|
|
60
|
+
"""
|
|
61
|
+
Return {comName, sciName} by scraping the overview page title/meta.
|
|
62
|
+
Used when building from an allaboutbirds URL (no eBird lookup needed).
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
resp = requests.get(f"{AAB_BASE}/{slug}/overview", headers=HEADERS, timeout=15)
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
html_text = resp.text
|
|
68
|
+
# <title>Black-capped Chickadee Overview, All About Birds…</title>
|
|
69
|
+
m_title = re.search(r"<title>([^<]+) Overview,", html_text)
|
|
70
|
+
com_name = m_title.group(1).strip() if m_title else slug.replace("_", " ")
|
|
71
|
+
# Scientific name appears in a consistent italics span
|
|
72
|
+
m_sci = re.search(r'<em class="sci-name">([^<]+)</em>', html_text)
|
|
73
|
+
if not m_sci:
|
|
74
|
+
m_sci = re.search(r'<i[^>]*itemprop="name"[^>]*>([^<]+)</i>', html_text)
|
|
75
|
+
sci_name = m_sci.group(1).strip() if m_sci else ""
|
|
76
|
+
return {"comName": com_name, "sciName": sci_name}
|
|
77
|
+
except Exception as e:
|
|
78
|
+
log.warning("slug_to_names failed (%s): %s", slug, e)
|
|
79
|
+
return {"comName": slug.replace("_", " "), "sciName": ""}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def species_slug(com_name: str) -> str:
|
|
83
|
+
"""'Black-capped Chickadee' → 'Black-capped_Chickadee'"""
|
|
84
|
+
return com_name.replace(" ", "_")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fetch_overview(slug: str) -> dict:
|
|
88
|
+
"""
|
|
89
|
+
Scrape the overview page for a species.
|
|
90
|
+
Returns {desc, sciName, images} — images are up to 2 720px JPG URLs.
|
|
91
|
+
"""
|
|
92
|
+
url = f"{AAB_BASE}/{slug}/overview"
|
|
93
|
+
log.debug("AAB overview: %s", url)
|
|
94
|
+
try:
|
|
95
|
+
resp = requests.get(url, headers=HEADERS, timeout=15)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
html_text = resp.text
|
|
98
|
+
|
|
99
|
+
m = re.search(r'<meta name="description" content="([^"]+)"', html_text)
|
|
100
|
+
desc = html.unescape(m.group(1)).strip() if m else ""
|
|
101
|
+
desc = re.sub(r"^<p>|</p>$", "", desc).strip()
|
|
102
|
+
|
|
103
|
+
m_sci = re.search(r'<em class="sci-name">([^<]+)</em>', html_text)
|
|
104
|
+
if not m_sci:
|
|
105
|
+
m_sci = re.search(r'<i[^>]*itemprop="name"[^>]*>([^<]+)</i>', html_text)
|
|
106
|
+
sci_name = m_sci.group(1).strip() if m_sci else ""
|
|
107
|
+
|
|
108
|
+
photo_ids = list(dict.fromkeys(
|
|
109
|
+
re.findall(r'/guide/assets/photo/(\d+)-\d+px\.jpg', html_text)
|
|
110
|
+
))
|
|
111
|
+
images = [f"{AAB_BASE}/assets/photo/{pid}-720px.jpg" for pid in photo_ids[:2]]
|
|
112
|
+
|
|
113
|
+
return {"desc": desc, "sciName": sci_name, "images": images}
|
|
114
|
+
except Exception as e:
|
|
115
|
+
log.warning("AAB overview failed (%s): %s", slug, e)
|
|
116
|
+
return {"desc": "", "images": []}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def fetch_sounds(slug: str) -> dict:
|
|
120
|
+
"""
|
|
121
|
+
Scrape the sounds page for a species.
|
|
122
|
+
Returns {calls: [url, …], songs: [url, …]} — direct MP3 URLs from allaboutbirds.
|
|
123
|
+
"""
|
|
124
|
+
url = f"{AAB_BASE}/{slug}/sounds"
|
|
125
|
+
log.debug("AAB sounds: %s", url)
|
|
126
|
+
try:
|
|
127
|
+
resp = requests.get(url, headers=HEADERS, timeout=15)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
html_text = resp.text
|
|
130
|
+
|
|
131
|
+
# Each entry: jp-jplayer name="URL" ... jp-flat-audio aria-label="Song|Calls|..."
|
|
132
|
+
entries = re.findall(
|
|
133
|
+
r'name="(https://www\.allaboutbirds\.org/guide/assets/sound/\d+\.mp3)"'
|
|
134
|
+
r'.*?aria-label="([^"]+)"',
|
|
135
|
+
html_text,
|
|
136
|
+
re.DOTALL,
|
|
137
|
+
)
|
|
138
|
+
calls = [u for u, label in entries if "Call" in label]
|
|
139
|
+
songs = [u for u, label in entries if "Song" in label]
|
|
140
|
+
return {"calls": calls, "songs": songs}
|
|
141
|
+
except Exception as e:
|
|
142
|
+
log.warning("AAB sounds failed (%s): %s", slug, e)
|
|
143
|
+
return {"calls": [], "songs": []}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Anki model definition for bird ID cards."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
import genanki
|
|
6
|
+
|
|
7
|
+
CSS = """
|
|
8
|
+
.card {
|
|
9
|
+
font-family: Georgia, serif;
|
|
10
|
+
font-size: 16px;
|
|
11
|
+
color: #1a1a1a;
|
|
12
|
+
background-color: #fafaf7;
|
|
13
|
+
max-width: 600px;
|
|
14
|
+
margin: 0 auto;
|
|
15
|
+
padding: 16px;
|
|
16
|
+
line-height: 1.5;
|
|
17
|
+
}
|
|
18
|
+
.bird-name { font-size: 1.6em; font-weight: bold; color: #2c5f2e; margin: 12px 0 4px 0; }
|
|
19
|
+
.sci-name { font-style: italic; color: #666; font-size: 0.9em; margin-bottom: 14px; }
|
|
20
|
+
.card img {
|
|
21
|
+
max-width: 48%;
|
|
22
|
+
max-height: 200px;
|
|
23
|
+
object-fit: contain;
|
|
24
|
+
display: inline-block;
|
|
25
|
+
margin: 4px 1%;
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
vertical-align: top;
|
|
28
|
+
}
|
|
29
|
+
.desc-box {
|
|
30
|
+
background: #f0f4f0;
|
|
31
|
+
border-left: 4px solid #2c5f2e;
|
|
32
|
+
padding: 10px 14px;
|
|
33
|
+
border-radius: 0 6px 6px 0;
|
|
34
|
+
margin: 10px 0;
|
|
35
|
+
font-size: 0.95em;
|
|
36
|
+
}
|
|
37
|
+
.prompt-label {
|
|
38
|
+
font-size: 0.85em; font-weight: bold; color: #888;
|
|
39
|
+
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;
|
|
40
|
+
}
|
|
41
|
+
.divider { border-top: 1px solid #ddd; margin: 14px 0; }
|
|
42
|
+
audio { width: 100%; margin: 4px 0; }
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Fields shared by both card templates
|
|
46
|
+
FIELDS = [
|
|
47
|
+
{"name": "BirdName"},
|
|
48
|
+
{"name": "SciName"},
|
|
49
|
+
{"name": "Image1"}, # <img src="..."> or ""
|
|
50
|
+
{"name": "Image2"}, # <img src="..."> or ""
|
|
51
|
+
{"name": "Call"}, # [sound:...] or ""
|
|
52
|
+
{"name": "Song"}, # [sound:...] or ""
|
|
53
|
+
{"name": "Description"},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# Shared answer side: name + both images + audio + description
|
|
57
|
+
_BACK = """
|
|
58
|
+
<div class="card">
|
|
59
|
+
<div class="bird-name">{{BirdName}}</div>
|
|
60
|
+
<div class="sci-name">{{SciName}}</div>
|
|
61
|
+
{{Image1}}{{Image2}}
|
|
62
|
+
{{#Song}}<div class="prompt-label">🎵 Song</div>{{Song}}{{/Song}}
|
|
63
|
+
{{#Call}}<div class="prompt-label">🔊 Call</div>{{Call}}{{/Call}}
|
|
64
|
+
<div class="divider"></div>
|
|
65
|
+
<div class="desc-box">{{Description}}</div>
|
|
66
|
+
</div>"""
|
|
67
|
+
|
|
68
|
+
TEMPLATES = [
|
|
69
|
+
{
|
|
70
|
+
"name": "Picture → Name",
|
|
71
|
+
"qfmt": """
|
|
72
|
+
<div class="card">
|
|
73
|
+
<div class="prompt-label">🖼 What bird is this?</div>
|
|
74
|
+
{{Image1}}{{Image2}}
|
|
75
|
+
{{#Song}}<div class="prompt-label">🎵 Song</div>{{Song}}{{/Song}}
|
|
76
|
+
{{#Call}}<div class="prompt-label">🔊 Call</div>{{Call}}{{/Call}}
|
|
77
|
+
</div>""",
|
|
78
|
+
"afmt": _BACK,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "Description → Name",
|
|
82
|
+
"qfmt": """
|
|
83
|
+
<div class="card">
|
|
84
|
+
<div class="prompt-label">🔊 What bird is this?</div>
|
|
85
|
+
{{#Song}}<div class="prompt-label">Song</div>{{Song}}{{/Song}}
|
|
86
|
+
{{#Call}}<div class="prompt-label">Call</div>{{Call}}{{/Call}}
|
|
87
|
+
<div class="divider"></div>
|
|
88
|
+
<div class="desc-box">{{Description}}</div>
|
|
89
|
+
</div>""",
|
|
90
|
+
"afmt": _BACK,
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _stable_id(seed: str) -> int:
|
|
96
|
+
return int(hashlib.md5(seed.encode()).hexdigest()[:8], 16)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
MODEL = genanki.Model(
|
|
100
|
+
_stable_id("BirdDeck_Model_v2"),
|
|
101
|
+
"Bird ID",
|
|
102
|
+
fields=FIELDS,
|
|
103
|
+
templates=TEMPLATES,
|
|
104
|
+
css=CSS,
|
|
105
|
+
)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
avianki.py — CLI entry point.
|
|
4
|
+
|
|
5
|
+
Scrapes images, audio, and descriptions from allaboutbirds.org and packages
|
|
6
|
+
everything into an Anki .apkg deck. Accepts three location formats:
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
avianki "https://www.allaboutbirds.org/guide/browse/..."
|
|
10
|
+
avianki ChIJGzE9DS1l44kRoOhiASS_fHg # Google Place ID
|
|
11
|
+
avianki US-MA # eBird region code
|
|
12
|
+
avianki US-MA --limit 40
|
|
13
|
+
|
|
14
|
+
Output:
|
|
15
|
+
Birds_<location>.apkg — import into Anki via File > Import
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import hashlib
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
import genanki
|
|
28
|
+
from dotenv import load_dotenv
|
|
29
|
+
|
|
30
|
+
from . import allaboutbirds
|
|
31
|
+
from . import anki_model
|
|
32
|
+
from . import ebird
|
|
33
|
+
from . import media
|
|
34
|
+
|
|
35
|
+
load_dotenv()
|
|
36
|
+
|
|
37
|
+
# ─── Logging ──────────────────────────────────────────────────────────────────
|
|
38
|
+
_fmt = logging.Formatter("%(asctime)s %(levelname)-7s %(message)s", datefmt="%H:%M:%S")
|
|
39
|
+
log = logging.getLogger("bird_deck")
|
|
40
|
+
log.handlers.clear() # avoid duplicate handlers if rerun in same Python session
|
|
41
|
+
log.setLevel(logging.DEBUG)
|
|
42
|
+
log.propagate = False
|
|
43
|
+
|
|
44
|
+
_sh = logging.StreamHandler(sys.stdout)
|
|
45
|
+
_sh.setFormatter(_fmt)
|
|
46
|
+
_sh.setLevel(logging.INFO)
|
|
47
|
+
log.addHandler(_sh)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _setup_logging(log_file: str, verbose: bool, quiet: bool) -> logging.FileHandler:
|
|
51
|
+
fh = logging.FileHandler(log_file, encoding="utf-8", mode="w")
|
|
52
|
+
fh.setFormatter(_fmt)
|
|
53
|
+
fh.setLevel(logging.DEBUG)
|
|
54
|
+
log.addHandler(fh)
|
|
55
|
+
if verbose:
|
|
56
|
+
_sh.setLevel(logging.DEBUG)
|
|
57
|
+
elif quiet:
|
|
58
|
+
_sh.setLevel(logging.WARNING)
|
|
59
|
+
return fh
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _safe_name(com_name: str) -> str:
|
|
66
|
+
"""Filesystem-safe version of a common name for use in filenames."""
|
|
67
|
+
return re.sub(r"[^A-Za-z0-9_]", "_", com_name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _get_audio(
|
|
71
|
+
sounds: dict, kind: str, safe: str, media_dir: str
|
|
72
|
+
) -> tuple[str, list[str]]:
|
|
73
|
+
"""
|
|
74
|
+
Download, trim, and cache one audio clip (call or song).
|
|
75
|
+
Returns ([sound:file] field value, [absolute path]) tuple.
|
|
76
|
+
"""
|
|
77
|
+
base = f"bird_{safe}_{kind}"
|
|
78
|
+
cached = media.find_cached_audio(media_dir, base)
|
|
79
|
+
if cached:
|
|
80
|
+
log.info(" ✓ %s (cached)", kind)
|
|
81
|
+
return f"[sound:{cached}]", [os.path.join(media_dir, cached)]
|
|
82
|
+
|
|
83
|
+
urls = sounds.get(kind + "s", []) # "calls" or "songs"
|
|
84
|
+
if not urls:
|
|
85
|
+
log.warning(" no %s audio found", kind)
|
|
86
|
+
return "", []
|
|
87
|
+
|
|
88
|
+
raw_path = os.path.join(media_dir, f"{base}_raw.mp3")
|
|
89
|
+
out_file = f"{base}.mp3"
|
|
90
|
+
out_path = os.path.join(media_dir, out_file)
|
|
91
|
+
|
|
92
|
+
if media.download_file(urls[0], raw_path) and media.trim_to_mp3(raw_path, out_path):
|
|
93
|
+
os.remove(raw_path)
|
|
94
|
+
log.info(" ✓ %s %.1f KB", kind, os.path.getsize(out_path) / 1024)
|
|
95
|
+
return f"[sound:{out_file}]", [out_path]
|
|
96
|
+
|
|
97
|
+
log.warning(" %s download/trim failed", kind)
|
|
98
|
+
return "", []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _get_images(
|
|
102
|
+
img_urls: list[str], safe: str, media_dir: str
|
|
103
|
+
) -> tuple[list[str], list[str]]:
|
|
104
|
+
"""
|
|
105
|
+
Download and cache up to 2 images.
|
|
106
|
+
Returns (img_fields, media_paths) where img_fields are '<img src="...">' strings.
|
|
107
|
+
"""
|
|
108
|
+
img_fields = []
|
|
109
|
+
media_paths = []
|
|
110
|
+
|
|
111
|
+
for idx, img_url in enumerate(img_urls, 1):
|
|
112
|
+
ext = os.path.splitext(img_url.split("?")[0])[1].lower() or ".jpg"
|
|
113
|
+
img_base = f"bird_{safe}_img{idx}"
|
|
114
|
+
cached = media.find_cached_image(media_dir, img_base)
|
|
115
|
+
if cached:
|
|
116
|
+
log.info(" ✓ image %d (cached)", idx)
|
|
117
|
+
img_fields.append(f'<img src="{cached}">')
|
|
118
|
+
media_paths.append(os.path.join(media_dir, cached))
|
|
119
|
+
else:
|
|
120
|
+
img_file = img_base + ext
|
|
121
|
+
img_path = os.path.join(media_dir, img_file)
|
|
122
|
+
if media.download_file(img_url, img_path):
|
|
123
|
+
log.info(" ✓ image %d %.1f KB", idx, os.path.getsize(img_path) / 1024)
|
|
124
|
+
img_fields.append(f'<img src="{img_file}">')
|
|
125
|
+
media_paths.append(img_path)
|
|
126
|
+
time.sleep(0.5)
|
|
127
|
+
|
|
128
|
+
while len(img_fields) < 2:
|
|
129
|
+
img_fields.append("")
|
|
130
|
+
|
|
131
|
+
return img_fields, media_paths
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ─── Main ─────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main() -> None:
|
|
138
|
+
parser = argparse.ArgumentParser(
|
|
139
|
+
description="Build an Anki bird ID deck from allaboutbirds.org",
|
|
140
|
+
epilog=(
|
|
141
|
+
"LOCATION can be:\n"
|
|
142
|
+
" - An allaboutbirds.org browse URL (copy from your browser)\n"
|
|
143
|
+
" - A Google Place ID (e.g. ChIJGzE9DS1l44kRoOhiASS_fHg)\n"
|
|
144
|
+
" - An eBird region code (e.g. US-MA or US-MA-017)\n"
|
|
145
|
+
),
|
|
146
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"location", help="allaboutbirds.org URL, Google Place ID, or eBird region code"
|
|
150
|
+
)
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"--limit", type=int, default=None, help="Max number of species to include"
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument(
|
|
155
|
+
"--output", default=None, help="Output .apkg filename (default: auto-generated)"
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--deck-name", default=None, help="Override the deck name shown in Anki"
|
|
159
|
+
)
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"--no-audio", action="store_true", help="Skip downloading audio clips"
|
|
162
|
+
)
|
|
163
|
+
parser.add_argument(
|
|
164
|
+
"--no-images", action="store_true", help="Skip downloading images"
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--delay", type=float, default=0.5, help="Seconds to wait between requests (default: 0.5)"
|
|
168
|
+
)
|
|
169
|
+
parser.add_argument(
|
|
170
|
+
"--clear-cache", action="store_true", help="Delete cached media before running"
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--log-file", default="avianki.log", help="Log file path (default: avianki.log)"
|
|
174
|
+
)
|
|
175
|
+
verbosity = parser.add_mutually_exclusive_group()
|
|
176
|
+
verbosity.add_argument("--verbose", action="store_true", help="Show debug output")
|
|
177
|
+
verbosity.add_argument("--quiet", action="store_true", help="Only show warnings and errors")
|
|
178
|
+
args = parser.parse_args()
|
|
179
|
+
|
|
180
|
+
fh = _setup_logging(args.log_file, args.verbose, args.quiet)
|
|
181
|
+
|
|
182
|
+
location = args.location
|
|
183
|
+
media_dir = "media"
|
|
184
|
+
os.makedirs(media_dir, exist_ok=True)
|
|
185
|
+
|
|
186
|
+
if args.clear_cache:
|
|
187
|
+
shutil.rmtree(media_dir)
|
|
188
|
+
os.makedirs(media_dir)
|
|
189
|
+
log.info("Media cache cleared.")
|
|
190
|
+
|
|
191
|
+
# Determine species source: allaboutbirds URL/place ID, or eBird region code
|
|
192
|
+
use_ebird = re.match(r"^[A-Z]{2}(-[A-Z]{2}(-\d+)?)?$", location.upper())
|
|
193
|
+
if use_ebird:
|
|
194
|
+
region = location.upper()
|
|
195
|
+
if not os.getenv("EBIRD_API_KEY"):
|
|
196
|
+
log.error("EBIRD_API_KEY not set — add it to .env")
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
raw = ebird.fetch_species(region, limit=args.limit)
|
|
199
|
+
slugs = [allaboutbirds.species_slug(b["comName"]) for b in raw]
|
|
200
|
+
names = {allaboutbirds.species_slug(b["comName"]): b for b in raw}
|
|
201
|
+
deck_name = args.deck_name or f"Birds – {region}"
|
|
202
|
+
deck_seed = region
|
|
203
|
+
else:
|
|
204
|
+
slugs = allaboutbirds.fetch_browse_species(location, limit=args.limit)
|
|
205
|
+
names = {} # resolved lazily from each overview page
|
|
206
|
+
deck_name = args.deck_name or "Birds – Local"
|
|
207
|
+
deck_seed = location
|
|
208
|
+
|
|
209
|
+
if not slugs:
|
|
210
|
+
log.error("No species found for: %s", location)
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
deck_id = int(hashlib.md5(deck_seed.encode()).hexdigest()[:8], 16)
|
|
214
|
+
deck = genanki.Deck(deck_id, deck_name)
|
|
215
|
+
all_media = []
|
|
216
|
+
skipped = 0
|
|
217
|
+
|
|
218
|
+
for slug in slugs:
|
|
219
|
+
# Resolve common + scientific name
|
|
220
|
+
if slug in names:
|
|
221
|
+
name = names[slug]["comName"]
|
|
222
|
+
sci = names[slug]["sciName"]
|
|
223
|
+
else:
|
|
224
|
+
resolved = allaboutbirds.slug_to_names(slug)
|
|
225
|
+
name = resolved["comName"]
|
|
226
|
+
sci = resolved["sciName"]
|
|
227
|
+
|
|
228
|
+
safe = _safe_name(name)
|
|
229
|
+
log.info("── %s ──", name)
|
|
230
|
+
|
|
231
|
+
overview = allaboutbirds.fetch_overview(slug)
|
|
232
|
+
if not overview["desc"] and not overview["images"]:
|
|
233
|
+
log.warning(" no allaboutbirds page found — skipping")
|
|
234
|
+
skipped += 1
|
|
235
|
+
time.sleep(args.delay)
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if not sci:
|
|
239
|
+
sci = overview.get("sciName", "")
|
|
240
|
+
|
|
241
|
+
if not args.no_images:
|
|
242
|
+
img_fields, img_paths = _get_images(overview["images"], safe, media_dir)
|
|
243
|
+
all_media.extend(img_paths)
|
|
244
|
+
else:
|
|
245
|
+
img_fields = ["", ""]
|
|
246
|
+
|
|
247
|
+
time.sleep(args.delay)
|
|
248
|
+
|
|
249
|
+
if not args.no_audio:
|
|
250
|
+
sounds = allaboutbirds.fetch_sounds(slug)
|
|
251
|
+
call_field, call_paths = _get_audio(sounds, "call", safe, media_dir)
|
|
252
|
+
all_media.extend(call_paths)
|
|
253
|
+
time.sleep(args.delay)
|
|
254
|
+
song_field, song_paths = _get_audio(sounds, "song", safe, media_dir)
|
|
255
|
+
all_media.extend(song_paths)
|
|
256
|
+
time.sleep(args.delay)
|
|
257
|
+
else:
|
|
258
|
+
call_field, song_field = "", ""
|
|
259
|
+
|
|
260
|
+
note = genanki.Note(
|
|
261
|
+
model=anki_model.MODEL,
|
|
262
|
+
fields=[
|
|
263
|
+
name,
|
|
264
|
+
sci,
|
|
265
|
+
img_fields[0],
|
|
266
|
+
img_fields[1],
|
|
267
|
+
call_field,
|
|
268
|
+
song_field,
|
|
269
|
+
overview["desc"],
|
|
270
|
+
],
|
|
271
|
+
guid=genanki.guid_for(deck_seed, name, "v1"),
|
|
272
|
+
)
|
|
273
|
+
deck.add_note(note)
|
|
274
|
+
|
|
275
|
+
pkg = genanki.Package(deck)
|
|
276
|
+
pkg.media_files = all_media
|
|
277
|
+
output = args.output or f"Birds_{re.sub(r'[^A-Za-z0-9]', '_', deck_seed[:30])}.apkg"
|
|
278
|
+
pkg.write_to_file(output)
|
|
279
|
+
|
|
280
|
+
log.info("✅ Saved → %s", output)
|
|
281
|
+
log.info(
|
|
282
|
+
" %d species, %d skipped, %d notes, %d media files",
|
|
283
|
+
len(slugs),
|
|
284
|
+
skipped,
|
|
285
|
+
len(deck.notes),
|
|
286
|
+
len(all_media),
|
|
287
|
+
)
|
|
288
|
+
log.info(" File > Import > %s", output)
|
|
289
|
+
|
|
290
|
+
fh.close()
|
|
291
|
+
log.removeHandler(fh)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""eBird API helpers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger("bird_deck")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _headers() -> dict:
|
|
12
|
+
return {"X-eBirdApiToken": os.getenv("EBIRD_API_KEY", "")}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch_species(region_code: str, limit: int | None = None) -> list[dict]:
|
|
16
|
+
"""
|
|
17
|
+
Return [{speciesCode, comName, sciName}] for every species recorded in a region.
|
|
18
|
+
Results are in eBird taxonomic order.
|
|
19
|
+
"""
|
|
20
|
+
log.info("Fetching eBird species list for %s…", region_code)
|
|
21
|
+
r = requests.get(
|
|
22
|
+
f"https://api.ebird.org/v2/product/spplist/{region_code}",
|
|
23
|
+
headers=_headers(),
|
|
24
|
+
timeout=15,
|
|
25
|
+
)
|
|
26
|
+
r.raise_for_status()
|
|
27
|
+
codes: list[str] = r.json()
|
|
28
|
+
log.info(" %d species recorded in %s", len(codes), region_code)
|
|
29
|
+
|
|
30
|
+
if limit:
|
|
31
|
+
codes = codes[:limit]
|
|
32
|
+
|
|
33
|
+
# Resolve codes → full names in batches (safe URL length)
|
|
34
|
+
species: list[dict] = []
|
|
35
|
+
for i in range(0, len(codes), 200):
|
|
36
|
+
batch = codes[i : i + 200]
|
|
37
|
+
r2 = requests.get(
|
|
38
|
+
"https://api.ebird.org/v2/ref/taxonomy/ebird",
|
|
39
|
+
params={"species": ",".join(batch), "fmt": "json"},
|
|
40
|
+
headers=_headers(),
|
|
41
|
+
timeout=30,
|
|
42
|
+
)
|
|
43
|
+
r2.raise_for_status()
|
|
44
|
+
for t in r2.json():
|
|
45
|
+
species.append({
|
|
46
|
+
"speciesCode": t["speciesCode"],
|
|
47
|
+
"comName": t["comName"],
|
|
48
|
+
"sciName": t["sciName"],
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
log.info(" Resolved %d species names", len(species))
|
|
52
|
+
return species
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Media download, caching, and ffmpeg trimming."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("bird_deck")
|
|
10
|
+
|
|
11
|
+
AUDIO_MAX_SECONDS = 10
|
|
12
|
+
HEADERS = {
|
|
13
|
+
"User-Agent": (
|
|
14
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
15
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
16
|
+
),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_cached(media_dir: str, base: str, exts: list[str]) -> str | None:
|
|
21
|
+
"""Return 'base.ext' if a file with any of the given extensions exists, else None."""
|
|
22
|
+
for ext in exts:
|
|
23
|
+
if os.path.exists(os.path.join(media_dir, base + ext)):
|
|
24
|
+
return base + ext
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_cached_image(media_dir: str, base: str) -> str | None:
|
|
29
|
+
return find_cached(media_dir, base, [".jpg", ".jpeg", ".png", ".webp"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_cached_audio(media_dir: str, base: str) -> str | None:
|
|
33
|
+
return find_cached(media_dir, base, [".mp3", ".wav", ".ogg"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def download_file(url: str, path: str) -> bool:
|
|
37
|
+
"""Download a URL to a local path. Returns True on success."""
|
|
38
|
+
try:
|
|
39
|
+
resp = requests.get(url, headers=HEADERS, timeout=30)
|
|
40
|
+
resp.raise_for_status()
|
|
41
|
+
with open(path, "wb") as f:
|
|
42
|
+
f.write(resp.content)
|
|
43
|
+
return True
|
|
44
|
+
except Exception as e:
|
|
45
|
+
log.warning("Download failed (%s): %s", url, e)
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def trim_to_mp3(src: str, dst: str, seconds: int = AUDIO_MAX_SECONDS) -> bool:
|
|
50
|
+
"""Trim audio to `seconds` and re-encode as MP3 via ffmpeg. Returns True on success."""
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
["ffmpeg", "-y", "-i", src,
|
|
54
|
+
"-t", str(seconds),
|
|
55
|
+
"-acodec", "libmp3lame", "-q:a", "4",
|
|
56
|
+
dst],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
timeout=30,
|
|
59
|
+
)
|
|
60
|
+
return result.returncode == 0
|
|
61
|
+
except Exception as e:
|
|
62
|
+
log.warning("ffmpeg trim failed (%s): %s", src, e)
|
|
63
|
+
return False
|