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

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +19 -13
  2. app.py +77 -47
  3. templates/index.html +396 -208
Dockerfile CHANGED
@@ -1,23 +1,29 @@
1
  # Use an official Python runtime as a parent image
2
  FROM python:3.11-slim
3
 
4
- # Set the working directory in the container
5
- WORKDIR /app
 
 
 
6
 
7
- # Copy the requirements file into the container at /app
8
- COPY requirement.txt .
 
 
9
 
10
- # Install any needed packages specified in requirement.txt
11
- RUN pip install --no-cache-dir -r requirement.txt
 
12
 
13
- # Copy the current directory contents into the container at /app
14
- COPY . .
15
 
16
- # Make port 5000 available to the world outside this container
17
- EXPOSE 7860
18
 
19
- # Define environment variable
20
- ENV FLASK_APP=app.py
21
 
22
- # Run app.py when the container launches
23
  CMD ["python", "app.py"]
 
1
  # Use an official Python runtime as a parent image
2
  FROM python:3.11-slim
3
 
4
+ # Set environment variables
5
+ ENV PYTHONUNBUFFERED=1 \
6
+ PYTHONDONTWRITEBYTECODE=1 \
7
+ FLASK_APP=app.py \
8
+ HOME=/home/user
9
 
10
+ # Create a non-root user
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ WORKDIR $HOME/app
14
 
15
+ # Copy requirements and install
16
+ COPY --chown=user requirement.txt .
17
+ RUN pip install --no-cache-dir --user -r requirement.txt
18
 
19
+ # Add user bin to path
20
+ ENV PATH="/home/user/.local/bin:${PATH}"
21
 
22
+ # Copy the rest of the application
23
+ COPY --chown=user . .
24
 
25
+ # Hugging Face Spaces required port
26
+ EXPOSE 7860
27
 
28
+ # Launch the app
29
  CMD ["python", "app.py"]
app.py CHANGED
@@ -1,12 +1,11 @@
1
  import urllib.parse
2
- import pandas as pd
3
- import io
4
  import math
5
- from flask import Flask, request, render_template, send_file, jsonify
6
  from google_play_scraper import reviews, Sort, search, app as app_info
7
 
8
  app = Flask(__name__)
9
 
 
10
  def extract_app_id(url_or_name: str) -> str:
11
  if "play.google.com" in url_or_name:
12
  parsed = urllib.parse.urlparse(url_or_name)
@@ -15,80 +14,111 @@ def extract_app_id(url_or_name: str) -> str:
15
  return query_params['id'][0]
16
  return ""
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  @app.route('/scrape', methods=['POST'])
19
  def scrape():
20
  try:
21
  data = request.json
22
- identifier = data.get('identifier', '').strip()
23
- count_type = data.get('review_count_type', 'fixed')
24
- star_rating_input = data.get('star_rating', 'all')
25
- sort_choice = data.get('sort_order', 'MOST_RELEVANT')
26
-
27
  sort_map = {
28
  'MOST_RELEVANT': Sort.MOST_RELEVANT,
29
- 'NEWEST': Sort.NEWEST,
30
- 'RATING': Sort.RATING
31
  }
32
  selected_sort = sort_map.get(sort_choice, Sort.MOST_RELEVANT)
33
-
34
- filter_score = None
35
- if star_rating_input != 'all':
36
- filter_score = int(star_rating_input)
37
 
 
 
 
 
 
 
 
 
 
 
 
38
  app_id = extract_app_id(identifier)
39
-
40
  if not app_id:
41
- search_results = search(identifier, lang="en", country="us", n_hits=1)
42
- if search_results:
43
- app_id = search_results[0]['appId']
44
  else:
45
  return jsonify({"error": f"Could not find any app matching '{identifier}'"}), 404
46
-
47
- # Get app info for the header
48
  info = app_info(app_id, lang='en', country='us')
49
-
 
50
  if count_type == 'all':
51
- review_limit = 100000
52
  else:
53
  review_limit = int(data.get('review_count', 150))
54
 
55
- result, _ = reviews(
56
- app_id,
57
- lang='en',
58
- country='us',
59
- sort=selected_sort,
60
- count=review_limit,
61
- filter_score_with=filter_score
62
- )
63
-
64
- if not result:
65
- return jsonify({"error": f"No reviews found for '{info['title']}'"}), 404
66
-
67
- # Clean the dates for JSON serialization
68
- for r in result:
69
- if 'at' in r:
70
- r['at'] = r['at'].isoformat()
71
- if 'repliedAt' in r and r['repliedAt']:
72
- r['repliedAt'] = r['repliedAt'].isoformat()
 
 
 
 
 
 
73
 
74
  return jsonify({
75
  "app_info": {
76
- "title": info['title'],
77
- "icon": info['icon'],
78
- "score": info['score'],
79
  "reviews": info['reviews'],
80
- "appId": app_id,
81
- "summary": info.get('summary', '')
82
  },
83
- "reviews": result
84
  })
