Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- app.py +182 -67
- requirement.txt +2 -1
- templates/batch.html +310 -0
- templates/index.html +247 -11
- templates/index2.html +230 -0
- templates/landing.html +290 -0
app.py
CHANGED
|
@@ -1,19 +1,48 @@
|
|
| 1 |
import urllib.parse
|
| 2 |
import math
|
|
|
|
|
|
|
| 3 |
from flask import Flask, request, render_template, jsonify
|
| 4 |
from google_play_scraper import reviews, Sort, search, app as app_info
|
| 5 |
|
| 6 |
app = Flask(__name__)
|
| 7 |
|
| 8 |
-
|
| 9 |
def extract_app_id(url_or_name: str) -> str:
|
|
|
|
|
|
|
|
|
|
| 10 |
if "play.google.com" in url_or_name:
|
| 11 |
parsed = urllib.parse.urlparse(url_or_name)
|
| 12 |
query_params = urllib.parse.parse_qs(parsed.query)
|
| 13 |
if 'id' in query_params:
|
| 14 |
return query_params['id'][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
return ""
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
def serialize_review(r: dict) -> dict:
|
| 19 |
"""Return all useful fields from a review, with dates serialized to ISO strings."""
|
|
@@ -30,84 +59,84 @@ def serialize_review(r: dict) -> dict:
|
|
| 30 |
"repliedAt": r["repliedAt"].isoformat() if r.get("repliedAt") else "", # dev reply date
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
@app.route('/scrape', methods=['POST'])
|
| 35 |
def scrape():
|
| 36 |
try:
|
| 37 |
data = request.json
|
| 38 |
-
identifier
|
| 39 |
-
count_type
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
sort_map = {
|
| 44 |
-
'MOST_RELEVANT': Sort.MOST_RELEVANT,
|
| 45 |
-
'NEWEST': Sort.NEWEST,
|
| 46 |
-
'RATING': Sort.RATING,
|
| 47 |
-
}
|
| 48 |
-
selected_sort = sort_map.get(sort_choice, Sort.MOST_RELEVANT)
|
| 49 |
-
|
| 50 |
-
# ── Star filter buckets ──────────────────────────────────────────
|
| 51 |
-
# star_ratings can be 'all' or a list such as [5, 3, 1]
|
| 52 |
-
if star_ratings_input == 'all' or not star_ratings_input:
|
| 53 |
-
star_filters = [None] # None = no filter = all stars in one call
|
| 54 |
-
else:
|
| 55 |
-
star_filters = sorted(
|
| 56 |
-
{int(s) for s in star_ratings_input if str(s).isdigit() and 1 <= int(s) <= 5},
|
| 57 |
-
reverse=True
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
# ── Resolve App ID ───────────────────────────────────────────────
|
| 61 |
app_id = extract_app_id(identifier)
|
| 62 |
if not app_id:
|
|
|
|
| 63 |
results = search(identifier, lang="en", country="us", n_hits=1)
|
| 64 |
-
if results:
|
| 65 |
app_id = results[0]['appId']
|
| 66 |
else:
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
else:
|
| 76 |
-
review_limit = int(data.get('review_count', 150))
|
| 77 |
-
|
| 78 |
-
# Divide quota evenly across star buckets so totals stay predictable
|
| 79 |
-
per_bucket = math.ceil(review_limit / len(star_filters))
|
| 80 |
-
|
| 81 |
-
# ── Fetch (one call per star bucket, then merge) ─────────────────
|
| 82 |
-
all_reviews = []
|
| 83 |
-
seen_ids = set()
|
| 84 |
-
|
| 85 |
-
for star in star_filters:
|
| 86 |
-
result, _ = reviews(
|
| 87 |
-
app_id,
|
| 88 |
-
lang='en',
|
| 89 |
-
country='us',
|
| 90 |
-
sort=selected_sort,
|
| 91 |
-
count=per_bucket,
|
| 92 |
-
filter_score_with=star,
|
| 93 |
-
)
|
| 94 |
-
for r in result:
|
| 95 |
-
rid = r.get('reviewId', '')
|
| 96 |
-
if rid not in seen_ids:
|
| 97 |
-
seen_ids.add(rid)
|
| 98 |
-
all_reviews.append(serialize_review(r))
|
| 99 |
-
|
| 100 |
-
if not all_reviews:
|
| 101 |
-
return jsonify({"error": f"No reviews found for '{info['title']}' with the selected filters"}), 404
|
| 102 |
|
| 103 |
return jsonify({
|
| 104 |
"app_info": {
|
| 105 |
-
"title":
|
| 106 |
-
"icon":
|
| 107 |
-
"score":
|
| 108 |
"reviews": info['reviews'],
|
| 109 |
-
"appId":
|
| 110 |
-
"summary": info.get('summary', ''),
|
| 111 |
},
|
| 112 |
"reviews": all_reviews,
|
| 113 |
})
|
|
@@ -116,9 +145,95 @@ def scrape():
|
|
| 116 |
return jsonify({"error": str(e)}), 500
|
| 117 |
|
| 118 |
|
| 119 |
-
@app.route('/')
|
| 120 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
return render_template('index.html')
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
if __name__ == "__main__":
|
| 124 |
app.run(host="0.0.0.0", debug=True, port=7860)
|
|
|
|
| 1 |
import urllib.parse
|
| 2 |
import math
|
| 3 |
+
import requests
|
| 4 |
+
import re
|
| 5 |
from flask import Flask, request, render_template, jsonify
|
| 6 |
from google_play_scraper import reviews, Sort, search, app as app_info
|
| 7 |
|
| 8 |
app = Flask(__name__)
|
| 9 |
|
| 10 |
+
# --- The Rock-Solid Old App Logic ---
|
| 11 |
def extract_app_id(url_or_name: str) -> str:
|
| 12 |
+
url_or_name = url_or_name.strip()
|
| 13 |
+
|
| 14 |
+
# 1. If it's a Play Store URL, parse out the 'id' param safely
|
| 15 |
if "play.google.com" in url_or_name:
|
| 16 |
parsed = urllib.parse.urlparse(url_or_name)
|
| 17 |
query_params = urllib.parse.parse_qs(parsed.query)
|
| 18 |
if 'id' in query_params:
|
| 19 |
return query_params['id'][0]
|
| 20 |
+
|
| 21 |
+
# 2. If it's a raw package name (e.g. com.whatsapp) with no spaces
|
| 22 |
+
if "." in url_or_name and " " not in url_or_name:
|
| 23 |
+
return url_or_name
|
| 24 |
+
|
| 25 |
return ""
|
| 26 |
|
| 27 |
+
def scrape_store_ids(query: str, n_hits: int = 5):
|
| 28 |
+
"""Directly scrape Play Store search results page to get valid package IDs."""
|
| 29 |
+
try:
|
| 30 |
+
url = f"https://play.google.com/store/search?q={urllib.parse.quote(query)}&c=apps"
|
| 31 |
+
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"}
|
| 32 |
+
resp = requests.get(url, headers=headers, timeout=10)
|
| 33 |
+
if resp.status_code != 200: return []
|
| 34 |
+
|
| 35 |
+
# Regex to find package names like details?id=com.something
|
| 36 |
+
pids = re.findall(r'details\?id=([a-zA-Z0-9._]+)', resp.text)
|
| 37 |
+
|
| 38 |
+
# Remove duplicates and known junk
|
| 39 |
+
unique_pids = []
|
| 40 |
+
for p in pids:
|
| 41 |
+
if p not in unique_pids and "None" not in p:
|
| 42 |
+
unique_pids.append(p)
|
| 43 |
+
return unique_pids[:n_hits]
|
| 44 |
+
except:
|
| 45 |
+
return []
|
| 46 |
|
| 47 |
def serialize_review(r: dict) -> dict:
|
| 48 |
"""Return all useful fields from a review, with dates serialized to ISO strings."""
|
|
|
|
| 59 |
"repliedAt": r["repliedAt"].isoformat() if r.get("repliedAt") else "", # dev reply date
|
| 60 |
}
|
| 61 |
|
| 62 |
+
def fetch_app_reviews(app_id, review_count, sort_order, star_ratings_input):
|
| 63 |
+
"""Core helper to fetch reviews for a single app ID."""
|
| 64 |
+
info = app_info(app_id, lang='en', country='us')
|
| 65 |
+
|
| 66 |
+
sort_map = {
|
| 67 |
+
'MOST_RELEVANT': Sort.MOST_RELEVANT,
|
| 68 |
+
'NEWEST': Sort.NEWEST,
|
| 69 |
+
'RATING': Sort.RATING,
|
| 70 |
+
}
|
| 71 |
+
selected_sort = sort_map.get(sort_order, Sort.MOST_RELEVANT)
|
| 72 |
+
|
| 73 |
+
if star_ratings_input == 'all' or not star_ratings_input:
|
| 74 |
+
star_filters = [None]
|
| 75 |
+
else:
|
| 76 |
+
star_filters = sorted(
|
| 77 |
+
{int(s) for s in star_ratings_input if str(s).isdigit() and 1 <= int(s) <= 5},
|
| 78 |
+
reverse=True
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
per_bucket = math.ceil(review_limit_val(review_count) / len(star_filters))
|
| 82 |
+
all_reviews = []
|
| 83 |
+
seen_ids = set()
|
| 84 |
+
|
| 85 |
+
for star in star_filters:
|
| 86 |
+
result, _ = reviews(
|
| 87 |
+
app_id,
|
| 88 |
+
lang='en',
|
| 89 |
+
country='us',
|
| 90 |
+
sort=selected_sort,
|
| 91 |
+
count=per_bucket,
|
| 92 |
+
filter_score_with=star,
|
| 93 |
+
)
|
| 94 |
+
for r in result:
|
| 95 |
+
rid = r.get('reviewId', '')
|
| 96 |
+
if rid not in seen_ids:
|
| 97 |
+
seen_ids.add(rid)
|
| 98 |
+
serialized = serialize_review(r)
|
| 99 |
+
serialized['appTitle'] = info['title']
|
| 100 |
+
serialized['appId'] = app_id
|
| 101 |
+
all_reviews.append(serialized)
|
| 102 |
+
|
| 103 |
+
return info, all_reviews
|
| 104 |
+
|
| 105 |
+
def review_limit_val(count):
|
| 106 |
+
try: return int(count)
|
| 107 |
+
except: return 150
|
| 108 |
|
| 109 |
@app.route('/scrape', methods=['POST'])
|
| 110 |
def scrape():
|
| 111 |
try:
|
| 112 |
data = request.json
|
| 113 |
+
identifier = data.get('identifier', '').strip()
|
| 114 |
+
count_type = data.get('review_count_type', 'fixed')
|
| 115 |
+
count = 100000 if count_type == 'all' else data.get('review_count', 150)
|
| 116 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
app_id = extract_app_id(identifier)
|
| 118 |
if not app_id:
|
| 119 |
+
# Fallback 1: Library search
|
| 120 |
results = search(identifier, lang="en", country="us", n_hits=1)
|
| 121 |
+
if results and results[0].get('appId'):
|
| 122 |
app_id = results[0]['appId']
|
| 123 |
else:
|
| 124 |
+
# Fallback 2: Direct HTML scrape (Rock-solid)
|
| 125 |
+
pids = scrape_store_ids(identifier, n_hits=1)
|
| 126 |
+
if pids: app_id = pids[0]
|
| 127 |
+
else: return jsonify({"error": f"App '{identifier}' not found"}), 404
|
| 128 |
+
|
| 129 |
+
info, all_reviews = fetch_app_reviews(
|
| 130 |
+
app_id, count, data.get('sort_order'), data.get('star_ratings')
|
| 131 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
return jsonify({
|
| 134 |
"app_info": {
|
| 135 |
+
"title": info['title'],
|
| 136 |
+
"icon": info['icon'],
|
| 137 |
+
"score": info['score'],
|
| 138 |
"reviews": info['reviews'],
|
| 139 |
+
"appId": app_id,
|
|
|
|
| 140 |
},
|
| 141 |
"reviews": all_reviews,
|
| 142 |
})
|
|
|
|
| 145 |
return jsonify({"error": str(e)}), 500
|
| 146 |
|
| 147 |
|
| 148 |
+
@app.route('/scrape-batch', methods=['POST'])
|
| 149 |
+
def scrape_batch():
|
| 150 |
+
try:
|
| 151 |
+
data = request.json
|
| 152 |
+
query = data.get('query', '').strip()
|
| 153 |
+
app_count = int(data.get('app_count', 5))
|
| 154 |
+
count_type = data.get('review_count_type', 'fixed')
|
| 155 |
+
reviews_per_app = 100000 if count_type == 'all' else int(data.get('reviews_per_app', 100))
|
| 156 |
+
|
| 157 |
+
# 1. Find apps (Try direct scraping first for reliability)
|
| 158 |
+
app_ids = scrape_store_ids(query, n_hits=app_count)
|
| 159 |
+
|
| 160 |
+
# If scraper fails, try library search
|
| 161 |
+
if not app_ids:
|
| 162 |
+
hits = search(query, lang="en", country="us", n_hits=app_count)
|
| 163 |
+
app_ids = [h['appId'] for h in hits if h.get('appId')]
|
| 164 |
+
|
| 165 |
+
if not app_ids:
|
| 166 |
+
return jsonify({"error": "No apps found for this query"}), 404
|
| 167 |
+
|
| 168 |
+
batch_results = []
|
| 169 |
+
all_combined_reviews = []
|
| 170 |
+
|
| 171 |
+
for app_id in app_ids:
|
| 172 |
+
try:
|
| 173 |
+
info, app_reviews = fetch_app_reviews(
|
| 174 |
+
app_id, reviews_per_app, data.get('sort_order'), data.get('star_ratings')
|
| 175 |
+
)
|
| 176 |
+
batch_results.append({
|
| 177 |
+
"title": info['title'],
|
| 178 |
+
"icon": info['icon'],
|
| 179 |
+
"score": info['score'],
|
| 180 |
+
"appId": app_id
|
| 181 |
+
})
|
| 182 |
+
all_combined_reviews.extend(app_reviews)
|
| 183 |
+
except:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
return jsonify({
|
| 187 |
+
"apps": batch_results,
|
| 188 |
+
"reviews": all_combined_reviews
|
| 189 |
+
})
|
| 190 |
+
except Exception as e:
|
| 191 |
+
return jsonify({"error": str(e)}), 500
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@app.route("/search-suggestions", methods=["POST"])
|
| 196 |
+
def search_suggestions():
|
| 197 |
+
"""Return top app matches for a keyword — used by the UI search dropdown."""
|
| 198 |
+
try:
|
| 199 |
+
query = (request.json or {}).get("query", "").strip()
|
| 200 |
+
if not query or len(query) < 2:
|
| 201 |
+
return jsonify({"results": []})
|
| 202 |
+
|
| 203 |
+
hits = search(query, lang="en", country="us", n_hits=6)
|
| 204 |
+
results = []
|
| 205 |
+
for h in hits:
|
| 206 |
+
aid = h.get("appId", "")
|
| 207 |
+
if not aid or aid == "None" or "." not in aid:
|
| 208 |
+
continue
|
| 209 |
+
results.append({
|
| 210 |
+
"appId": aid,
|
| 211 |
+
"storeUrl": f"https://play.google.com/store/apps/details?id={aid}",
|
| 212 |
+
"title": h.get("title", ""),
|
| 213 |
+
"icon": h.get("icon", ""),
|
| 214 |
+
"score": round(h.get("score") or 0, 1),
|
| 215 |
+
"developer": h.get("developer", ""),
|
| 216 |
+
"installs": h.get("installs", ""),
|
| 217 |
+
})
|
| 218 |
+
|
| 219 |
+
return jsonify({"results": results[:5]})
|
| 220 |
+
except Exception as e:
|
| 221 |
+
return jsonify({"error": str(e)}), 500
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@app.route('/scraper')
|
| 225 |
+
def scraper():
|
| 226 |
return render_template('index.html')
|
| 227 |
|
| 228 |
+
|
| 229 |
+
@app.route('/batch')
|
| 230 |
+
def batch():
|
| 231 |
+
return render_template('batch.html')
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
@app.route('/')
|
| 235 |
+
def landing():
|
| 236 |
+
return render_template('landing.html')
|
| 237 |
+
|
| 238 |
if __name__ == "__main__":
|
| 239 |
app.run(host="0.0.0.0", debug=True, port=7860)
|
requirement.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
google-play-scraper
|
| 2 |
pandas
|
| 3 |
-
flask
|
|
|
|
|
|
| 1 |
google-play-scraper
|
| 2 |
pandas
|
| 3 |
+
flask
|
| 4 |
+
requests
|
templates/batch.html
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>Batch Intelligence | PlayPulse</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 |
+
--surface2: #1c2333;
|
| 13 |
+
--border: #232a35;
|
| 14 |
+
--accent: #3b82f6;
|
| 15 |
+
--accent-dim: rgba(59,130,246,0.12);
|
| 16 |
+
--green: #22c55e;
|
| 17 |
+
--green-dim: rgba(34,197,94,0.12);
|
| 18 |
+
--amber: #f59e0b;
|
| 19 |
+
--text: #f1f5f9;
|
| 20 |
+
--muted: #64748b;
|
| 21 |
+
--muted2: #94a3b8;
|
| 22 |
+
}
|
| 23 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 24 |
+
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
| 25 |
+
|
| 26 |
+
.header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
|
| 27 |
+
.main { flex: 1; display: flex; overflow: hidden; }
|
| 28 |
+
.sidebar { width: 320px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
|
| 29 |
+
.content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
|
| 30 |
+
|
| 31 |
+
.mode-toggle { display: grid; grid-template-columns: 1fr 1fr; background: var(--bg); padding: 4px; border-radius: 10px; border: 1px solid var(--border); margin-bottom: 5px; }
|
| 32 |
+
.mode-btn { padding: 8px; border-radius: 7px; text-align: center; cursor: pointer; font-size: 11px; font-weight: 700; color: var(--muted); transition: 0.2s; }
|
| 33 |
+
.mode-btn.active { background: var(--surface2); color: white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
|
| 34 |
+
|
| 35 |
+
.logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; text-decoration: none; }
|
| 36 |
+
.input-group { display: flex; flex-direction: column; gap: 8px; }
|
| 37 |
+
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); letter-spacing: 0.5px; }
|
| 38 |
+
input, select { background: var(--bg); border: 1px solid var(--border); color: white; padding: 12px; border-radius: 8px; font-size: 13px; outline: none; width: 100%; }
|
| 39 |
+
input:focus { border-color: var(--accent); }
|
| 40 |
+
|
| 41 |
+
.btn-main { background: var(--accent); color: white; border: none; padding: 14px; border-radius: 10px; font-weight: 800; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: 0.2s; width: 100%; border-bottom: 3px solid rgba(0,0,0,0.2); }
|
| 42 |
+
.btn-main:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
|
| 43 |
+
.btn-main:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 44 |
+
|
| 45 |
+
.scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
|
| 46 |
+
|
| 47 |
+
/* Results Header */
|
| 48 |
+
.batch-summary { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
|
| 49 |
+
.apps-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
| 50 |
+
.app-mini-card { background: var(--surface2); border: 1px solid var(--border); border-radius: 10px; padding: 10px; display: flex; align-items: center; gap: 10px; }
|
| 51 |
+
.app-mini-card img { width: 32px; height: 32px; border-radius: 6px; }
|
| 52 |
+
.app-mini-info { flex: 1; min-width: 0; }
|
| 53 |
+
.app-mini-title { font-size: 12px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 54 |
+
.app-mini-score { font-size: 10px; color: var(--amber); }
|
| 55 |
+
|
| 56 |
+
/* Table Styles */
|
| 57 |
+
.table-container { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
|
| 58 |
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 59 |
+
th { text-align: left; background: var(--surface2); padding: 12px 16px; color: var(--muted2); font-weight: 700; font-size: 11px; text-transform: uppercase; border-bottom: 1px solid var(--border); }
|
| 60 |
+
td { padding: 14px 16px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
| 61 |
+
tr:last-child td { border-bottom: none; }
|
| 62 |
+
tr:hover td { background: rgba(255,255,255,0.02); }
|
| 63 |
+
|
| 64 |
+
.app-tag { display: inline-flex; align-items: center; gap: 6px; background: var(--accent-dim); color: var(--accent); padding: 4px 8px; border-radius: 6px; font-weight: 700; font-size: 10px; margin-bottom: 6px; border: 1px solid rgba(59,130,246,0.2); }
|
| 65 |
+
.score-stars { color: var(--amber); white-space: nowrap; }
|
| 66 |
+
.review-content { color: #cbd5e1; line-height: 1.5; max-width: 400px; }
|
| 67 |
+
|
| 68 |
+
/* Overlays */
|
| 69 |
+
.star-filter-grid { display: flex; flex-direction: column; gap: 6px; }
|
| 70 |
+
.star-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); cursor: pointer; transition: border-color 0.15s; user-select: none; }
|
| 71 |
+
.star-row:hover { border-color: var(--accent); }
|
| 72 |
+
.star-row input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; padding: 0; border: none; background: transparent; flex-shrink: 0; }
|
| 73 |
+
.star-label { display: flex; align-items: center; gap: 5px; font-size: 13px; font-weight: 600; flex: 1; }
|
| 74 |
+
.stars-on { color: var(--amber); letter-spacing: -1px; }
|
| 75 |
+
.stars-off { color: var(--border); letter-spacing: -1px; }
|
| 76 |
+
|
| 77 |
+
.loader-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; z-index: 10; }
|
| 78 |
+
.spinner { width: 40px; height: 40px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
| 79 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 80 |
+
|
| 81 |
+
.hidden { display: none !important; }
|
| 82 |
+
</style>
|
| 83 |
+
</head>
|
| 84 |
+
<body>
|
| 85 |
+
|
| 86 |
+
<div class="header">
|
| 87 |
+
<a href="/" class="logo">
|
| 88 |
+
<svg width="22" height="22" 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>
|
| 89 |
+
BATCH INTEL
|
| 90 |
+
</a>
|
| 91 |
+
<nav style="margin-left: 30px; display: flex; gap: 20px;">
|
| 92 |
+
<a href="/" style="color: var(--muted2); text-decoration: none; font-size: 13px; font-weight: 600; transition: 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Home</a>
|
| 93 |
+
<a href="/scraper" style="color: var(--muted2); text-decoration: none; font-size: 13px; font-weight: 600; transition: 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Single Explorer</a>
|
| 94 |
+
<a href="/batch" style="color: var(--text); text-decoration: none; font-size: 13px; font-weight: 700; border-bottom: 2px solid var(--accent); padding-bottom: 4px;">Batch Intelligence</a>
|
| 95 |
+
</nav>
|
| 96 |
+
<div style="flex:1"></div>
|
| 97 |
+
<button class="btn-icon" onclick="downloadCSV()" style="background:var(--surface2); border:1px solid var(--border); color:white; padding:8px 16px; border-radius:8px; cursor:pointer; font-size:12px; font-weight:700;">Export Combined CSV</button>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div class="main">
|
| 101 |
+
<aside class="sidebar">
|
| 102 |
+
<div class="input-group">
|
| 103 |
+
<div class="label">Category / Search Query</div>
|
| 104 |
+
<input type="text" id="query" placeholder="e.g. Multiplayer Games, Fintech..." value="Multiplayer Games">
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div class="input-group">
|
| 108 |
+
<div class="label">App Comparison Count</div>
|
| 109 |
+
<input type="number" id="app_count" value="5" min="1" max="20">
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
<div class="input-group">
|
| 113 |
+
<div class="label">Reviews Per App</div>
|
| 114 |
+
<div class="mode-toggle">
|
| 115 |
+
<div class="mode-btn active" id="btn-fixed" onclick="setMode('fixed')">Custom</div>
|
| 116 |
+
<div class="mode-btn" id="btn-all" onclick="setMode('all')">Fetch All</div>
|
| 117 |
+
</div>
|
| 118 |
+
<input type="number" id="reviews_per_app" value="100" min="10" step="10">
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div class="input-group">
|
| 122 |
+
<div class="label">Sort Method</div>
|
| 123 |
+
<select id="sort">
|
| 124 |
+
<option value="MOST_RELEVANT">Most Relevant</option>
|
| 125 |
+
<option value="NEWEST">Newest</option>
|
| 126 |
+
<option value="RATING">Top Ratings</option>
|
| 127 |
+
</select>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<div class="input-group">
|
| 131 |
+
<div class="label">
|
| 132 |
+
<span>Star Rating Filter</span>
|
| 133 |
+
<div style="display:flex;gap:5px">
|
| 134 |
+
<button class="quick-btn" style="font-size:9px; padding:2px 5px; cursor:pointer; background:var(--surface2); border:1px solid var(--border); color:white; border-radius:4px;" onclick="selectAllStars(true)">All</button>
|
| 135 |
+
<button class="quick-btn" style="font-size:9px; padding:2px 5px; cursor:pointer; background:var(--surface2); border:1px solid var(--border); color:white; border-radius:4px;" onclick="selectAllStars(false)">None</button>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
<div class="star-filter-grid">
|
| 139 |
+
<label class="star-row"><input type="checkbox" class="star-cb" value="5" checked><span class="star-label"><span class="stars-on">★★★★★</span></span></label>
|
| 140 |
+
<label class="star-row"><input type="checkbox" class="star-cb" value="4" checked><span class="star-label"><span class="stars-on">★★★★</span><span class="stars-off">★</span></span></label>
|
| 141 |
+
<label class="star-row"><input type="checkbox" class="star-cb" value="3" checked><span class="star-label"><span class="stars-on">★★★</span><span class="stars-off">★★</span></span></label>
|
| 142 |
+
<label class="star-row"><input type="checkbox" class="star-cb" value="2" checked><span class="star-label"><span class="stars-on">★★</span><span class="stars-off">★★★</span></span></label>
|
| 143 |
+
<label class="star-row"><input type="checkbox" class="star-cb" value="1" checked><span class="star-label"><span class="stars-on">★</span><span class="stars-off">★★★★</span></span></label>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<button class="btn-main" id="go" onclick="runBatch()">
|
| 148 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
| 149 |
+
RUN BATCH ANALYSIS
|
| 150 |
+
</button>
|
| 151 |
+
|
| 152 |
+
<div style="background:var(--bg); padding:15px; border-radius:12px; border:1px solid var(--border); font-size:11px; color:var(--muted); line-height:1.6;">
|
| 153 |
+
<strong style="color:var(--text)">About Batch Mode</strong><br>
|
| 154 |
+
This will search for apps matching your query, scrape reviews for each, and combine them into a single comparison set.
|
| 155 |
+
</div>
|
| 156 |
+
</aside>
|
| 157 |
+
|
| 158 |
+
<div class="content">
|
| 159 |
+
<div id="dataView" class="scroll-view">
|
| 160 |
+
<div id="welcome" style="display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;color:var(--muted);gap:12px">
|
| 161 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
| 162 |
+
<p>Run batch analysis to compare app data</p>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div id="results" class="hidden">
|
| 166 |
+
<div class="batch-summary">
|
| 167 |
+
<div class="label">Comparing These Apps:</div>
|
| 168 |
+
<div class="apps-grid" id="appsGrid"></div>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div class="table-container">
|
| 172 |
+
<table id="reviewsTable">
|
| 173 |
+
<thead>
|
| 174 |
+
<tr>
|
| 175 |
+
<th style="width:160px">Application</th>
|
| 176 |
+
<th style="width:80px">Score</th>
|
| 177 |
+
<th>Review Snippet</th>
|
| 178 |
+
<th style="width:120px">Date</th>
|
| 179 |
+
</tr>
|
| 180 |
+
</thead>
|
| 181 |
+
<tbody id="reviewsBody"></tbody>
|
| 182 |
+
</table>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
<div id="loader" class="loader-overlay hidden">
|
| 188 |
+
<div class="spinner"></div>
|
| 189 |
+
<p style="color:var(--muted);font-size:14px" id="loaderMsg">Searching for apps...</p>
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<script>
|
| 195 |
+
let currentData = null;
|
| 196 |
+
let currentMode = 'fixed';
|
| 197 |
+
|
| 198 |
+
function setMode(m) {
|
| 199 |
+
currentMode = m;
|
| 200 |
+
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
| 201 |
+
document.getElementById('btn-' + m).classList.add('active');
|
| 202 |
+
document.getElementById('reviews_per_app').classList.toggle('hidden', m === 'all');
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
function selectAllStars(check) {
|
| 206 |
+
document.querySelectorAll('.star-cb').forEach(cb => cb.checked = check);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
async function runBatch() {
|
| 210 |
+
const q = document.getElementById('query').value.trim();
|
| 211 |
+
if (!q) return;
|
| 212 |
+
|
| 213 |
+
const stars = [...document.querySelectorAll('.star-cb:checked')].map(cb => parseInt(cb.value));
|
| 214 |
+
if (!stars.length) return alert('Select at least one star rating');
|
| 215 |
+
|
| 216 |
+
document.getElementById('welcome').classList.add('hidden');
|
| 217 |
+
document.getElementById('results').classList.add('hidden');
|
| 218 |
+
document.getElementById('loader').classList.remove('hidden');
|
| 219 |
+
document.getElementById('go').disabled = true;
|
| 220 |
+
|
| 221 |
+
try {
|
| 222 |
+
const res = await fetch('/scrape-batch', {
|
| 223 |
+
method: 'POST',
|
| 224 |
+
headers: { 'Content-Type': 'application/json' },
|
| 225 |
+
body: JSON.stringify({
|
| 226 |
+
query: q,
|
| 227 |
+
app_count: document.getElementById('app_count').value,
|
| 228 |
+
review_count_type: currentMode,
|
| 229 |
+
reviews_per_app: document.getElementById('reviews_per_app').value,
|
| 230 |
+
sort_order: document.getElementById('sort').value,
|
| 231 |
+
star_ratings: stars.length === 5 ? 'all' : stars
|
| 232 |
+
})
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
const data = await res.json();
|
| 236 |
+
if (!res.ok) throw new Error(data.error || 'Batch scraping failed');
|
| 237 |
+
|
| 238 |
+
currentData = data;
|
| 239 |
+
render(data);
|
| 240 |
+
} catch(e) {
|
| 241 |
+
alert(e.message);
|
| 242 |
+
} finally {
|
| 243 |
+
document.getElementById('loader').classList.add('hidden');
|
| 244 |
+
document.getElementById('go').disabled = false;
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
function render(data) {
|
| 249 |
+
document.getElementById('results').classList.remove('hidden');
|
| 250 |
+
|
| 251 |
+
// Render apps list
|
| 252 |
+
document.getElementById('appsGrid').innerHTML = data.apps.map(a => `
|
| 253 |
+
<div class="app-mini-card">
|
| 254 |
+
<img src="${a.icon}" alt="">
|
| 255 |
+
<div class="app-mini-info">
|
| 256 |
+
<div class="app-mini-title">${a.title}</div>
|
| 257 |
+
<div class="app-mini-score">${a.score.toFixed(1)} ★</div>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
`).join('');
|
| 261 |
+
|
| 262 |
+
// Render reviews table
|
| 263 |
+
document.getElementById('reviewsBody').innerHTML = data.reviews.map(r => {
|
| 264 |
+
const app = data.apps.find(a => a.appId === r.appId) || {title: r.appTitle};
|
| 265 |
+
return `
|
| 266 |
+
<tr>
|
| 267 |
+
<td>
|
| 268 |
+
<div class="app-tag">${app.title}</div>
|
| 269 |
+
<div style="font-size:11px; font-weight:700;">${r.userName}</div>
|
| 270 |
+
</td>
|
| 271 |
+
<td>
|
| 272 |
+
<div class="score-stars">${'★'.repeat(r.score)}</div>
|
| 273 |
+
</td>
|
| 274 |
+
<td>
|
| 275 |
+
<div class="review-content">${r.content}</div>
|
| 276 |
+
</td>
|
| 277 |
+
<td>
|
| 278 |
+
<div style="color:var(--muted); font-size:11px;">${new Date(r.at).toLocaleDateString()}</div>
|
| 279 |
+
</td>
|
| 280 |
+
</tr>
|
| 281 |
+
`;
|
| 282 |
+
}).join('');
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function downloadCSV() {
|
| 286 |
+
if (!currentData) return;
|
| 287 |
+
const esc = v => `"${String(v||'').replace(/"/g,'""')}"`;
|
| 288 |
+
const hdr = ['App Name', 'App ID', 'User', 'Score', 'Date', 'Content', 'Thumbs Up'];
|
| 289 |
+
|
| 290 |
+
const rows = currentData.reviews.map(r => [
|
| 291 |
+
esc(r.appTitle),
|
| 292 |
+
esc(r.appId),
|
| 293 |
+
esc(r.userName),
|
| 294 |
+
r.score,
|
| 295 |
+
esc(r.at.slice(0,10)),
|
| 296 |
+
esc(r.content),
|
| 297 |
+
r.thumbsUpCount
|
| 298 |
+
].join(','));
|
| 299 |
+
|
| 300 |
+
const blob = new Blob([[hdr.join(','), ...rows].join('\n')], { type: 'text/csv' });
|
| 301 |
+
const url = URL.createObjectURL(blob);
|
| 302 |
+
const a = document.createElement('a');
|
| 303 |
+
a.href = url;
|
| 304 |
+
a.download = `batch_comparison_${new Date().getTime()}.csv`;
|
| 305 |
+
a.click();
|
| 306 |
+
}
|
| 307 |
+
</script>
|
| 308 |
+
|
| 309 |
+
</body>
|
| 310 |
+
</html>
|
templates/index.html
CHANGED
|
@@ -114,15 +114,123 @@
|
|
| 114 |
|
| 115 |
.hidden { display: none !important; }
|
| 116 |
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
</style>
|
| 118 |
</head>
|
| 119 |
<body>
|
| 120 |
|
| 121 |
<div class="header">
|
| 122 |
-
<
|
| 123 |
<svg width="22" height="22" 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>
|
| 124 |
PLAYPULSE
|
| 125 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
<div style="flex:1"></div>
|
| 127 |
<div class="view-tabs">
|
| 128 |
<div class="tab active" onclick="switchView('data',event)">Data List</div>
|
|
@@ -137,16 +245,19 @@
|
|
| 137 |
<aside class="sidebar">
|
| 138 |
<div class="input-group">
|
| 139 |
<div class="label">App Identity</div>
|
| 140 |
-
<
|
|
|
|
|
|
|
|
|
|
| 141 |
</div>
|
| 142 |
|
| 143 |
<div class="input-group">
|
| 144 |
<div class="label">Amount of Data</div>
|
| 145 |
<div class="toggle-grp">
|
| 146 |
-
<button class="toggle-btn
|
| 147 |
-
<button class="toggle-btn" id="btnLimit" onclick="setMode('limit')">Custom</button>
|
| 148 |
</div>
|
| 149 |
-
<input type="number" id="manualCount"
|
| 150 |
</div>
|
| 151 |
|
| 152 |
<div class="input-group">
|
|
@@ -215,7 +326,7 @@
|
|
| 215 |
</div>
|
| 216 |
|
| 217 |
<script>
|
| 218 |
-
let mode = '
|
| 219 |
let currentData = null;
|
| 220 |
|
| 221 |
function setMode(m) {
|
|
@@ -281,6 +392,7 @@
|
|
| 281 |
document.getElementById('welcome').classList.add('hidden');
|
| 282 |
document.getElementById('results').classList.add('hidden');
|
| 283 |
document.getElementById('loader').classList.remove('hidden');
|
|
|
|
| 284 |
document.getElementById('go').disabled=true;
|
| 285 |
|
| 286 |
const msgs=['Connecting to servers…','Fetching app info…','Scraping reviews…','Processing data…'];
|
|
@@ -295,22 +407,31 @@
|
|
| 295 |
body:JSON.stringify({
|
| 296 |
identifier:query,
|
| 297 |
review_count_type:mode,
|
| 298 |
-
review_count:parseInt(document.getElementById('manualCount').value)||
|
| 299 |
sort_order:document.getElementById('sort').value,
|
| 300 |
star_ratings:selectedStars.length===5?'all':selectedStars
|
| 301 |
})
|
| 302 |
});
|
| 303 |
const data=await res.json();
|
| 304 |
-
if (!res.ok) throw new Error(data.error);
|
|
|
|
| 305 |
currentData=data;
|
|
|
|
| 306 |
render(data,selectedStars);
|
| 307 |
save(data.app_info);
|
| 308 |
} catch(e) {
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
} finally {
|
| 311 |
clearInterval(msgInt);
|
| 312 |
document.getElementById('loader').classList.add('hidden');
|
| 313 |
-
document.getElementById('results').classList.remove('hidden');
|
| 314 |
document.getElementById('go').disabled=false;
|
| 315 |
}
|
| 316 |
}
|
|
@@ -462,6 +583,121 @@
|
|
| 462 |
}
|
| 463 |
|
| 464 |
loadRecent();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
</script>
|
| 466 |
</body>
|
| 467 |
</html>
|
|
|
|
| 114 |
|
| 115 |
.hidden { display: none !important; }
|
| 116 |
@keyframes spin { to { transform: rotate(360deg); } }
|
| 117 |
+
|
| 118 |
+
/* Search Suggestions Overlay */
|
| 119 |
+
.search-wrap { position: relative; }
|
| 120 |
+
.suggestions-box {
|
| 121 |
+
position: absolute;
|
| 122 |
+
top: calc(100% + 8px);
|
| 123 |
+
left: 0;
|
| 124 |
+
right: 0;
|
| 125 |
+
background: var(--surface2);
|
| 126 |
+
border: 1px solid var(--border);
|
| 127 |
+
border-radius: 12px;
|
| 128 |
+
z-index: 1000;
|
| 129 |
+
max-height: 400px;
|
| 130 |
+
overflow-y: auto;
|
| 131 |
+
box-shadow: 0 15px 40px rgba(0,0,0,0.6);
|
| 132 |
+
backdrop-filter: blur(10px);
|
| 133 |
+
}
|
| 134 |
+
.suggestion-item {
|
| 135 |
+
display: flex;
|
| 136 |
+
align-items: center;
|
| 137 |
+
padding: 12px 14px;
|
| 138 |
+
gap: 12px;
|
| 139 |
+
cursor: pointer;
|
| 140 |
+
transition: .2s cubic-bezier(0.4, 0, 0.2, 1);
|
| 141 |
+
border-bottom: 1px solid var(--border);
|
| 142 |
+
}
|
| 143 |
+
.suggestion-item:last-child { border-bottom: none; }
|
| 144 |
+
.suggestion-item:hover { background: var(--accent-dim); border-color: var(--accent); }
|
| 145 |
+
.suggestion-item img {
|
| 146 |
+
width: 44px;
|
| 147 |
+
height: 44px;
|
| 148 |
+
border-radius: 10px;
|
| 149 |
+
object-fit: cover;
|
| 150 |
+
border: 1px solid var(--border);
|
| 151 |
+
}
|
| 152 |
+
.suggestion-info { flex: 1; min-width: 0; }
|
| 153 |
+
.suggestion-title {
|
| 154 |
+
font-size: 13px;
|
| 155 |
+
font-weight: 700;
|
| 156 |
+
white-space: nowrap;
|
| 157 |
+
overflow: hidden;
|
| 158 |
+
text-overflow: ellipsis;
|
| 159 |
+
color: var(--text);
|
| 160 |
+
}
|
| 161 |
+
.suggestion-sub {
|
| 162 |
+
font-size: 11px;
|
| 163 |
+
color: var(--muted);
|
| 164 |
+
margin-top: 2px;
|
| 165 |
+
white-space: nowrap;
|
| 166 |
+
overflow: hidden;
|
| 167 |
+
text-overflow: ellipsis;
|
| 168 |
+
}
|
| 169 |
+
.suggestion-score {
|
| 170 |
+
font-size: 11px;
|
| 171 |
+
font-weight: 700;
|
| 172 |
+
color: var(--amber);
|
| 173 |
+
background: rgba(245, 158, 11, 0.1);
|
| 174 |
+
padding: 2px 6px;
|
| 175 |
+
border-radius: 4px;
|
| 176 |
+
}
|
| 177 |
+
.suggestion-loading {
|
| 178 |
+
padding: 30px 20px;
|
| 179 |
+
text-align: center;
|
| 180 |
+
color: var(--muted);
|
| 181 |
+
font-size: 12px;
|
| 182 |
+
display: flex;
|
| 183 |
+
flex-direction: column;
|
| 184 |
+
align-items: center;
|
| 185 |
+
gap: 10px;
|
| 186 |
+
}
|
| 187 |
+
.suggestion-loading .spinner-small {
|
| 188 |
+
width: 20px;
|
| 189 |
+
height: 20px;
|
| 190 |
+
border: 2px solid var(--border);
|
| 191 |
+
border-top-color: var(--accent);
|
| 192 |
+
border-radius: 50%;
|
| 193 |
+
animation: spin .8s linear infinite;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/* Search Section Page Styles */
|
| 197 |
+
.search-results-grid {
|
| 198 |
+
display: grid;
|
| 199 |
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 200 |
+
gap: 15px;
|
| 201 |
+
}
|
| 202 |
+
.search-app-card {
|
| 203 |
+
background: var(--surface);
|
| 204 |
+
border: 1px solid var(--border);
|
| 205 |
+
border-radius: 16px;
|
| 206 |
+
padding: 16px;
|
| 207 |
+
display: flex;
|
| 208 |
+
gap: 15px;
|
| 209 |
+
cursor: pointer;
|
| 210 |
+
transition: .2s;
|
| 211 |
+
}
|
| 212 |
+
.search-app-card:hover { border-color: var(--accent); background: var(--surface2); transform: translateY(-2px); }
|
| 213 |
+
.search-app-card img { width: 60px; height: 60px; border-radius: 12px; }
|
| 214 |
+
.search-app-info { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
|
| 215 |
+
.search-app-title { font-size: 14px; font-weight: 700; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 216 |
+
.search-app-dev { font-size: 11px; color: var(--muted); margin-bottom: 5px; }
|
| 217 |
+
.search-app-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; font-weight: 600; }
|
| 218 |
+
.search-app-score { color: var(--amber); }
|
| 219 |
+
.search-app-installs { color: var(--muted2); }
|
| 220 |
</style>
|
| 221 |
</head>
|
| 222 |
<body>
|
| 223 |
|
| 224 |
<div class="header">
|
| 225 |
+
<a href="/" class="logo" style="text-decoration: none;">
|
| 226 |
<svg width="22" height="22" 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>
|
| 227 |
PLAYPULSE
|
| 228 |
+
</a>
|
| 229 |
+
<nav style="margin-left: 30px; display: flex; gap: 20px;">
|
| 230 |
+
<a href="/" style="color: var(--muted2); text-decoration: none; font-size: 13px; font-weight: 600; transition: 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Home</a>
|
| 231 |
+
<a href="/scraper" style="color: var(--text); text-decoration: none; font-size: 13px; font-weight: 700; border-bottom: 2px solid var(--accent); padding-bottom: 4px;">Single Explorer</a>
|
| 232 |
+
<a href="/batch" style="color: var(--muted2); text-decoration: none; font-size: 13px; font-weight: 600; transition: 0.2s;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Batch Intelligence</a>
|
| 233 |
+
</nav>
|
| 234 |
<div style="flex:1"></div>
|
| 235 |
<div class="view-tabs">
|
| 236 |
<div class="tab active" onclick="switchView('data',event)">Data List</div>
|
|
|
|
| 245 |
<aside class="sidebar">
|
| 246 |
<div class="input-group">
|
| 247 |
<div class="label">App Identity</div>
|
| 248 |
+
<div class="search-wrap">
|
| 249 |
+
<input type="text" id="target" placeholder="Paste Play Store Link or Name" value="WhatsApp" autocomplete="off">
|
| 250 |
+
<div class="suggestions-box hidden" id="suggestionsBox"></div>
|
| 251 |
+
</div>
|
| 252 |
</div>
|
| 253 |
|
| 254 |
<div class="input-group">
|
| 255 |
<div class="label">Amount of Data</div>
|
| 256 |
<div class="toggle-grp">
|
| 257 |
+
<button class="toggle-btn" id="btnAll" onclick="setMode('all')">Fetch All</button>
|
| 258 |
+
<button class="toggle-btn active" id="btnLimit" onclick="setMode('limit')">Custom</button>
|
| 259 |
</div>
|
| 260 |
+
<input type="number" id="manualCount" value="200" placeholder="Count (e.g. 500)">
|
| 261 |
</div>
|
| 262 |
|
| 263 |
<div class="input-group">
|
|
|
|
| 326 |
</div>
|
| 327 |
|
| 328 |
<script>
|
| 329 |
+
let mode = 'limit';
|
| 330 |
let currentData = null;
|
| 331 |
|
| 332 |
function setMode(m) {
|
|
|
|
| 392 |
document.getElementById('welcome').classList.add('hidden');
|
| 393 |
document.getElementById('results').classList.add('hidden');
|
| 394 |
document.getElementById('loader').classList.remove('hidden');
|
| 395 |
+
hideSuggestions();
|
| 396 |
document.getElementById('go').disabled=true;
|
| 397 |
|
| 398 |
const msgs=['Connecting to servers…','Fetching app info…','Scraping reviews…','Processing data…'];
|
|
|
|
| 407 |
body:JSON.stringify({
|
| 408 |
identifier:query,
|
| 409 |
review_count_type:mode,
|
| 410 |
+
review_count:parseInt(document.getElementById('manualCount').value)||200,
|
| 411 |
sort_order:document.getElementById('sort').value,
|
| 412 |
star_ratings:selectedStars.length===5?'all':selectedStars
|
| 413 |
})
|
| 414 |
});
|
| 415 |
const data=await res.json();
|
| 416 |
+
if (!res.ok) throw new Error(data.error || 'Scraping failed');
|
| 417 |
+
|
| 418 |
currentData=data;
|
| 419 |
+
document.getElementById('results').classList.remove('hidden'); // CRITICAL FIX: show the div!
|
| 420 |
render(data,selectedStars);
|
| 421 |
save(data.app_info);
|
| 422 |
} catch(e) {
|
| 423 |
+
document.getElementById('results').classList.remove('hidden'); // Also show for errors
|
| 424 |
+
document.getElementById('results').innerHTML = `
|
| 425 |
+
<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);border-radius:14px;padding:24px 28px;display:flex;flex-direction:column;gap:10px">
|
| 426 |
+
<div style="display:flex;align-items:center;gap:10px">
|
| 427 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
| 428 |
+
<span style="font-weight:700;color:#ef4444;font-size:14px">Operation Failed</span>
|
| 429 |
+
</div>
|
| 430 |
+
<p style="color:var(--muted2);font-size:13px;line-height:1.6">${e.message}</p>
|
| 431 |
+
</div>`;
|
| 432 |
} finally {
|
| 433 |
clearInterval(msgInt);
|
| 434 |
document.getElementById('loader').classList.add('hidden');
|
|
|
|
| 435 |
document.getElementById('go').disabled=false;
|
| 436 |
}
|
| 437 |
}
|
|
|
|
| 583 |
}
|
| 584 |
|
| 585 |
loadRecent();
|
| 586 |
+
|
| 587 |
+
// ── Live search suggestions ──────────────────────────────────────────
|
| 588 |
+
let searchTimer = null;
|
| 589 |
+
const targetEl = document.getElementById('target');
|
| 590 |
+
const sugBox = document.getElementById('suggestionsBox');
|
| 591 |
+
|
| 592 |
+
function hideSuggestions() {
|
| 593 |
+
sugBox.classList.add('hidden');
|
| 594 |
+
sugBox.innerHTML = '';
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
targetEl.addEventListener('input', () => {
|
| 598 |
+
clearTimeout(searchTimer);
|
| 599 |
+
const q = targetEl.value.trim();
|
| 600 |
+
|
| 601 |
+
// If it looks like a URL or package ID, skip suggestions
|
| 602 |
+
if (q.startsWith('http') || /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i.test(q)) {
|
| 603 |
+
hideSuggestions(); return;
|
| 604 |
+
}
|
| 605 |
+
if (q.length < 2) { hideSuggestions(); return; }
|
| 606 |
+
|
| 607 |
+
// Show loading state in sidebar
|
| 608 |
+
sugBox.classList.remove('hidden');
|
| 609 |
+
sugBox.innerHTML = '<div class="suggestion-loading"><div class="spinner-small"></div>Searching…</div>';
|
| 610 |
+
|
| 611 |
+
// Also show in main section if current view is empty/welcome
|
| 612 |
+
if (document.getElementById('results').classList.contains('hidden')) {
|
| 613 |
+
document.getElementById('welcome').classList.add('hidden');
|
| 614 |
+
document.getElementById('results').classList.remove('hidden');
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
document.getElementById('results').innerHTML = `
|
| 618 |
+
<div style="margin-bottom: 20px; display: flex; align-items: center; gap: 10px;">
|
| 619 |
+
<span style="font-size: 18px; font-weight: 800;">Search Results: "${q}"</span>
|
| 620 |
+
</div>
|
| 621 |
+
<div class="search-results-grid" id="searchGrid">
|
| 622 |
+
<div style="grid-column: 1/-1; padding: 40px; text-align: center;">
|
| 623 |
+
<div class="spinner" style="margin: 0 auto 15px;"></div>
|
| 624 |
+
<p style="color:var(--muted)">Searching the Play Store...</p>
|
| 625 |
+
</div>
|
| 626 |
+
</div>
|
| 627 |
+
`;
|
| 628 |
+
|
| 629 |
+
searchTimer = setTimeout(async () => {
|
| 630 |
+
try {
|
| 631 |
+
const res = await fetch('/search-suggestions', {
|
| 632 |
+
method: 'POST',
|
| 633 |
+
headers: { 'Content-Type': 'application/json' },
|
| 634 |
+
body: JSON.stringify({ query: q })
|
| 635 |
+
});
|
| 636 |
+
const data = await res.json();
|
| 637 |
+
|
| 638 |
+
if (!data.results || !data.results.length) {
|
| 639 |
+
sugBox.innerHTML = '<div class="suggestion-loading">No results found</div>';
|
| 640 |
+
return;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
sugBox.innerHTML = data.results.map(r => `
|
| 644 |
+
<div class="suggestion-item" onclick="selectSuggestion('${r.appId}', '${r.title.replace(/'/g,"\\'")}')">
|
| 645 |
+
<img src="${r.icon}" alt="">
|
| 646 |
+
<div class="suggestion-info">
|
| 647 |
+
<div class="suggestion-title">${r.title}</div>
|
| 648 |
+
<div class="suggestion-sub">${r.developer} • ${r.installs}</div>
|
| 649 |
+
</div>
|
| 650 |
+
<div class="suggestion-score">${r.score > 0 ? r.score + ' ★' : ''}</div>
|
| 651 |
+
</div>`).join('');
|
| 652 |
+
|
| 653 |
+
// Grid view for the main section
|
| 654 |
+
const gridHTML = data.results.map(r => `
|
| 655 |
+
<div class="search-app-card" onclick="selectSuggestion('${r.appId}', '${r.title.replace(/'/g,"\\'")}')">
|
| 656 |
+
<img src="${r.icon}" alt="">
|
| 657 |
+
<div class="search-app-info">
|
| 658 |
+
<div>
|
| 659 |
+
<div class="search-app-title" title="${r.title}">${r.title}</div>
|
| 660 |
+
<div style="font-size:10px; color:var(--accent); font-family:monospace; margin-bottom:4px;">${r.appId}</div>
|
| 661 |
+
<div class="search-app-dev">${r.developer}</div>
|
| 662 |
+
</div>
|
| 663 |
+
<div class="search-app-meta">
|
| 664 |
+
<span class="search-app-score">${r.score > 0 ? '★ ' + r.score : 'N/A'}</span>
|
| 665 |
+
<span class="search-app-installs">${r.installs}</span>
|
| 666 |
+
<a href="${r.storeUrl}" target="_blank" onclick="event.stopPropagation()" style="margin-left:auto; color:var(--muted); font-size:10px; text-decoration:none; display:flex; align-items:center; gap:4px;">
|
| 667 |
+
Store <svg width="10" height="10" 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>
|
| 668 |
+
</a>
|
| 669 |
+
</div>
|
| 670 |
+
</div>
|
| 671 |
+
</div>
|
| 672 |
+
`).join('');
|
| 673 |
+
|
| 674 |
+
document.getElementById('searchGrid').innerHTML = gridHTML;
|
| 675 |
+
|
| 676 |
+
} catch (err) {
|
| 677 |
+
console.error(err);
|
| 678 |
+
hideSuggestions();
|
| 679 |
+
document.getElementById('results').innerHTML = `<div style="padding: 20px; color: var(--amber);">Failed to load search results.</div>`;
|
| 680 |
+
}
|
| 681 |
+
}, 400); // 400ms debounce
|
| 682 |
+
});
|
| 683 |
+
|
| 684 |
+
function selectSuggestion(appId, title) {
|
| 685 |
+
const validId = appId && appId !== 'null' && appId !== 'None' && !appId.includes('None');
|
| 686 |
+
const query = validId ? `https://play.google.com/store/apps/details?id=${appId}` : title;
|
| 687 |
+
targetEl.value = query;
|
| 688 |
+
hideSuggestions();
|
| 689 |
+
run(); // auto-start scraping
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
// Hide on click outside
|
| 693 |
+
document.addEventListener('click', e => {
|
| 694 |
+
if (!targetEl.contains(e.target) && !sugBox.contains(e.target)) hideSuggestions();
|
| 695 |
+
});
|
| 696 |
+
|
| 697 |
+
targetEl.addEventListener('keydown', e => {
|
| 698 |
+
if (e.key === 'Escape') hideSuggestions();
|
| 699 |
+
if (e.key === 'Enter') { hideSuggestions(); run(); }
|
| 700 |
+
});
|
| 701 |
</script>
|
| 702 |
</body>
|
| 703 |
</html>
|
templates/index2.html
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
--surface2: #1c2333;
|
| 13 |
+
--border: #232a35;
|
| 14 |
+
--accent: #3b82f6;
|
| 15 |
+
--accent-dim: rgba(59,130,246,0.12);
|
| 16 |
+
--green: #22c55e;
|
| 17 |
+
--green-dim: rgba(34,197,94,0.12);
|
| 18 |
+
--amber: #f59e0b;
|
| 19 |
+
--text: #f1f5f9;
|
| 20 |
+
--muted: #64748b;
|
| 21 |
+
--muted2: #94a3b8;
|
| 22 |
+
}
|
| 23 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 24 |
+
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
| 25 |
+
header { padding: 20px; border-bottom: 1px solid var(--border); background: var(--surface); text-align: center; }
|
| 26 |
+
h1 { font-size: 24px; font-weight: 800; color: var(--accent); }
|
| 27 |
+
.container { flex: 1; max-width: 1000px; margin: 0 auto; width: 100%; padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
|
| 28 |
+
|
| 29 |
+
.search-container { position: relative; width: 100%; }
|
| 30 |
+
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; }
|
| 31 |
+
input[type="text"]:focus { border-color: var(--accent); }
|
| 32 |
+
|
| 33 |
+
.controls { display: flex; gap: 10px; flex-wrap: wrap; }
|
| 34 |
+
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; }
|
| 35 |
+
button.primary { background: var(--accent); color: #fff; border: none; font-weight: 600; }
|
| 36 |
+
button.primary:hover { background: #2563eb; }
|
| 37 |
+
|
| 38 |
+
/* Suggestions Dropdown */
|
| 39 |
+
#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); }
|
| 40 |
+
.suggestion-card { display: flex; align-items: center; gap: 12px; padding: 12px 16px; cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.2s; }
|
| 41 |
+
.suggestion-card:last-child { border-bottom: none; }
|
| 42 |
+
.suggestion-card:hover { background: var(--surface2); }
|
| 43 |
+
.suggestion-icon { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; }
|
| 44 |
+
.suggestion-info { flex: 1; display: flex; flex-direction: column; }
|
| 45 |
+
.suggestion-title { font-weight: 600; font-size: 14px; }
|
| 46 |
+
.suggestion-dev { font-size: 12px; color: var(--muted); }
|
| 47 |
+
|
| 48 |
+
/* Results */
|
| 49 |
+
#results { flex: 1; background: var(--surface); border-radius: 12px; border: 1px solid var(--border); padding: 20px; }
|
| 50 |
+
.app-header { display: flex; gap: 20px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--border); align-items: center;}
|
| 51 |
+
.app-header img { width: 80px; height: 80px; border-radius: 16px; }
|
| 52 |
+
.app-title { font-size: 20px; font-weight: 700; }
|
| 53 |
+
.app-stats { color: var(--muted); font-size: 14px; margin-top: 4px; }
|
| 54 |
+
|
| 55 |
+
.review-card { background: var(--surface2); padding: 16px; border-radius: 12px; margin-bottom: 16px; border: 1px solid var(--border); }
|
| 56 |
+
.review-header { display: flex; justify-content: space-between; margin-bottom: 12px; }
|
| 57 |
+
.reviewer { display: flex; gap: 10px; align-items: center; font-weight: 600; }
|
| 58 |
+
.reviewer img { width: 32px; height: 32px; border-radius: 50%; }
|
| 59 |
+
.stars { color: var(--amber); }
|
| 60 |
+
.review-date { color: var(--muted); font-size: 12px; }
|
| 61 |
+
.review-body { font-size: 14px; line-height: 1.5; color: var(--text); }
|
| 62 |
+
|
| 63 |
+
.loading { display: none; text-align: center; color: var(--muted); padding: 20px; }
|
| 64 |
+
</style>
|
| 65 |
+
</head>
|
| 66 |
+
<body>
|
| 67 |
+
<header>
|
| 68 |
+
<h1>PlayPulse</h1>
|
| 69 |
+
</header>
|
| 70 |
+
<div class="container">
|
| 71 |
+
<div class="search-container">
|
| 72 |
+
<input type="text" id="target" placeholder="Search app name or paste URL..." autocomplete="off" />
|
| 73 |
+
<div id="suggestions">
|
| 74 |
+
<div id="searchGrid"></div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div class="controls">
|
| 79 |
+
<select id="review_count">
|
| 80 |
+
<option value="50">50 Reviews</option>
|
| 81 |
+
<option value="150" selected>150 Reviews</option>
|
| 82 |
+
<option value="500">500 Reviews</option>
|
| 83 |
+
</select>
|
| 84 |
+
<select id="sort_order">
|
| 85 |
+
<option value="NEWEST">Newest</option>
|
| 86 |
+
<option value="MOST_RELEVANT" selected>Most Relevant</option>
|
| 87 |
+
<option value="RATING">Rating</option>
|
| 88 |
+
</select>
|
| 89 |
+
<button class="primary" onclick="run()">Scrape Reviews</button>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div class="loading" id="loading">Scraping reviews... Please wait.</div>
|
| 93 |
+
<div id="results">
|
| 94 |
+
<div style="color: var(--muted); text-align: center; margin-top: 40px;">Search for an app to see reviews.</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<script>
|
| 99 |
+
const targetEl = document.getElementById('target');
|
| 100 |
+
const suggestionsBox = document.getElementById('suggestions');
|
| 101 |
+
const searchGrid = document.getElementById('searchGrid');
|
| 102 |
+
let debounceTimer;
|
| 103 |
+
|
| 104 |
+
targetEl.addEventListener('input', (e) => {
|
| 105 |
+
clearTimeout(debounceTimer);
|
| 106 |
+
const query = e.target.value.trim();
|
| 107 |
+
|
| 108 |
+
if (query.length < 2) {
|
| 109 |
+
hideSuggestions();
|
| 110 |
+
return;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
debounceTimer = setTimeout(async () => {
|
| 114 |
+
try {
|
| 115 |
+
const res = await fetch('/search-suggestions', {
|
| 116 |
+
method: 'POST',
|
| 117 |
+
headers: { 'Content-Type': 'application/json' },
|
| 118 |
+
body: JSON.stringify({ query })
|
| 119 |
+
});
|
| 120 |
+
const data = await res.json();
|
| 121 |
+
|
| 122 |
+
if (!data.results || data.results.length === 0) {
|
| 123 |
+
hideSuggestions();
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
suggestionsBox.style.display = 'block';
|
| 128 |
+
searchGrid.innerHTML = data.results.map(app => `
|
| 129 |
+
<div class="suggestion-card" onclick="selectSuggestion('${app.appId}', '${app.title.replace(/'/g, "\\'")}')">
|
| 130 |
+
<img src="${app.icon}" class="suggestion-icon" alt="icon" />
|
| 131 |
+
<div class="suggestion-info">
|
| 132 |
+
<span class="suggestion-title">${app.title}</span>
|
| 133 |
+
<span class="suggestion-dev">${app.developer} • ${app.score} ★</span>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
`).join('');
|
| 137 |
+
} catch (err) {
|
| 138 |
+
console.error(err);
|
| 139 |
+
}
|
| 140 |
+
}, 400);
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
function hideSuggestions() {
|
| 144 |
+
suggestionsBox.style.display = 'none';
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// --- THE SMART MOVE ---
|
| 148 |
+
// Instead of passing "com.whatsapp", we construct the FULL Play Store URL.
|
| 149 |
+
// This allows the old reliable `extract_app_id` to parse out the ?id= properly.
|
| 150 |
+
function selectSuggestion(appId, title) {
|
| 151 |
+
const validId = appId && appId !== 'null' && appId !== 'None' && !appId.includes('None');
|
| 152 |
+
|
| 153 |
+
const query = validId ? `https://play.google.com/store/apps/details?id=${appId}` : title;
|
| 154 |
+
|
| 155 |
+
targetEl.value = query;
|
| 156 |
+
hideSuggestions();
|
| 157 |
+
run(); // auto-start scraping
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
document.addEventListener('click', (e) => {
|
| 161 |
+
if (!e.target.closest('.search-container')) {
|
| 162 |
+
hideSuggestions();
|
| 163 |
+
}
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
async function run() {
|
| 167 |
+
const identifier = targetEl.value.trim();
|
| 168 |
+
if (!identifier) return;
|
| 169 |
+
|
| 170 |
+
document.getElementById('loading').style.display = 'block';
|
| 171 |
+
document.getElementById('results').innerHTML = '';
|
| 172 |
+
hideSuggestions();
|
| 173 |
+
|
| 174 |
+
try {
|
| 175 |
+
const res = await fetch('/scrape', {
|
| 176 |
+
method: 'POST',
|
| 177 |
+
headers: { 'Content-Type': 'application/json' },
|
| 178 |
+
body: JSON.stringify({
|
| 179 |
+
identifier,
|
| 180 |
+
review_count: document.getElementById('review_count').value,
|
| 181 |
+
sort_order: document.getElementById('sort_order').value,
|
| 182 |
+
star_ratings: 'all'
|
| 183 |
+
})
|
| 184 |
+
});
|
| 185 |
+
const data = await res.json();
|
| 186 |
+
document.getElementById('loading').style.display = 'none';
|
| 187 |
+
|
| 188 |
+
if (data.error) {
|
| 189 |
+
document.getElementById('results').innerHTML = `<div style="color: var(--amber);">${data.error}</div>`;
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
let html = `
|
| 194 |
+
<div class="app-header">
|
| 195 |
+
<img src="${data.app_info.icon}" alt="App Icon" />
|
| 196 |
+
<div>
|
| 197 |
+
<div class="app-title">${data.app_info.title}</div>
|
| 198 |
+
<div class="app-stats">${data.app_info.score} ★ • ${data.app_info.reviews} Total Reviews</div>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
`;
|
| 202 |
+
|
| 203 |
+
data.reviews.forEach(r => {
|
| 204 |
+
const stars = '★'.repeat(r.score) + '☆'.repeat(5 - r.score);
|
| 205 |
+
const date = new Date(r.at).toLocaleDateString();
|
| 206 |
+
html += `
|
| 207 |
+
<div class="review-card">
|
| 208 |
+
<div class="review-header">
|
| 209 |
+
<div class="reviewer">
|
| 210 |
+
<img src="${r.userImage}" alt="User" onerror="this.src='https://via.placeholder.com/32'" />
|
| 211 |
+
<span>${r.userName}</span>
|
| 212 |
+
</div>
|
| 213 |
+
<div class="stars">${stars}</div>
|
| 214 |
+
</div>
|
| 215 |
+
<div class="review-body">${r.content}</div>
|
| 216 |
+
<div class="review-date">${date}</div>
|
| 217 |
+
</div>
|
| 218 |
+
`;
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
document.getElementById('results').innerHTML = html;
|
| 222 |
+
|
| 223 |
+
} catch (err) {
|
| 224 |
+
document.getElementById('loading').style.display = 'none';
|
| 225 |
+
document.getElementById('results').innerHTML = `<div style="color: var(--amber);">Error: ${err.message}</div>`;
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
</script>
|
| 229 |
+
</body>
|
| 230 |
+
</html>
|
templates/landing.html
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 | Intelligence Platform</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
:root {
|
| 10 |
+
--bg: #0b0e14;
|
| 11 |
+
--surface: #151921;
|
| 12 |
+
--border: rgba(255, 255, 255, 0.08);
|
| 13 |
+
--accent: #3b82f6;
|
| 14 |
+
--accent-gradient: linear-gradient(135deg, #3b82f6 0%, #2dd4bf 100%);
|
| 15 |
+
--text: #f1f5f9;
|
| 16 |
+
--muted: #94a3b8;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 20 |
+
|
| 21 |
+
body {
|
| 22 |
+
font-family: 'Inter', sans-serif;
|
| 23 |
+
background: var(--bg);
|
| 24 |
+
color: var(--text);
|
| 25 |
+
min-height: 100vh;
|
| 26 |
+
display: flex;
|
| 27 |
+
flex-direction: column;
|
| 28 |
+
overflow-x: hidden;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.blob {
|
| 32 |
+
position: fixed;
|
| 33 |
+
width: 500px;
|
| 34 |
+
height: 500px;
|
| 35 |
+
background: var(--accent);
|
| 36 |
+
filter: blur(120px);
|
| 37 |
+
opacity: 0.1;
|
| 38 |
+
z-index: -1;
|
| 39 |
+
border-radius: 50%;
|
| 40 |
+
}
|
| 41 |
+
.blob-1 { top: -100px; right: -100px; }
|
| 42 |
+
.blob-2 { bottom: -100px; left: -100px; background: #2dd4bf; }
|
| 43 |
+
|
| 44 |
+
header {
|
| 45 |
+
padding: 30px 5%;
|
| 46 |
+
display: flex;
|
| 47 |
+
justify-content: space-between;
|
| 48 |
+
align-items: center;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.logo {
|
| 52 |
+
font-family: 'Outfit', sans-serif;
|
| 53 |
+
font-weight: 800;
|
| 54 |
+
font-size: 24px;
|
| 55 |
+
letter-spacing: -1px;
|
| 56 |
+
color: var(--text);
|
| 57 |
+
display: flex;
|
| 58 |
+
align-items: center;
|
| 59 |
+
gap: 10px;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.logo-icon {
|
| 63 |
+
width: 32px;
|
| 64 |
+
height: 32px;
|
| 65 |
+
background: var(--accent-gradient);
|
| 66 |
+
border-radius: 8px;
|
| 67 |
+
display: flex;
|
| 68 |
+
align-items: center;
|
| 69 |
+
justify-content: center;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
main {
|
| 73 |
+
flex: 1;
|
| 74 |
+
display: flex;
|
| 75 |
+
flex-direction: column;
|
| 76 |
+
align-items: center;
|
| 77 |
+
justify-content: center;
|
| 78 |
+
padding: 60px 5%;
|
| 79 |
+
max-width: 1200px;
|
| 80 |
+
margin: 0 auto;
|
| 81 |
+
text-align: center;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.hero-tag {
|
| 85 |
+
background: rgba(59, 130, 246, 0.1);
|
| 86 |
+
border: 1px solid rgba(59, 130, 246, 0.2);
|
| 87 |
+
color: var(--accent);
|
| 88 |
+
padding: 6px 16px;
|
| 89 |
+
border-radius: 100px;
|
| 90 |
+
font-size: 13px;
|
| 91 |
+
font-weight: 700;
|
| 92 |
+
margin-bottom: 24px;
|
| 93 |
+
text-transform: uppercase;
|
| 94 |
+
letter-spacing: 1px;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
h1 {
|
| 98 |
+
font-family: 'Outfit', sans-serif;
|
| 99 |
+
font-size: clamp(40px, 8vw, 72px);
|
| 100 |
+
font-weight: 800;
|
| 101 |
+
line-height: 1.1;
|
| 102 |
+
margin-bottom: 20px;
|
| 103 |
+
letter-spacing: -2px;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
h1 span {
|
| 107 |
+
background: var(--accent-gradient);
|
| 108 |
+
-webkit-background-clip: text;
|
| 109 |
+
background-clip: text;
|
| 110 |
+
-webkit-text-fill-color: transparent;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.sub-hero {
|
| 114 |
+
color: var(--muted);
|
| 115 |
+
font-size: clamp(16px, 2vw, 20px);
|
| 116 |
+
max-width: 600px;
|
| 117 |
+
margin-bottom: 60px;
|
| 118 |
+
line-height: 1.6;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.cards-container {
|
| 122 |
+
display: grid;
|
| 123 |
+
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
| 124 |
+
gap: 30px;
|
| 125 |
+
width: 100%;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.card {
|
| 129 |
+
background: var(--surface);
|
| 130 |
+
border: 1px solid var(--border);
|
| 131 |
+
padding: 40px;
|
| 132 |
+
border-radius: 24px;
|
| 133 |
+
text-align: left;
|
| 134 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 135 |
+
cursor: pointer;
|
| 136 |
+
position: relative;
|
| 137 |
+
overflow: hidden;
|
| 138 |
+
display: flex;
|
| 139 |
+
flex-direction: column;
|
| 140 |
+
height: 100%;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.card:hover {
|
| 144 |
+
border-color: rgba(59, 130, 246, 0.5);
|
| 145 |
+
transform: translateY(-8px);
|
| 146 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.card.disabled {
|
| 150 |
+
cursor: not-allowed;
|
| 151 |
+
opacity: 0.8;
|
| 152 |
+
}
|
| 153 |
+
.card.disabled:hover { transform: none; border-color: var(--border); }
|
| 154 |
+
|
| 155 |
+
.card-icon {
|
| 156 |
+
width: 56px;
|
| 157 |
+
height: 56px;
|
| 158 |
+
background: rgba(255, 255, 255, 0.03);
|
| 159 |
+
border: 1px solid var(--border);
|
| 160 |
+
border-radius: 16px;
|
| 161 |
+
display: flex;
|
| 162 |
+
align-items: center;
|
| 163 |
+
justify-content: center;
|
| 164 |
+
margin-bottom: 24px;
|
| 165 |
+
transition: 0.3s;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.card:hover .card-icon {
|
| 169 |
+
background: var(--accent);
|
| 170 |
+
color: white;
|
| 171 |
+
border-color: var(--accent);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.card h2 {
|
| 175 |
+
font-family: 'Outfit', sans-serif;
|
| 176 |
+
font-size: 24px;
|
| 177 |
+
margin-bottom: 12px;
|
| 178 |
+
font-weight: 700;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.card p {
|
| 182 |
+
color: var(--muted);
|
| 183 |
+
line-height: 1.6;
|
| 184 |
+
font-size: 15px;
|
| 185 |
+
margin-bottom: 24px;
|
| 186 |
+
flex: 1;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.badge {
|
| 190 |
+
position: absolute;
|
| 191 |
+
top: 20px;
|
| 192 |
+
right: 20px;
|
| 193 |
+
background: rgba(255, 255, 255, 0.05);
|
| 194 |
+
border: 1px solid var(--border);
|
| 195 |
+
padding: 4px 12px;
|
| 196 |
+
border-radius: 100px;
|
| 197 |
+
font-size: 11px;
|
| 198 |
+
font-weight: 700;
|
| 199 |
+
color: var(--muted);
|
| 200 |
+
text-transform: uppercase;
|
| 201 |
+
}
|
| 202 |
+
.badge.active {
|
| 203 |
+
background: var(--accent-dim);
|
| 204 |
+
color: var(--accent);
|
| 205 |
+
border-color: rgba(59, 130, 246, 0.2);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.btn {
|
| 209 |
+
display: inline-flex;
|
| 210 |
+
align-items: center;
|
| 211 |
+
gap: 8px;
|
| 212 |
+
font-weight: 700;
|
| 213 |
+
font-size: 14px;
|
| 214 |
+
color: var(--text);
|
| 215 |
+
transition: 0.3s;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.card:hover .btn {
|
| 219 |
+
color: var(--accent);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.card.disabled .btn { color: var(--muted); }
|
| 223 |
+
|
| 224 |
+
footer {
|
| 225 |
+
padding: 40px;
|
| 226 |
+
text-align: center;
|
| 227 |
+
color: var(--muted);
|
| 228 |
+
font-size: 13px;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
@media (max-width: 768px) {
|
| 232 |
+
h1 { font-size: 48px; }
|
| 233 |
+
.cards-container { grid-template-columns: 1fr; }
|
| 234 |
+
}
|
| 235 |
+
</style>
|
| 236 |
+
</head>
|
| 237 |
+
<body>
|
| 238 |
+
<div class="blob blob-1"></div>
|
| 239 |
+
<div class="blob blob-2"></div>
|
| 240 |
+
|
| 241 |
+
<header>
|
| 242 |
+
<div class="logo">
|
| 243 |
+
<div class="logo-icon">
|
| 244 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
| 245 |
+
</div>
|
| 246 |
+
PLAYPULSE
|
| 247 |
+
</div>
|
| 248 |
+
</header>
|
| 249 |
+
|
| 250 |
+
<main>
|
| 251 |
+
<div class="hero-tag">Next-Gen Intelligence</div>
|
| 252 |
+
<h1>Extract Insights from <span>Global App Data</span></h1>
|
| 253 |
+
<p class="sub-hero">The most powerful tool for analyzing app reviews, sentiment, and developer responses in real-time.</p>
|
| 254 |
+
|
| 255 |
+
<div class="cards-container">
|
| 256 |
+
<!-- Single Search Card -->
|
| 257 |
+
<div class="card" onclick="location.href='/scraper'">
|
| 258 |
+
<div class="badge active">Live Now</div>
|
| 259 |
+
<div class="card-icon">
|
| 260 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35"/></svg>
|
| 261 |
+
</div>
|
| 262 |
+
<h2>Single App Explorer</h2>
|
| 263 |
+
<p>Deep-dive into any Play Store app. Extract hundreds of reviews, analyze ratings, and export clean data instantly.</p>
|
| 264 |
+
<div class="btn">
|
| 265 |
+
Explore Now
|
| 266 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
<!-- Batch Intelligence Card -->
|
| 271 |
+
<div class="card" onclick="location.href='/batch'">
|
| 272 |
+
<div class="badge active">New Mode</div>
|
| 273 |
+
<div class="card-icon">
|
| 274 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
| 275 |
+
</div>
|
| 276 |
+
<h2>Batch Intelligence</h2>
|
| 277 |
+
<p>Compare multiple apps side-by-side. Track competitor updates and aggregate sentiment across entire categories.</p>
|
| 278 |
+
<div class="btn">
|
| 279 |
+
Start Analysis
|
| 280 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</main>
|
| 285 |
+
|
| 286 |
+
<footer>
|
| 287 |
+
© 2026 PlayPulse Intelligence. Powered by Google Play Scraper Engine.
|
| 288 |
+
</footer>
|
| 289 |
+
</body>
|
| 290 |
+
</html>
|