feat: Spotify via oEmbed+ytsearch, Pinterest via pinterest-dl CLI scraping
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:
asepharyana
2026-09-01 17:14:38 +07:00
parent 6b3fad1ad4
commit 4dee99cc43
+259 -166
View File
@@ -250,6 +250,134 @@ async fn run_playwright_scraper(
.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.
fn playwright_to_download_result(data: &serde_json::Value) -> DownloadResult {
let title = data
@@ -1390,21 +1518,59 @@ pub async fn fetch_spotify(url: &str) -> Result<DownloadResult, ScrapingError> {
let captures = re
.captures(url)
.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("");
// Use yt-dlp to extract track info and download URL.
// Spotify tracks are DRM-protected: yt-dlp returns a "DRM" error without
// Premium auth, so catch that and return a clean informational message.
let data = match run_ytdlp_json(url, &["--extract-audio", "--audio-format", "mp3"]).await {
// Downtify-style flow: Spotify is DRM-protected, so we can't download from
// Spotify directly. Instead we (1) resolve the track title via the Spotify
// oEmbed endpoint (works with no auth), then (2) search YouTube Music via
// 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,
Err(_) => {
// Spotify tracks are DRM-protected (yt-dlp returns a "DRM" error without
// Premium auth). Return a clean informational message instead of an error.
Err(e) => {
// Both oEmbed and yt-dlp failed. Return a graceful message.
let mut result = DownloadResult::success(None);
result.provider = Some(format!("spotify-metadata ({})", resource_type));
result.provider = Some("spotify".to_string());
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()),
file_type: Some(MediaType::File),
extension: Some("json".to_string()),
@@ -1413,19 +1579,21 @@ pub async fn fetch_spotify(url: &str) -> Result<DownloadResult, ScrapingError> {
size_bytes: None,
frame_width: None,
frame_height: None,
note: Some(
"Spotify is DRM-protected: direct audio download requires a Spotify Premium account. Returned track metadata instead."
.to_string(),
),
note: Some(format!(
"Spotify is DRM-protected; could not resolve a matching source on YouTube. {}",
e
)),
});
return Ok(result);
}
};
// Extract title/author/thumbnail from the matched YouTube track.
let title = data
.get("title")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
.map(|s| s.to_string())
.or(oembed_title);
let author = data
.get("artist")
.and_then(|v| v.as_str())
@@ -1438,55 +1606,71 @@ pub async fn fetch_spotify(url: &str) -> Result<DownloadResult, ScrapingError> {
.get("duration")
.and_then(|v| v.as_u64())
.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.thumbnail = thumbnail;
result.thumbnail = thumbnail.clone();
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(|| {
data.get("formats")
.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()))
});
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 {
result.media.push(MediaItem {
url: dl_url.to_string(),
quality: Some("320kbps".to_string()),
quality: Some("high".to_string()),
file_type: Some(MediaType::Audio),
extension: Some("mp3".to_string()),
thumbnail: data
.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()),
thumbnail,
file_size: None,
size_bytes: None,
frame_width: 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
@@ -1976,132 +2160,41 @@ pub async fn fetch_soundcloud(url: &str) -> Result<DownloadResult, ScrapingError
// ============================================================================
pub async fn fetch_pinterest(url: &str) -> Result<DownloadResult, ScrapingError> {
let client = http_client();
let encoded = urlencoding::encode(url).to_string();
// Try the pinterestdownloader.io API — but treat any failure (network, non-JSON,
// error body) as non-fatal so we can fall through to Playwright scraping below.
let resp_opt = match client
.client()
.get(format!(
"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)
// Use the `pinterest-dl` CLI (guest-token handling + parsing) which works
// 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.
let media_result: Option<DownloadResult> = tokio::task::spawn_blocking({
let url_owned = url.to_string();
move || {
run_pinterest_dl(&url_owned)
.map(|data| pinterest_dl_to_download_result(&data))
.ok()
.filter(|resp| {
resp.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}),
Err(_) => None,
},
Err(_) => None,
}
})
.await
.unwrap_or(None);
let mut result = match media_result {
Some(mut r) => {
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 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 pinterest-dl returned nothing, fall back to Playwright scraping.
if result.media.is_empty() {
if let Ok(data) = run_playwright_scraper(url, "pinterest").await {
let pw_result = playwright_to_download_result(&data);