85
 
86
  except Exception as e:
87
  return jsonify({"error": str(e)}), 500
88
 
 
89
  @app.route('/')
90
  def index():
91
  return render_template('index.html')
92
 
93
  if __name__ == "__main__":
94
- app.run(debug=True, port=7860)
 
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)
 
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."""
20
+ return {
21
+ "reviewId": r.get("reviewId", ""),
22
+ "userName": r.get("userName", ""),
23
+ "userImage": r.get("userImage", ""), # avatar URL
24
+ "content": r.get("content", ""),
25
+ "score": r.get("score", 0), # 1-5 stars
26
+ "thumbsUpCount": r.get("thumbsUpCount", 0), # helpful votes
27
+ "reviewCreatedVersion": r.get("reviewCreatedVersion", ""), # app version when review was written
28
+ "at": r["at"].isoformat() if r.get("at") else "", # review date
29
+ "replyContent": r.get("replyContent", "") or "", # developer reply text
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
  })
114
 
115
  except Exception as e:
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)
templates/index.html CHANGED
@@ -9,271 +9,459 @@
9
  :root {
10
  --bg: #0b0e14;
11
  --surface: #151921;
 
12
  --border: #232a35;
13
  --accent: #3b82f6;
 
 
 
 
14
  --text: #f1f5f9;
15
- --muted: #94a3b8;
 
16
  }
17
  * { box-sizing: border-box; margin: 0; padding: 0; }
18
  body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
19
-
20
- /* Layout */
21
  .header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
22
  .main { flex: 1; display: flex; overflow: hidden; }
23
  .sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 20px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
24
  .content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
25
 
26
- /* UI Components */
27
  .logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
28
  .input-group { display: flex; flex-direction: column; gap: 8px; }
29
- .label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); letter-spacing: 0.5px; }
30
- input, select { background: var(--bg); border: 1px solid var(--border); color: white; padding: 10px; border-radius: 8px; font-size: 13px; outline: none; }
31
  input:focus { border-color: var(--accent); }
32
-
33
  .toggle-grp { display: flex; background: var(--bg); padding: 4px; border-radius: 10px; border: 1px solid var(--border); }
34
- .toggle-btn { flex: 1; padding: 8px; border: none; background: transparent; color: var(--muted); cursor: pointer; border-radius: 7px; font-weight: 600; font-size: 12px; }
35
  .toggle-btn.active { background: var(--accent); color: white; }
36
-
37
- .btn-main { background: var(--accent); color: white; border: none; padding: 12px; border-radius: 10px; font-weight: 800; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: 0.2s; }
38
- .btn-main:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
39
- .btn-main:disabled { opacity: 0.5; cursor: not-allowed; }
40
-
41
- .btn-icon { width: 40px; height: 40px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); color: var(--muted); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.2s; }
42
  .btn-icon:hover { color: white; border-color: var(--accent); }
43
  .btn-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
44
 
45
- /* Tabs */
46
  .view-tabs { display: flex; gap: 10px; }
47
  .tab { padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 700; cursor: pointer; border: 1px solid var(--border); color: var(--muted); transition: 0.2s; }
48
  .tab.active { background: var(--accent); color: white; border-color: var(--accent); }
49
 
50
- /* Result Area */
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  .scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
52
- .app-card { background: var(--surface); border: 1px solid var(--border); padding: 20px; border-radius: 15px; display: flex; gap: 20px; }
53
- .app-card img { width: 80px; height: 80px; border-radius: 15px; }
54
- .review-item { background: var(--surface); border: 1px solid var(--border); padding: 15px; border-radius: 12px; }
55
- .review-item .name { font-weight: 700; font-size: 13px; margin-bottom: 5px; }
56
- .review-item .text { font-size: 13px; color: var(--muted); line-height: 1.5; }
57
 
58
- /* Iframe Error Message */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  .site-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 15px; padding: 40px; }
60
  .site-overlay h3 { font-size: 18px; font-weight: 700; }
61
  .site-overlay p { color: var(--muted); font-size: 14px; max-width: 400px; }
62
 
63
  .hidden { display: none !important; }
64
- iframe { width: 100%; height: 100%; border: none; }
65
  </style>
66
  </head>
67
  <body>
68
 
69
- <div class="header">
70
- <div class="logo">
71
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
72
- PLAYPULSE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  </div>
74
-
75
- <div style="flex: 1"></div>
76
 
77
- <div class="view-tabs" id="viewTabs">
78
- <div class="tab active" onclick="switchView('data')">Data List</div>
79
- <div class="tab" id="siteTabBtn" onclick="switchView('site')">Live Website</div>
 
 
 
 
80
  </div>
81
 
82
- <button class="btn-icon" id="dlBtn" onclick="downloadCSV()" title="Download CSV">
83
- <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
84
- </button>
85
- </div>
86
-
87
- <div class="main">
88
- <aside class="sidebar">
89
- <div class="input-group">
90
- <div class="label">App Identity</div>
91
- <input type="text" id="target" placeholder="Paste Play Store Link or Name" value="WhatsApp">
92
- </div>
93
 
94
- <div class="input-group">
95
- <div class="label">Amount of Data</div>
96
- <div class="toggle-grp">
97
- <button class="toggle-btn active" id="btnAll" onclick="setMode('all')">Fetch All</button>
98
- <button class="toggle-btn" id="btnLimit" onclick="setMode('limit')">Custom</button>
 
99
  </div>
100
- <input type="number" id="manualCount" class="hidden" value="100" placeholder="Count (e.g. 500)">
101
  </div>
102
-
103
- <div class="input-group">
104
- <div class="label">Strategy</div>
105
- <select id="sort">
106
- <option value="MOST_RELEVANT">Most Relevant</option>
107
- <option value="NEWEST">Newest</option>
108
- <option value="RATING">Top Ratings</option>
109
- </select>
110
  </div>
 
 
111
 
112
- <button class="btn-main" id="go" onclick="run()">
113
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="13 17 18 12 13 7"/><line x1="6" y1="17" x2="6" y2="7"/></svg>
114
- START SCRAPING
115
- </button>
116
 
117
- <div class="input-group" id="recents">
118
- <div class="label">Recent Sessions</div>
119
- <div id="recentList" style="display:flex; flex-direction:column; gap:8px;"></div>
120
- </div>
121
- </aside>
122
-
123
- <div class="content">
124
- <!-- Data View -->
125
- <div id="dataView" class="scroll-view">
126
- <div id="welcome" style="display:flex; flex-direction:column; align-items:center; justify-content:center; flex:1; color:var(--muted)">
127
- <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
128
- <p style="margin-top:10px">Enter app details to see scrapped data</p>
129
- </div>
130
- <div id="results" class="hidden"></div>
131
- </div>
132
 
133
- <!-- Loading Spinner -->
134
- <div id="loader" class="site-overlay hidden">
135
- <div style="width:40px; height:40px; border:4px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite"></div>
136
- <p>Connecting to servers...</p>
 
137
  </div>
 
 
138
 
139
- <!-- Website View -->
140
- <div id="siteView" class="hidden" style="height:100%">
141
- <div class="site-overlay">
142
- <h3>Web View Shielded</h3>
143
- <p>Google Play Store blocks previewing inside other apps for security. Use this button to view the live site in a new tab.</p>
144
- <button class="btn-main" id="externalBtn" onclick="openTarget()">
145
- Open Officially on Google Play
146
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg>
147
- </button>
148
- </div>
149
- <iframe id="iframe" src=""></iframe>
 
 
150
  </div>
151
  </div>
152
  </div>
153
-
154
- <style> @keyframes spin { to { transform: rotate(360deg); } } </style>
155
- <script>
156
- let mode = 'all';
157
- let currentData = null;
158
-
159
- function setMode(m) {
160
- mode = m;
161
- document.getElementById('btnAll').classList.toggle('active', m === 'all');
162
- document.getElementById('btnLimit').classList.toggle('active', m === 'limit');
163
- document.getElementById('manualCount').classList.toggle('hidden', m === 'all');
164
- }
165
-
166
- function switchView(v) {
167
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
168
- event.target.classList.add('active');
169
- document.getElementById('dataView').classList.toggle('hidden', v !== 'data');
170
- document.getElementById('siteView').classList.toggle('hidden', v !== 'site');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  }
172
-
173
- function openTarget() {
174
- const url = document.getElementById('target').value;
175
- if (url.startsWith('http')) window.open(url, '_blank');
176
- else if (currentData) window.open(`https://play.google.com/store/apps/details?id=${currentData.app_info.appId}`, '_blank');
177
- }
178
-
179
- async function run() {
180
- const query = document.getElementById('target').value;
181
- if (!query) return;
182
-
183
- document.getElementById('results').innerHTML = '';
184
- currentData = null;
185
- document.getElementById('welcome').classList.add('hidden');
186
- document.getElementById('results').classList.add('hidden');
187
- document.getElementById('loader').classList.remove('hidden');
188
- document.getElementById('go').disabled = true;
189
-
190
- try {
191
- const res = await fetch('/scrape', {
192
- method: 'POST',
193
- headers: { 'Content-Type': 'application/json' },
194
- body: JSON.stringify({
195
- identifier: query,
196
- review_count_type: mode,
197
- review_count: document.getElementById('manualCount').value || 150,
198
- sort_order: document.getElementById('sort').value,
199
- star_rating: 'all'
200
- })
201
- });
202
- const data = await res.json();
203
- if (!res.ok) throw new Error(data.error);
204
-
205
- currentData = data;
206
- render(data);
207
- save(data.app_info);
208
-
209
- // Try to update iframe just in case (though it might still show blank/error)
210
- const siteUrl = query.startsWith('http') ? query : `https://play.google.com/store/apps/details?id=${data.app_info.appId}`;
211
- document.getElementById('iframe').src = siteUrl;
212
-
213
- } catch (e) {
214
- alert(e.message);
215
- } finally {
216
- document.getElementById('loader').classList.add('hidden');
217
- document.getElementById('results').classList.remove('hidden');
218
- document.getElementById('go').disabled = false;
219
- }
220
- }
221
-
222
- function render(data) {
223
- const html = `
224
- <div class="app-card">
225
- <img src="${data.app_info.icon}">
226
- <div style="flex:1">
227
- <h2 style="font-size:22px; margin-bottom:4px">${data.app_info.title}</h2>
228
- <div style="color:var(--accent); font-weight:700; font-size:12px">${data.app_info.appId}</div>
229
- <div style="display:flex; gap:15px; margin-top:10px">
230
- <div><strong>${(data.app_info.score || 0).toFixed(1)}</strong> <span style="color:var(--muted);font-size:10px uppercase">Rating</span></div>
231
- <div><strong>${data.reviews.length}</strong> <span style="color:var(--muted);font-size:10px uppercase">Fetched</span></div>
232
- </div>
233
  </div>
234
- </div>
235
- <div style="display:grid; gap:12px">
236
- ${data.reviews.map(r => `
237
- <div class="review-item">
238
- <div class="name">${r.userName} <span style="color:var(--accent);margin-left:8px">${'★'.repeat(r.score)}</span></div>
239
- <div class="text">${r.content}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  </div>
241
- `).join('')}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  </div>
243
- `;
244
- document.getElementById('results').innerHTML = html;
245
- }
246
-
247
- function downloadCSV() {
248
- if (!currentData) return;
249
- const csv = ["Name,Score,Content", ...currentData.reviews.map(r => `"${r.userName}",${r.score},"${r.content.replace(/"/g, '""')}"`)].join('\n');
250
- const blob = new Blob([csv], { type: 'text/csv' });
251
- const url = window.URL.createObjectURL(blob);
252
- const a = document.createElement('a');
253
- a.href = url;
254
- a.download = `${currentData.app_info.appId}_data.csv`;
255
- a.click();
256
- }
257
-
258
- function save(info) {
259
- let list = JSON.parse(localStorage.getItem('scrapes') || '[]');
260
- list = [info, ...list.filter(x => x.appId !== info.appId)].slice(0, 5);
261
- localStorage.setItem('scrapes', JSON.stringify(list));
262
- loadRecent();
263
- }
264
 
265
- function loadRecent() {
266
- const list = JSON.parse(localStorage.getItem('scrapes') || '[]');
267
- document.getElementById('recentList').innerHTML = list.map(x => `
268
- <div onclick="document.getElementById('target').value='${x.appId}';run()" style="cursor:pointer; background:var(--bg); padding:10px; border-radius:8px; display:flex; gap:10px; align-items:center; border:1px solid var(--border)">
269
- <img src="${x.icon}" style="width:24px;height:24px;border-radius:4px">
270
- <span style="font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">${x.title}</span>
271
- <div style="font-size: 10px; color: var(--muted)">${(x.score || 0).toFixed(1)} ★</div>
272
  </div>
273
- `).join('');
274
- }
 
 
 
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  loadRecent();
277
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  </body>
279
  </html>
 
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: 300px; 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
  .logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; }
32
  .input-group { display: flex; flex-direction: column; gap: 8px; }
33
+ .label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); letter-spacing: 0.5px; display:flex; align-items:center; justify-content:space-between; }
34
+ input, select { background: var(--bg); border: 1px solid var(--border); color: white; padding: 10px; border-radius: 8px; font-size: 13px; outline: none; width: 100%; }
35
  input:focus { border-color: var(--accent); }
 
36
  .toggle-grp { display: flex; background: var(--bg); padding: 4px; border-radius: 10px; border: 1px solid var(--border); }
37
+ .toggle-btn { flex: 1; padding: 8px; border: none; background: transparent; color: var(--muted); cursor: pointer; border-radius: 7px; font-weight: 600; font-size: 12px; transition: .15s; }
38
  .toggle-btn.active { background: var(--accent); color: white; }
39
+ .btn-main { background: var(--accent); color: white; border: none; padding: 12px; border-radius: 10px; font-weight: 800; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: 0.2s; width: 100%; }
40
+ .btn-main:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
41
+ .btn-main:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
42
+ .btn-icon { width: 40px; height: 40px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); color: var(--muted); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: 0.2s; flex-shrink: 0; }
 
 
43
  .btn-icon:hover { color: white; border-color: var(--accent); }
44
  .btn-icon svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
45
 
 
46
  .view-tabs { display: flex; gap: 10px; }
47
  .tab { padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 700; cursor: pointer; border: 1px solid var(--border); color: var(--muted); transition: 0.2s; }
48
  .tab.active { background: var(--accent); color: white; border-color: var(--accent); }
49
 
50
+ /* Star Filter */
51
+ .star-filter-grid { display: flex; flex-direction: column; gap: 6px; }
52
+ .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; }
53
+ .star-row:hover { border-color: var(--accent); }
54
+ .star-row input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; padding: 0; border: none; background: transparent; flex-shrink: 0; }
55
+ .star-label { display: flex; align-items: center; gap: 5px; font-size: 13px; font-weight: 600; flex: 1; }
56
+ .stars-on { color: var(--amber); letter-spacing: -1px; }
57
+ .stars-off { color: var(--border); letter-spacing: -1px; }
58
+ .quick-btn { font-size: 10px; font-weight: 700; color: var(--muted); cursor: pointer; padding: 2px 6px; border-radius: 4px; border: 1px solid var(--border); background: transparent; transition: .15s; }
59
+ .quick-btn:hover { color: white; border-color: var(--accent); }
60
+ .filter-chips { display: flex; flex-wrap: wrap; gap: 6px; }
61
+ .chip { font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 20px; background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(59,130,246,.3); }
62
+
63
+ /* Layout */
64
  .scroll-view { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
 
 
 
 
 
65
 
66
+ /* App header card */
67
+ .app-card { background: var(--surface); border: 1px solid var(--border); padding: 20px; border-radius: 16px; display: flex; gap: 20px; }
68
+ .app-card img { width: 80px; height: 80px; border-radius: 16px; object-fit: cover; }
69
+ .app-stats { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
70
+ .stat-pill { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 6px 12px; display: flex; flex-direction: column; align-items: center; min-width: 58px; }
71
+ .stat-val { font-size: 15px; font-weight: 800; line-height: 1; }
72
+ .stat-key { font-size: 9px; font-weight: 700; text-transform: uppercase; color: var(--muted); margin-top: 3px; letter-spacing: .5px; }
73
+
74
+ /* Summary bar */
75
+ .summary-bar { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
76
+ .star-dist { flex: 1; display: flex; flex-direction: column; gap: 5px; min-width: 160px; }
77
+ .star-bar-row { display: flex; align-items: center; gap: 7px; font-size: 11px; }
78
+ .star-bar-track { flex: 1; height: 5px; background: var(--border); border-radius: 3px; overflow: hidden; }
79
+ .star-bar-fill { height: 100%; border-radius: 3px; background: var(--amber); }
80
+
81
+ /* Review card */
82
+ .review-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; transition: border-color .15s; }
83
+ .review-card:hover { border-color: #2d3a4f; }
84
+ .review-main { padding: 16px 18px; }
85
+ .review-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
86
+ .user-avatar { width: 34px; height: 34px; border-radius: 50%; background: var(--surface2); flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12px; color: var(--muted2); border: 1px solid var(--border); overflow: hidden; }
87
+ .user-avatar img { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; }
88
+ .review-meta { flex: 1; min-width: 0; }
89
+ .review-username { font-weight: 700; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
90
+ .review-date { font-size: 11px; color: var(--muted); margin-top: 1px; }
91
+ .review-stars { display: flex; gap: 1px; flex-shrink: 0; }
92
+ .review-text { font-size: 13px; color: #cbd5e1; line-height: 1.6; }
93
+
94
+ /* Meta pills row */
95
+ .review-footer { padding: 10px 18px; background: var(--bg); border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
96
+ .meta-pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; padding: 4px 9px; border-radius: 20px; border: 1px solid var(--border); color: var(--muted2); background: var(--surface); }
97
+ .meta-pill svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 2.5; flex-shrink: 0; }
98
+ .meta-pill.thumbs { color: #3b82f6; border-color: rgba(59,130,246,.25); background: var(--accent-dim); }
99
+ .meta-pill.version { color: #a78bfa; border-color: rgba(167,139,250,.25); background: rgba(167,139,250,.08); }
100
+ .meta-pill.replied { color: var(--green); border-color: rgba(34,197,94,.25); background: var(--green-dim); }
101
+
102
+ /* Dev reply block */
103
+ .dev-reply { margin: 0 18px 16px; background: var(--surface2); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: 10px; padding: 12px 14px; }
104
+ .dev-reply-header { font-size: 11px; font-weight: 700; color: var(--green); margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
105
+ .dev-reply-text { font-size: 12px; color: var(--muted2); line-height: 1.55; }
106
+ .dev-reply-date { font-size: 10px; color: var(--muted); margin-top: 5px; }
107
+
108
+ /* Overlays */
109
+ .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; }
110
+ .spinner { width: 40px; height: 40px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
111
  .site-overlay { position: absolute; inset: 0; background: var(--bg); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 15px; padding: 40px; }
112
  .site-overlay h3 { font-size: 18px; font-weight: 700; }
113
  .site-overlay p { color: var(--muted); font-size: 14px; max-width: 400px; }
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>
129
+ <div class="tab" onclick="switchView('site',event)">Live Website</div>
130
+ </div>
131
+ <button class="btn-icon" onclick="downloadCSV()" title="Download CSV">
132
+ <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
133
+ </button>
134
+ </div>
135
+
136
+ <div class="main">
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">
153
+ <div class="label">Strategy</div>
154
+ <select id="sort">
155
+ <option value="MOST_RELEVANT">Most Relevant</option>
156
+ <option value="NEWEST">Newest</option>
157
+ <option value="RATING">Top Ratings</option>
158
+ </select>
159
+ </div>
 
 
 
160
 
161
+ <div class="input-group">
162
+ <div class="label">
163
+ <span>Star Rating Filter</span>
164
+ <div style="display:flex;gap:5px">
165
+ <button class="quick-btn" onclick="selectAllStars(true)">All</button>
166
+ <button class="quick-btn" onclick="selectAllStars(false)">None</button>
167
  </div>
 
168
  </div>
169
+ <div class="star-filter-grid">
170
+ <label class="star-row"><input type="checkbox" class="star-cb" value="5" checked><span class="star-label"><span class="stars-on">★★★★★</span>&nbsp;5 Stars</span></label>
171
+ <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>&nbsp;4 Stars</span></label>
172
+ <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>&nbsp;3 Stars</span></label>
173
+ <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>&nbsp;2 Stars</span></label>
174
+ <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>&nbsp;1 Star</span></label>
 
 
175
  </div>
176
+ <div class="filter-chips" id="filterChips"></div>
177
+ </div>
178
 
179
+ <button class="btn-main" id="go" onclick="run()">
180
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="13 17 18 12 13 7"/><line x1="6" y1="17" x2="6" y2="7"/></svg>
181
+ START SCRAPING
182
+ </button>
183
 
184
+ <div class="input-group">
185
+ <div class="label">Recent Sessions</div>
186
+ <div id="recentList" style="display:flex;flex-direction:column;gap:8px;"></div>
187
+ </div>
188
+ </aside>
 
 
 
 
 
 
 
 
 
 
189
 
190
+ <div class="content">
191
+ <div id="dataView" class="scroll-view">
192
+ <div id="welcome" style="display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;color:var(--muted);gap:12px">
193
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
194
+ <p>Enter app details to see scraped data</p>
195
  </div>
196
+ <div id="results" class="hidden"></div>
197
+ </div>
198
 
199
+ <div id="loader" class="loader-overlay hidden">
200
+ <div class="spinner"></div>
201
+ <p style="color:var(--muted);font-size:14px" id="loaderMsg">Connecting to servers…</p>
202
+ </div>
203
+
204
+ <div id="siteView" class="hidden" style="height:100%">
205
+ <div class="site-overlay">
206
+ <h3>Web View Shielded</h3>
207
+ <p>Google Play Store blocks previewing inside other apps for security. Use this button to view the live site in a new tab.</p>
208
+ <button class="btn-main" style="width:auto;padding:12px 24px" onclick="openTarget()">
209
+ Open on Google Play
210
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg>
211
+ </button>
212
  </div>
213
  </div>
214
  </div>
215
+ </div>
216
+
217
+ <script>
218
+ let mode = 'all';
219
+ let currentData = null;
220
+
221
+ function setMode(m) {
222
+ mode = m;
223
+ document.getElementById('btnAll').classList.toggle('active', m==='all');
224
+ document.getElementById('btnLimit').classList.toggle('active', m==='limit');
225
+ document.getElementById('manualCount').classList.toggle('hidden', m==='all');
226
+ }
227
+
228
+ function switchView(v, event) {
229
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
230
+ event.target.classList.add('active');
231
+ document.getElementById('dataView').classList.toggle('hidden', v!=='data');
232
+ document.getElementById('siteView').classList.toggle('hidden', v!=='site');
233
+ }
234
+
235
+ function openTarget() {
236
+ const url = document.getElementById('target').value;
237
+ if (url.startsWith('http')) window.open(url,'_blank');
238
+ else if (currentData) window.open(`https://play.google.com/store/apps/details?id=${currentData.app_info.appId}`,'_blank');
239
+ }
240
+
241
+ function selectAllStars(checked) {
242
+ document.querySelectorAll('.star-cb').forEach(cb => cb.checked = checked);
243
+ updateChips();
244
+ }
245
+ function updateChips() {
246
+ const sel = getSelectedStars();
247
+ document.getElementById('filterChips').innerHTML =
248
+ (sel.length===5||sel.length===0) ? '' : sel.map(s=>`<span class="chip">${s}★</span>`).join('');
249
+ }
250
+ function getSelectedStars() {
251
+ return [...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value));
252
+ }
253
+ document.querySelectorAll('.star-cb').forEach(cb => cb.addEventListener('change', updateChips));
254
+
255
+ function renderStars(score) {
256
+ let out = '';
257
+ for (let i=1;i<=5;i++) out += `<span style="font-size:13px;color:${i<=score?'var(--amber)':'var(--border)'}">${i<=score?'★':'★'}</span>`;
258
+ return out;
259
+ }
260
+
261
+ function fmtDate(iso) {
262
+ if (!iso) return '';
263
+ return new Date(iso).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'});
264
+ }
265
+
266
+ function fmtNum(n) {
267
+ if (!n) return null;
268
+ if (n>=1000000) return (n/1000000).toFixed(1)+'M';
269
+ if (n>=1000) return (n/1000).toFixed(1)+'k';
270
+ return String(n);
271
+ }
272
+
273
+ async function run() {
274
+ const query = document.getElementById('target').value.trim();
275
+ if (!query) return;
276
+ const selectedStars = getSelectedStars();
277
+ if (!selectedStars.length) { alert('Select at least one star rating.'); return; }
278
+
279
+ document.getElementById('results').innerHTML='';
280
+ currentData=null;
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…'];
287
+ let mi=0;
288
+ document.getElementById('loaderMsg').textContent=msgs[0];
289
+ const msgInt=setInterval(()=>{mi=(mi+1)%msgs.length;document.getElementById('loaderMsg').textContent=msgs[mi];},2200);
290
+
291
+ try {
292
+ const res = await fetch('/scrape',{
293
+ method:'POST',
294
+ headers:{'Content-Type':'application/json'},
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
+ }
317
+
318
+ function render(data, selectedStars) {
319
+ const { reviews, app_info: info } = data;
320
+
321
+ // Compute stats
322
+ const dist={1:0,2:0,3:0,4:0,5:0};
323
+ reviews.forEach(r=>{if(r.score>=1&&r.score<=5)dist[r.score]++;});
324
+ const total=reviews.length;
325
+ const repliedCount=reviews.filter(r=>r.replyContent).length;
326
+ const avgScore=total?(reviews.reduce((a,r)=>a+(r.score||0),0)/total).toFixed(2):'—';
327
+ const totalLikes=reviews.reduce((a,r)=>a+(r.thumbsUpCount||0),0);
328
+ const filterLabel=selectedStars.length===5?'All Ratings':selectedStars.sort((a,b)=>b-a).map(s=>`${s}★`).join(', ');
329
+
330
+ // Star distribution bars
331
+ const starDistHTML=[5,4,3,2,1].map(s=>{
332
+ const pct=total?Math.round((dist[s]/total)*100):0;
333
+ return `<div class="star-bar-row">
334
+ <span style="color:var(--amber);width:12px;text-align:right">${s}</span>
335
+ <div class="star-bar-track"><div class="star-bar-fill" style="width:${pct}%"></div></div>
336
+ <span style="color:var(--muted);width:30px;text-align:right">${pct}%</span>
337
+ </div>`;
338
+ }).join('');
339
+
340
+ // Individual review cards
341
+ const reviewsHTML=reviews.map(r=>{
342
+ const thumbsLabel=fmtNum(r.thumbsUpCount);
343
+ const hasReply=r.replyContent&&r.replyContent.trim();
344
+ const version=r.reviewCreatedVersion;
345
+
346
+ // Build meta pills — only if data exists
347
+ const pills=[
348
+ thumbsLabel ? `<span class="meta-pill thumbs">
349
+ <svg viewBox="0 0 24 24"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
350
+ ${thumbsLabel} helpful
351
+ </span>` : '',
352
+ version ? `<span class="meta-pill version">
353
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
354
+ v${version}
355
+ </span>` : '',
356
+ hasReply ? `<span class="meta-pill replied">
357
+ <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
358
+ Dev replied
359
+ </span>` : ''
360
+ ].filter(Boolean).join('');
361
+
362
+ // Developer reply block
363
+ const replyHTML=hasReply?`
364
+ <div class="dev-reply">
365
+ <div class="dev-reply-header">
366
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
367
+ Developer Response
 
 
 
 
 
 
 
 
 
368
  </div>
369
+ <div class="dev-reply-text">${r.replyContent}</div>
370
+ ${r.repliedAt?`<div class="dev-reply-date">${fmtDate(r.repliedAt)}</div>`:''}
371
+ </div>`:'';
372
+
373
+ // User avatar
374
+ const initials=(r.userName||'?').trim().split(/\s+/).map(w=>w[0]).join('').slice(0,2).toUpperCase();
375
+ const avatarHTML=r.userImage
376
+ ?`<div class="user-avatar"><img src="${r.userImage}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${initials}'"></div>`
377
+ :`<div class="user-avatar">${initials}</div>`;
378
+
379
+ return `
380
+ <div class="review-card">
381
+ <div class="review-main">
382
+ <div class="review-header">
383
+ ${avatarHTML}
384
+ <div class="review-meta">
385
+ <div class="review-username">${r.userName||'Anonymous'}</div>
386
+ <div class="review-date">${fmtDate(r.at)}</div>
387
+ </div>
388
+ <div class="review-stars">${renderStars(r.score)}</div>
389
  </div>
390
+ <div class="review-text">${r.content||'<em style="color:var(--muted)">No review text</em>'}</div>
391
+ </div>
392
+ ${pills?`<div class="review-footer">${pills}</div>`:''}
393
+ ${replyHTML}
394
+ </div>`;
395
+ }).join('');
396
+
397
+ document.getElementById('results').innerHTML=`
398
+ <div class="app-card">
399
+ <img src="${info.icon}" alt="icon">
400
+ <div style="flex:1">
401
+ <h2 style="font-size:20px;font-weight:800;margin-bottom:3px">${info.title}</h2>
402
+ <div style="color:var(--accent);font-weight:700;font-size:11px;margin-bottom:10px">${info.appId}</div>
403
+ <div class="app-stats">
404
+ <div class="stat-pill"><span class="stat-val" style="color:var(--amber)">${(info.score||0).toFixed(1)}</span><span class="stat-key">Store Avg</span></div>
405
+ <div class="stat-pill"><span class="stat-val">${total.toLocaleString()}</span><span class="stat-key">Fetched</span></div>
406
+ <div class="stat-pill"><span class="stat-val" style="color:var(--green)">${repliedCount}</span><span class="stat-key">Replied</span></div>
407
+ <div class="stat-pill"><span class="stat-val" style="color:var(--accent)">${fmtNum(totalLikes)||'0'}</span><span class="stat-key">Total Likes</span></div>
408
+ </div>
409
  </div>
410
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
 
412
+ <div class="summary-bar">
413
+ <div>
414
+ <div style="font-size:24px;font-weight:800;color:var(--amber)">${avgScore}</div>
415
+ <div style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.5px;margin-top:2px">Avg Score</div>
 
 
 
416
  </div>
417
+ <div class="star-dist">${starDistHTML}</div>
418
+ <div style="font-size:11px;color:var(--muted);padding:6px 12px;border-radius:8px;background:var(--bg);border:1px solid var(--border)">
419
+ Filter:<br><strong style="color:var(--accent)">${filterLabel}</strong>
420
+ </div>
421
+ </div>
422
 
423
+ <div style="display:flex;flex-direction:column;gap:10px">${reviewsHTML}</div>
424
+ `;
425
+ }
426
+
427
+ function downloadCSV() {
428
+ if (!currentData) return;
429
+ const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`;
430
+ const hdr=['Review ID','User','Score','Date','Content','Thumbs Up','App Version','Dev Reply','Dev Reply Date'];
431
+ const rows=currentData.reviews.map(r=>[
432
+ esc(r.reviewId||''),
433
+ esc(r.userName||''),
434
+ r.score||0,
435
+ esc((r.at||'').slice(0,10)),
436
+ esc(r.content||''),
437
+ r.thumbsUpCount||0,
438
+ esc(r.reviewCreatedVersion||''),
439
+ esc(r.replyContent||''),
440
+ esc((r.repliedAt||'').slice(0,10))
441
+ ].join(','));
442
+ const blob=new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'});
443
+ const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`${currentData.app_info.appId}_reviews.csv`});
444
+ a.click(); URL.revokeObjectURL(a.href);
445
+ }
446
+
447
+ function save(info) {
448
+ let list=JSON.parse(localStorage.getItem('scrapes')||'[]');
449
+ list=[info,...list.filter(x=>x.appId!==info.appId)].slice(0,5);
450
+ localStorage.setItem('scrapes',JSON.stringify(list));
451
  loadRecent();
452
+ }
453
+
454
+ function loadRecent() {
455
+ const list=JSON.parse(localStorage.getItem('scrapes')||'[]');
456
+ document.getElementById('recentList').innerHTML=list.map(x=>`
457
+ <div onclick="document.getElementById('target').value='${x.appId}';run()" style="cursor:pointer;background:var(--bg);padding:10px;border-radius:8px;display:flex;gap:10px;align-items:center;border:1px solid var(--border);transition:.15s" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
458
+ <img src="${x.icon}" style="width:26px;height:26px;border-radius:5px" alt="">
459
+ <span style="font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${x.title}</span>
460
+ <span style="font-size:10px;color:var(--muted)">${(x.score||0).toFixed(1)}★</span>
461
+ </div>`).join('');
462
+ }
463
+
464
+ loadRecent();
465
+ </script>
466
  </body>
467
  </html>