Skip to main content

hyperdb_bootstrap/
scrape.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Best-effort scraper for the public Hyper releases page.
5//!
6//! When no version pin is supplied, [`crate::scrape::scrape_latest`] fetches
7//! `https://tableau.github.io/hyper-db/docs/releases` and parses out the
8//! most recent version + build id for the given platform. This bypasses
9//! the compile-time pin and can lag or break when the page layout changes
10//! — prefer an explicit [`crate::VersionSource::Builtin`] or
11//! [`crate::VersionSource::TomlFile`] in production.
12
13use regex::Regex;
14use std::collections::HashMap;
15
16use crate::platform::Platform;
17use crate::release::PinnedRelease;
18use crate::Error;
19
20const RELEASES_URL: &str = "https://tableau.github.io/hyper-db/docs/releases";
21
22/// Fetches the public releases page and returns the newest `PinnedRelease`
23/// that has a Java download for `platform`.
24///
25/// The returned `PinnedRelease` has an empty SHA-256 map — scraping only
26/// recovers the version + build id, never a digest.
27///
28/// # Errors
29///
30/// - [`Error::Http`] on network / TLS failure.
31/// - [`Error::HttpStatus`] on a non-success HTTP response.
32/// - [`Error::ScrapeFailed`] when the page layout no longer matches the
33///   expected structure.
34pub fn scrape_latest(platform: Platform) -> Result<PinnedRelease, Error> {
35    tracing::info!(url = RELEASES_URL, "scraping latest release");
36    let client = reqwest::blocking::Client::builder()
37        .user_agent(concat!("hyperd-bootstrap/", env!("CARGO_PKG_VERSION")))
38        .build()
39        .map_err(Error::Http)?;
40    let resp = client.get(RELEASES_URL).send().map_err(Error::Http)?;
41    if !resp.status().is_success() {
42        return Err(Error::http_status(RELEASES_URL, resp.status().as_u16()));
43    }
44    let html = resp.text().map_err(Error::Http)?;
45    parse_latest(&html, platform)
46}
47
48fn parse_latest(html: &str, platform: Platform) -> Result<PinnedRelease, Error> {
49    // The releases page lists entries in reverse-chronological order as
50    // `<h3>VERSION [DATE]</h3>`. The first match is the newest.
51    let h3_re =
52        Regex::new(r"<h3[^>]*>\s*([0-9]+(?:\.[0-9]+){1,3})\s*\[[^\]]+\]").expect("valid regex");
53    let version = h3_re
54        .captures(html)
55        .and_then(|c| c.get(1))
56        .map(|m| m.as_str().to_string())
57        .ok_or(Error::ScrapeFailed(
58            "no <h3>VERSION [DATE]</h3> heading found",
59        ))?;
60
61    // For that version, find the Java zip for the requested platform to
62    // recover the build id.
63    let href_re = Regex::new(&format!(
64        r"tableauhyperapi-java-{plat}-release-main\.{ver}\.(rc[a-z0-9]+)\.zip",
65        plat = regex::escape(platform.slug()),
66        ver = regex::escape(&version),
67    ))
68    .expect("valid regex");
69    let build_id = href_re
70        .captures(html)
71        .and_then(|c| c.get(1))
72        .map(|m| m.as_str().to_string())
73        .ok_or(Error::ScrapeFailed(
74            "no matching java zip href for scraped version",
75        ))?;
76
77    Ok(PinnedRelease {
78        version,
79        build_id,
80        sha256: HashMap::new(),
81    })
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn parse_real_page_snippet() {
90        let html = r#"
91            <h3>0.0.24457 [February 12 2026]</h3>
92            <ul><li>Release notes</li></ul>
93            <a href="https://downloads.tableau.com/tssoftware//tableauhyperapi-java-macos-arm64-release-main.0.0.24457.rc36858b6.zip">Java (macOS arm64)</a>
94            <a href="https://downloads.tableau.com/tssoftware//tableauhyperapi-java-linux-x86_64-release-main.0.0.24457.rc36858b6.zip">Java (Linux)</a>
95            <h3>0.0.20000 [January 1 2025]</h3>
96        "#;
97        let release = parse_latest(html, Platform::MacosArm64).unwrap();
98        assert_eq!(release.version, "0.0.24457");
99        assert_eq!(release.build_id, "rc36858b6");
100    }
101
102    #[test]
103    fn parse_errors_when_no_heading() {
104        let html = "<p>nothing here</p>";
105        assert!(matches!(
106            parse_latest(html, Platform::LinuxX86_64),
107            Err(Error::ScrapeFailed(_))
108        ));
109    }
110
111    #[test]
112    fn parse_errors_when_no_matching_href() {
113        let html = r"<h3>0.0.24457 [Feb 12 2026]</h3>";
114        assert!(matches!(
115            parse_latest(html, Platform::WindowsX86_64),
116            Err(Error::ScrapeFailed(_))
117        ));
118    }
119}