Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>PlayPulse | Scraper</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root { | |
| --bg: #0b0e14; | |
| --surface: #151921; | |
| --surface2: #1c2333; | |
| --border: #232a35; | |
| --accent: #3b82f6; | |
| --accent-dim: rgba(59,130,246,0.12); | |
| --green: #22c55e; | |
| --green-dim: rgba(34,197,94,0.12); | |
| --amber: #f59e0b; | |
| --text: #f1f5f9; | |
| --muted: #64748b; | |
| --muted2: #94a3b8; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; } | |
| header { padding: 20px; border-bottom: 1px solid var(--border); background: var(--surface); text-align: center; } | |
| h1 { font-size: 24px; font-weight: 800; color: var(--accent); } | |
| .container { flex: 1; max-width: 1000px; margin: 0 auto; width: 100%; padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; } | |
| .search-container { position: relative; width: 100%; } | |
| input[type="text"] { width: 100%; padding: 16px 20px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--border); color: var(--text); font-size: 16px; outline: none; transition: border 0.2s; } | |
| input[type="text"]:focus { border-color: var(--accent); } | |
| .controls { display: flex; gap: 10px; flex-wrap: wrap; } | |
| select, button { padding: 12px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface2); color: var(--text); outline: none; font-size: 14px; cursor: pointer; } | |
| button.primary { background: var(--accent); color: #fff; border: none; font-weight: 600; } | |
| button.primary:hover { background: #2563eb; } | |
| /* Suggestions Dropdown */ | |
| #suggestions { position: absolute; top: calc(100% + 8px); left: 0; right: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; z-index: 50; overflow: hidden; display: none; box-shadow: 0 10px 25px rgba(0,0,0,0.5); } | |
| .suggestion-card { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.2s; } | |
| .suggestion-card:last-child { border-bottom: none; } | |
| .suggestion-card:hover { background: var(--surface2); } | |
| .suggestion-icon { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; } | |
| .suggestion-info { flex: 1; display: flex; flex-direction: column; } | |
| .suggestion-title { font-weight: 600; font-size: 14px; } | |
| .suggestion-dev { font-size: 12px; color: var(--muted); } | |
| /* Results */ | |
| #results { flex: 1; background: var(--surface); border-radius: 12px; border: 1px solid var(--border); padding: 20px; } | |
| .app-header { display: flex; gap: 20px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--border); align-items: center;} | |
| .app-header img { width: 80px; height: 80px; border-radius: 16px; } | |
| .app-title { font-size: 20px; font-weight: 700; } | |
| .app-stats { color: var(--muted); font-size: 14px; margin-top: 4px; } | |
| .review-card { background: var(--surface2); padding: 16px; border-radius: 12px; margin-bottom: 16px; border: 1px solid var(--border); } | |
| .review-header { display: flex; justify-content: space-between; margin-bottom: 12px; } | |
| .reviewer { display: flex; gap: 10px; align-items: center; font-weight: 600; } | |
| .reviewer img { width: 32px; height: 32px; border-radius: 50%; } | |
| .stars { color: var(--amber); } | |
| .review-date { color: var(--muted); font-size: 12px; } | |
| .review-body { font-size: 14px; line-height: 1.5; color: var(--text); } | |
| .loading { display: none; text-align: center; color: var(--muted); padding: 20px; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>PlayPulse</h1> | |
| </header> | |
| <div class="container"> | |
| <div class="search-container"> | |
| <input type="text" id="target" placeholder="Search app name or paste URL..." autocomplete="off" /> | |
| <div id="suggestions"> | |
| <div id="searchGrid"></div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <select id="review_count"> | |
| <option value="50">50 Reviews</option> | |
| <option value="150" selected>150 Reviews</option> | |
| <option value="500">500 Reviews</option> | |
| </select> | |
| <select id="sort_order"> | |
| <option value="NEWEST">Newest</option> | |
| <option value="MOST_RELEVANT" selected>Most Relevant</option> | |
| <option value="RATING">Rating</option> | |
| </select> | |
| <button class="primary" onclick="run()">Scrape Reviews</button> | |
| </div> | |
| <div class="loading" id="loading">Scraping reviews... Please wait.</div> | |
| <div id="results"> | |
| <div style="color: var(--muted); text-align: center; margin-top: 40px;">Search for an app to see reviews.</div> | |
| </div> | |
| </div> | |
| <script> | |
| const targetEl = document.getElementById('target'); | |
| const suggestionsBox = document.getElementById('suggestions'); | |
| const searchGrid = document.getElementById('searchGrid'); | |
| let debounceTimer; | |
| targetEl.addEventListener('input', (e) => { | |
| clearTimeout(debounceTimer); | |
| const query = e.target.value.trim(); | |
| if (query.length < 2) { | |
| hideSuggestions(); | |
| return; | |
| } | |
| debounceTimer = setTimeout(async () => { | |
| try { | |
| const res = await fetch('/search-suggestions', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ query }) | |
| }); | |
| const data = await res.json(); | |
| if (!data.results || data.results.length === 0) { | |
| hideSuggestions(); | |
| return; | |
| } | |
| suggestionsBox.style.display = 'block'; | |
| searchGrid.innerHTML = data.results.map(app => ` | |
| <div class="suggestion-card" onclick="selectSuggestion('${app.appId}', '${app.title.replace(/'/g, "\\'")}')"> | |
| <img src="${app.icon}" class="suggestion-icon" alt="icon" /> | |
| <div class="suggestion-info"> | |
| <span class="suggestion-title">${app.title}</span> | |
| <span class="suggestion-dev">${app.developer} • ${app.score} ★</span> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } catch (err) { | |
| console.error(err); | |
| } | |
| }, 400); | |
| }); | |
| function hideSuggestions() { | |
| suggestionsBox.style.display = 'none'; | |
| } | |
| // --- THE SMART MOVE --- | |
| // Instead of passing "com.whatsapp", we construct the FULL Play Store URL. | |
| // This allows the old reliable `extract_app_id` to parse out the ?id= properly. | |
| function selectSuggestion(appId, title) { | |
| const validId = appId && appId !== 'null' && appId !== 'None' && !appId.includes('None'); | |
| const query = validId ? `https://play.google.com/store/apps/details?id=${appId}` : title; | |
| targetEl.value = query; | |
| hideSuggestions(); | |
| run(); // auto-start scraping | |
| } | |
| document.addEventListener('click', (e) => { | |
| if (!e.target.closest('.search-container')) { | |
| hideSuggestions(); | |
| } | |
| }); | |
| async function run() { | |
| const identifier = targetEl.value.trim(); | |
| if (!identifier) return; | |
| document.getElementById('loading').style.display = 'block'; | |
| document.getElementById('results').innerHTML = ''; | |
| hideSuggestions(); | |
| try { | |
| const res = await fetch('/scrape', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| identifier, | |
| review_count: document.getElementById('review_count').value, | |
| sort_order: document.getElementById('sort_order').value, | |
| star_ratings: 'all' | |
| }) | |
| }); | |
| const data = await res.json(); | |
| document.getElementById('loading').style.display = 'none'; | |
| if (data.error) { | |
| document.getElementById('results').innerHTML = `<div style="color: var(--amber);">${data.error}</div>`; | |
| return; | |
| } | |
| let html = ` | |
| <div class="app-header"> | |
| <img src="${data.app_info.icon}" alt="App Icon" /> | |
| <div> | |
| <div class="app-title">${data.app_info.title}</div> | |
| <div class="app-stats">${data.app_info.score} ★ • ${data.app_info.reviews} Total Reviews</div> | |
| </div> | |
| </div> | |
| `; | |
| data.reviews.forEach(r => { | |
| const stars = '★'.repeat(r.score) + '☆'.repeat(5 - r.score); | |
| const date = new Date(r.at).toLocaleDateString(); | |
| html += ` | |
| <div class="review-card"> | |
| <div class="review-header"> | |
| <div class="reviewer"> | |
| <img src="${r.userImage}" alt="User" onerror="this.src='https://via.placeholder.com/32'" /> | |
| <span>${r.userName}</span> | |
| </div> | |
| <div class="stars">${stars}</div> | |
| </div> | |
| <div class="review-body">${r.content}</div> | |
| <div class="review-date">${date}</div> | |
| </div> | |
| `; | |
| }); | |
| document.getElementById('results').innerHTML = html; | |
| } catch (err) { | |
| document.getElementById('loading').style.display = 'none'; | |
| document.getElementById('results').innerHTML = `<div style="color: var(--amber);">Error: ${err.message}</div>`; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |