WebashalarForML commited on
Commit
74c31b0
·
verified ·
1 Parent(s): adc3c6c

Upload 5 files

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