Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Batch Intelligence | PlayPulse</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root { --bg:#0b0e14; --surface:#151921; --surface2:#1c2333; --border:#232a35; --accent:#3b82f6; --accent-dim:rgba(59,130,246,0.12); --green:#22c55e; --green-dim:rgba(34,197,94,0.12); --amber:#f59e0b; --text:#f1f5f9; --muted:#64748b; --muted2:#94a3b8; } | |
| * { box-sizing:border-box; margin:0; padding:0; } | |
| ::-webkit-scrollbar{width:6px;height:6px;} ::-webkit-scrollbar-track{background:transparent;} ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:10px;} ::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.2);} *{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.1) transparent;} | |
| body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;} | |
| .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;} .btn-sm:hover{border-color:var(--accent);} | |
| .header{height:60px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:20px;} | |
| .main{flex:1;display:flex;overflow:hidden;} | |
| .sidebar{width:300px;background:var(--surface);border-right:1px solid var(--border);padding:15px;display:flex;flex-direction:column;gap:15px;overflow-y:auto;} | |
| .content{flex:1;background:var(--bg);position:relative;display:flex;flex-direction:column;} | |
| .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;} | |
| .mode-btn{padding:8px;border-radius:7px;text-align:center;cursor:pointer;font-size:11px;font-weight:700;color:var(--muted);transition:0.2s;} .mode-btn.active{background:var(--surface2);color:white;box-shadow:0 2px 4px rgba(0,0,0,0.2);} | |
| .logo{font-weight:800;font-size:18px;color:var(--accent);display:flex;align-items:center;gap:8px;text-decoration:none;} | |
| .input-group{display:flex;flex-direction:column;gap:6px;} | |
| .label{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:0.5px;} | |
| input,select{background:var(--bg);border:1px solid var(--border);color:white;padding:10px;border-radius:8px;font-size:12px;outline:none;width:100%;} input:focus{border-color:var(--accent);} | |
| .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);} .btn-main:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(59,130,246,0.3);} .btn-main:disabled{opacity:0.5;cursor:not-allowed;} | |
| .scroll-view{flex:1;overflow-y:auto;padding:30px;display:flex;flex-direction:column;gap:25px;} | |
| .batch-summary{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:15px;} | |
| .apps-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;} | |
| .app-mini-card{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px;display:flex;align-items:center;gap:10px;} | |
| .app-mini-card img{width:32px;height:32px;border-radius:6px;} | |
| .app-mini-info{flex:1;min-width:0;} .app-mini-title{font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .app-mini-score{font-size:10px;color:var(--amber);} | |
| .table-container{background:var(--surface);border:1px solid var(--border);border-radius:16px;overflow:hidden;} | |
| table{width:100%;border-collapse:collapse;font-size:13px;} | |
| th{text-align:left;background:var(--surface2);padding:12px 16px;color:var(--muted2);font-weight:700;font-size:11px;text-transform:uppercase;border-bottom:1px solid var(--border);} | |
| td{padding:14px 16px;border-bottom:1px solid var(--border);vertical-align:top;} tr:last-child td{border-bottom:none;} tr:hover td{background:rgba(255,255,255,0.02);} | |
| .app-tag{display:inline-flex;align-items:center;gap:6px;background:var(--accent-dim);color:var(--accent);padding:4px 8px;border-radius:6px;font-weight:700;font-size:10px;margin-bottom:6px;border:1px solid rgba(59,130,246,0.2);} | |
| .score-stars{color:var(--amber);white-space:nowrap;} | |
| .review-content{color:#cbd5e1;line-height:1.5;max-width:500px;word-wrap:break-word;} | |
| .dev-reply{margin-top:8px;padding:8px 12px;background:rgba(59,130,246,0.05);border-left:2px solid var(--accent);border-radius:0 6px 6px 0;font-size:11px;color:var(--muted2);} | |
| .dev-reply-label{font-weight:700;color:var(--accent);font-size:9px;text-transform:uppercase;margin-bottom:3px;display:block;} | |
| .helpful-pill{display:inline-flex;align-items:center;gap:4px;background:var(--surface2);padding:4px 8px;border-radius:12px;font-size:10px;color:var(--muted2);border:1px solid var(--border);} | |
| .helpful-pill svg{width:10px;height:10px;color:var(--accent);} | |
| .star-filter-grid{display:flex;flex-direction:column;gap:6px;} | |
| .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;} .star-row:hover{border-color:var(--accent);} | |
| .star-row input[type="checkbox"]{width:15px;height:15px;accent-color:var(--accent);cursor:pointer;padding:0;border:none;background:transparent;flex-shrink:0;} | |
| .star-label{display:flex;align-items:center;gap:5px;font-size:13px;font-weight:600;flex:1;} .stars-on{color:var(--amber);letter-spacing:-1px;} .stars-off{color:var(--border);letter-spacing:-1px;} | |
| .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;} | |
| .spinner{width:40px;height:40px;border:4px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;} | |
| @keyframes spin{to{transform:rotate(360deg);}} | |
| .hidden{display:none;} | |
| /* ββ Chat styles ββ */ | |
| #chat-dialer{position:fixed;bottom:24px;right:24px;width:56px;height:56px;background:var(--accent);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 32px rgba(59,130,246,0.4);cursor:pointer;z-index:1000;transition:0.3s cubic-bezier(0.175,0.885,0.32,1.275);border:2px solid rgba(255,255,255,0.1);} | |
| #chat-dialer:hover{transform:scale(1.1) rotate(5deg);box-shadow:0 12px 40px rgba(59,130,246,0.6);} | |
| #chat-dialer svg{width:24px;height:24px;color:white;fill:none;stroke:currentColor;stroke-width:2.5;} | |
| #chat-window{position:fixed;bottom:90px;right:24px;width:420px;height:600px;background:var(--surface);border:1px solid var(--border);border-radius:20px;display:flex;flex-direction:column;box-shadow:0 20px 50px rgba(0,0,0,0.5);z-index:1001;overflow:hidden;transform:translateY(20px) scale(0.95);opacity:0;pointer-events:none;transition:0.3s cubic-bezier(0.4,0,0.2,1);backdrop-filter:blur(20px);} | |
| #chat-window.open{transform:translateY(0) scale(1);opacity:1;pointer-events:auto;} | |
| .chat-header{padding:14px 18px;background:var(--accent);color:white;display:flex;align-items:center;gap:12px;flex-shrink:0;} | |
| .chat-header-info{flex:1;} .chat-header-title{font-weight:800;font-size:15px;} .chat-header-status{font-size:10px;opacity:0.8;display:flex;align-items:center;gap:4px;} .status-dot{width:6px;height:6px;background:#22c55e;border-radius:50%;} | |
| .chat-header-actions{display:flex;gap:8px;align-items:center;} | |
| .chat-clear-btn{background:rgba(255,255,255,0.15);border:none;color:white;font-size:11px;padding:4px 10px;border-radius:8px;cursor:pointer;transition:0.2s;} .chat-clear-btn:hover{background:rgba(255,255,255,0.25);} | |
| .chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;background-image:radial-gradient(var(--border) 1px,transparent 1px);background-size:20px 20px;} | |
| .msg-row{display:flex;flex-direction:column;gap:4px;} .msg-row.user{align-items:flex-end;} .msg-row.bot{align-items:flex-start;} | |
| .message{max-width:88%;padding:11px 15px;border-radius:16px;font-size:13px;line-height:1.6;} | |
| .message.user{background:var(--accent);color:white;border-bottom-right-radius:4px;} | |
| .message.bot{background:var(--surface2);color:var(--text);border:1px solid var(--border);border-bottom-left-radius:4px;white-space:pre-wrap;word-break:break-word;} | |
| .msg-section{margin-top:10px;font-weight:700;font-size:11px;color:var(--accent);letter-spacing:0.05em;text-transform:uppercase;} | |
| .msg-item{display:flex;gap:8px;margin-top:5px;} .msg-item-num{font-weight:700;color:var(--accent);min-width:16px;} .msg-bullet{color:var(--accent);min-width:14px;} | |
| .chat-table-wrap{max-width:100%;overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface2);margin-top:4px;} | |
| .chat-table-title{padding:8px 12px;font-size:11px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border);letter-spacing:0.05em;text-transform:uppercase;} | |
| .chat-table{width:100%;border-collapse:collapse;font-size:12px;} | |
| .chat-table th{padding:7px 12px;text-align:left;font-weight:700;font-size:11px;color:var(--muted2);background:var(--bg);border-bottom:1px solid var(--border);white-space:nowrap;} | |
| .chat-table td{padding:7px 12px;border-bottom:1px solid var(--border);color:var(--text);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .chat-table tr:last-child td{border-bottom:none;} .chat-table tr:hover td{background:var(--surface);} | |
| .typing-indicator{display:flex;gap:4px;padding:12px 16px;background:var(--surface2);border:1px solid var(--border);border-radius:16px;width:fit-content;} | |
| .dot{width:6px;height:6px;background:var(--muted);border-radius:50%;animation:bounce 1.4s infinite;} .dot:nth-child(2){animation-delay:0.2s;} .dot:nth-child(3){animation-delay:0.4s;} | |
| @keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-6px)}} | |
| .chat-input-area{padding:14px 16px;background:var(--surface);border-top:1px solid var(--border);display:flex;gap:10px;flex-shrink:0;} | |
| #chat-input{flex:1;background:var(--bg);border:1px solid var(--border);color:white;padding:10px 14px;border-radius:12px;font-size:13px;outline:none;} #chat-input:focus{border-color:var(--accent);} | |
| .btn-send{width:40px;height:40px;background:var(--accent);color:white;border:none;border-radius:10px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:0.2s;flex-shrink:0;} .btn-send:hover{transform:scale(1.05);} .btn-send svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2.5;} | |
| .chat-suggestions{display:flex;flex-wrap:wrap;gap:6px;padding:0 16px 10px;} | |
| .sug-chip{font-size:11px;padding:5px 10px;border-radius:20px;background:var(--surface2);border:1px solid var(--border);color:var(--muted2);cursor:pointer;transition:0.2s;} .sug-chip:hover{border-color:var(--accent);color:var(--accent);} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <a href="/" class="logo"> | |
| <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> | |
| BATCH INTEL | |
| </a> | |
| <nav style="margin-left:30px;display:flex;gap:20px;"> | |
| <a href="/" style="color:var(--muted2);text-decoration:none;font-size:13px;font-weight:600;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Home</a> | |
| <a href="/scraper" style="color:var(--muted2);text-decoration:none;font-size:13px;font-weight:600;" onmouseover="this.style.color='var(--text)'" onmouseout="this.style.color='var(--muted2)'">Single Explorer</a> | |
| <a href="/batch" style="color:var(--text);text-decoration:none;font-size:13px;font-weight:700;border-bottom:2px solid var(--accent);padding-bottom:4px;">Batch Intelligence</a> | |
| </nav> | |
| <div style="flex:1"></div> | |
| <button onclick="downloadCSV()" style="background:var(--surface2);border:1px solid var(--border);color:white;padding:8px 16px;border-radius:8px;cursor:pointer;font-size:12px;font-weight:700;">Export Combined CSV</button> | |
| </div> | |
| <div class="main"> | |
| <aside class="sidebar"> | |
| <div class="input-group"> | |
| <div class="label">Step 1: Discover Apps</div> | |
| <div style="display:flex;gap:8px;"> | |
| <input type="text" id="query" placeholder="e.g. Multiplayer Games..." value="Multiplayer Games" style="flex:1"> | |
| <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> | |
| </div> | |
| </div> | |
| <div class="input-group"> | |
| <div class="label">Discovery Limit</div> | |
| <input type="number" id="app_count" value="10" min="1" max="50"> | |
| </div> | |
| <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;"> | |
| <div class="label" style="display:flex;justify-content:space-between;align-items:center;"><span>Select Apps</span><span id="selectedCount" style="color:var(--accent);font-size:9px;">0 selected</span></div> | |
| <div id="appList" style="max-height:160px;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:4px;padding-right:4px;"></div> | |
| <div style="display:flex;gap:5px;"><button onclick="toggleAllApps(true)" class="btn-sm" style="flex:1">All</button><button onclick="toggleAllApps(false)" class="btn-sm" style="flex:1">None</button></div> | |
| </div> | |
| <div class="input-group"> | |
| <div class="label">Step 2: Scrape Settings</div> | |
| <div class="label" style="font-size:10px;margin-top:10px;">Reviews Per App</div> | |
| <div class="mode-toggle"> | |
| <div class="mode-btn active" id="btn-fixed" onclick="setMode('fixed')">Custom</div> | |
| <div class="mode-btn" id="btn-all" onclick="setMode('all')">Fetch All</div> | |
| </div> | |
| <input type="number" id="reviews_per_app" value="50" min="10" step="10"> | |
| </div> | |
| <div class="input-group"> | |
| <div class="label">Sort Method</div> | |
| <select id="sort"><option value="MOST_RELEVANT">Most Relevant</option><option value="NEWEST">Newest</option><option value="RATING">Top Ratings</option></select> | |
| </div> | |
| <div class="input-group"> | |
| <div class="label" style="display:flex;justify-content:space-between;align-items:center;"> | |
| <span>Star Rating Filter</span> | |
| <div style="display:flex;gap:5px;"> | |
| <button class="quick-btn" style="font-size:9px;padding:2px 5px;cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:white;border-radius:4px;" onclick="selectAllStars(true)">All</button> | |
| <button class="quick-btn" style="font-size:9px;padding:2px 5px;cursor:pointer;background:var(--surface2);border:1px solid var(--border);color:white;border-radius:4px;" onclick="selectAllStars(false)">None</button> | |
| </div> | |
| </div> | |
| <div class="star-filter-grid"> | |
| <label class="star-row"><input type="checkbox" class="star-cb" value="5" checked><span class="star-label"><span class="stars-on">β β β β β </span></span></label> | |
| <label class="star-row"><input type="checkbox" class="star-cb" value="4" checked><span class="star-label"><span class="stars-on">β β β β </span><span class="stars-off">β </span></span></label> | |
| <label class="star-row"><input type="checkbox" class="star-cb" value="3" checked><span class="star-label"><span class="stars-on">β β β </span><span class="stars-off">β β </span></span></label> | |
| <label class="star-row"><input type="checkbox" class="star-cb" value="2" checked><span class="star-label"><span class="stars-on">β β </span><span class="stars-off">β β β </span></span></label> | |
| <label class="star-row"><input type="checkbox" class="star-cb" value="1" checked><span class="star-label"><span class="stars-on">β </span><span class="stars-off">β β β β </span></span></label> | |
| </div> | |
| </div> | |
| <button class="btn-main" id="go" onclick="runBatch()"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> | |
| RUN BATCH ANALYSIS | |
| </button> | |
| <div style="background:var(--bg);padding:15px;border-radius:12px;border:1px solid var(--border);font-size:11px;color:var(--muted);line-height:1.6;"><strong style="color:var(--text)">About Batch Mode</strong><br>Search for apps, scrape reviews for each, and compare them side-by-side with AI chat support.</div> | |
| </aside> | |
| <div class="content"> | |
| <div id="dataView" class="scroll-view"> | |
| <div id="welcome" style="display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;color:var(--muted);gap:12px"> | |
| <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> | |
| <p>Run batch analysis to compare app data</p> | |
| </div> | |
| <div id="results" class="hidden"> | |
| <div class="batch-summary"> | |
| <div class="label">Comparing These Apps:</div> | |
| <div class="apps-grid" id="appsGrid"></div> | |
| </div> | |
| <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;"> | |
| <div class="label">Reviews Comparison</div> | |
| <div style="font-size:11px;color:var(--muted);" id="resultStats"></div> | |
| </div> | |
| <div class="table-container"> | |
| <table id="reviewsTable"> | |
| <thead> | |
| <tr> | |
| <th style="width:180px">Application / User</th> | |
| <th style="width:90px">Score</th> | |
| <th>Feedback & Developer Response</th> | |
| <th style="width:100px">Helpful</th> | |
| <th style="width:110px">Date</th> | |
| </tr> | |
| </thead> | |
| <tbody id="reviewsBody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="loader" class="loader-overlay hidden"> | |
| <div class="spinner"></div> | |
| <p style="color:var(--muted);font-size:14px" id="loaderMsg">Searching for apps...</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat bubble --> | |
| <div id="chat-dialer" onclick="toggleChat()"> | |
| <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> | |
| </div> | |
| <div id="chat-window"> | |
| <div class="chat-header"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg> | |
| <div class="chat-header-info"> | |
| <div class="chat-header-title">PlayPulse Intelligence</div> | |
| <div class="chat-header-status"><span class="status-dot"></span> Agent Online</div> | |
| </div> | |
| <div class="chat-header-actions"> | |
| <button class="chat-clear-btn" onclick="clearChat()">Clear</button> | |
| <div style="cursor:pointer;opacity:0.7;" onclick="toggleChat()"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="chat-messages" id="chat-messages"> | |
| <div class="msg-row bot"> | |
| <div class="message bot">π Hi! I'm PlayPulse Intelligence. Run a batch scrape, then ask me to compare apps, find issues, show tables, or analyze sentiment!</div> | |
| </div> | |
| </div> | |
| <div class="chat-suggestions" id="chat-sug"> | |
| <div class="sug-chip" onclick="fillChat('Compare all apps by rating')">Compare apps</div> | |
| <div class="sug-chip" onclick="fillChat('Which app has the most complaints?')">Most complaints</div> | |
| <div class="sug-chip" onclick="fillChat('Show 1 star reviews in table')">1β table</div> | |
| <div class="sug-chip" onclick="fillChat('What are the common issues?')">Common issues</div> | |
| </div> | |
| <div class="chat-input-area"> | |
| <input type="text" id="chat-input" placeholder="Ask about the batch analysisβ¦" onkeydown="if(event.key==='Enter') sendChatMessage()"> | |
| <button class="btn-send" onclick="sendChatMessage()"> | |
| <svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| let currentData=null; | |
| let currentMode='fixed'; | |
| function setMode(m){currentMode=m;document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('active'));document.getElementById('btn-'+m).classList.add('active');document.getElementById('reviews_per_app').classList.toggle('hidden',m==='all');} | |
| function selectAllStars(check){document.querySelectorAll('.star-cb').forEach(cb=>cb.checked=check);} | |
| let foundApps=[]; | |
| async function findApps(){ | |
| const q=document.getElementById('query').value.trim();if(!q)return; | |
| const btn=document.getElementById('btnFind');btn.disabled=true;btn.innerText='Searching...'; | |
| try{ | |
| const res=await fetch('/find-apps',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q,app_count:document.getElementById('app_count').value})}); | |
| const data=await res.json();if(!res.ok)throw new Error(data.error||'Discovery failed'); | |
| foundApps=data.results;renderAppSelection(); | |
| }catch(e){alert(e.message);}finally{btn.disabled=false;btn.innerText='Find';} | |
| } | |
| function renderAppSelection(){ | |
| const list=document.getElementById('appList'); | |
| document.getElementById('selectionArea').classList.remove('hidden'); | |
| list.innerHTML=foundApps.map(a=>`<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;"><input type="checkbox" class="app-cb" value="${a.appId}" onchange="updateSelectionCount()" checked style="width:14px;height:14px;margin:0;"><img src="${a.icon}" style="width:20px;height:20px;border-radius:4px;flex-shrink:0;"><div style="flex:1;min-width:0;"><div style="font-size:10px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);">${a.title}</div><div style="font-size:9px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${a.developer}</div></div></label>`).join(''); | |
| updateSelectionCount(); | |
| } | |
| function toggleAllApps(check){document.querySelectorAll('.app-cb').forEach(cb=>cb.checked=check);updateSelectionCount();} | |
| function updateSelectionCount(){const count=document.querySelectorAll('.app-cb:checked').length;document.getElementById('selectedCount').innerText=`${count} selected`;document.getElementById('go').disabled=count===0;} | |
| async function runBatch(){ | |
| const selectedAppIds=[...document.querySelectorAll('.app-cb:checked')].map(cb=>cb.value); | |
| if(!selectedAppIds.length)return alert('Select at least one app'); | |
| const stars=[...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value)); | |
| if(!stars.length)return alert('Select at least one star rating'); | |
| document.getElementById('welcome').classList.add('hidden');document.getElementById('results').classList.add('hidden'); | |
| document.getElementById('loader').classList.remove('hidden');document.getElementById('go').disabled=true; | |
| try{ | |
| const res=await fetch('/scrape-batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({app_ids:selectedAppIds,review_count_type:currentMode,reviews_per_app:document.getElementById('reviews_per_app').value,sort_order:document.getElementById('sort').value,star_ratings:stars.length===5?'all':stars})}); | |
| const data=await res.json();if(!res.ok)throw new Error(data.error||'Batch failed'); | |
| currentData=data;render(data); | |
| }catch(e){alert(e.message);}finally{document.getElementById('loader').classList.add('hidden');document.getElementById('go').disabled=false;} | |
| } | |
| function render(data,customReviews){ | |
| const reviews=customReviews||data.reviews; | |
| document.getElementById('results').classList.remove('hidden'); | |
| document.getElementById('appsGrid').innerHTML=data.apps.map(a=>`<div class="app-mini-card"><img src="${a.icon}" alt=""><div class="app-mini-info"><div class="app-mini-title">${a.title}</div><div class="app-mini-score">${a.score.toFixed(1)} β </div></div></div>`).join(''); | |
| document.getElementById('resultStats').innerText=`Found ${reviews.length} reviews`; | |
| document.getElementById('reviewsBody').innerHTML=reviews.map(r=>{ | |
| const app=data.apps.find(a=>a.appId===r.appId)||{title:r.appTitle}; | |
| const replyHtml = r.replyContent ? `<div class="dev-reply"><span class="dev-reply-label">Developer Reply</span>${r.replyContent}</div>` : ''; | |
| const helpfulHtml = `<div class="helpful-pill"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><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.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>${r.thumbsUpCount || 0}</div>`; | |
| return ` | |
| <tr> | |
| <td> | |
| <div class="app-tag">${app.title}</div> | |
| <div style="font-size:11px;font-weight:700;color:var(--text);">${r.userName}</div> | |
| </td> | |
| <td> | |
| <div class="score-stars">${'β '.repeat(r.score)}<span style="color:var(--border)">${'β '.repeat(5-r.score)}</span></div> | |
| </td> | |
| <td> | |
| <div class="review-content">${r.content}</div> | |
| ${replyHtml} | |
| </td> | |
| <td>${helpfulHtml}</td> | |
| <td><div style="color:var(--muted);font-size:11px;">${new Date(r.at).toLocaleDateString(undefined, {month:'short', day:'numeric', year:'numeric'})}</div></td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| } | |
| function downloadCSV(){ | |
| if(!currentData)return; | |
| const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`; | |
| const hdr=['App Name','App ID','User','Score','Date','Content','Thumbs Up','Developer Reply']; | |
| const rows=currentData.reviews.map(r=>[esc(r.appTitle),esc(r.appId),esc(r.userName),r.score,esc(r.at.slice(0,10)),esc(r.content),r.thumbsUpCount,esc(r.replyContent)].join(',')); | |
| const blob=new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'}); | |
| const a=Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`batch_${Date.now()}.csv`});a.click(); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // CHAT (shared logic identical to index.html) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const SESSION_ID=(()=>{let id=sessionStorage.getItem('pp_sid');if(!id){id='sess_'+Math.random().toString(36).slice(2);sessionStorage.setItem('pp_sid',id);}return id;})(); | |
| function toggleChat(){document.getElementById('chat-window').classList.toggle('open');} | |
| function fillChat(t){const i=document.getElementById('chat-input');i.value=t;i.focus();} | |
| async function clearChat(){ | |
| document.getElementById('chat-messages').innerHTML=`<div class="msg-row bot"><div class="message bot">Chat cleared. Run a batch scrape then ask me anything!</div></div>`; | |
| await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})}); | |
| } | |
| async function sendChatMessage(){ | |
| const input=document.getElementById('chat-input');const msg=input.value.trim();if(!msg)return; | |
| appendUserMsg(msg);input.value=''; | |
| const container=document.getElementById('chat-messages'); | |
| const typing=document.createElement('div');typing.className='typing-indicator';typing.innerHTML='<div class="dot"></div><div class="dot"></div><div class="dot"></div>'; | |
| container.appendChild(typing);container.scrollTop=container.scrollHeight; | |
| try{ | |
| const res=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,session_id:SESSION_ID,reviews:(currentData&¤tData.reviews)?currentData.reviews:[]})}); | |
| const data=await res.json(); | |
| if(typing.parentNode)container.removeChild(typing); | |
| if(data.error){appendBotMsg('β οΈ '+data.error,null);return;} | |
| appendBotMsg(data.reply||'',data.table||null); | |
| if(data.type==='filter'&&data.filters)applyChatFilters(data.filters); | |
| }catch(e){if(typing.parentNode)container.removeChild(typing);appendBotMsg('Connection error.',null);} | |
| } | |
| function appendUserMsg(text){const c=document.getElementById('chat-messages');const row=document.createElement('div');row.className='msg-row user';row.innerHTML=`<div class="message user">${escHtml(text)}</div>`;c.appendChild(row);c.scrollTop=c.scrollHeight;} | |
| function appendBotMsg(text,table){ | |
| const c=document.getElementById('chat-messages');const row=document.createElement('div');row.className='msg-row bot'; | |
| if(text&&text.trim()){const b=document.createElement('div');b.className='message bot';b.innerHTML=renderMD(text);row.appendChild(b);} | |
| if(table&&table.rows&&table.rows.length){row.appendChild(buildTable(table));} | |
| c.appendChild(row);c.scrollTop=c.scrollHeight; | |
| } | |
| function renderMD(text){ | |
| const lines=text.split('\n');let html='',inList=false; | |
| for(let raw of lines){ | |
| if(/^\*\*[^*]+\*\*:?$/.test(raw.trim())){if(inList){html+='</div>';inList=false;}html+=`<div class="msg-section">${escHtml(raw.trim().replace(/^\*\*/,'').replace(/\*\*:?$/,''))}</div>`;continue;} | |
| const nm=raw.match(/^(\d+)\.\s+(.+)/);if(nm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-item-num">${nm[1]}.</span><span>${inlineFmt(nm[2])}</span></div>`;continue;} | |
| const bm=raw.match(/^[β’\-\*]\s+(.+)/);if(bm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-bullet">β’</span><span>${inlineFmt(bm[1])}</span></div>`;continue;} | |
| if(inList&&raw.trim()===''){html+='</div>';inList=false;} | |
| if(raw.trim()===''){html+='<br>';}else{html+=`<span>${inlineFmt(raw)}</span><br>`;} | |
| } | |
| if(inList)html+='</div>';return html; | |
| } | |
| function inlineFmt(t){return escHtml(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em style="color:var(--muted2)">$1</em>');} | |
| function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');} | |
| function buildTable(td){ | |
| const{title,columns,rows}=td;const w=document.createElement('div');w.className='chat-table-wrap'; | |
| let h='';if(title)h+=`<div class="chat-table-title">${escHtml(title)}</div>`; | |
| h+='<table class="chat-table"><thead><tr>';for(const c of columns)h+=`<th>${escHtml(c)}</th>`;h+='</tr></thead><tbody>'; | |
| for(const row of rows){h+='<tr>';for(const c of columns){const v=row[c]!==undefined?row[c]:'';h+=`<td title="${escHtml(String(v))}">${escHtml(String(v))}</td>`;}h+='</tr>';} | |
| h+='</tbody></table>';w.innerHTML=h;return w; | |
| } | |
| function applyChatFilters(raw){ | |
| if(!currentData)return; | |
| try{ | |
| const f=typeof raw==='string'?JSON.parse(raw):raw;let filtered=currentData.reviews; | |
| if(f.stars&&f.stars.length)filtered=filtered.filter(r=>f.stars.includes(r.score)); | |
| if(f.app){const q=f.app.toLowerCase();filtered=filtered.filter(r=>{const app=currentData.apps.find(a=>a.appId===r.appId)||{title:r.appTitle||""};return(app.title||"").toLowerCase().includes(q)||r.appId.toLowerCase().includes(q);});} | |
| if(f.query){const q=f.query.toLowerCase();filtered=filtered.filter(r=>(r.content||'').toLowerCase().includes(q)||(r.userName||'').toLowerCase().includes(q));} | |
| render(currentData,filtered); | |
| }catch(e){console.error('Filter error',e);} | |
| } | |
| </script> | |
| </body> | |
| </html> |