Skip to main content

yeda_reader_lib/
fetch.rs

1//! `yeda fetch`: a browser window that fetches PDFs through the library proxy. You sign in and
2//! click through where needed; every download lands in the downloads folder. Reads a `Job` as
3//! JSON on stdin and prints one JSON `Outcome` per paper on stdout.
4
5use serde::{Deserialize, Serialize};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
9use std::time::{Duration, Instant};
10use std::sync::{Arc, Mutex};
11use tauri::menu::{Menu, MenuItem, Submenu};
12use tauri::webview::DownloadEvent;
13use tauri::{Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
14use yeda_core::fulltext::{self, Settings};
15
16#[derive(Deserialize)]
17pub struct Job {
18    /// Absolute; downloads are saved here.
19    pub downloads: PathBuf,
20    pub settings: Settings,
21    pub papers: Vec<Paper>,
22}
23
24#[derive(Deserialize, Clone)]
25pub struct Paper {
26    pub key: String,
27    pub doi: String,
28    /// The file name to save the PDF as.
29    pub file: String,
30}
31
32#[derive(Serialize)]
33#[serde(untagged)]
34pub enum Outcome {
35    Saved { key: String, file: PathBuf },
36    Failed { key: String, error: String },
37    /// A page the window loaded, for the log.
38    Visited { key: String, page: String },
39}
40
41enum Event {
42    Loaded(String),
43    /// A page asked for a new window, which the fetch window opens itself instead.
44    NewWindow(String),
45    /// The page's title; the script reports through it, as pages from the web get no IPC.
46    Title(String),
47    Downloaded(PathBuf),
48    NextProxy,
49    Skip,
50    Stop,
51}
52
53/// How trying a paper through one proxy ended.
54enum Step {
55    Done(Outcome),
56    NextProxy,
57    Stop,
58}
59
60fn report(outcome: &Outcome) {
61    let mut out = std::io::stdout().lock();
62    let _ = serde_json::to_writer(&mut out, outcome);
63    let _ = writeln!(out);
64    let _ = out.flush();
65}
66
67const HTML: &str = "yeda:html ";
68
69/// Reports the page and its `citation_pdf_url` in the title, the link through the proxy when the
70/// page came through it. WebKit's PDF viewer runs no scripts, so a page that never reports is a PDF.
71fn report_script(proxy: Option<&str>) -> String {
72    let host = serde_json::to_string(&proxy.and_then(fulltext::proxy_host)).unwrap();
73    let proxy = serde_json::to_string(&proxy).unwrap();
74    format!(
75        r#"(() => {{
76  const m = document.querySelector('meta[name="citation_pdf_url"]');
77  let url = m && m.content ? new URL(m.content, location.href).href : '';
78  const proxy = {proxy}, host = {host};
79  if (url && host && location.hostname.endsWith('.' + host) && !new URL(url).hostname.endsWith('.' + host)) url = proxy.replace('{{url}}', url);
80  document.title = '{HTML}' + location.href + ' ' + url;
81}})()"#
82    )
83}
84
85/// Has the page download `url` as a click on a link would, so the browser's own session and
86/// checks apply; a link to another site opens instead.
87fn download_in_page(window: &WebviewWindow, url: &str) {
88    let url = serde_json::to_string(url).unwrap();
89    let _ = window.eval(format!("(() => {{ const a = document.createElement('a'); a.href = {url}; a.download = ''; document.body.appendChild(a); a.click(); }})()"));
90}
91
92/// Has WebKit download `url` itself, which reaches `on_download` like any download.
93fn download_natively(window: &WebviewWindow, url: &str) {
94    // ponytail: WebKitGTK only; elsewhere the viewer's own download button remains.
95    #[cfg(target_os = "linux")]
96    {
97        use webkit2gtk::WebViewExt;
98        let url = url.to_string();
99        let _ = window.with_webview(move |w| drop(w.inner().download_uri(&url)));
100    }
101    #[cfg(not(target_os = "linux"))]
102    let _ = (window, url);
103}
104
105pub fn run(job: Job) {
106    let (tx, rx) = mpsc::channel::<Event>();
107    // The file name of the paper being fetched, so `on_download` knows where to save.
108    let current: Arc<Mutex<String>> = Arc::default();
109    let mut context = tauri::generate_context!();
110    // The reader's config opens its own window; this mode makes its own.
111    context.config_mut().app.windows.clear();
112    let downloads = job.downloads.clone();
113    tauri::Builder::default()
114        .setup(move |app| {
115            std::fs::create_dir_all(&downloads)?;
116            let fetch = Submenu::with_items(
117                app,
118                "Fetch",
119                true,
120                &[&MenuItem::with_id(app, "skip", "Skip this paper", true, None::<&str>)?, &MenuItem::with_id(app, "stop", "Stop", true, None::<&str>)?],
121            )?;
122            if job.settings.proxies.len() > 1 {
123                fetch.prepend(&MenuItem::with_id(app, "next", "Try the next proxy", true, None::<&str>)?)?;
124            }
125            let menu = Menu::with_items(app, &[&fetch])?;
126            let (load_tx, dl_tx, menu_tx, title_tx, new_tx) = (tx.clone(), tx.clone(), tx.clone(), tx.clone(), tx.clone());
127            let target = current.clone();
128            let dir = downloads.clone();
129            let window = WebviewWindowBuilder::new(app, "fetch", WebviewUrl::External("about:blank".parse().unwrap()))
130                .title("yeda fetch")
131                .inner_size(1100.0, 850.0)
132                .menu(menu)
133                // A profile of its own keeps the proxy session between runs.
134                .data_directory(app.path().app_data_dir()?.join("fetch-browser"))
135                .on_document_title_changed(move |_, title| drop(title_tx.send(Event::Title(title))))
136                .on_new_window(move |url, _| {
137                    let _ = new_tx.send(Event::NewWindow(url.to_string()));
138                    tauri::webview::NewWindowResponse::Deny
139                })
140                .on_page_load(move |_, payload| {
141                    if payload.event() == tauri::webview::PageLoadEvent::Finished {
142                        let _ = load_tx.send(Event::Loaded(payload.url().to_string()));
143                    }
144                })
145                .on_download(move |_, event| {
146                    match event {
147                        DownloadEvent::Requested { destination, .. } => {
148                            *destination = dir.join(format!("{}.part", target.lock().unwrap()));
149                        }
150                        // wry 0.57 shares one failure flag among all downloads and never clears it, so
151                        // after one failure `success` stays false; `keep_pdf` checks the file instead.
152                        DownloadEvent::Finished { .. } => {
153                            let _ = dl_tx.send(Event::Downloaded(dir.join(format!("{}.part", target.lock().unwrap()))));
154                        }
155                        _ => {}
156                    }
157                    true
158                })
159                .build()?;
160            app.on_menu_event(move |_, e| {
161                let _ = menu_tx.send(match e.id().as_ref() {
162                    "next" => Event::NextProxy,
163                    "skip" => Event::Skip,
164                    _ => Event::Stop,
165                });
166            });
167            let close_tx = tx.clone();
168            window.on_window_event(move |e| {
169                if let tauri::WindowEvent::CloseRequested { .. } = e {
170                    let _ = close_tx.send(Event::Stop);
171                }
172            });
173            let handle = app.handle().clone();
174            std::thread::spawn(move || {
175                drive(&window, &job, &rx, &current);
176                handle.exit(0);
177            });
178            Ok(())
179        })
180        .run(context)
181        .expect("error while running the fetch window");
182}
183
184fn drive(window: &WebviewWindow, job: &Job, rx: &Receiver<Event>, current: &Mutex<String>) {
185    let proxies: Vec<Option<&str>> = match job.settings.proxies.as_slice() {
186        [] => vec![None],
187        list => list.iter().map(|p| Some(p.as_str())).collect(),
188    };
189    for (n, paper) in job.papers.iter().enumerate() {
190        *current.lock().unwrap() = paper.file.clone();
191        let mut outcome = Outcome::Failed { key: paper.key.clone(), error: "no proxy gave a PDF".into() };
192        for (i, proxy) in proxies.iter().enumerate() {
193            let through = if proxies.len() > 1 { format!(" · proxy {}/{}", i + 1, proxies.len()) } else { String::new() };
194            let _ = window.set_title(&format!("yeda fetch {}/{} · {}{through} — sign in or click the PDF link if it stops", n + 1, job.papers.len(), paper.key));
195            match through_proxy(window, job, paper, *proxy, rx) {
196                Step::Done(o) => {
197                    outcome = o;
198                    break;
199                }
200                Step::NextProxy => {}
201                Step::Stop => {
202                    for p in &job.papers[n..] {
203                        report(&Outcome::Failed { key: p.key.clone(), error: "fetch window closed".into() });
204                    }
205                    return;
206                }
207            }
208        }
209        report(&outcome);
210    }
211}
212
213/// Opens `paper` through `proxy` and waits for its PDF, or for the user to move on. The browser
214/// makes every request: a publisher's bot check ties its cookies to the browser that earned
215/// them, and the same cookies sent by another client count as a failed check.
216fn through_proxy(window: &WebviewWindow, job: &Job, paper: &Paper, proxy: Option<&str>, rx: &Receiver<Event>) -> Step {
217    // Events from the last page (a repeated report, a late download) would be taken as this one's.
218    if !blank_page(window, rx) {
219        return Step::Stop;
220    }
221    let target = job.downloads.join(&paper.file);
222    let part = job.downloads.join(format!("{}.part", paper.file));
223    let saved = |file: PathBuf| Step::Done(Outcome::Saved { key: paper.key.clone(), file });
224    // Signed in already: a publisher's known PDF URL may need no page at all.
225    for url in signed_in_urls(window, job, paper, proxy) {
226        download_natively(window, &url);
227        match wait_for_download(rx, &part, &target, paper) {
228            Some(step) => return step,
229            None => continue,
230        }
231    }
232    let doi = format!("https://doi.org/{}", paper.doi);
233    let url = proxy.map_or(doi.clone(), |p| fulltext::proxied(&doi, p));
234    if let Err(e) = url.parse().map_err(anyhow::Error::from).and_then(|u| Ok(window.navigate(u)?)) {
235        return Step::Done(Outcome::Failed { key: paper.key.clone(), error: format!("{e:#}") });
236    }
237    let script = report_script(proxy);
238    // The page waiting for its script to report, and the links already tried.
239    let mut loaded: Option<String> = None;
240    let mut tried: Vec<String> = Vec::new();
241    loop {
242        let event = match loaded {
243            Some(_) => rx.recv_timeout(Duration::from_secs(2)),
244            None => rx.recv().map_err(|_| RecvTimeoutError::Disconnected),
245        };
246        match event {
247            Ok(Event::Loaded(url)) => {
248                if url != "about:blank" {
249                    report(&Outcome::Visited { key: paper.key.clone(), page: without_query(&url) });
250                }
251                // ScienceDirect's PDF link is an intermediate page that ends in the viewer, and IEEE's
252                // document pages don't render here; its PDF page is what the PDF button opens.
253                let next = match (fulltext::sciencedirect_pdf(&url), fulltext::ieee(&url)) {
254                    (Some(pdf), _) => Some(pdf),
255                    (_, Some(fulltext::Ieee::Document { stamp })) => Some(stamp),
256                    _ => None,
257                };
258                if let Some(next) = next.filter(|n| !tried.contains(n)) {
259                    tried.push(next.clone());
260                    let _ = next.parse().map(|u| window.navigate(u));
261                    // Left at once, so this page's report isn't waited for.
262                    loaded = None;
263                    continue;
264                }
265                // On every load, so a bot check passed on the PDF page is followed by the PDF.
266                if let Some(fulltext::Ieee::Stamp { pdf }) = fulltext::ieee(&url) {
267                    download_natively(window, &pdf);
268                }
269                let _ = window.eval(&script);
270                loaded = Some(url);
271            }
272            Ok(Event::Title(title)) => {
273                let Some((page, pdf)) = title.strip_prefix(HTML).and_then(|t| t.split_once(' ')) else { continue };
274                // A report from a page already left (a redirect) mustn't cancel the wait for this one.
275                if loaded.as_deref().is_some_and(|l| without_fragment(l) == without_fragment(page)) {
276                    loaded = None;
277                }
278                if pdf.is_empty() || tried.iter().any(|t| t == pdf) {
279                    continue;
280                }
281                tried.push(pdf.to_string());
282                // A link to another site just opens, showing what stands in the way: a sign-in or a check.
283                download_in_page(window, pdf);
284            }
285            // No report: WebKit's PDF viewer, which can be downloaded from like any page.
286            Err(RecvTimeoutError::Timeout) => {
287                let url = loaded.take().unwrap_or_default();
288                if !url.is_empty() && !tried.contains(&url) {
289                    tried.push(url.clone());
290                    report(&Outcome::Visited { key: paper.key.clone(), page: format!("WebKit download of {}", without_query(&url)) });
291                    download_natively(window, &url);
292                }
293            }
294            // Anything but a PDF (an HTML page, a cancelled download) waits for the next one.
295            Ok(Event::Downloaded(done)) if done == part => {
296                if let Some(file) = keep_pdf_or_report(&part, &target, paper) {
297                    return saved(file);
298                }
299            }
300            Ok(Event::Downloaded(_)) => {}
301            Ok(Event::NewWindow(url)) => drop(url.parse().map(|u| window.navigate(u))),
302            Ok(Event::NextProxy) => return Step::NextProxy,
303            Ok(Event::Skip) => return skipped(paper),
304            Ok(Event::Stop) | Err(RecvTimeoutError::Disconnected) => return Step::Stop,
305        }
306    }
307}
308
309fn skipped(paper: &Paper) -> Step {
310    Step::Done(Outcome::Failed { key: paper.key.clone(), error: "skipped in the browser".into() })
311}
312
313/// The publisher's known PDF URLs, through `proxy`, for which the window holds that proxy's cookies.
314fn signed_in_urls(window: &WebviewWindow, job: &Job, paper: &Paper, proxy: Option<&str>) -> Vec<String> {
315    let Some(host) = proxy.and_then(fulltext::proxy_host) else { return Vec::new() };
316    let signed_in = |url: &String| url.parse().ok().and_then(|u| window.cookies_for_url(u).ok()).is_some_and(|c| !c.is_empty());
317    fulltext::publisher_urls(&paper.doi, &job.settings).iter().filter_map(|u| fulltext::proxied_host(u, &host)).filter(signed_in).collect()
318}
319
320/// Waits up to half a minute for the download of `part`; the step when it ended the paper (saved,
321/// skipped, stopped), `None` when nothing usable came.
322fn wait_for_download(rx: &Receiver<Event>, part: &Path, target: &Path, paper: &Paper) -> Option<Step> {
323    let deadline = Instant::now() + Duration::from_secs(30);
324    loop {
325        match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
326            Ok(Event::Downloaded(done)) if done == part => {
327                return keep_pdf_or_report(part, target, paper).map(|file| Step::Done(Outcome::Saved { key: paper.key.clone(), file }));
328            }
329            Ok(Event::Skip) => return Some(skipped(paper)),
330            Ok(Event::NextProxy) => return Some(Step::NextProxy),
331            Ok(Event::Stop) | Err(RecvTimeoutError::Disconnected) => return Some(Step::Stop),
332            Err(RecvTimeoutError::Timeout) => return None,
333            Ok(_) => {}
334        }
335    }
336}
337
338/// `keep_pdf`, reporting what came instead of a PDF: a bot check or an access page, say.
339fn keep_pdf_or_report(part: &Path, target: &Path, paper: &Paper) -> Option<PathBuf> {
340    let bytes = std::fs::read(part).unwrap_or_default();
341    if !bytes.is_empty() && !fulltext::is_pdf(&bytes) {
342        let gist = fulltext::page_gist(&String::from_utf8_lossy(&bytes[..bytes.len().min(512 << 10)]));
343        report(&Outcome::Visited { key: paper.key.clone(), page: format!("downloaded a page, not a PDF: {gist}") });
344    }
345    keep_pdf(part, target)
346}
347
348/// Loads an empty page and drops every event until it has; false if the window was closed.
349fn blank_page(window: &WebviewWindow, rx: &Receiver<Event>) -> bool {
350    if window.navigate("about:blank".parse().unwrap()).is_err() {
351        return false;
352    }
353    loop {
354        match rx.recv_timeout(Duration::from_secs(10)) {
355            Ok(Event::Loaded(url)) if url == "about:blank" => return true,
356            Ok(Event::Stop) | Err(RecvTimeoutError::Disconnected) => return false,
357            // ponytail: a blank page that never reports loading is waited for once, then ignored.
358            Err(RecvTimeoutError::Timeout) => return true,
359            Ok(_) => {}
360        }
361    }
362}
363
364fn without_fragment(url: &str) -> &str {
365    url.split('#').next().unwrap_or_default()
366}
367
368/// `url` without its query and fragment, which can hold sign-in tickets.
369fn without_query(url: &str) -> String {
370    url.split(['?', '#']).next().unwrap_or_default().to_string()
371}
372
373/// Renames a finished download to `target` if it is a whole PDF; anything else is deleted.
374fn keep_pdf(part: &Path, target: &Path) -> Option<PathBuf> {
375    let bytes = std::fs::read(part).ok()?;
376    let tail = &bytes[bytes.len().saturating_sub(2048)..];
377    if !fulltext::is_pdf(&bytes) || !tail.windows(5).any(|w| w == b"%%EOF") {
378        let _ = std::fs::remove_file(part);
379        return None;
380    }
381    std::fs::rename(part, target).ok()?;
382    Some(target.to_path_buf())
383}
384
385#[cfg(test)]
386mod tests {
387    #[test]
388    fn keeps_only_whole_pdfs() {
389        let dir = std::env::temp_dir().join(format!("yeda-keep-pdf-{}", std::process::id()));
390        std::fs::create_dir_all(&dir).unwrap();
391        let (part, target) = (dir.join("x.pdf.part"), dir.join("x.pdf"));
392        std::fs::write(&part, b"%PDF-1.7\n1 0 obj").unwrap();
393        assert!(super::keep_pdf(&part, &target).is_none());
394        assert!(!part.exists());
395        std::fs::write(&part, b"%PDF-1.7\n1 0 obj\nendobj\n%%EOF\n").unwrap();
396        assert_eq!(super::keep_pdf(&part, &target), Some(target.clone()));
397        std::fs::remove_dir_all(&dir).unwrap();
398    }
399}