dizhen 0.1.0

Library to retrieve seismic data
Documentation
use {
	std::str::FromStr,
	ureq::get,
	chrono::FixedOffset,
	serde::Deserialize,
	once_cell::sync::Lazy,
	crate::{
		Event, ReportStatus,
		sources::{Source, SourceInfo, SourceId, QueryPlan, opt_to_str},
		DateTimeF, Result, Error
	},
};

static EAST8: Lazy<FixedOffset> = Lazy::new(|| FixedOffset::east_opt(8 * 60 * 60).unwrap());

/** China Earthquake Network Center data source.
 *
 * No support for page sizing; all pages are (currently) max 20 records.
 *
 * Data in ad-hoc format, no public documentation.
 *
 * Time values are `YYYY-MM-DD HH:mm:ss`, in UTC+8 but no offset. URL params
 * seem to ignore parts after that, so RFC3339 formatted strings are fine.
 */
pub struct CENCSource;

impl CENCSource {
	pub fn new() -> Self {
		Self{}
	}
}

impl SourceInfo for CENCSource {
	const SOURCE_ID: SourceId = "CENC";
	const LATEST_API_URL: &'static str = "https://www.ceic.ac.cn/ajax/speedsearch";
	const HISTORY_API_URL: &'static str = "https://www.ceic.ac.cn/ajax/search";
}

fn request<'a, P>(base: &str, queries: P) -> Result<Vec<Event>>
// ureq::Request::query_pairs
where P: IntoIterator<Item = (&'a str, &'a str)> {
	let resp = get(base)
		.query_pairs(queries)
		.call()?
		.into_string().map_err(|e| Error::InvalidResponse { source: e })?;
	// skip JSONP ()
	let resp = &resp[1..resp.len() - 1];
	let raw_data: CENCRawData = serde_json::from_str(resp)?;
	let mut events: Vec<Event> = raw_data.shuju
		.into_iter().filter_map(|i| i.try_into().ok()).collect();
	events.sort_by(|a, b| b.time.cmp(&a.time));
	Ok(events)
}

impl Source for CENCSource {
	fn get_latest(&self) -> Result<Vec<Event>> {
		request(Self::LATEST_API_URL, [("num", "1"), ("page", "1")])
	}

	fn run_query(&self, query: &QueryPlan) -> Result<Vec<Event>> {
		request(Self::HISTORY_API_URL, [
			("start", opt_to_str(query.since.map(|v| v.with_timezone(&*EAST8).to_rfc3339())).as_str()),
			("end", opt_to_str(query.until.map(|v| v.with_timezone(&*EAST8).to_rfc3339())).as_str()),
			("jingdu1", opt_to_str(query.min_lon).as_str()),
			("jingdu2", opt_to_str(query.max_lon).as_str()),
			("weidu1", opt_to_str(query.min_lat).as_str()),
			("weidu2", opt_to_str(query.max_lat).as_str()),
			("height1", opt_to_str(query.min_depth).as_str()),
			("height2", opt_to_str(query.max_depth).as_str()),
			("zhenji1", opt_to_str(query.min_mag).as_str()),
			("zhenji2", opt_to_str(query.max_mag).as_str()),
			("page", &query.page.to_string()),
		])
	}
}

#[allow(non_snake_case, dead_code)]
#[derive(serde::Deserialize, Debug)]
struct CENCRawItem {
	id: String,
	CATA_ID: String,
	SAVE_TIME: String,
	O_TIME: String,
	EPI_LAT: String,
	EPI_LON: String,
	EPI_DEPTH: f64,
	AUTO_FLAG: String,
	EQ_TYPE: String,
	O_TIME_FRA: String,
	M: String,
	M_MS: String,
	M_MS7: String,
	M_ML: String,
	M_MB: String,
	M_MB2: String,
	SUM_STN: String,
	LOC_STN: String,
	LOCATION_C: String,
	LOCATION_S: String,
	CATA_TYPE: String,
	SYNC_TIME: String,
	IS_DEL: String,
	EQ_CATA_TYPE: String,
	NEW_DID: String,
}

#[derive(Deserialize)]
struct CENCRawData {
	shuju: Vec<CENCRawItem>,
}

impl TryFrom<CENCRawItem> for Event {
	type Error = Error;

	fn try_from(value: CENCRawItem) -> Result<Self> {
		let mut date = value.O_TIME.clone();
		date = date.replace(' ', "T");
		date.push_str("+08:00");
		let time = DateTimeF::parse_from_rfc3339(&date)?.into();
		date = value.SYNC_TIME.clone();
		date = date.replace(' ', "T");
		date.push_str("+08:00");
		let updated = DateTimeF::parse_from_rfc3339(&date)?.into();
		Ok(Self{
			id: value.NEW_DID.clone(),
			time,
			updated,
			url: format!("https://news.cenc.ac.cn/{}.html", value.NEW_DID),
			source: CENCSource::SOURCE_ID,
			// TODO: verify this check; it's currently only a guess
			status: if value.AUTO_FLAG == "M" {
				ReportStatus::Reviewed
			} else if value.IS_DEL.is_empty() {
				ReportStatus::Automatic
			} else {
				ReportStatus::Deleted
			},

			magnitude: f64::from_str(&value.M)?,
			longitude: f64::from_str(&value.EPI_LON)?,
			latitude: f64::from_str(&value.EPI_LAT)?,
			depth: value.EPI_DEPTH,
			place: value.LOCATION_C,
		})
	}
}

#[cfg(test)]
mod test {
	use crate::{Source, CENCSource, DateTimeF, Result};

	#[test]
	fn test_get_latest() -> Result<()> {
		CENCSource::new().get_latest()?;
		Ok(())
	}

	#[test]
	fn test_query() -> Result<()> {
		let since = DateTimeF::parse_from_rfc3339("2023-01-01T00:00:00Z")?.into();
		let until = DateTimeF::parse_from_rfc3339("2023-01-02T00:00:00Z")?.into();
		let events = CENCSource::new().query()
				.since(&since).until(&until)
				.run()?;
		assert_eq!(
			vec!["CD20230102161429", "CC20230102023507", "CC20230102022429"],
			events.iter().map(|e| e.id.as_str()).collect::<Vec<&str>>()
		);
		Ok(())
	}
}