1use std::sync::LazyLock;
11use std::time::Duration;
12
13use pyo3::exceptions::PyValueError;
14use pyo3::prelude::*;
15use pyo3::types::{PyDict, PyList};
16
17use phrona_core::{Category, Profile, SearchClient, SearchOptions};
18
19static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
23 tokio::runtime::Builder::new_multi_thread()
24 .enable_all()
25 .build()
26 .expect("tokio runtime")
27});
28
29fn parse_profile(s: &str) -> PyResult<Profile> {
30 let name = s.trim().to_ascii_lowercase();
31 let p = match name.as_str() {
32 "chrome" | "chrome148" => Profile::Chrome,
33 "chrome100" => Profile::Chrome100,
34 "chrome120" => Profile::Chrome120,
35 "chrome131" => Profile::Chrome131,
36 "chrome140" => Profile::Chrome140,
37 "chrome149" => Profile::Chrome149,
38 "firefox" | "firefox148" => Profile::Firefox,
39 "firefox139" => Profile::Firefox139,
40 "edge" | "edge148" => Profile::Edge,
41 "safari" | "safari26" => Profile::Safari,
42 "opera" | "opera131" => Profile::Opera,
43 "okhttp" => Profile::OkHttp,
44 "random" => Profile::Random,
45 _ => return Err(PyValueError::new_err(format!("unknown profile '{s}'"))),
46 };
47 Ok(p)
48}
49
50fn parse_category(s: &str) -> PyResult<Category> {
51 s.parse::<Category>().map_err(|_| {
52 PyValueError::new_err("category must be one of: web, images, news, videos, books")
53 })
54}
55
56fn to_py(py: Python<'_>, v: &impl serde::Serialize) -> PyResult<Py<PyAny>> {
57 let j =
58 serde_json::to_value(v).map_err(|e| PyValueError::new_err(format!("serialize: {e}")))?;
59 json_to_py(py, &j)
60}
61
62fn json_to_py(py: Python<'_>, v: &serde_json::Value) -> PyResult<Py<PyAny>> {
64 let o: Py<PyAny> = match v {
65 serde_json::Value::Null => py.None(),
66 serde_json::Value::Bool(b) => (*b).into_pyobject(py)?.to_owned().into_any().unbind(),
67 serde_json::Value::Number(n) => {
68 if let Some(i) = n.as_i64() {
69 i.into_pyobject(py)?.into_any().unbind()
70 } else {
71 n.as_f64()
72 .unwrap_or(0.0)
73 .into_pyobject(py)?
74 .into_any()
75 .unbind()
76 }
77 }
78 serde_json::Value::String(s) => s.into_pyobject(py)?.into_any().unbind(),
79 serde_json::Value::Array(items) => {
80 let list = PyList::empty(py);
81 for item in items {
82 list.append(json_to_py(py, item)?)?;
83 }
84 list.into_any().unbind()
85 }
86 serde_json::Value::Object(map) => {
87 let d = PyDict::new(py);
88 for (k, val) in map {
89 d.set_item(k, json_to_py(py, val)?)?;
90 }
91 d.into_any().unbind()
92 }
93 };
94 Ok(o)
95}
96
97#[pyclass]
99struct Client {
100 client: SearchClient,
101}
102
103#[pymethods]
104impl Client {
105 #[new]
106 #[pyo3(signature = (profile="chrome", timeout=15.0))]
107 fn new(profile: &str, timeout: f64) -> PyResult<Self> {
108 let client = SearchClient::with_options(
109 parse_profile(profile)?,
110 Some(Duration::from_secs_f64(timeout.max(1.0))),
111 None,
112 phrona_core::TargetPolicy::default(),
113 )
114 .map_err(|e| PyValueError::new_err(e.to_string()))?;
115 Ok(Self { client })
116 }
117
118 #[pyo3(signature = (query, category="web", engines=None, page=1, max_results=20,
121 safesearch="moderate", region=None, language=None,
122 time_range=None, filters=None))]
123 #[allow(clippy::too_many_arguments)]
124 fn search(
125 &self,
126 py: Python<'_>,
127 query: &str,
128 category: &str,
129 engines: Option<Vec<String>>,
130 page: u32,
131 max_results: usize,
132 safesearch: &str,
133 region: Option<String>,
134 language: Option<String>,
135 time_range: Option<String>,
136 filters: Option<String>,
137 ) -> PyResult<Py<PyAny>> {
138 let mut opts = SearchOptions::new(query);
139 opts.category = parse_category(category)?;
140 opts.engines = engines.unwrap_or_default();
141 opts.page = page.max(1);
142 opts.max_results = max_results.clamp(1, 200);
143 opts.safesearch = safesearch.parse::<phrona_core::SafeSearch>().map_err(|_| {
144 PyValueError::new_err("safesearch must be one of: off, moderate, strict")
145 })?;
146 opts.region = region;
147 opts.language = language;
148 opts.time_range = time_range
149 .map(|t| {
150 t.parse::<phrona_core::TimeRange>().map_err(|_| {
151 PyValueError::new_err("time_range must be one of: day, week, month, year")
152 })
153 })
154 .transpose()?;
155 opts.filters = filters;
156 let resp = py
157 .detach(|| {
158 RUNTIME
159 .block_on(self.client.search(opts))
160 .map_err(|e| e.to_string())
161 })
162 .map_err(PyValueError::new_err)?;
163 to_py(py, &resp)
164 }
165
166 #[pyo3(signature = (query, source=None, region="us-en"))]
169 fn suggest(
170 &self,
171 py: Python<'_>,
172 query: &str,
173 source: Option<String>,
174 region: &str,
175 ) -> PyResult<Py<PyAny>> {
176 let http = self.client.http();
177 let value = py
178 .detach(|| -> Result<serde_json::Value, String> {
179 match source {
180 Some(name) => {
181 let s = phrona_core::SuggestSource::from_name(&name).ok_or_else(|| {
182 format!(
183 "unknown source '{name}', expected one of: {}",
184 phrona_core::SuggestSource::ALL
185 .iter()
186 .map(|s| s.name())
187 .collect::<Vec<_>>()
188 .join(", ")
189 )
190 })?;
191 let list = RUNTIME
192 .block_on(phrona_core::suggest(http, s, query, region))
193 .map_err(|e| e.to_string())?;
194 Ok(serde_json::json!({"query": query, "source": name, "suggestions": list}))
195 }
196 None => {
197 let all = RUNTIME.block_on(phrona_core::suggest_all(http, query, region));
198 let map: serde_json::Map<String, serde_json::Value> = all
199 .into_iter()
200 .map(|(s, list)| (s.name().to_string(), serde_json::json!(list)))
201 .collect();
202 Ok(serde_json::json!({"query": query, "suggestions": map}))
203 }
204 }
205 })
206 .map_err(PyValueError::new_err)?;
207 to_py(py, &value)
208 }
209
210 #[pyo3(signature = (url, max_chars=8000, query=None))]
212 fn extract(
213 &self,
214 py: Python<'_>,
215 url: &str,
216 max_chars: usize,
217 query: Option<&str>,
218 ) -> PyResult<Py<PyAny>> {
219 let page = py
220 .detach(|| {
221 RUNTIME
222 .block_on(phrona_core::extract(
223 self.client.http(),
224 url,
225 max_chars,
226 query,
227 ))
228 .map_err(|e| e.to_string())
229 })
230 .map_err(PyValueError::new_err)?;
231 to_py(py, &page)
232 }
233
234 #[pyo3(signature = (category=None))]
236 fn engines(&self, py: Python<'_>, category: Option<String>) -> PyResult<Py<PyAny>> {
237 let out = py.detach(|| {
238 RUNTIME.block_on(async {
239 let mut out = serde_json::Map::new();
240 let cats: Vec<Category> = match category {
241 Some(c) => vec![parse_category(&c)?],
242 None => Category::ALL.to_vec(),
243 };
244 for cat in cats {
245 let names: Vec<String> = phrona_core::available_engines(cat)
246 .iter()
247 .map(|e| e.name.clone())
248 .collect();
249 out.insert(cat.as_str().to_string(), serde_json::json!(names));
250 }
251 Ok::<_, PyErr>(serde_json::Value::Object(out))
252 })
253 });
254 to_py(py, &out?)
255 }
256}
257
258fn build_client(profile: &str, timeout: f64) -> PyResult<Client> {
259 Client::new(profile, timeout)
260}
261
262#[pyfunction]
264#[pyo3(signature = (query, category="web", engines=None, page=1, max_results=20,
265 safesearch="moderate", region=None, language=None,
266 time_range=None, filters=None, profile="chrome", timeout=15.0))]
267#[allow(clippy::too_many_arguments)]
268fn search(
269 py: Python<'_>,
270 query: &str,
271 category: &str,
272 engines: Option<Vec<String>>,
273 page: u32,
274 max_results: usize,
275 safesearch: &str,
276 region: Option<String>,
277 language: Option<String>,
278 time_range: Option<String>,
279 filters: Option<String>,
280 profile: &str,
281 timeout: f64,
282) -> PyResult<Py<PyAny>> {
283 let client = build_client(profile, timeout)?;
284 client.search(
285 py,
286 query,
287 category,
288 engines,
289 page,
290 max_results,
291 safesearch,
292 region,
293 language,
294 time_range,
295 filters,
296 )
297}
298
299#[pyfunction]
301#[pyo3(signature = (query, source=None, region="us-en"))]
302fn suggest(
303 py: Python<'_>,
304 query: &str,
305 source: Option<String>,
306 region: &str,
307) -> PyResult<Py<PyAny>> {
308 build_client("chrome", 15.0)?.suggest(py, query, source, region)
309}
310
311#[pyfunction]
313#[pyo3(signature = (url, max_chars=8000, query=None))]
314fn extract(
315 py: Python<'_>,
316 url: &str,
317 max_chars: usize,
318 query: Option<&str>,
319) -> PyResult<Py<PyAny>> {
320 build_client("chrome", 15.0)?.extract(py, url, max_chars, query)
321}
322
323#[pyfunction]
325#[pyo3(signature = (category=None))]
326fn engines(py: Python<'_>, category: Option<String>) -> PyResult<Py<PyAny>> {
327 build_client("chrome", 15.0)?.engines(py, category)
328}
329
330#[pyfunction]
331fn version() -> String {
332 phrona_core::version().to_string()
333}
334
335#[pymodule]
336fn phrona(m: &Bound<'_, PyModule>) -> PyResult<()> {
337 m.add_class::<Client>()?;
338 m.add_function(wrap_pyfunction!(search, m)?)?;
339 m.add_function(wrap_pyfunction!(suggest, m)?)?;
340 m.add_function(wrap_pyfunction!(extract, m)?)?;
341 m.add_function(wrap_pyfunction!(engines, m)?)?;
342 m.add_function(wrap_pyfunction!(version, m)?)?;
343 Ok(())
344}