Skip to main content

howler_core/
gbif.rs

1use crate::db::Database;
2use crate::models::{Sighting, Source};
3use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use serde::Deserialize;
6
7const GBIF_API_BASE: &str = "https://api.gbif.org/v1/occurrence/search";
8
9#[derive(Debug, Deserialize)]
10struct GBIFResponse {
11    results: Vec<GBIFOccurrence>,
12}
13
14#[derive(Debug, Deserialize)]
15struct GBIFOccurrence {
16    species: Option<String>,
17    scientific_name: Option<String>,
18    decimal_latitude: Option<f64>,
19    decimal_longitude: Option<f64>,
20    event_date: Option<String>,
21    gbif_id: Option<i64>,
22    occurrence_id: Option<String>,
23    #[serde(rename = "verbatimLocality")]
24    verbatim_locality: Option<String>,
25}
26
27pub struct GBIFClient {
28    client: reqwest::Client,
29}
30
31impl GBIFClient {
32    pub fn new() -> Self {
33        Self {
34            client: reqwest::Client::new(),
35        }
36    }
37
38    pub async fn fetch_wolf_sightings(&self, limit: u32) -> Result<Vec<Sighting>> {
39        let url = format!(
40            "{}?speciesKey=5219408&hasCoordinate=true&limit={}",
41            GBIF_API_BASE, limit
42        );
43
44        let response = self
45            .client
46            .get(&url)
47            .header("User-Agent", "Howler/0.1.0 (wolf tracking application)")
48            .send()
49            .await
50            .context("Failed to send GBIF request")?;
51
52        if !response.status().is_success() {
53            anyhow::bail!("GBIF API returned error: {}", response.status());
54        }
55
56        let gbif_response: GBIFResponse = response
57            .json()
58            .await
59            .context("Failed to parse GBIF response")?;
60
61        let sightings = gbif_response
62            .results
63            .into_iter()
64            .filter_map(|occ| self.occurrence_to_sighting(occ))
65            .collect();
66
67        Ok(sightings)
68    }
69
70    fn occurrence_to_sighting(&self, occ: GBIFOccurrence) -> Option<Sighting> {
71        let latitude = occ.decimal_latitude?;
72        let longitude = occ.decimal_longitude?;
73
74        let observed_on = if let Some(date_str) = occ.event_date {
75            DateTime::parse_from_rfc3339(&date_str)
76                .or_else(|_| DateTime::parse_from_rfc2822(&date_str))
77                .map(|dt| dt.with_timezone(&Utc))
78                .ok()
79        } else {
80            None
81        }
82        .unwrap_or_else(Utc::now);
83
84        let source_id = occ
85            .occurrence_id
86            .unwrap_or_else(|| occ.gbif_id.map(|id| id.to_string()).unwrap_or_default());
87
88        Some(Sighting {
89            id: None,
90            species: occ.species.unwrap_or_else(|| "Canis lupus".to_string()),
91            scientific_name: occ.scientific_name,
92            latitude,
93            longitude,
94            observed_on,
95            source: Source::GBIF,
96            source_id,
97            details: occ.verbatim_locality,
98        })
99    }
100}
101
102impl Default for GBIFClient {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108pub async fn fetch_and_cache_gbif(db: &Database, limit: u32) -> Result<usize> {
109    let client = GBIFClient::new();
110    let sightings = client.fetch_wolf_sightings(limit).await?;
111
112    let mut count = 0;
113    for sighting in sightings {
114        db.insert_sighting(&sighting)
115            .context("Failed to insert sighting into database")?;
116        count += 1;
117    }
118
119    Ok(count)
120}