feat: Spotify via oEmbed+ytsearch, Pinterest via pinterest-dl CLI scraping
Deploy Scraper / build-and-deploy (push) Canceled after 0s
Deploy Scraper / build-and-deploy (push) Canceled after 0s
Spotify (was returning DRM metadata stub): - Resolve track title via Spotify oEmbed API (no auth, works from VPS) - Search YouTube Music via yt-dlp ytsearch: -> return real audio stream URL - Provider: spotify-oembed+ytsearch Pinterest (was returning 'No download URLs found'): - Replace dead pinterestdownloader.io API with pinterest-dl CLI scraping - pinterest-dl handles guest-token/cookie dance, returns real media for public pins: HLS video stream (v1.pinimg.com/videos/*.m3u8) + poster image - Provider: pinterest-dl, with Playwright fallback retained Both verified live: Spotify returns real googlevideo audio URL (itag=251, 3.4MB); Pinterest returns real v1.pinimg.com HLS streams (200 verified).
This commit is contained in:
@@ -250,6 +250,134 @@ async fn run_playwright_scraper(
|
|||||||
.map_err(|e| ScrapingError::Http(format!("playwright JSON parse failed: {}", e)))
|
.map_err(|e| ScrapingError::Http(format!("playwright JSON parse failed: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the `pinterest-dl` CLI to scrape a Pinterest pin, returning the parsed
|
||||||
|
/// JSON. pinterest-dl handles Pinterest's guest-token/cookie dance and returns
|
||||||
|
/// real media URLs (HLS video streams or images) for public pins — no login.
|
||||||
|
fn run_pinterest_dl(url: &str) -> Result<serde_json::Value, ScrapingError> {
|
||||||
|
// Locate the pinterest-dl executable: prefer the scraper's venv python's
|
||||||
|
// script dir, then PATH.
|
||||||
|
let candidates = [
|
||||||
|
"/home/code/hermes-agent/.venv/bin/pinterest-dl",
|
||||||
|
"/usr/local/bin/pinterest-dl",
|
||||||
|
"pinterest-dl",
|
||||||
|
];
|
||||||
|
let bin = candidates
|
||||||
|
.iter()
|
||||||
|
.find(|p| {
|
||||||
|
if p.contains('/') {
|
||||||
|
std::path::Path::new(p).exists()
|
||||||
|
} else {
|
||||||
|
std::process::Command::new("sh")
|
||||||
|
.args(["-c", "command -v"])
|
||||||
|
.arg(p)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ScrapingError::Http(
|
||||||
|
"pinterest-dl not found; install it in the scraper venv".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let output = std::process::Command::new(bin)
|
||||||
|
.args(["scrape", url, "--json", "-n", "1"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.map_err(|e| ScrapingError::Http(format!("pinterest-dl spawn failed: {}", e)))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
return Err(ScrapingError::Http(format!(
|
||||||
|
"pinterest-dl failed: {}",
|
||||||
|
stderr.trim().lines().last().unwrap_or("unknown error")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
serde_json::from_str(&stdout_str)
|
||||||
|
.map_err(|e| ScrapingError::Http(format!("pinterest-dl JSON parse failed: {}", e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a pinterest-dl JSON result (from `scrape --json`) into DownloadResult.
|
||||||
|
/// Extracts image `src` URLs and, for video pins, the `media_stream.video.url`
|
||||||
|
/// (HLS m3u8) plus the poster image.
|
||||||
|
fn pinterest_dl_to_download_result(data: &serde_json::Value) -> DownloadResult {
|
||||||
|
let mut result = DownloadResult::success(None);
|
||||||
|
result.provider = Some("pinterest-dl".to_string());
|
||||||
|
let mut media_list: Vec<MediaItem> = Vec::new();
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
if let Some(results) = data.get("results").and_then(|v| v.as_array()) {
|
||||||
|
for res in results {
|
||||||
|
if let Some(items) = res.get("items").and_then(|v| v.as_array()) {
|
||||||
|
for it in items {
|
||||||
|
// Poster/alt image (i.pinimg.com) — always add.
|
||||||
|
if let Some(src) = it.get("src").and_then(|v| v.as_str()) {
|
||||||
|
if !src.is_empty() && !seen.contains(src) {
|
||||||
|
seen.insert(src.to_string());
|
||||||
|
media_list.push(MediaItem {
|
||||||
|
url: src.to_string(),
|
||||||
|
quality: Some("poster".to_string()),
|
||||||
|
file_type: Some(MediaType::Image),
|
||||||
|
extension: Some("jpg".to_string()),
|
||||||
|
thumbnail: Some(src.to_string()),
|
||||||
|
file_size: None,
|
||||||
|
size_bytes: None,
|
||||||
|
frame_width: None,
|
||||||
|
frame_height: None,
|
||||||
|
note: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Video HLS stream (v1.pinimg.com/videos).
|
||||||
|
if let Some(ms) = it.get("media_stream").and_then(|v| v.get("video")) {
|
||||||
|
if let Some(url) = ms.get("url").and_then(|v| v.as_str()) {
|
||||||
|
if !url.is_empty() && !seen.contains(url) {
|
||||||
|
seen.insert(url.to_string());
|
||||||
|
let (w, h) = match (
|
||||||
|
ms.get("resolution").and_then(|v| v.as_array()),
|
||||||
|
ms.get("resolution"),
|
||||||
|
) {
|
||||||
|
(Some(arr), _) if arr.len() >= 2 => (
|
||||||
|
arr[0].as_u64().map(|v| v.to_string()),
|
||||||
|
arr[1].as_u64().map(|v| v.to_string()),
|
||||||
|
),
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
let dur = ms.get("duration").and_then(|v| v.as_u64());
|
||||||
|
media_list.push(MediaItem {
|
||||||
|
url: url.to_string(),
|
||||||
|
quality: Some(format!(
|
||||||
|
"{}x{}",
|
||||||
|
w.clone().unwrap_or_else(|| "?".into()),
|
||||||
|
h.clone().unwrap_or_else(|| "?".into())
|
||||||
|
)),
|
||||||
|
file_type: Some(MediaType::Video),
|
||||||
|
extension: Some("m3u8".to_string()),
|
||||||
|
thumbnail: it
|
||||||
|
.get("src")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
file_size: None,
|
||||||
|
size_bytes: None,
|
||||||
|
frame_width: w,
|
||||||
|
frame_height: h,
|
||||||
|
note: dur.map(|d| format!("{}s", d)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.media = media_list;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a Playwright scraper JSON result (from scrape_media.py) into DownloadResult.
|
/// Convert a Playwright scraper JSON result (from scrape_media.py) into DownloadResult.
|
||||||
fn playwright_to_download_result(data: &serde_json::Value) -> DownloadResult {
|
fn playwright_to_download_result(data: &serde_json::Value) -> DownloadResult {
|
||||||
let title = data
|
let title = data
|
||||||
@@ -1390,21 +1518,59 @@ pub async fn fetch_spotify(url: &str) -> Result<DownloadResult, ScrapingError> {
|
|||||||
let captures = re
|
let captures = re
|
||||||
.captures(url)
|
.captures(url)
|
||||||
.ok_or_else(|| ScrapingError::Http("Invalid Spotify URL".to_string()))?;
|
.ok_or_else(|| ScrapingError::Http("Invalid Spotify URL".to_string()))?;
|
||||||
let resource_type = captures.get(1).map(|m| m.as_str()).unwrap_or("track");
|
let _resource_type = captures.get(1).map(|m| m.as_str()).unwrap_or("track");
|
||||||
let resource_id = captures.get(2).map(|m| m.as_str()).unwrap_or("");
|
let resource_id = captures.get(2).map(|m| m.as_str()).unwrap_or("");
|
||||||
|
|
||||||
// Use yt-dlp to extract track info and download URL.
|
// Downtify-style flow: Spotify is DRM-protected, so we can't download from
|
||||||
// Spotify tracks are DRM-protected: yt-dlp returns a "DRM" error without
|
// Spotify directly. Instead we (1) resolve the track title via the Spotify
|
||||||
// Premium auth, so catch that and return a clean informational message.
|
// oEmbed endpoint (works with no auth), then (2) search YouTube Music via
|
||||||
let data = match run_ytdlp_json(url, &["--extract-audio", "--audio-format", "mp3"]).await {
|
// yt-dlp and return the matched audio URL. This scraping-based approach
|
||||||
|
// yields a real, playable YouTube media URL without Premium.
|
||||||
|
//
|
||||||
|
// Step 1: resolve track metadata via oEmbed.
|
||||||
|
let oembed_url = format!(
|
||||||
|
"https://open.spotify.com/oembed?url=https://open.spotify.com/track/{}",
|
||||||
|
resource_id
|
||||||
|
);
|
||||||
|
let oembed_title: Option<String> = {
|
||||||
|
let client = http_client();
|
||||||
|
match client
|
||||||
|
.client()
|
||||||
|
.get(&oembed_url)
|
||||||
|
.header("User-Agent", "Mozilla/5.0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
match resp.json::<serde_json::Value>().await {
|
||||||
|
Ok(v) => v
|
||||||
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: search YouTube Music via yt-dlp for the track.
|
||||||
|
let search_query = match &oembed_title {
|
||||||
|
Some(t) if !t.trim().is_empty() => t.clone(),
|
||||||
|
// Fallback: if oEmbed failed, try a web search for the title via
|
||||||
|
// yt-dlp using the track ID as a last resort.
|
||||||
|
_ => format!("spotify:track:{}", resource_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
let search = format!("ytsearch:{}", search_query);
|
||||||
|
let data = match run_ytdlp_json(&search, &["--default-search", "auto"]).await {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
// Spotify tracks are DRM-protected (yt-dlp returns a "DRM" error without
|
// Both oEmbed and yt-dlp failed. Return a graceful message.
|
||||||
// Premium auth). Return a clean informational message instead of an error.
|
|
||||||
let mut result = DownloadResult::success(None);
|
let mut result = DownloadResult::success(None);
|
||||||
result.provider = Some(format!("spotify-metadata ({})", resource_type));
|
result.provider = Some("spotify".to_string());
|
||||||
result.media.push(MediaItem {
|
result.media.push(MediaItem {
|
||||||
url: format!("https://open.spotify.com/{}/{}", resource_type, resource_id),
|
url: format!("https://open.spotify.com/track/{}", resource_id),
|
||||||
quality: Some("metadata".to_string()),
|
quality: Some("metadata".to_string()),
|
||||||
file_type: Some(MediaType::File),
|
file_type: Some(MediaType::File),
|
||||||
extension: Some("json".to_string()),
|
extension: Some("json".to_string()),
|
||||||
@@ -1413,19 +1579,21 @@ pub async fn fetch_spotify(url: &str) -> Result<DownloadResult, ScrapingError> {
|
|||||||
size_bytes: None,
|
size_bytes: None,
|
||||||
frame_width: None,
|
frame_width: None,
|
||||||
frame_height: None,
|
frame_height: None,
|
||||||
note: Some(
|
note: Some(format!(
|
||||||
"Spotify is DRM-protected: direct audio download requires a Spotify Premium account. Returned track metadata instead."
|
"Spotify is DRM-protected; could not resolve a matching source on YouTube. {}",
|
||||||
.to_string(),
|
e
|
||||||
),
|
)),
|
||||||
});
|
});
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract title/author/thumbnail from the matched YouTube track.
|
||||||
let title = data
|
let title = data
|
||||||
.get("title")
|
.get("title")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string())
|
||||||
|
.or(oembed_title);
|
||||||
let author = data
|
let author = data
|
||||||
.get("artist")
|
.get("artist")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -1438,55 +1606,71 @@ pub async fn fetch_spotify(url: &str) -> Result<DownloadResult, ScrapingError> {
|
|||||||
.get("duration")
|
.get("duration")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.map(|d| format!("{}s", d));
|
.map(|d| format!("{}s", d));
|
||||||
|
let webpage_url = data
|
||||||
|
.get("webpage_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
let mut result = DownloadResult::success(title);
|
let mut result = DownloadResult::success(title.clone());
|
||||||
result.author = author;
|
result.author = author;
|
||||||
result.thumbnail = thumbnail;
|
result.thumbnail = thumbnail.clone();
|
||||||
result.duration = duration;
|
result.duration = duration;
|
||||||
result.provider = Some(format!("spotify-yt-dlp ({})", resource_type));
|
result.provider = Some("spotify-oembed+ytsearch".to_string());
|
||||||
|
|
||||||
|
// Extract the best audio format URL.
|
||||||
let download_url = data.get("url").and_then(|v| v.as_str()).or_else(|| {
|
let download_url = data.get("url").and_then(|v| v.as_str()).or_else(|| {
|
||||||
data.get("formats")
|
data.get("formats")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.and_then(|f| f.first())
|
.and_then(|f| {
|
||||||
|
f.iter()
|
||||||
|
.filter(|fmt| {
|
||||||
|
fmt.get("acodec")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.map(|c| c != "none")
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.max_by_key(|fmt| fmt.get("abr").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||||
|
})
|
||||||
.and_then(|f| f.get("url").and_then(|v| v.as_str()))
|
.and_then(|f| f.get("url").and_then(|v| v.as_str()))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let note = Some(
|
||||||
|
"Resolved this Spotify track to a YouTube Music source via oEmbed + yt-dlp search (Spotify itself is DRM-protected)."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(dl_url) = download_url {
|
if let Some(dl_url) = download_url {
|
||||||
result.media.push(MediaItem {
|
result.media.push(MediaItem {
|
||||||
url: dl_url.to_string(),
|
url: dl_url.to_string(),
|
||||||
quality: Some("320kbps".to_string()),
|
quality: Some("high".to_string()),
|
||||||
file_type: Some(MediaType::Audio),
|
file_type: Some(MediaType::Audio),
|
||||||
extension: Some("mp3".to_string()),
|
extension: Some("mp3".to_string()),
|
||||||
thumbnail: data
|
thumbnail,
|
||||||
.get("thumbnail")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
file_size: data
|
|
||||||
.get("filesize")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.map(format_filesize),
|
|
||||||
size_bytes: data.get("filesize").and_then(|v| v.as_u64()),
|
|
||||||
frame_width: None,
|
|
||||||
frame_height: None,
|
|
||||||
note: None,
|
|
||||||
});
|
|
||||||
Ok(result)
|
|
||||||
} else {
|
|
||||||
result.media.push(MediaItem {
|
|
||||||
url: format!("https://open.spotify.com/track/{}", resource_id),
|
|
||||||
quality: Some("metadata".to_string()),
|
|
||||||
file_type: Some(MediaType::File),
|
|
||||||
extension: Some("json".to_string()),
|
|
||||||
thumbnail: data.get("thumbnail").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
|
||||||
file_size: None,
|
file_size: None,
|
||||||
size_bytes: None,
|
size_bytes: None,
|
||||||
frame_width: None,
|
frame_width: None,
|
||||||
frame_height: None,
|
frame_height: None,
|
||||||
note: Some("Spotify track metadata extracted via yt-dlp. Direct audio download requires Spotify Premium.".to_string()),
|
note,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.media.push(MediaItem {
|
||||||
|
url: webpage_url
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("https://open.spotify.com/track/{}", resource_id)),
|
||||||
|
quality: Some("metadata".to_string()),
|
||||||
|
file_type: Some(MediaType::File),
|
||||||
|
extension: Some("html".to_string()),
|
||||||
|
thumbnail,
|
||||||
|
file_size: None,
|
||||||
|
size_bytes: None,
|
||||||
|
frame_width: None,
|
||||||
|
frame_height: None,
|
||||||
|
note: Some(
|
||||||
|
"Matched YouTube source found but no direct audio URL was extractable.".to_string(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
Ok(result)
|
|
||||||
}
|
}
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Twitter downloaders
|
// Twitter downloaders
|
||||||
@@ -1976,132 +2160,41 @@ pub async fn fetch_soundcloud(url: &str) -> Result<DownloadResult, ScrapingError
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
pub async fn fetch_pinterest(url: &str) -> Result<DownloadResult, ScrapingError> {
|
pub async fn fetch_pinterest(url: &str) -> Result<DownloadResult, ScrapingError> {
|
||||||
let client = http_client();
|
// Use the `pinterest-dl` CLI (guest-token handling + parsing) which works
|
||||||
let encoded = urlencoding::encode(url).to_string();
|
// from the VPS and returns real image/video URLs for public pins.
|
||||||
|
// Run it in spawn_blocking since it does blocking subprocess + network I/O.
|
||||||
// Try the pinterestdownloader.io API — but treat any failure (network, non-JSON,
|
let media_result: Option<DownloadResult> = tokio::task::spawn_blocking({
|
||||||
// error body) as non-fatal so we can fall through to Playwright scraping below.
|
let url_owned = url.to_string();
|
||||||
let resp_opt = match client
|
move || {
|
||||||
.client()
|
run_pinterest_dl(&url_owned)
|
||||||
.get(format!(
|
.map(|data| pinterest_dl_to_download_result(&data))
|
||||||
"https://pinterestdownloader.io/frontendService/DownloaderService?url={}",
|
|
||||||
encoded
|
|
||||||
))
|
|
||||||
.header(USER_AGENT, "Mozilla/5.0")
|
|
||||||
.header("accept", "*/*")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.header("origin", "https://pinterestdownloader.io")
|
|
||||||
.header("referer", "https://pinterestdownloader.io/")
|
|
||||||
.timeout(Duration::from_secs(15))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(r) => match r.text().await {
|
|
||||||
Ok(body) => serde_json::from_str::<serde_json::Value>(&body)
|
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|resp| {
|
}
|
||||||
resp.get("success")
|
})
|
||||||
.and_then(|v| v.as_bool())
|
.await
|
||||||
.unwrap_or(false)
|
.unwrap_or(None);
|
||||||
}),
|
|
||||||
Err(_) => None,
|
let mut result = match media_result {
|
||||||
},
|
Some(mut r) => {
|
||||||
Err(_) => None,
|
r.media.sort_by(|a, b| {
|
||||||
|
let wa = a
|
||||||
|
.frame_width
|
||||||
|
.clone()
|
||||||
|
.and_then(|w| w.parse::<u64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let wb = b
|
||||||
|
.frame_width
|
||||||
|
.clone()
|
||||||
|
.and_then(|w| w.parse::<u64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
wb.cmp(&wa)
|
||||||
|
});
|
||||||
|
r
|
||||||
|
}
|
||||||
|
None => DownloadResult::success(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut result = DownloadResult::success(None);
|
// If pinterest-dl returned nothing, fall back to Playwright scraping.
|
||||||
|
|
||||||
if let Some(resp) = resp_opt {
|
|
||||||
result.provider = Some("pinterestdownloader".to_string());
|
|
||||||
|
|
||||||
let originals: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
let mut media_list: Vec<MediaItem> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(medias) = resp.get("media").and_then(|v| v.as_array()) {
|
|
||||||
for m in medias {
|
|
||||||
if m.get("extension").and_then(|v| v.as_str()) == Some("jpg")
|
|
||||||
&& m.get("url")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map_or(false, |u| u.contains("i.pinimg.com/"))
|
|
||||||
{
|
|
||||||
// Add original (high-res) variant
|
|
||||||
if let Some(u) = m.get("url").and_then(|v| v.as_str()) {
|
|
||||||
let original_url = u.replace("/2/", "/originals/");
|
|
||||||
if !originals.contains(&original_url) {
|
|
||||||
media_list.push(MediaItem {
|
|
||||||
url: original_url.clone(),
|
|
||||||
quality: Some("original".to_string()),
|
|
||||||
file_type: Some(MediaType::Image),
|
|
||||||
extension: Some("jpg".to_string()),
|
|
||||||
thumbnail: m
|
|
||||||
.get("thumbnail")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
file_size: m
|
|
||||||
.get("size")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
size_bytes: None,
|
|
||||||
frame_width: None,
|
|
||||||
frame_height: None,
|
|
||||||
note: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
media_list.push(MediaItem {
|
|
||||||
url: m
|
|
||||||
.get("url")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string(),
|
|
||||||
quality: m
|
|
||||||
.get("quality")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
file_type: Some(MediaType::Image),
|
|
||||||
extension: m
|
|
||||||
.get("extension")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
thumbnail: m
|
|
||||||
.get("thumbnail")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
file_size: m
|
|
||||||
.get("size")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
size_bytes: None,
|
|
||||||
frame_width: None,
|
|
||||||
frame_height: None,
|
|
||||||
note: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by size desc (like Shirokami)
|
|
||||||
media_list.sort_by(|a, b| {
|
|
||||||
let sa = a
|
|
||||||
.file_size
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let sb = b
|
|
||||||
.file_size
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
sb.cmp(&sa)
|
|
||||||
});
|
|
||||||
|
|
||||||
result.media = media_list;
|
|
||||||
} // end if let Some(resp) — API returned nothing on error, fall through below
|
|
||||||
|
|
||||||
// If the pinterestdownloader.io API returned nothing, fall back to
|
|
||||||
// Playwright browser scraping (same pattern as Instagram/Facebook) —
|
|
||||||
// the Playwright scraper captures v.pinimg.com video URLs from network responses.
|
|
||||||
if result.media.is_empty() {
|
if result.media.is_empty() {
|
||||||
if let Ok(data) = run_playwright_scraper(url, "pinterest").await {
|
if let Ok(data) = run_playwright_scraper(url, "pinterest").await {
|
||||||
let pw_result = playwright_to_download_result(&data);
|
let pw_result = playwright_to_download_result(&data);
|
||||||
|
|||||||
Reference in New Issue
Block a user