Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- Dockerfile +23 -0
- app.py +94 -0
- requirement.txt +3 -0
- static/js/main.js +183 -0
- templates/index.html +279 -0
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory in the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy the requirements file into the container at /app
|
| 8 |
+
COPY requirement.txt .
|
| 9 |
+
|
| 10 |
+
# Install any needed packages specified in requirement.txt
|
| 11 |
+
RUN pip install --no-cache-dir -r requirement.txt
|
| 12 |
+
|
| 13 |
+
# Copy the current directory contents into the container at /app
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Make port 5000 available to the world outside this container
|
| 17 |
+
EXPOSE 5000
|
| 18 |
+
|
| 19 |
+
# Define environment variable
|
| 20 |
+
ENV FLASK_APP=app.py
|
| 21 |
+
|
| 22 |
+
# Run app.py when the container launches
|
| 23 |
+
CMD ["python", "app.py"]
|
app.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import urllib.parse
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import io
|
| 4 |
+
import math
|
| 5 |
+
from flask import Flask, request, render_template, send_file, jsonify
|
| 6 |
+
from google_play_scraper import reviews, Sort, search, app as app_info
|
| 7 |
+
|
| 8 |
+
app = Flask(__name__)
|
| 9 |
+
|
| 10 |
+
def extract_app_id(url_or_name: str) -> str:
|
| 11 |
+
if "play.google.com" in url_or_name:
|
| 12 |
+
parsed = urllib.parse.urlparse(url_or_name)
|
| 13 |
+
query_params = urllib.parse.parse_qs(parsed.query)
|
| 14 |
+
if 'id' in query_params:
|
| 15 |
+
return query_params['id'][0]
|
| 16 |
+
return ""
|
| 17 |
+
|
| 18 |
+
@app.route('/scrape', methods=['POST'])
|
| 19 |
+
def scrape():
|
| 20 |
+
try:
|
| 21 |
+
data = request.json
|
| 22 |
+
identifier = data.get('identifier', '').strip()
|
| 23 |
+
count_type = data.get('review_count_type', 'fixed')
|
| 24 |
+
star_rating_input = data.get('star_rating', 'all')
|
| 25 |
+
sort_choice = data.get('sort_order', 'MOST_RELEVANT')
|
| 26 |
+
|
| 27 |
+
sort_map = {
|
| 28 |
+
'MOST_RELEVANT': Sort.MOST_RELEVANT,
|
| 29 |
+
'NEWEST': Sort.NEWEST,
|
| 30 |
+
'RATING': Sort.RATING
|
| 31 |
+
}
|
| 32 |
+
selected_sort = sort_map.get(sort_choice, Sort.MOST_RELEVANT)
|
| 33 |
+
|
| 34 |
+
filter_score = None
|
| 35 |
+
if star_rating_input != 'all':
|
| 36 |
+
filter_score = int(star_rating_input)
|
| 37 |
+
|
| 38 |
+
app_id = extract_app_id(identifier)
|
| 39 |
+
|
| 40 |
+
if not app_id:
|
| 41 |
+
search_results = search(identifier, lang="en", country="us", n_hits=1)
|
| 42 |
+
if search_results:
|
| 43 |
+
app_id = search_results[0]['appId']
|
| 44 |
+
else:
|
| 45 |
+
return jsonify({"error": f"Could not find any app matching '{identifier}'"}), 404
|
| 46 |
+
|
| 47 |
+
# Get app info for the header
|
| 48 |
+
info = app_info(app_id, lang='en', country='us')
|
| 49 |
+
|
| 50 |
+
if count_type == 'all':
|
| 51 |
+
review_limit = 100000
|
| 52 |
+
else:
|
| 53 |
+
review_limit = int(data.get('review_count', 150))
|
| 54 |
+
|
| 55 |
+
result, _ = reviews(
|
| 56 |
+
app_id,
|
| 57 |
+
lang='en',
|
| 58 |
+
country='us',
|
| 59 |
+
sort=selected_sort,
|
| 60 |
+
count=review_limit,
|
| 61 |
+
filter_score_with=filter_score
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
if not result:
|
| 65 |
+
return jsonify({"error": f"No reviews found for '{info['title']}'"}), 404
|
| 66 |
+
|
| 67 |
+
# Clean the dates for JSON serialization
|
| 68 |
+
for r in result:
|
| 69 |
+
if 'at' in r:
|
| 70 |
+
r['at'] = r['at'].isoformat()
|
| 71 |
+
if 'repliedAt' in r and r['repliedAt']:
|
| 72 |
+
r['repliedAt'] = r['repliedAt'].isoformat()
|
| 73 |
+
|
| 74 |
+
return jsonify({
|
| 75 |
+
"app_info": {
|
| 76 |
+
"title": info['title'],
|
| 77 |
+
"icon": info['icon'],
|
| 78 |
+
"score": info['score'],
|
| 79 |
+
"reviews": info['reviews'],
|
| 80 |
+
"appId": app_id,
|
| 81 |
+
"summary": info.get('summary', '')
|
| 82 |
+
},
|
| 83 |
+
"reviews": result
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
return jsonify({"error": str(e)}), 500
|
| 88 |
+
|
| 89 |
+
@app.route('/')
|
| 90 |
+
def index():
|
| 91 |
+
return render_template('index.html')
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
app.run(debug=True)
|
requirement.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
google-play-scraper
|
| 2 |
+
pandas
|
| 3 |
+
flask
|
static/js/main.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 2 |
+
// Initialize Lucide Icons
|
| 3 |
+
lucide.createIcons();
|
| 4 |
+
|
| 5 |
+
// Elements
|
| 6 |
+
const themeToggle = document.getElementById('themeToggle');
|
| 7 |
+
const startScrape = document.getElementById('startScrape');
|
| 8 |
+
const userInput = document.getElementById('userInput');
|
| 9 |
+
const landingHero = document.getElementById('landingHero');
|
| 10 |
+
const loadingState = document.getElementById('loadingState');
|
| 11 |
+
const resultsSection = document.getElementById('resultsSection');
|
| 12 |
+
const statusText = document.getElementById('statusText');
|
| 13 |
+
const progressBar = document.getElementById('progressBar');
|
| 14 |
+
|
| 15 |
+
// Config Elements
|
| 16 |
+
const reviewCountType = document.getElementById('reviewCountType');
|
| 17 |
+
const sortOrder = document.getElementById('sortOrder');
|
| 18 |
+
const starRating = document.getElementById('starRating');
|
| 19 |
+
|
| 20 |
+
// Theme Logic
|
| 21 |
+
themeToggle.addEventListener('click', () => {
|
| 22 |
+
const html = document.documentElement;
|
| 23 |
+
if (html.classList.contains('dark')) {
|
| 24 |
+
html.classList.remove('dark');
|
| 25 |
+
localStorage.setItem('theme', 'light');
|
| 26 |
+
} else {
|
| 27 |
+
html.classList.add('dark');
|
| 28 |
+
localStorage.setItem('theme', 'dark');
|
| 29 |
+
}
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
// Load Saved Theme
|
| 33 |
+
if (localStorage.getItem('theme') === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
| 34 |
+
document.documentElement.classList.add('dark');
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Scrape Logic
|
| 38 |
+
startScrape.addEventListener('click', async () => {
|
| 39 |
+
const identifier = userInput.value.trim();
|
| 40 |
+
if (!identifier) {
|
| 41 |
+
alert('Please enter an app name or URL');
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// 1. Transition to Loading
|
| 46 |
+
landingHero.classList.add('hidden');
|
| 47 |
+
loadingState.classList.remove('hidden');
|
| 48 |
+
|
| 49 |
+
// 2. Mock Progress Simulation
|
| 50 |
+
let progress = 0;
|
| 51 |
+
const progressInterval = setInterval(() => {
|
| 52 |
+
if (progress < 90) {
|
| 53 |
+
progress += Math.random() * 5;
|
| 54 |
+
progressBar.style.width = `${progress}%`;
|
| 55 |
+
|
| 56 |
+
if (progress < 30) statusText.innerText = 'Connecting to Google Play...';
|
| 57 |
+
else if (progress < 60) statusText.innerText = 'Bypassing Protection...';
|
| 58 |
+
else statusText.innerText = 'Parsing Reviews...';
|
| 59 |
+
}
|
| 60 |
+
}, 500);
|
| 61 |
+
|
| 62 |
+
// 3. Actual API Call
|
| 63 |
+
try {
|
| 64 |
+
const countValue = reviewCountType.value === 'fixed_500' ? 500 : (reviewCountType.value === 'all' ? 'all' : 150);
|
| 65 |
+
|
| 66 |
+
const response = await fetch('/scrape', {
|
| 67 |
+
method: 'POST',
|
| 68 |
+
headers: { 'Content-Type': 'application/json' },
|
| 69 |
+
body: JSON.stringify({
|
| 70 |
+
identifier,
|
| 71 |
+
review_count_type: reviewCountType.value === 'all' ? 'all' : 'fixed',
|
| 72 |
+
review_count: countValue,
|
| 73 |
+
sort_order: sortOrder.value,
|
| 74 |
+
star_rating: starRating.value
|
| 75 |
+
})
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
const data = await response.json();
|
| 79 |
+
|
| 80 |
+
if (!response.ok) {
|
| 81 |
+
throw new Error(data.error || 'Failed to fetch reviews');
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// 4. Success - Render Results
|
| 85 |
+
clearInterval(progressInterval);
|
| 86 |
+
progressBar.style.width = '100%';
|
| 87 |
+
statusText.innerText = 'Done!';
|
| 88 |
+
|
| 89 |
+
setTimeout(() => {
|
| 90 |
+
renderResults(data);
|
| 91 |
+
loadingState.classList.add('hidden');
|
| 92 |
+
resultsSection.classList.remove('hidden');
|
| 93 |
+
}, 600);
|
| 94 |
+
|
| 95 |
+
} catch (err) {
|
| 96 |
+
clearInterval(progressInterval);
|
| 97 |
+
alert(err.message);
|
| 98 |
+
loadingState.classList.add('hidden');
|
| 99 |
+
landingHero.classList.remove('hidden');
|
| 100 |
+
progressBar.style.width = '0%';
|
| 101 |
+
}
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
function renderResults(data) {
|
| 105 |
+
const { app_info, reviews } = data;
|
| 106 |
+
|
| 107 |
+
// Update Header
|
| 108 |
+
document.getElementById('appIcon').src = app_info.icon;
|
| 109 |
+
document.getElementById('appTitle').innerText = app_info.title;
|
| 110 |
+
document.getElementById('appScore').innerText = app_info.score.toFixed(1);
|
| 111 |
+
document.getElementById('appReviewCount').innerText = formatNumber(app_info.reviews);
|
| 112 |
+
document.getElementById('appDesc').innerText = app_info.summary;
|
| 113 |
+
document.getElementById('reviewStats').innerText = `Showing ${reviews.length} results`;
|
| 114 |
+
|
| 115 |
+
// Stars
|
| 116 |
+
const stars = Math.round(app_info.score);
|
| 117 |
+
const starContainer = document.getElementById('starContainer');
|
| 118 |
+
starContainer.innerHTML = '';
|
| 119 |
+
for (let i = 0; i < 5; i++) {
|
| 120 |
+
const starIcon = document.createElement('i');
|
| 121 |
+
starIcon.setAttribute('data-lucide', 'star');
|
| 122 |
+
starIcon.classList.add('w-5', 'h-5');
|
| 123 |
+
if (i < stars) starIcon.classList.add('fill-current');
|
| 124 |
+
starContainer.appendChild(starIcon);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Review Feed
|
| 128 |
+
const feed = document.getElementById('reviewFeed');
|
| 129 |
+
feed.innerHTML = '';
|
| 130 |
+
|
| 131 |
+
reviews.forEach(review => {
|
| 132 |
+
const card = document.createElement('div');
|
| 133 |
+
card.className = 'bg-white dark:bg-slate-900 p-6 rounded-2xl border border-slate-200 dark:border-slate-800 space-y-3 transition-hover hover:border-primary/30';
|
| 134 |
+
|
| 135 |
+
card.innerHTML = `
|
| 136 |
+
<div class="flex justify-between items-start">
|
| 137 |
+
<div class="flex items-center gap-3">
|
| 138 |
+
<img src="${review.userImage || 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y'}"
|
| 139 |
+
class="w-10 h-10 rounded-full border border-slate-200 dark:border-slate-700">
|
| 140 |
+
<div>
|
| 141 |
+
<div class="font-bold text-sm">${review.userName}</div>
|
| 142 |
+
<div class="text-xs text-slate-400">${new Date(review.at).toLocaleDateString()}</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
<div class="flex text-yellow-500">
|
| 146 |
+
${Array(review.score).fill('<i data-lucide="star" class="w-3 h-3 fill-current"></i>').join('')}
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
<p class="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">${review.content}</p>
|
| 150 |
+
<div class="flex items-center gap-4 text-xs font-bold text-slate-400">
|
| 151 |
+
<span class="flex items-center gap-1"><i data-lucide="thumbs-up" class="w-3 h-3"></i> ${review.thumbsUpCount}</span>
|
| 152 |
+
</div>
|
| 153 |
+
`;
|
| 154 |
+
feed.appendChild(card);
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
lucide.createIcons();
|
| 158 |
+
|
| 159 |
+
// Download Action
|
| 160 |
+
document.getElementById('downloadCSV').onclick = () => {
|
| 161 |
+
downloadCSV(data.reviews, `${app_info.appId}_reviews.csv`);
|
| 162 |
+
};
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function downloadCSV(arr, filename) {
|
| 166 |
+
const headers = Object.keys(arr[0]).join(',');
|
| 167 |
+
const rows = arr.map(obj => Object.values(obj).map(val => `"${val}"`).join(',')).join('\\n');
|
| 168 |
+
const csvContent = "data:text/csv;charset=utf-8," + headers + '\\n' + rows;
|
| 169 |
+
const encodedUri = encodeURI(csvContent);
|
| 170 |
+
const link = document.createElement("a");
|
| 171 |
+
link.setAttribute("href", encodedUri);
|
| 172 |
+
link.setAttribute("download", filename);
|
| 173 |
+
document.body.appendChild(link);
|
| 174 |
+
link.click();
|
| 175 |
+
document.body.removeChild(link);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function formatNumber(num) {
|
| 179 |
+
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
| 180 |
+
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
| 181 |
+
return num;
|
| 182 |
+
}
|
| 183 |
+
});
|
templates/index.html
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>PlayPulse | Scraper</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #0b0e14;
|
| 11 |
+
--surface: #151921;
|
| 12 |
+
--border: #232a35;
|
| 13 |
+
--accent: #3b82f6;
|
| 14 |
+
--text: #f1f5f9;
|
| 15 |
+
--muted: #94a3b8;
|
| 16 |
+
}
|
| 17 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 18 |
+
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
| 19 |
+
|
| 20 |
+
/* Layout */
|
| 21 |
+
.header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
|
| 22 |
+
.main { flex: 1; display: flex; overflow: hidden; }
|
| 23 |
+
.sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
|
| 24 |
+
.content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
|
| 25 |
+
|
| 26 |
+
/* UI Components */
|
| 27 |
+
.logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
|
| 28 |
+
.input-group { display: flex; flex-direction: column; gap: 8px; }
|
| 29 |
+
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); letter-spacing: 0.5px; }
|
| 30 |
+
input, select { background: var(--bg); border: 1px solid var(--border); color: white; padding: 10px; border-radius: 8px; font-size: 13px; outline: none; }
|
| 31 |
+
input:focus { border-color: var(--accent); }
|
| 32 |
+
|
| 33 |
+
.toggle-grp { display: flex; background: var(--bg); padding: 4px; border-radius: 10px; border: 1px solid var(--border); }
|
| 34 |
+
.toggle-btn { flex: 1; padding: 8px; border: none; background: transparent; color: var(--muted); cursor: pointer; border-radius: 7px; font-weight: 600; font-size: 12px; }
|
| 35 |
+
.toggle-btn.active { background: var(--accent); color: white; }
|
| 36 |
+
|
| 37 |
+
.btn-main { background: var(--accent); color: white; border: none; padding: 12px; border-radius: 10px; font-weight: 800; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: 0.2s; }
|
| 38 |
+
.btn-main:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
|
| 39 |
+
.btn-main:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 40 |
+
|
| 41 |
+
.btn-icon { width: 40px; height: 40px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); color: var(--muted); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.2s; }
|
| 42 |
+
.btn-icon:hover { color: white; border-color: var(--accent); }
|
| 43 |
+
.btn-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
|
| 44 |
+
|
| 45 |
+
/* Tabs */
|
| 46 |
+
.view-tabs { display: flex; gap: 10px; }
|
| 47 |
+
.tab { padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 700; cursor: pointer; border: 1px solid var(--border); color: var(--muted); transition: 0.2s; }
|
| 48 |
+
.tab.active { background: var(--accent); color: white; border-color: var(--accent); }
|
| 49 |
+
|
| 50 |
+
/* Result Area */
|
| 51 |
+
.scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
|
| 52 |
+
.app-card { background: var(--surface); border: 1px solid var(--border); padding: 20px; border-radius: 15px; display: flex; gap: 20px; }
|
| 53 |
+
.app-card img { width: 80px; height: 80px; border-radius: 15px; }
|
| 54 |
+
.review-item { background: var(--surface); border: 1px solid var(--border); padding: 15px; border-radius: 12px; }
|
| 55 |
+
.review-item .name { font-weight: 700; font-size: 13px; margin-bottom: 5px; }
|
| 56 |
+
.review-item .text { font-size: 13px; color: var(--muted); line-height: 1.5; }
|
| 57 |
+
|
| 58 |
+
/* Iframe Error Message */
|
| 59 |
+
.site-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 15px; padding: 40px; }
|
| 60 |
+
.site-overlay h3 { font-size: 18px; font-weight: 700; }
|
| 61 |
+
.site-overlay p { color: var(--muted); font-size: 14px; max-width: 400px; }
|
| 62 |
+
|
| 63 |
+
.hidden { display: none !important; }
|
| 64 |
+
iframe { width: 100%; height: 100%; border: none; }
|
| 65 |
+
</style>
|
| 66 |
+
</head>
|
| 67 |
+
<body>
|
| 68 |
+
|
| 69 |
+
<div class="header">
|
| 70 |
+
<div class="logo">
|
| 71 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
| 72 |
+
PLAYPULSE
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div style="flex: 1"></div>
|
| 76 |
+
|
| 77 |
+
<div class="view-tabs" id="viewTabs">
|
| 78 |
+
<div class="tab active" onclick="switchView('data')">Data List</div>
|
| 79 |
+
<div class="tab" id="siteTabBtn" onclick="switchView('site')">Live Website</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<button class="btn-icon" id="dlBtn" onclick="downloadCSV()" title="Download CSV">
|
| 83 |
+
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
|
| 84 |
+
</button>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
<div class="main">
|
| 88 |
+
<aside class="sidebar">
|
| 89 |
+
<div class="input-group">
|
| 90 |
+
<div class="label">App Identity</div>
|
| 91 |
+
<input type="text" id="target" placeholder="Paste Play Store Link or Name" value="WhatsApp">
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div class="input-group">
|
| 95 |
+
<div class="label">Amount of Data</div>
|
| 96 |
+
<div class="toggle-grp">
|
| 97 |
+
<button class="toggle-btn active" id="btnAll" onclick="setMode('all')">Fetch All</button>
|
| 98 |
+
<button class="toggle-btn" id="btnLimit" onclick="setMode('limit')">Custom</button>
|
| 99 |
+
</div>
|
| 100 |
+
<input type="number" id="manualCount" class="hidden" value="100" placeholder="Count (e.g. 500)">
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<div class="input-group">
|
| 104 |
+
<div class="label">Strategy</div>
|
| 105 |
+
<select id="sort">
|
| 106 |
+
<option value="MOST_RELEVANT">Most Relevant</option>
|
| 107 |
+
<option value="NEWEST">Newest</option>
|
| 108 |
+
<option value="RATING">Top Ratings</option>
|
| 109 |
+
</select>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<button class="btn-main" id="go" onclick="run()">
|
| 113 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="13 17 18 12 13 7"/><line x1="6" y1="17" x2="6" y2="7"/></svg>
|
| 114 |
+
START SCRAPING
|
| 115 |
+
</button>
|
| 116 |
+
|
| 117 |
+
<div class="input-group" id="recents">
|
| 118 |
+
<div class="label">Recent Sessions</div>
|
| 119 |
+
<div id="recentList" style="display:flex; flex-direction:column; gap:8px;"></div>
|
| 120 |
+
</div>
|
| 121 |
+
</aside>
|
| 122 |
+
|
| 123 |
+
<div class="content">
|
| 124 |
+
<!-- Data View -->
|
| 125 |
+
<div id="dataView" class="scroll-view">
|
| 126 |
+
<div id="welcome" style="display:flex; flex-direction:column; align-items:center; justify-content:center; flex:1; color:var(--muted)">
|
| 127 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
|
| 128 |
+
<p style="margin-top:10px">Enter app details to see scrapped data</p>
|
| 129 |
+
</div>
|
| 130 |
+
<div id="results" class="hidden"></div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<!-- Loading Spinner -->
|
| 134 |
+
<div id="loader" class="site-overlay hidden">
|
| 135 |
+
<div style="width:40px; height:40px; border:4px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite"></div>
|
| 136 |
+
<p>Connecting to servers...</p>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<!-- Website View -->
|
| 140 |
+
<div id="siteView" class="hidden" style="height:100%">
|
| 141 |
+
<div class="site-overlay">
|
| 142 |
+
<h3>Web View Shielded</h3>
|
| 143 |
+
<p>Google Play Store blocks previewing inside other apps for security. Use this button to view the live site in a new tab.</p>
|
| 144 |
+
<button class="btn-main" id="externalBtn" onclick="openTarget()">
|
| 145 |
+
Open Officially on Google Play
|
| 146 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg>
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
<iframe id="iframe" src=""></iframe>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<style> @keyframes spin { to { transform: rotate(360deg); } } </style>
|
| 155 |
+
<script>
|
| 156 |
+
let mode = 'all';
|
| 157 |
+
let currentData = null;
|
| 158 |
+
|
| 159 |
+
function setMode(m) {
|
| 160 |
+
mode = m;
|
| 161 |
+
document.getElementById('btnAll').classList.toggle('active', m === 'all');
|
| 162 |
+
document.getElementById('btnLimit').classList.toggle('active', m === 'limit');
|
| 163 |
+
document.getElementById('manualCount').classList.toggle('hidden', m === 'all');
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function switchView(v) {
|
| 167 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 168 |
+
event.target.classList.add('active');
|
| 169 |
+
document.getElementById('dataView').classList.toggle('hidden', v !== 'data');
|
| 170 |
+
document.getElementById('siteView').classList.toggle('hidden', v !== 'site');
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function openTarget() {
|
| 174 |
+
const url = document.getElementById('target').value;
|
| 175 |
+
if (url.startsWith('http')) window.open(url, '_blank');
|
| 176 |
+
else if (currentData) window.open(`https://play.google.com/store/apps/details?id=${currentData.app_info.appId}`, '_blank');
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
async function run() {
|
| 180 |
+
const query = document.getElementById('target').value;
|
| 181 |
+
if (!query) return;
|
| 182 |
+
|
| 183 |
+
document.getElementById('results').innerHTML = '';
|
| 184 |
+
currentData = null;
|
| 185 |
+
document.getElementById('welcome').classList.add('hidden');
|
| 186 |
+
document.getElementById('results').classList.add('hidden');
|
| 187 |
+
document.getElementById('loader').classList.remove('hidden');
|
| 188 |
+
document.getElementById('go').disabled = true;
|
| 189 |
+
|
| 190 |
+
try {
|
| 191 |
+
const res = await fetch('/scrape', {
|
| 192 |
+
method: 'POST',
|
| 193 |
+
headers: { 'Content-Type': 'application/json' },
|
| 194 |
+
body: JSON.stringify({
|
| 195 |
+
identifier: query,
|
| 196 |
+
review_count_type: mode,
|
| 197 |
+
review_count: document.getElementById('manualCount').value || 150,
|
| 198 |
+
sort_order: document.getElementById('sort').value,
|
| 199 |
+
star_rating: 'all'
|
| 200 |
+
})
|
| 201 |
+
});
|
| 202 |
+
const data = await res.json();
|
| 203 |
+
if (!res.ok) throw new Error(data.error);
|
| 204 |
+
|
| 205 |
+
currentData = data;
|
| 206 |
+
render(data);
|
| 207 |
+
save(data.app_info);
|
| 208 |
+
|
| 209 |
+
// Try to update iframe just in case (though it might still show blank/error)
|
| 210 |
+
const siteUrl = query.startsWith('http') ? query : `https://play.google.com/store/apps/details?id=${data.app_info.appId}`;
|
| 211 |
+
document.getElementById('iframe').src = siteUrl;
|
| 212 |
+
|
| 213 |
+
} catch (e) {
|
| 214 |
+
alert(e.message);
|
| 215 |
+
} finally {
|
| 216 |
+
document.getElementById('loader').classList.add('hidden');
|
| 217 |
+
document.getElementById('results').classList.remove('hidden');
|
| 218 |
+
document.getElementById('go').disabled = false;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function render(data) {
|
| 223 |
+
const html = `
|
| 224 |
+
<div class="app-card">
|
| 225 |
+
<img src="${data.app_info.icon}">
|
| 226 |
+
<div style="flex:1">
|
| 227 |
+
<h2 style="font-size:22px; margin-bottom:4px">${data.app_info.title}</h2>
|
| 228 |
+
<div style="color:var(--accent); font-weight:700; font-size:12px">${data.app_info.appId}</div>
|
| 229 |
+
<div style="display:flex; gap:15px; margin-top:10px">
|
| 230 |
+
<div><strong>${(data.app_info.score || 0).toFixed(1)}</strong> <span style="color:var(--muted);font-size:10px uppercase">Rating</span></div>
|
| 231 |
+
<div><strong>${data.reviews.length}</strong> <span style="color:var(--muted);font-size:10px uppercase">Fetched</span></div>
|
| 232 |
+
</div>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
<div style="display:grid; gap:12px">
|
| 236 |
+
${data.reviews.map(r => `
|
| 237 |
+
<div class="review-item">
|
| 238 |
+
<div class="name">${r.userName} <span style="color:var(--accent);margin-left:8px">${'★'.repeat(r.score)}</span></div>
|
| 239 |
+
<div class="text">${r.content}</div>
|
| 240 |
+
</div>
|
| 241 |
+
`).join('')}
|
| 242 |
+
</div>
|
| 243 |
+
`;
|
| 244 |
+
document.getElementById('results').innerHTML = html;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function downloadCSV() {
|
| 248 |
+
if (!currentData) return;
|
| 249 |
+
const csv = ["Name,Score,Content", ...currentData.reviews.map(r => `"${r.userName}",${r.score},"${r.content.replace(/"/g, '""')}"`)].join('\n');
|
| 250 |
+
const blob = new Blob([csv], { type: 'text/csv' });
|
| 251 |
+
const url = window.URL.createObjectURL(blob);
|
| 252 |
+
const a = document.createElement('a');
|
| 253 |
+
a.href = url;
|
| 254 |
+
a.download = `${currentData.app_info.appId}_data.csv`;
|
| 255 |
+
a.click();
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function save(info) {
|
| 259 |
+
let list = JSON.parse(localStorage.getItem('scrapes') || '[]');
|
| 260 |
+
list = [info, ...list.filter(x => x.appId !== info.appId)].slice(0, 5);
|
| 261 |
+
localStorage.setItem('scrapes', JSON.stringify(list));
|
| 262 |
+
loadRecent();
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function loadRecent() {
|
| 266 |
+
const list = JSON.parse(localStorage.getItem('scrapes') || '[]');
|
| 267 |
+
document.getElementById('recentList').innerHTML = list.map(x => `
|
| 268 |
+
<div onclick="document.getElementById('target').value='${x.appId}';run()" style="cursor:pointer; background:var(--bg); padding:10px; border-radius:8px; display:flex; gap:10px; align-items:center; border:1px solid var(--border)">
|
| 269 |
+
<img src="${x.icon}" style="width:24px;height:24px;border-radius:4px">
|
| 270 |
+
<span style="font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">${x.title}</span>
|
| 271 |
+
<div style="font-size: 10px; color: var(--muted)">${(x.score || 0).toFixed(1)} ★</div>
|
| 272 |
+
</div>
|
| 273 |
+
`).join('');
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
loadRecent();
|
| 277 |
+
</script>
|
| 278 |
+
</body>
|
| 279 |
+
</html>
|