Spaces:
Running
Running
Upload 5 files
Browse files- app.py +32 -8
- templates/batch.html +99 -13
- templates/index.html +8 -0
- templates/landing.html +7 -0
app.py
CHANGED
|
@@ -145,25 +145,49 @@ def scrape():
|
|
| 145 |
return jsonify({"error": str(e)}), 500
|
| 146 |
|
| 147 |
|
| 148 |
-
@app.route('/
|
| 149 |
-
def
|
| 150 |
try:
|
| 151 |
data = request.json
|
| 152 |
query = data.get('query', '').strip()
|
| 153 |
-
app_count = int(data.get('app_count',
|
| 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 |
-
#
|
| 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
|
| 167 |
|
| 168 |
batch_results = []
|
| 169 |
all_combined_reviews = []
|
|
|
|
| 145 |
return jsonify({"error": str(e)}), 500
|
| 146 |
|
| 147 |
|
| 148 |
+
@app.route('/find-apps', methods=['POST'])
|
| 149 |
+
def find_apps():
|
| 150 |
try:
|
| 151 |
data = request.json
|
| 152 |
query = data.get('query', '').strip()
|
| 153 |
+
app_count = int(data.get('app_count', 10))
|
|
|
|
|
|
|
| 154 |
|
| 155 |
+
# Using the robust scraper to get IDs
|
| 156 |
app_ids = scrape_store_ids(query, n_hits=app_count)
|
| 157 |
|
|
|
|
| 158 |
if not app_ids:
|
| 159 |
+
# Fallback to library
|
| 160 |
hits = search(query, lang="en", country="us", n_hits=app_count)
|
| 161 |
app_ids = [h['appId'] for h in hits if h.get('appId')]
|
| 162 |
|
| 163 |
+
results = []
|
| 164 |
+
for aid in app_ids:
|
| 165 |
+
try:
|
| 166 |
+
info = app_info(aid, lang='en', country='us')
|
| 167 |
+
results.append({
|
| 168 |
+
"appId": aid,
|
| 169 |
+
"title": info['title'],
|
| 170 |
+
"icon": info['icon'],
|
| 171 |
+
"score": info['score'],
|
| 172 |
+
"developer": info.get('developer', 'Unknown'),
|
| 173 |
+
"installs": info.get('installs', '0+')
|
| 174 |
+
})
|
| 175 |
+
except: continue
|
| 176 |
+
|
| 177 |
+
return jsonify({"results": results})
|
| 178 |
+
except Exception as e:
|
| 179 |
+
return jsonify({"error": str(e)}), 500
|
| 180 |
+
|
| 181 |
+
@app.route('/scrape-batch', methods=['POST'])
|
| 182 |
+
def scrape_batch():
|
| 183 |
+
try:
|
| 184 |
+
data = request.json
|
| 185 |
+
app_ids = data.get('app_ids', [])
|
| 186 |
+
count_type = data.get('review_count_type', 'fixed')
|
| 187 |
+
reviews_per_app = 100000 if count_type == 'all' else int(data.get('reviews_per_app', 100))
|
| 188 |
+
|
| 189 |
if not app_ids:
|
| 190 |
+
return jsonify({"error": "No app IDs provided"}), 400
|
| 191 |
|
| 192 |
batch_results = []
|
| 193 |
all_combined_reviews = []
|
templates/batch.html
CHANGED
|
@@ -21,11 +21,22 @@
|
|
| 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:
|
| 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; }
|
|
@@ -33,9 +44,9 @@
|
|
| 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:
|
| 37 |
-
.label { font-size:
|
| 38 |
-
input, select { background: var(--bg); border: 1px solid var(--border); color: white; padding:
|
| 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); }
|
|
@@ -100,22 +111,41 @@
|
|
| 100 |
<div class="main">
|
| 101 |
<aside class="sidebar">
|
| 102 |
<div class="input-group">
|
| 103 |
-
<div class="label">
|
| 104 |
-
<
|
|
|
|
|
|
|
|
|
|
| 105 |
</div>
|
| 106 |
|
| 107 |
<div class="input-group">
|
| 108 |
-
<div class="label">
|
| 109 |
-
<input type="number" id="app_count" value="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
</div>
|
| 111 |
|
| 112 |
<div class="input-group">
|
| 113 |
-
<div class="label">
|
|
|
|
| 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="
|
| 119 |
</div>
|
| 120 |
|
| 121 |
<div class="input-group">
|
|
@@ -206,10 +236,67 @@
|
|
| 206 |
document.querySelectorAll('.star-cb').forEach(cb => cb.checked = check);
|
| 207 |
}
|
| 208 |
|
| 209 |
-
|
|
|
|
|
|
|
| 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 |
|
|
@@ -223,8 +310,7 @@
|
|
| 223 |
method: 'POST',
|
| 224 |
headers: { 'Content-Type': 'application/json' },
|
| 225 |
body: JSON.stringify({
|
| 226 |
-
|
| 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,
|
|
|
|
| 21 |
--muted2: #94a3b8;
|
| 22 |
}
|
| 23 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 24 |
+
|
| 25 |
+
/* Modern Scrollbar */
|
| 26 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 27 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 28 |
+
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
|
| 29 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
| 30 |
+
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
|
| 31 |
+
|
| 32 |
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
| 33 |
|
| 34 |
+
.btn-sm { background: var(--surface2); border: 1px solid var(--border); color: white; padding: 4px 10px; border-radius: 6px; font-size: 10px; cursor: pointer; transition: 0.2s; }
|
| 35 |
+
.btn-sm:hover { border-color: var(--accent); }
|
| 36 |
+
|
| 37 |
.header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
|
| 38 |
.main { flex: 1; display: flex; overflow: hidden; }
|
| 39 |
+
.sidebar { width: 300px; background: var(--surface); border-right: 1px solid var(--border); padding: 15px; display: flex; flex-direction: column; gap: 15px; overflow-y: auto; }
|
| 40 |
.content { flex: 1; background: var(--bg); position: relative; display: flex; flex-direction: column; }
|
| 41 |
|
| 42 |
.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; }
|
|
|
|
| 44 |
.mode-btn.active { background: var(--surface2); color: white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
|
| 45 |
|
| 46 |
.logo { font-weight: 800; font-size: 18px; color: var(--accent); display: flex; align-items: center; gap: 8px; text-decoration: none; }
|
| 47 |
+
.input-group { display: flex; flex-direction: column; gap: 6px; }
|
| 48 |
+
.label { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--muted); letter-spacing: 0.5px; }
|
| 49 |
+
input, select { background: var(--bg); border: 1px solid var(--border); color: white; padding: 10px; border-radius: 8px; font-size: 12px; outline: none; width: 100%; }
|
| 50 |
input:focus { border-color: var(--accent); }
|
| 51 |
|
| 52 |
.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); }
|
|
|
|
| 111 |
<div class="main">
|
| 112 |
<aside class="sidebar">
|
| 113 |
<div class="input-group">
|
| 114 |
+
<div class="label">Step 1: Discover Apps</div>
|
| 115 |
+
<div style="display:flex;gap:8px;">
|
| 116 |
+
<input type="text" id="query" placeholder="e.g. Multiplayer Games..." value="Multiplayer Games" style="flex:1">
|
| 117 |
+
<button onclick="findApps()" id="btnFind" style="background:var(--accent); border:none; color:white; padding:0 15px; border-radius:8px; cursor:pointer; font-weight:700;">Find</button>
|
| 118 |
+
</div>
|
| 119 |
</div>
|
| 120 |
|
| 121 |
<div class="input-group">
|
| 122 |
+
<div class="label">Discovery Limit</div>
|
| 123 |
+
<input type="number" id="app_count" value="10" min="1" max="50">
|
| 124 |
+
<div style="font-size:10px; color:var(--muted); margin-top:4px;">How many apps to search for initially.</div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div id="selectionArea" class="hidden" style="background:var(--surface2); border:1px solid var(--border); border-radius:12px; padding:10px; display:flex; flex-direction:column; gap:8px;">
|
| 128 |
+
<div class="label" style="display:flex; justify-content:space-between; align-items:center;">
|
| 129 |
+
<span>Select Apps</span>
|
| 130 |
+
<span id="selectedCount" style="color:var(--accent); font-size:9px;">0 selected</span>
|
| 131 |
+
</div>
|
| 132 |
+
<div id="appList" style="max-height:160px; overflow-y:auto; overflow-x:hidden; display:flex; flex-direction:column; gap:4px; padding-right:4px;">
|
| 133 |
+
<!-- Compact apps list -->
|
| 134 |
+
</div>
|
| 135 |
+
<div style="display:flex; gap:5px;">
|
| 136 |
+
<button onclick="toggleAllApps(true)" class="btn-sm" style="flex:1">All</button>
|
| 137 |
+
<button onclick="toggleAllApps(false)" class="btn-sm" style="flex:1">None</button>
|
| 138 |
+
</div>
|
| 139 |
</div>
|
| 140 |
|
| 141 |
<div class="input-group">
|
| 142 |
+
<div class="label">Step 2: Scrape Settings</div>
|
| 143 |
+
<div class="label" style="font-size:10px; margin-top:10px;">Reviews Per App</div>
|
| 144 |
<div class="mode-toggle">
|
| 145 |
<div class="mode-btn active" id="btn-fixed" onclick="setMode('fixed')">Custom</div>
|
| 146 |
<div class="mode-btn" id="btn-all" onclick="setMode('all')">Fetch All</div>
|
| 147 |
</div>
|
| 148 |
+
<input type="number" id="reviews_per_app" value="50" min="10" step="10">
|
| 149 |
</div>
|
| 150 |
|
| 151 |
<div class="input-group">
|
|
|
|
| 236 |
document.querySelectorAll('.star-cb').forEach(cb => cb.checked = check);
|
| 237 |
}
|
| 238 |
|
| 239 |
+
let foundApps = [];
|
| 240 |
+
|
| 241 |
+
async function findApps() {
|
| 242 |
const q = document.getElementById('query').value.trim();
|
| 243 |
if (!q) return;
|
| 244 |
|
| 245 |
+
const btn = document.getElementById('btnFind');
|
| 246 |
+
btn.disabled = true;
|
| 247 |
+
btn.innerText = 'Searching...';
|
| 248 |
+
|
| 249 |
+
try {
|
| 250 |
+
const res = await fetch('/find-apps', {
|
| 251 |
+
method: 'POST',
|
| 252 |
+
headers: { 'Content-Type': 'application/json' },
|
| 253 |
+
body: JSON.stringify({ query: q, app_count: document.getElementById('app_count').value })
|
| 254 |
+
});
|
| 255 |
+
const data = await res.json();
|
| 256 |
+
if (!res.ok) throw new Error(data.error || 'Discovery failed');
|
| 257 |
+
|
| 258 |
+
foundApps = data.results;
|
| 259 |
+
renderAppSelection();
|
| 260 |
+
} catch(e) {
|
| 261 |
+
alert(e.message);
|
| 262 |
+
} finally {
|
| 263 |
+
btn.disabled = false;
|
| 264 |
+
btn.innerText = 'Find';
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
function renderAppSelection() {
|
| 269 |
+
const list = document.getElementById('appList');
|
| 270 |
+
document.getElementById('selectionArea').classList.remove('hidden');
|
| 271 |
+
|
| 272 |
+
list.innerHTML = foundApps.map(a => `
|
| 273 |
+
<label style="display:flex; align-items:center; gap:8px; padding:6px; background:var(--bg); border-radius:6px; border:1px solid var(--border); cursor:pointer; min-width:0;">
|
| 274 |
+
<input type="checkbox" class="app-cb" value="${a.appId}" onchange="updateSelectionCount()" checked style="width:14px; height:14px; margin:0;">
|
| 275 |
+
<img src="${a.icon}" style="width:20px; height:20px; border-radius:4px; flex-shrink:0;">
|
| 276 |
+
<div style="flex:1; min-width:0;">
|
| 277 |
+
<div style="font-size:10px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--text);">${a.title}</div>
|
| 278 |
+
<div style="font-size:9px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${a.developer}</div>
|
| 279 |
+
</div>
|
| 280 |
+
</label>
|
| 281 |
+
`).join('');
|
| 282 |
+
updateSelectionCount();
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function toggleAllApps(check) {
|
| 286 |
+
document.querySelectorAll('.app-cb').forEach(cb => cb.checked = check);
|
| 287 |
+
updateSelectionCount();
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function updateSelectionCount() {
|
| 291 |
+
const count = document.querySelectorAll('.app-cb:checked').length;
|
| 292 |
+
document.getElementById('selectedCount').innerText = `${count} selected`;
|
| 293 |
+
document.getElementById('go').disabled = count === 0;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
async function runBatch() {
|
| 297 |
+
const selectedAppIds = [...document.querySelectorAll('.app-cb:checked')].map(cb => cb.value);
|
| 298 |
+
if (!selectedAppIds.length) return alert('Select at least one app');
|
| 299 |
+
|
| 300 |
const stars = [...document.querySelectorAll('.star-cb:checked')].map(cb => parseInt(cb.value));
|
| 301 |
if (!stars.length) return alert('Select at least one star rating');
|
| 302 |
|
|
|
|
| 310 |
method: 'POST',
|
| 311 |
headers: { 'Content-Type': 'application/json' },
|
| 312 |
body: JSON.stringify({
|
| 313 |
+
app_ids: selectedAppIds,
|
|
|
|
| 314 |
review_count_type: currentMode,
|
| 315 |
reviews_per_app: document.getElementById('reviews_per_app').value,
|
| 316 |
sort_order: document.getElementById('sort').value,
|
templates/index.html
CHANGED
|
@@ -21,6 +21,14 @@
|
|
| 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; }
|
|
|
|
| 21 |
--muted2: #94a3b8;
|
| 22 |
}
|
| 23 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 24 |
+
|
| 25 |
+
/* Modern Scrollbar */
|
| 26 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 27 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 28 |
+
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
|
| 29 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
| 30 |
+
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
|
| 31 |
+
|
| 32 |
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
| 33 |
|
| 34 |
.header { height: 60px; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 20px; }
|
templates/landing.html
CHANGED
|
@@ -17,6 +17,13 @@
|
|
| 17 |
}
|
| 18 |
|
| 19 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
body {
|
| 22 |
font-family: 'Inter', sans-serif;
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 20 |
+
|
| 21 |
+
/* Modern Scrollbar */
|
| 22 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 23 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 24 |
+
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
|
| 25 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
| 26 |
+
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.1) transparent; }
|
| 27 |
|
| 28 |
body {
|
| 29 |
font-family: 'Inter', sans-serif;
|