WebashalarForML commited on
Commit
3e89456
·
verified ·
1 Parent(s): ed57925

Upload 6 files

Browse files
Files changed (6) hide show
  1. app.py +182 -67
  2. requirement.txt +2 -1
  3. templates/batch.html +310 -0
  4. templates/index.html +247 -11
  5. templates/index2.html +230 -0
  6. 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 = data.get('identifier', '').strip()
39
- count_type = data.get('review_count_type', 'fixed')
40
- sort_choice = data.get('sort_order', 'MOST_RELEVANT')
41
- star_ratings_input = data.get('star_ratings', 'all')
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
- return jsonify({"error": f"Could not find any app matching '{identifier}'"}), 404
68
-
69
- # ── App metadata ─────────────────────────────────────────────────
70
- info = app_info(app_id, lang='en', country='us')
71
-
72
- # ── Review count ─────────────────────────────────────────────────
73
- if count_type == 'all':
74
- review_limit = 100_000
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": info['title'],
106
- "icon": info['icon'],
107
- "score": info['score'],
108
  "reviews": info['reviews'],
109
- "appId": app_id,
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 index():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div class="logo">
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
- </div>
 
 
 
 
 
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
- <input type="text" id="target" placeholder="Paste Play Store Link or Name" value="WhatsApp">
 
 
 
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 active" id="btnAll" onclick="setMode('all')">Fetch All</button>
147
- <button class="toggle-btn" id="btnLimit" onclick="setMode('limit')">Custom</button>
148
  </div>
149
- <input type="number" id="manualCount" class="hidden" value="100" placeholder="Count (e.g. 500)">
150
  </div>
151
 
152
  <div class="input-group">
@@ -215,7 +326,7 @@
215
  </div>
216
 
217
  <script>
218
- let mode = 'all';
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)||150,
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
- alert(e.message);
 
 
 
 
 
 
 
 
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} &bull; ${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
+ &copy; 2026 PlayPulse Intelligence. Powered by Google Play Scraper Engine.
288
+ </footer>
289
+ </body>
290
+ </html>