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 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.
@@ -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